Archived
上一版补占位时漏了一件:占位分**两种**空,我只做了一种。 「沟通记录」的注释写着「明说没接,不伪装成暂无数据」,正文却写「与客户、供应商的 邮件和消息都会归到这里」—— 注释和文案自相矛盾。按单归档的投影根本没做,`outbound_mail` 至今只有 channel 建表和 reset-runs 碰过,**任何界面都读不到**。所以在那一屏等下去 永远不会有内容,而文案在请人等。 改成如实说:「往来记录还没接通」。并给 EmptyState 加第三色调,让三种空长得不一样: ok 办完了、清空了 —— 这是好事(待批清空、无未读) brand 还没发生,但通路是通的 —— 等着就会有(还没有任务、还没有成果) muted 通路还没接 —— 等下去也不会有。灰环灰图标,视觉上就不许诺 `muted` 是新加的真色调(不是我编的类名 —— union 与 CSS 一并落地)。 文案也不把人指去「消息」面:那里同样读不到外部邮件,指过去是第二个谎。 顺手把十处空态过了一遍:其余 brand 都名副其实(起单、聚合、纪要、成果的装配都通), 只有沟通记录这一处在许诺。债务簿该条对账。 tsc ✓ eslint ✓ depcruise ✓ 481 测试 ✓ Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
232 lines
6.2 KiB
TypeScript
232 lines
6.2 KiB
TypeScript
/**
|
||
* 工作台后四要素的共用小件 —— 自 `~/coding/JCAI_PRD/workbench/_components/ModuleWorkspace.tsx`
|
||
* 第 21–160 行平移。**DOM 与 `jc-*` 类名原样保留**(README 平移规则 1/2/4)。
|
||
*
|
||
* 四要素同款三栏布局:左清单 `task-sidebar` / 中文档卡 `execution-panel` / 右会话 `session-chat`。
|
||
*/
|
||
import type { ReactNode } from 'react';
|
||
import { Ico } from '@web-shared/Ico.tsx';
|
||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||
|
||
/** 首字头像:名字 hash 取渐变色;外文名取首字母大写 */
|
||
const AVA_GRAD: ReadonlyArray<readonly [string, string]> = [
|
||
['#2bc7d4', '#0e9fd8'],
|
||
['#3370ff', '#245bdb'],
|
||
['#7f3bf5', '#5b21cf'],
|
||
['#ff8800', '#de7802'],
|
||
['#0aa294', '#0a6a64'],
|
||
['#f14ba9', '#c92d86'],
|
||
];
|
||
|
||
function avaOf(name: string): { ch: string; grad: readonly [string, string] } {
|
||
let h = 0;
|
||
for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
||
const first = name[0] ?? '·';
|
||
const ch = /[A-Za-z]/.test(first) ? first.toUpperCase() : first;
|
||
return { ch, grad: AVA_GRAD[h % AVA_GRAD.length]! };
|
||
}
|
||
|
||
export function Avatar({ name, size = 30 }: { name: string; size?: number }) {
|
||
const { ch, grad } = avaOf(name);
|
||
return (
|
||
<span
|
||
className="jc-ava"
|
||
style={{
|
||
width: size,
|
||
height: size,
|
||
fontSize: size * 0.42,
|
||
background: `linear-gradient(140deg, ${grad[0]}, ${grad[1]})`,
|
||
}}
|
||
>
|
||
{ch}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export function AvatarStack({ names, size = 26 }: { names: readonly string[]; size?: number }) {
|
||
const show = names.slice(0, 5);
|
||
return (
|
||
<span className="jc-ava-stack">
|
||
{show.map((n, i) => (
|
||
<span key={i} className="jc-ava-slot">
|
||
<Avatar name={n} size={size} />
|
||
</span>
|
||
))}
|
||
{names.length > show.length && <span className="jc-ava-slot jc-ava-more">+{names.length - show.length}</span>}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export type Tone = 'todo' | 'doing' | 'done' | 'warn' | 'brand' | 'ok';
|
||
|
||
export function Chip({ tone, label, pulse }: { tone: Tone; label: string; pulse?: boolean }) {
|
||
return (
|
||
<span className={'jc-chip t-' + tone}>
|
||
{pulse && <i />}
|
||
{label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** 文档头(渐变图标块 + 标题 + 元信息 + 右上状态章)—— 统一容器,彻底告别贴边 */
|
||
export function Hero({
|
||
grad,
|
||
icon,
|
||
kicker,
|
||
title,
|
||
meta,
|
||
chip,
|
||
}: {
|
||
grad: readonly [string, string];
|
||
icon: string;
|
||
kicker?: string;
|
||
title: string;
|
||
meta: ReactNode;
|
||
chip?: ReactNode;
|
||
}) {
|
||
return (
|
||
<header className="jc-hero">
|
||
<span className="jc-hero-ico" style={{ background: `linear-gradient(140deg, ${grad[0]}, ${grad[1]})` }}>
|
||
<Ico name={icon} s={22} />
|
||
</span>
|
||
<div className="jc-hero-txt">
|
||
{kicker && <span className="jc-hero-kicker">{kicker}</span>}
|
||
<h2>{title}</h2>
|
||
<div className="jc-hero-meta">{meta}</div>
|
||
</div>
|
||
{chip && <div className="jc-hero-chip">{chip}</div>}
|
||
</header>
|
||
);
|
||
}
|
||
|
||
export function Section({
|
||
icon,
|
||
title,
|
||
extra,
|
||
children,
|
||
}: {
|
||
icon: string;
|
||
title: string;
|
||
extra?: ReactNode;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<section className="jc-sec">
|
||
<div className="jc-sec-head">
|
||
<Ico name={icon} s={13} />
|
||
<span>{title}</span>
|
||
{extra && <span className="jc-sec-extra">{extra}</span>}
|
||
</div>
|
||
{children}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** 侧栏表头 —— 四要素共用 */
|
||
export function SidebarHead({ icon, title, sub }: { icon: string; title: string; sub: string }) {
|
||
return (
|
||
<div className="sidebar-head jc-wl-head">
|
||
<span className="jc-wl-ico">
|
||
<Ico name={icon} s={16} />
|
||
</span>
|
||
<div>
|
||
<h2>{title}</h2>
|
||
<span>{sub}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 列表区空态 —— 侧栏窄,用紧凑版:软色圆里一枚图标 + 主句 + 一小段说明。
|
||
* 与主面板的 `EmptyState`(整屏光环)同一语气,尺寸各归其位。
|
||
*/
|
||
export function ListEmpty({
|
||
icon,
|
||
title,
|
||
children,
|
||
}: {
|
||
icon: string;
|
||
title?: string;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="jc-side-empty">
|
||
<span className="jc-side-empty-ico">
|
||
<Ico name={icon} s={18} />
|
||
</span>
|
||
{title !== undefined && <b>{title}</b>}
|
||
<p>{children}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 中栏文档卡的滚动容器 */
|
||
export function DocPanel({ scrollKey, children }: { scrollKey: string; children: ReactNode }) {
|
||
return (
|
||
<main className="execution-panel jc-panel-card">
|
||
<ScrollArea key={scrollKey} className="jc-sa--full" vpClassName="jc-mod-scroll">
|
||
{children}
|
||
</ScrollArea>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 说明带 —— 原型里用来点明规范口径的那条浅色横条。
|
||
* 保留它不是为了好看:这些句子是「为什么这么设计」的现场注解。
|
||
*/
|
||
export function NoteBand({ children }: { children: ReactNode }) {
|
||
return (
|
||
<div className="jc-notrig">
|
||
<Ico name="info" s={15} />
|
||
<span>{children}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 空态整屏 —— 层叠光环 + 图标 + 主副文 + 提示囊(自原型 `EmptyState` 平移)。
|
||
*
|
||
* **空态是产品的常态,不是缺陷**:文案一律从用人的角度写「你手上现在没有什么」,
|
||
* 而不是从开发角度写「这块还没做」—— 后者是给自己看的,前者才是用户要的那句话。
|
||
*
|
||
* 三个色调分的是**三种空**,不是三种好看:
|
||
* - `ok` 办完了、清空了 —— 这是好事(待批清空、无未读)
|
||
* - `brand` 还没发生,但通路是通的 —— 等着就会有(还没有任务、还没有成果)
|
||
* - `muted` 通路还没接 —— 等下去也不会有内容。灰着,别让人白等
|
||
*/
|
||
export function EmptyState({
|
||
ico,
|
||
tone,
|
||
title,
|
||
desc,
|
||
hint,
|
||
}: {
|
||
ico: string;
|
||
tone: 'ok' | 'brand' | 'muted';
|
||
title: string;
|
||
desc: string;
|
||
hint?: string | undefined;
|
||
}) {
|
||
return (
|
||
<div className={'jc-empty t-' + tone}>
|
||
<span className="jc-empty-art">
|
||
<i className="r1" />
|
||
<i className="r2" />
|
||
<span className="jc-empty-ico">
|
||
<Ico name={ico} s={26} />
|
||
</span>
|
||
</span>
|
||
<b>{title}</b>
|
||
<p>{desc}</p>
|
||
{hint !== undefined && (
|
||
<span className="jc-empty-hint">
|
||
<Ico name="sparkles" s={12} />
|
||
{hint}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|