基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-27 20:43:35 +08:00
co-authored by Claude Fable 5
parent 61fe181678
commit ac85ecf2ea
119 changed files with 16634 additions and 1246 deletions
@@ -0,0 +1,643 @@
/**
* 对话流的气泡与卡族 —— 自原型 `_components/TaskChat.tsx` 平移(气泡二元制,2026-07-26 定稿)。
*
* **当前未挂载**:对话区正处在「一步一步描述、一步一步接」的阶段,暂为空占位。
* 这一份留着等接回 —— 描述到对话该长什么样时直接用,或按新口径改。
*
* 「对话流是**一切回环输入的唯一入口**」(OS 4.1);输入侧不参与控制权转交(AM 07)。
* AI 的内容只有两种形态、不允许中间态:
* · 纯文本气泡 —— 结论 / 说明 / 回执
* · 完整表单卡 —— 工单卡 / 验收单 / 决策单 / 补全单 / 评价单
* 一条消息 = 署名行(AI · 时间 · 类型章)+ 一或两个**纯**块。
* ⛔ 补全 / 决策卡吐出期间 composer 锁定 —— 先办卡,再说话。
*
* DOM 与类名逐字照搬原型:皮肤是原型的原样,自造类名等于没样式。
*/
import { useEffect, useRef, useState } from 'react';
import type { AiTag, ChatCardReply, ChatMessage, WorkOrder, WorkOrderReply } from '@contracts';
import { EVENT_TYPE_LABEL, isRejection } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { ChatAvatar } from '@web-shared/ChatAvatar.tsx';
/** 类型章:结论 = auto,指导 = exe,判据 = acc(原型 TAG_UI 口径) */
const TAG_UI: Readonly<Record<AiTag, { label: string; cls: string }>> = {
conclusion: { label: '阶段结论', cls: 'auto' },
guidance: { label: '派了工单', cls: 'exe' },
criterion: { label: '请你验收', cls: 'acc' },
};
/** ISO → HH:MM(署名行只要时分) */
const hm = (iso: string) => iso.slice(11, 16);
export type TaskChatProps = {
readonly chat: readonly ChatMessage[];
readonly orders: readonly WorkOrder[];
/** AI 正在回话 —— 请求在途即亮「输入中」 */
readonly thinking: boolean;
onSay(text: string): void;
onReplyCard(reply: ChatCardReply): void;
onReplyOrder(reply: WorkOrderReply): void;
/** 点卡上的「打开工单」→ 视图区定位到那张工单 */
onLocate(orderId: string): void;
};
export function ChatStream({ chat, orders, thinking, onSay, onReplyCard, onReplyOrder, onLocate }: TaskChatProps) {
const [text, setText] = useState('');
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
boxRef.current?.scrollTo({ top: 1e9, behavior: 'smooth' });
}, [chat.length, thinking]);
// ⛔ 补全 / 决策卡未办结期间不能对话 —— 锁的是**卡**,不是工单
const blocked = chat.some(
(m) => (m.k === 'elicitation' && m.done === undefined) || (m.k === 'decision' && m.chosen === undefined),
);
const send = () => {
const t = text.trim();
if (t === '' || blocked) return;
setText('');
onSay(t);
};
return (
<div className="jc-tch">
<header className="jc-tch-head">
<span className="jc-tch-mark">
<Ico name="bot" s={15} />
</span>
<b className="jc-tch-name">任务助理</b>
<em className="jc-tch-tag">Agent</em>
<span className="jc-tch-desc">盯着这单推进 · 有事随时说</span>
</header>
<div className="jc-tch-scroll" ref={boxRef}>
{chat.map((m) => (
<Msg
key={m.id}
m={m}
orders={orders}
onReplyCard={onReplyCard}
onReplyOrder={onReplyOrder}
onLocate={onLocate}
/>
))}
{thinking && (
<article className="jc-ag">
<ChatAvatar who="ai" />
<div className="jc-ag-col">
<div className="jc-msg-meta">AI</div>
<span className="jc-typing">
<i />
<i />
<i />
</span>
</div>
</article>
)}
</div>
<div className="copilot-composer session-composer">
{blocked && (
<div className="jc-comp-lock">
<Ico name="lock" s={12} /> 有一张卡在等你(补全 / 决策)——处理完就能继续对话
</div>
)}
<div className="composer-row">
<textarea
rows={2}
value={text}
disabled={blocked}
placeholder={blocked ? '先处理上面的卡片…' : '有什么要补充或交代的?(Enter 发送)'}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
}}
/>
<button className="copilot-send" disabled={text.trim() === '' || blocked} onClick={send}>
<Ico name="send" s={14} /> 发送
</button>
</div>
</div>
</div>
);
}
/* ── Agent 侧包装:头像 + (署名行 → 内容流) ── */
function Ag({ meta, tag, children }: { meta: string; tag?: AiTag | undefined; children: React.ReactNode }) {
const t = tag === undefined ? undefined : TAG_UI[tag];
return (
<article className="jc-ag">
<ChatAvatar who="ai" />
<div className="jc-ag-col">
<div className="jc-msg-meta">
{meta}
{t !== undefined && <b className={'jc-mtag m-' + t.cls}>{t.label}</b>}
</div>
{children}
</div>
</article>
);
}
function Msg({
m,
orders,
onReplyCard,
onReplyOrder,
onLocate,
}: {
m: ChatMessage;
orders: readonly WorkOrder[];
onReplyCard: (reply: ChatCardReply) => void;
onReplyOrder: (reply: WorkOrderReply) => void;
onLocate: (orderId: string) => void;
}) {
// 人的消息:右侧品牌气泡(纯文本)
if (m.k === 'human') {
return (
<article className="copilot-msg human">
<div className="jc-msg-col r">
<div className="jc-msg-meta r">{hm(m.at)}</div>
<div className="copilot-bubble">
<p className="jc-content">{m.text}</p>
</div>
</div>
<ChatAvatar who="user" />
</article>
);
}
// 系统事件:灰头像 + 灰调文本气泡。三类唤醒事件在章上标明是哪一类
if (m.k === 'system') {
return (
<article className="jc-ag">
<div className="chat-avatar jc-ava-sys" aria-label="系统">
<Ico name="server" s={15} />
</div>
<div className="jc-ag-col">
<div className="jc-msg-meta">
系统 · {hm(m.at)}
<b className="jc-mtag m-sys">{EVENT_TYPE_LABEL[m.type]}事件</b>
</div>
<div className="jc-ag-b sys">
<p className="jc-content">{m.text}</p>
</div>
</div>
</article>
);
}
// AI 发言:一次回复 = 一个气泡 —— 文字在上、表单卡嵌在**同一气泡**里
if (m.k === 'ai') {
const order = m.ref === undefined ? undefined : orders.find((o) => o.id === m.ref);
const settled = order?.done !== undefined;
return (
<Ag meta={`AI · ${hm(m.at)}`} tag={m.tag}>
<div className={'jc-ag-b' + (order === undefined ? '' : ' card')}>
<p className="jc-content">{m.text}</p>
{order !== undefined &&
(order.mode === 'acceptance' && m.tag === 'criterion' && !settled ? (
<AcceptForm order={order} onReplyOrder={onReplyOrder} onLocate={onLocate} />
) : (
<WoCard order={order} onLocate={onLocate} />
))}
</div>
</Ag>
);
}
// 补全:问题文字在上,表单卡嵌在同一气泡里
if (m.k === 'elicitation') {
return (
<Ag meta={`AI · ${hm(m.at)}`}>
<div className="jc-ag-b card">
<p className="jc-content">{m.ask}</p>
<div className="jc-fc t-fill">
<div className="jc-fc-h">
<Ico name="square-pen" s={13} />
<b>请你补全</b>
<em>{m.fields.length} 项</em>
</div>
<div className="jc-fc-b">
{m.basis !== undefined && m.basis.length > 0 && <Kv items={m.basis} />}
{m.done === undefined ? (
<FillForm
fields={m.fields}
onDone={(values) => onReplyCard({ k: 'elicitation', cardId: m.id, values })}
onCant={(reason) => onReplyCard({ k: 'elicitation', cardId: m.id, values: {}, reason })}
/>
) : m.done.reason === undefined ? (
<p className="jc-chosen">
<Ico name="circle-check" s={14} /> 你补了:
<b>{m.fields.map((f) => m.done?.values[f.key] ?? '—').join(';')}</b>
<code className="jc-mono">{hm(m.done.at)}</code>
</p>
) : (
<p className="jc-chosen cant">
<Ico name="alert-circle" s={14} /> 你说明了没法填:
<b>{m.done.reason}</b>
<code className="jc-mono">{hm(m.done.at)}</code>
</p>
)}
</div>
</div>
</div>
</Ag>
);
}
// 任务评价:星级打分 + 改进建议,并入活动记忆
if (m.k === 'rating') {
return (
<Ag meta={`AI · ${hm(m.at)}`}>
<div className="jc-ag-b card">
<p className="jc-content">{m.ask}</p>
<div className="jc-fc t-rate">
<div className="jc-fc-h">
<Ico name="star" s={13} />
<b>任务评价</b>
<em>5 分制</em>
</div>
<div className="jc-fc-b">
{m.basis !== undefined && m.basis.length > 0 && <Kv items={m.basis} />}
{m.done === undefined ? (
<RateForm
onDone={(score, note) =>
onReplyCard({ k: 'rating', cardId: m.id, score, ...(note === '' ? {} : { note }) })
}
/>
) : (
<p className="jc-chosen">
<span className="jc-stars ro">
{[1, 2, 3, 4, 5].map((n) => (
<i key={n} className={'jc-star' + (n <= (m.done?.score ?? 0) ? ' on' : '')}>
<Ico name="star" s={16} />
</i>
))}
</span>
<b>{m.done.score} 分</b>
{m.done.note !== undefined && <span className="jc-rate-note">{m.done.note}</span>}
<code className="jc-mono">{hm(m.done.at)}</code>
</p>
)}
</div>
</div>
</div>
</Ag>
);
}
// 决策:问题文字在上,选择卡嵌在同一气泡里
const chosenLabel =
m.chosen === undefined
? ''
: (m.options.find((o) => o.value === m.chosen?.value)?.label ??
(m.chosen.value.startsWith('custom:') ? m.chosen.value.slice(7) : m.chosen.value));
return (
<Ag meta={`AI · ${hm(m.at)}`}>
<div className="jc-ag-b card">
<p className="jc-content">{m.ask}</p>
<div className="jc-fc t-dec">
<div className="jc-fc-h">
<Ico name="git-branch" s={13} />
<b>需要你定</b>
<em>
{m.options.length + (m.allowCustom ? 1 : 0)} 选 1
</em>
</div>
<div className="jc-fc-b">
<Kv items={m.basis} />
{m.recommend !== undefined && (
<p className="jc-fc-note">
<Ico name="sparkles" s={13} />
<span>
<b>AI 建议</b>
{m.recommend}
</span>
</p>
)}
{m.chosen === undefined ? (
<div className="jc-branches">
{m.options.map((o, i) => (
<button
key={o.value}
className={'jc-branch' + (o.recommended === true ? ' rec' : '')}
onClick={() => onReplyCard({ k: 'decision', cardId: m.id, value: o.value })}
>
<span className="jc-branch-n jc-mono">{String.fromCharCode(65 + i)}</span>
<span className="jc-branch-bd">
<b>
{o.label}
{o.recommended === true && <em>推荐</em>}
</b>
{o.detail !== undefined && <p>{o.detail}</p>}
{o.consequence !== undefined && (
<p className="jc-conseq">
<Ico name="arrow-right" s={12} /> {o.consequence}
</p>
)}
</span>
<Ico name="chevron-right" s={14} />
</button>
))}
{m.allowCustom && (
<CustomOption
n={String.fromCharCode(65 + m.options.length)}
onPick={(txt) => onReplyCard({ k: 'decision', cardId: m.id, value: 'custom:' + txt, note: txt })}
/>
)}
</div>
) : (
<p className="jc-chosen">
<Ico name="circle-check" s={14} /> 你选了:
<b>{chosenLabel}</b>
<code className="jc-mono">{hm(m.chosen.at)}</code>
</p>
)}
</div>
</div>
</div>
</Ag>
);
}
/* 参考信息:表单卡内的结构化 kv 表 */
function Kv({ items }: { items: ReadonlyArray<{ k: string; v: string }> }) {
return (
<div className="jc-fc-kv">
<i>参考信息</i>
<div className="jc-fc-kv-g">
{items.map((b, i) => (
<span key={i}>
<em>{b.k}</em>
<b>{b.v}</b>
</span>
))}
</div>
</div>
);
}
/* 工单卡:题头 + 单体 + 打开动作 —— 派单 / 已裁决的验收都用它 */
function WoCard({ order, onLocate }: { order: WorkOrder; onLocate: (id: string) => void }) {
const result = order.done?.result;
const bad = result !== undefined && isRejection(result);
const exe = order.mode === 'execution';
return (
<div className={'jc-fc ' + (exe ? 't-exe' : 't-acc')}>
<div className="jc-fc-h">
<Ico name={exe ? 'file-text' : 'shield-check'} s={13} />
<b>工单 · {exe ? '执行' : '验收'}</b>
<em className={result === undefined ? '' : bad ? 'bad' : 'ok'}>
{result === undefined ? '待处理' : bad ? '已退回' : '已办结'}
</em>
</div>
<div className="jc-fc-b">
<p className="jc-fc-q strong">{order.title}</p>
</div>
<div className="jc-fc-ft">
<button className="jc-btn jc-btn-ghost jc-fc-open" onClick={() => onLocate(order.id)}>
<Ico name="panel-left-open" s={13} /> 打开工单 <Ico name="arrow-right" s={12} />
</button>
</div>
</div>
);
}
/* 验收单:报告入口 + 判据表 + 落地动作 + 裁决(通过 / 重做)—— 驳回必附意见 */
function AcceptForm({
order,
onReplyOrder,
onLocate,
}: {
order: Extract<WorkOrder, { mode: 'acceptance' }>;
onReplyOrder: (reply: WorkOrderReply) => void;
onLocate: (id: string) => void;
}) {
const [why, setWhy] = useState('');
const passN = order.criteria.filter((c) => c.pass).length;
return (
<div className="jc-fc t-acc">
<div className="jc-fc-h">
<Ico name="shield-check" s={13} />
<b>请你验收</b>
<em className={passN === order.criteria.length ? 'ok' : 'bad'}>
{passN}/{order.criteria.length} 达标
</em>
</div>
<div className="jc-fc-b">
<button className="jc-fc-doc" onClick={() => onLocate(order.id)}>
<span className="jc-fc-doc-ico">
<Ico name="file-text" s={15} />
</span>
<span className="jc-fc-doc-bd">
<em>成果报告 · 视图区可读可编辑</em>
<b>{order.title}</b>
</span>
<Ico name="chevron-right" s={14} />
</button>
<ul className="jc-rp-crit sm">
{order.criteria.map((c, i) => (
<li key={i} className={c.pass ? 'ok' : 'no'}>
<span className={'jc-lv jc-lv-' + c.level}>{c.level}</span>
<span className="t">{c.label}</span>
<span className="r">
<Ico name={c.pass ? 'circle-check' : 'circle-x'} s={13} />
{c.pass ? '达标' : '未达标'}
</span>
</li>
))}
</ul>
{order.actions !== undefined && order.actions.length > 0 && (
<Kv
items={order.actions.map((a) => ({
k: a.effect === 'write' ? '通过即执行 · 写' : '通过即执行 · 读',
v: a.label,
}))}
/>
)}
<textarea
className="jc-note"
placeholder="要重做?写明原因(也可以在下面对话里聊)"
value={why}
onChange={(e) => setWhy(e.target.value)}
/>
</div>
<div className="jc-fc-ft">
<button
className="jc-btn jc-btn-bad"
disabled={why.trim() === ''}
onClick={() =>
onReplyOrder({
mode: 'acceptance',
workOrderId: order.id,
verdict: 'redo',
values: {},
comment: why.trim(),
selectedPorts: {},
})
}
>
重做
</button>
<span className="jc-fc-sp" />
<button
className="jc-btn jc-btn-ok"
onClick={() =>
onReplyOrder({
mode: 'acceptance',
workOrderId: order.id,
verdict: 'pass',
values: {},
selectedPorts: {},
})
}
>
<Ico name="circle-check" s={14} /> {order.passLabel ?? '确认通过'}
</button>
</div>
</div>
);
}
/* 决策的收尾项:自定义口径 */
function CustomOption({ n, onPick }: { n: string; onPick: (txt: string) => void }) {
const [txt, setTxt] = useState('');
return (
<div className="jc-branch jc-branch-custom">
<span className="jc-branch-n jc-mono">{n}</span>
<span className="jc-branch-bd">
<b>都不合适,我来定</b>
<span className="jc-custom-row">
<input
value={txt}
placeholder="写下你的口径…"
onChange={(e) => setTxt(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && txt.trim() !== '') onPick(txt.trim());
}}
/>
<button className="jc-btn" disabled={txt.trim() === ''} onClick={() => onPick(txt.trim())}>
就按这个
</button>
</span>
</span>
</div>
);
}
/* 补全卡的小表单:就地作答;「没法填」是并列的第二条路(两条必走其一) */
function FillForm({
fields,
onDone,
onCant,
}: {
fields: ReadonlyArray<{ key: string; label: string; placeholder?: string | undefined }>;
onDone: (values: Record<string, string>) => void;
onCant: (reason: string) => void;
}) {
const [vals, setVals] = useState<Record<string, string>>({});
const [cant, setCant] = useState(false);
const [reason, setReason] = useState('');
const ok = fields.every((f) => (vals[f.key] ?? '').trim() !== '');
if (cant) {
return (
<div className="jc-cfill">
<label className="jc-cfill-f">
<span>没法填的原因(AI 会据此调整需求)</span>
<textarea
className="jc-note"
rows={2}
value={reason}
autoFocus
placeholder="如:没和客户谈过价位 / 这个数据在别人手里…"
onChange={(e) => setReason(e.target.value)}
/>
</label>
<div className="jc-cfill-acts">
<button type="button" className="jc-cant-link" onClick={() => setCant(false)}>
← 还是填吧
</button>
<button className="jc-btn" disabled={reason.trim() === ''} onClick={() => onCant(reason.trim())}>
<Ico name="send" s={13} /> 让 AI 调整需求
</button>
</div>
</div>
);
}
return (
<div className="jc-cfill">
{fields.map((f) => (
<label key={f.key} className="jc-cfill-f">
<span>{f.label}</span>
<input
value={vals[f.key] ?? ''}
placeholder={f.placeholder}
onChange={(e) => setVals({ ...vals, [f.key]: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter' && ok) onDone(vals);
}}
/>
</label>
))}
<div className="jc-cfill-acts">
<button type="button" className="jc-cant-link" onClick={() => setCant(true)}>
没法填?告诉 AI 原因
</button>
<button className="jc-btn" disabled={!ok} onClick={() => onDone(vals)}>
<Ico name="check" s={13} /> 补给 AI
</button>
</div>
</div>
);
}
/* 评价卡的打分器:星级 + 改进建议(建议进活动记忆) */
function RateForm({ onDone }: { onDone: (score: number, note: string) => void }) {
const [score, setScore] = useState(0);
const [hover, setHover] = useState(0);
const [note, setNote] = useState('');
const TXT = ['', '很不顺畅,返工严重', '不顺畅,返工明显', '一般,多处需人工介入', '顺畅,个别节点需修正', '非常顺畅,全程无需返工'];
const cur = hover !== 0 ? hover : score;
return (
<div className="jc-cfill">
<div className="jc-stars" role="radiogroup" aria-label="整体评分">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
className={'jc-star' + (n <= cur ? ' on' : '')}
onMouseEnter={() => setHover(n)}
onMouseLeave={() => setHover(0)}
onClick={() => setScore(n)}
aria-label={`${n} 分`}
>
<Ico name="star" s={20} />
</button>
))}
<em className="jc-stars-txt">{cur !== 0 ? `${cur} 分 · ${TXT[cur]}` : '点星打分'}</em>
</div>
<label className="jc-cfill-f">
<span>改进建议(会进入活动记忆,用于优化下一单)</span>
<textarea
className="jc-note"
rows={2}
value={note}
placeholder="如:报价台账自动关联客户项目号;英文报价邮件模板固化为标准件"
onChange={(e) => setNote(e.target.value)}
/>
</label>
<div className="jc-cfill-acts">
<button className="jc-btn" disabled={score === 0} onClick={() => onDone(score, note.trim())}>
<Ico name="check" s={13} /> 提交评价
</button>
</div>
</div>
);
}