This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/web/src/shared/desk/module-kit.tsx
T
ChaiHaoandClaude Opus 5 f7b6a3a107 空态分出第三种空 —— 「没接通」不许诺,别让人白等
上一版补占位时漏了一件:占位分**两种**空,我只做了一种。

「沟通记录」的注释写着「明说没接,不伪装成暂无数据」,正文却写「与客户、供应商的
邮件和消息都会归到这里」—— 注释和文案自相矛盾。按单归档的投影根本没做,`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
2026-07-28 10:32:23 +08:00

232 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 工作台后四要素的共用小件 —— 自 `~/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>
);
}