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

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
@@ -1,232 +1,384 @@
/**
* 标题区 + 工作区 —— 自原型 `_components/ViewRegion.tsx` 平移(四页签结构)。
* 标题区 + 工作区 —— 自原型 `_components/ViewRegion.tsx` 平移(2026-07-26 定稿)。
*
* 「视图区 = 全部结构化交互:HTML 交互界面(表单读写 + 图形展示)+ 工作事项列表 + 过程成果」;
* 容器渲染活动推送的内容,**无脚本执行权**(AM 07);渲染一律经 @ui 的 ViewContainer。
* 「视图区**承担全部结构化交互**」:表单读写 + 过程成果的图形展示,全在这里完成;
* 决策不在这里 —— 它是对话区的选择题(AM 07)。
*
* 五页签:待处理 / 沟通记录 / 工作成果 / 任务详情 / 工作日志。
* DOM 与类名逐字照搬原型 —— 皮肤是原型的原样,自造类名等于没样式。
*
* **空态即常态**:文案从用人的角度写「你手上现在没有什么、将会有什么」,
* 不写「这块还没做」—— 后者是开发视角,用户不关心,也看不懂。
*/
import { useState } from 'react';
import type { TaskDetail, ViewBlock, WorkOrder } from '@contracts';
import { ACTION_EFFECT_LABEL, WORK_ORDER_MODE_LABEL } from '@contracts';
import type { TaskDetail } from '@contracts';
import { TERMINAL_STATE_LABEL } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import ScrollArea from '@web-shared/ScrollArea.tsx';
import { ViewBlocks, ViewContainer } from '@ui';
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
import { ViewBlocks } from '@ui';
type Tab = 'todo' | 'result' | 'detail' | 'log';
const TABS: ReadonlyArray<{ key: Tab; label: string; icon: string }> = [
{ key: 'todo', label: '待处理', icon: 'list-checks' },
{ key: 'result', label: '工作成果', icon: 'file-check' },
{ key: 'detail', label: '任务详情', icon: 'bar-chart' },
{ key: 'log', label: '工作日志', icon: 'notebook-pen' },
];
type Tab = 'open' | 'comms' | 'done' | 'wf' | 'log';
export type ViewRegionProps = {
readonly detail: TaskDetail;
onReply(reply: Record<string, unknown>): void;
};
export function ViewRegion({ detail, onReply }: ViewRegionProps) {
const [tab, setTab] = useState<Tab>('todo');
const open = detail.workOrders.filter((o) => o.done === undefined);
export function ViewRegion({ detail }: ViewRegionProps) {
const [view, setView] = useState<Tab>('open');
const [page, setPage] = useState(0);
const orders = detail.workOrders;
const openList = orders.filter((o) => o.done === undefined);
const pos = Math.min(page, Math.max(0, openList.length - 1));
const cur = openList[pos];
const go = (to: Tab) => {
setView(to);
setPage(0);
};
const terminalLabel = detail.card.terminal === undefined ? undefined : TERMINAL_STATE_LABEL[detail.card.terminal];
return (
<main className="execution-panel jc-panel-card">
<header className="jc-vr-head">
<div className="jc-vr-title">
<h1>{detail.card.title}</h1>
<span className="jc-vr-sub">{detail.card.workflowSubject}</span>
<main className="execution-panel">
{/* ① 标题区 —— **不滚**,两行定高:主题 · 工作流 · 状态 · 谁做 */}
<header className="jc-topbar" aria-label="标题区">
<div className="jc-tb-crumb">
<span className="jc-tb-tag">
<Ico name="list-checks" s={12} />
{detail.card.activity}
</span>
<span className="jc-tb-wf" title={detail.card.workflowSubject}>
<Ico name="git-branch" s={12} />
<b>{detail.card.workflowSubject}</b>
</span>
<span className="jc-tb-sp" />
<span className={'jc-cp-run-st' + (terminalLabel === undefined ? '' : ' done')}>
{terminalLabel === undefined ? (
<>
<i className="jc-pulse-dot" />
AI 推进中
</>
) : (
<>
<Ico name="circle-check" s={13} />
{terminalLabel}
</>
)}
</span>
<span className="jc-tb-owner" title={`负责人 · ${detail.owner.name} · ${detail.owner.role}`}>
<em>{detail.owner.name.charAt(0)}</em>
{detail.owner.name} · {detail.owner.role}
</span>
</div>
<div className="jc-vr-budget jc-mono" title="预算与熔断由基座在环外强制">
<span>步 {detail.budget.steps}</span>
<span>token {detail.budget.tokens}</span>
<span>时长 {detail.budget.activeMinutes}</span>
<span>调人 {detail.budget.humanCalls}</span>
<div className="jc-tb-titlerow">
<span className="jc-tb-tico">
<Ico name="file-text" s={16} />
</span>
<h2 className="jc-tb-title" title={detail.card.title}>
{detail.card.title}
</h2>
<span className="jc-tb-node jc-mono">
{detail.card.position}/{detail.card.total}
</span>
</div>
</header>
<nav className="jc-vr-tabs" role="tablist">
{TABS.map((t) => (
<button key={t.key} className={tab === t.key ? 'active' : ''} onClick={() => setTab(t.key)} role="tab">
<Ico name={t.icon} s={14} />
<span>{t.label}</span>
{t.key === 'todo' && open.length > 0 ? <b className="jc-vr-tab-badge">{open.length}</b> : null}
{/* ② 工作区 —— 页签与点位条**不滚**,只有正文滚 */}
<section className="wf-card jc-wc-fill">
<div className="jc-tnav">
<button
type="button"
role="tab"
aria-selected={view === 'open'}
className={'jc-tnav-tab' + (view === 'open' ? ' on' : '')}
onClick={() => go('open')}
>
<Ico name="list-checks" s={14} /> 待处理{openList.length > 0 && <em>{openList.length}</em>}
</button>
))}
</nav>
<button
type="button"
role="tab"
aria-selected={view === 'comms'}
className={'jc-tnav-tab' + (view === 'comms' ? ' on' : '')}
onClick={() => go('comms')}
>
<Ico name="mail" s={14} /> 沟通记录
</button>
<button
type="button"
role="tab"
aria-selected={view === 'done'}
className={'jc-tnav-tab' + (view === 'done' ? ' on' : '')}
onClick={() => go('done')}
>
<Ico name="circle-check" s={14} /> 工作成果
</button>
<button
type="button"
role="tab"
aria-selected={view === 'wf'}
className={'jc-tnav-tab' + (view === 'wf' ? ' on' : '')}
onClick={() => go('wf')}
>
<Ico name="git-branch" s={14} /> 任务详情
</button>
<button
type="button"
role="tab"
aria-selected={view === 'log'}
className={'jc-tnav-tab' + (view === 'log' ? ' on' : '')}
onClick={() => go('log')}
>
<Ico name="archive" s={14} /> 工作日志
</button>
<span className="jc-tnav-sp" />
{/*
翻页**恒显示**:只有一件也出 1/1,两侧箭头灰显。
按件数隐藏会让点位条忽有忽无,右侧跟着跳 —— 页签条要定得住。
*/}
{view === 'open' && (
<span className="jc-tnav-pg" aria-label="翻页">
<button
type="button"
className="jc-tnav-btn"
disabled={pos === 0}
onClick={() => setPage(pos - 1)}
aria-label="上一件"
>
<Ico name="chevron-left" s={15} />
</button>
<b className="jc-tnav-n jc-mono">
{openList.length === 0 ? 0 : pos + 1}/{openList.length}
</b>
<button
type="button"
className="jc-tnav-btn"
disabled={pos >= openList.length - 1}
onClick={() => setPage(pos + 1)}
aria-label="下一件"
>
<Ico name="chevron-right" s={15} />
</button>
</span>
)}
</div>
<ScrollArea className="jc-sa--fill" vpClassName="jc-vr-body">
{tab === 'todo' &&
(open.length === 0 ? (
<div className="jc-list-empty">
<Ico name="list-checks" s={18} />
当前没有等你的工作事项
</div>
{/* key:换件 / 换页签即重挂载滚动区,回到正文顶部 */}
<ScrollArea
key={`${view}:${cur?.id ?? '-'}`}
className="jc-sa--fill"
vpClassName="wf-body"
inset={-6}
>
{view === 'comms' ? (
/* 占位:与外界的往来(邮件线程等)还没接进来 —— 明说没接,不装成「暂无数据」 */
<EmptyState
ico="mail"
tone="brand"
title="还没有往来记录"
desc="与客户、供应商的邮件和消息都会归到这里,原件一并存档 —— 谈过什么、什么时候谈的,回头都查得到。"
/>
) : view === 'wf' ? (
<WfDetail detail={detail} openN={openList.length} doneN={orders.length - openList.length} />
) : view === 'log' ? (
<EmptyState
ico="archive"
tone="brand"
title="还没有进展"
desc="AI 每推进一步都记在这里,并标明是谁的产出:AI 完成、我填的、我确认的、等外部。"
/>
) : view === 'done' ? (
<EmptyState
ico="archive"
tone="brand"
title="还没有成果"
desc="这一单产出的报告与文件会归档在这里,一行一份、点开整篇看。"
/>
) : (
open.map((order) => <WorkOrderCard key={order.id} order={order} onReply={onReply} />)
))}
{/*
活动推的 HTML 优先:那是它按当下局面组织的**完整一屏**;
没推 HTML 时退回结构化块。两者都经 ViewContainer 渲染 —— feature 不碰 innerHTML。
*/}
{tab === 'result' &&
(detail.viewHtml === undefined ? (
<ViewBlocks blocks={detail.view as ViewBlock[]} />
) : (
<ViewContainer html={detail.viewHtml} title={`${detail.card.activity} · 视图`} />
))}
{tab === 'detail' && (
<div className="jc-vr-detail">
<ViewBlocks blocks={detail.view as ViewBlock[]} />
</div>
)}
{tab === 'log' && (
<ol className="jc-log">
{detail.log.map((line) => (
<li key={line.id} className={'jc-log-item side-' + line.side}>
<span className="jc-log-side">{line.sideLabel}</span>
<span className="jc-log-title">{line.title}</span>
<time className="jc-mono">{line.at.slice(11, 19)}</time>
</li>
))}
</ol>
)}
</ScrollArea>
<EmptyState
ico="list-checks"
tone="brand"
title="手上没有待办"
desc="需要你执行或验收的事会送到这里,带着办这件事要用的全部上下文 —— 不用你去别处找。"
hint="AI 正在往下推进,轮到你时会第一时间出现"
/>
)}
</ScrollArea>
</section>
</main>
);
}
/** 工单两模式:执行 = 指导书 + 空表单;验收 = 可修订报告 + 判据核对 + 放行三选一 */
function WorkOrderCard({ order, onReply }: { order: WorkOrder; onReply(reply: Record<string, unknown>): void }) {
const [values, setValues] = useState<Record<string, string>>(() =>
Object.fromEntries(
(order.mode === 'execution' ? order.fields : order.fields).map((f) => [f.key, f.ai ?? '']),
),
);
const [comment, setComment] = useState('');
const set = (key: string, value: string) => setValues((v) => ({ ...v, [key]: value }));
/* ═════════════════ 任务详情:KPI 带 + 流程链 + 上下文档案 ═════════════════ */
function WSec({ ico, label, aside }: { ico: string; label: string; aside?: React.ReactNode }) {
return (
<article className="jc-wo jc-panel-card">
<header className="jc-wo-head">
<span className={'jc-wo-mode mode-' + order.mode}>{WORK_ORDER_MODE_LABEL[order.mode]}</span>
<h3>{order.title}</h3>
</header>
{/* 调用必附上下文 —— 依据快照 */}
<dl className="jc-wo-basis">
{order.basis.map((b, i) => (
<div key={i}>
<dt>{b.k}</dt>
<dd>{b.v}</dd>
</div>
))}
</dl>
{order.mode === 'execution' ? (
<>
<p className="jc-wo-ask">{order.ask}</p>
{order.standard !== undefined && <p className="jc-wo-standard">完成标准:{order.standard}</p>}
{order.steps !== undefined && (
<ol className="jc-wo-steps">
{order.steps.map((s, i) => (
<li key={i}>{s}</li>
))}
</ol>
)}
<div className="jc-wo-form">
{order.fields.map((f) => (
<label key={f.key} className={'jc-field' + (f.cols === 2 ? ' half' : '')}>
<span>
{f.label}
{f.required === true ? <i className="jc-req">*</i> : null}
</span>
{f.kind === 'textarea' ? (
<textarea value={values[f.key] ?? ''} onChange={(e) => set(f.key, e.target.value)} rows={3} />
) : f.kind === 'radio' || f.kind === 'select' ? (
<select value={values[f.key] ?? ''} onChange={(e) => set(f.key, e.target.value)}>
<option value="">请选择</option>
{(f.options ?? []).map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : (
<input value={values[f.key] ?? ''} onChange={(e) => set(f.key, e.target.value)} placeholder={f.placeholder} />
)}
{f.hint !== undefined && <em className="jc-hint">{f.hint}</em>}
</label>
))}
</div>
<footer className="jc-wo-foot">
<button
className="jc-btn primary"
onClick={() => onReply({ mode: 'execution', workOrderId: order.id, values })}
>
{order.submitLabel ?? '提交'}
</button>
</footer>
</>
) : (
<>
{/* 判据逐条核对 —— 成果清单的成色标记 */}
<ul className="jc-criteria">
{order.criteria.map((c, i) => (
<li key={i} className={c.pass ? 'pass' : 'fail'}>
<b className="jc-lv">{c.level}</b>
<span>{c.label}</span>
<i>{c.pass ? '✓' : '○'}</i>
</li>
))}
</ul>
{order.actions !== undefined && order.actions.length > 0 && (
<ul className="jc-actions" title="预检全过才发卡:这里是已验证的动作清单">
{order.actions.map((a, i) => (
<li key={i}>
<b className={'jc-eff eff-' + a.effect}>{ACTION_EFFECT_LABEL[a.effect]}</b>
<span>{a.label}</span>
<em>{a.detail}</em>
</li>
))}
</ul>
)}
<label className="jc-field">
<span>意见(驳回必填)</span>
<textarea value={comment} onChange={(e) => setComment(e.target.value)} rows={2} />
</label>
<footer className="jc-wo-foot">
{/* 三选一:放行通过 / 驳回重做 / 驳回迭代 —— 完成只出自放行通过 */}
<button
className="jc-btn primary"
onClick={() =>
onReply({ mode: 'acceptance', workOrderId: order.id, verdict: 'pass', values, selectedPorts: {} })
}
>
{order.passLabel ?? '放行通过'}
</button>
<button
className="jc-btn"
onClick={() =>
onReply({ mode: 'acceptance', workOrderId: order.id, verdict: 'revise', values, comment, selectedPorts: {} })
}
>
驳回迭代
</button>
<button
className="jc-btn danger"
onClick={() =>
onReply({ mode: 'acceptance', workOrderId: order.id, verdict: 'redo', values, comment, selectedPorts: {} })
}
>
驳回重做
</button>
</footer>
</>
)}
</article>
<div className="jc-wfd-sec">
<span className="jc-wfd-sec-ico">
<Ico name={ico} s={12} />
</span>
<b>{label}</b>
<i className="ln" />
{aside}
</div>
);
}
function WfDetail({ detail, openN, doneN }: { detail: TaskDetail; openN: number; doneN: number }) {
return (
<div className="jc-wfd-body">
<KpiBand budget={detail.budget} />
<WSec
ico="git-branch"
label="流程链"
aside={
<span className="jc-wfd-pos">
<i className="jc-pulse-dot" />
当前 · {detail.card.position}/{detail.card.total} {detail.card.activity}
</span>
}
/>
<div className="jc-wfg2">
{detail.chain.map((n, k) => (
<div
key={n.nodeId}
className={`jc-map-n s-${n.state === 'current' ? 'cur' : n.state}${n.kind === 'approval' ? ' apr' : ''}`}
title={`${n.nodeId} ${n.label} · ${n.ownerName} · ${n.ownerRole}${n.kind === 'approval' ? '(审批点)' : ''}`}
>
{n.state === 'current' && (
<span className="jc-map-me">
<i className="jc-pulse-dot" />
你在这
</span>
)}
<div className="jc-map-nh">
<em className="jc-mono">{k + 1}</em>
{n.state === 'done' ? (
<span className="jc-map-ok">
<Ico name="check" s={11} />
</span>
) : n.kind === 'approval' ? (
<span className="jc-map-ap">
<Ico name="shield-check" s={11} />
</span>
) : null}
</div>
<b>{n.label}</b>
<span className="jc-map-who">
<i>{n.ownerName.charAt(0)}</i>
{n.ownerName} · {n.ownerRole}
</span>
</div>
))}
{detail.chain.length === 0 && <p className="jc-ck-none">这单不在流程上(直接起单)</p>}
</div>
<div className="jc-duo2">
<div className="jc-panel">
<div className="jc-panel-h">
<span className="jc-panel-ico">
<Ico name="bar-chart" s={12} />
</span>
<b>过程视图</b>
<em>活动推送</em>
</div>
<ViewBlocks blocks={detail.view} />
</div>
<div className="jc-panel">
<div className="jc-panel-h">
<span className="jc-panel-ico">
<Ico name="notebook-pen" s={12} />
</span>
<b>上下文</b>
<em className="jc-mono">{detail.card.id}</em>
</div>
<div className="jc-file-rows">
<div className="jc-file-row">
<i>
<Ico name="target" s={13} />
</i>
<em>本单目标</em>
<p>{detail.goal}</p>
</div>
<div className="jc-file-row">
<i>
<Ico name="git-branch" s={13} />
</i>
<em>所属工作流</em>
<p>
{detail.card.workflowSubject}
<span>
序位 {detail.card.position}/{detail.card.total}
</span>
</p>
</div>
<div className="jc-file-row">
<i>
<Ico name="activity" s={13} />
</i>
<em>运行状态</em>
<p>
<b>已派 {openN + doneN} 件</b> · 办完 {doneN} · 待办 {openN}
<span>AI 边推进边派工,件数还会增加</span>
</p>
</div>
<div className="jc-file-row">
<i>
<Ico name="hash" s={13} />
</i>
<em>编号</em>
<p className="jc-mono">
{detail.card.id}
<span className="jc-mono">起于 {detail.card.startedAt.slice(0, 16).replace('T', ' ')}</span>
</p>
</div>
</div>
</div>
</div>
</div>
);
}
/** KPI 指标带:四层卡解剖(维度徽 → 指标名 → 大数 → 限额条);限额是基座在环外强制的 */
function KpiBand({ budget }: { budget: TaskDetail['budget'] }) {
const meter = (p: string) => {
const [a = '0', z = '0'] = p.split('/').map((x) => x.trim());
const r = Math.max(0, Math.min(1, Number(a) / (Number(z) || 1)));
return { a, z, r, tone: r >= 0.85 ? 'hot' : r >= 0.6 ? 'warm' : 'cool' };
};
const KPI = [
{ dim: '步数', icon: 'activity', ...meter(budget.steps) },
{ dim: 'Tokens', icon: 'cpu', ...meter(budget.tokens) },
{ dim: '持续时长', icon: 'clock', ...meter(budget.activeMinutes) },
{ dim: '调人次数', icon: 'users', ...meter(budget.humanCalls) },
];
return (
<div className="jc-kpis">
{KPI.map((k) => (
<div key={k.dim} className="jc-kpi">
<span className="jc-kpi-dim d-act">
<Ico name={k.icon} s={10} />
当前活动
</span>
<em>{k.dim}</em>
<b className="jc-mono">
{k.a}
<i>/ {k.z}</i>
</b>
<span className={'jc-ck-bar t-' + k.tone}>
<i style={{ width: `${Math.round(k.r * 100)}%` }} />
</span>
</div>
))}
</div>
);
}
/* ═════════════════ 工单报告页 ═════════════════ */