工作台空占位补齐 —— 有内容渲染内容,没内容才落空态

任务面原来缺两处占位:中栏是一行裸文字,对话区在没选中任务时**整栏不渲染**。
补齐后三栏恒在:中栏落设计空态「手上没有任务」,对话区给全壳(题头 +
空态 + 禁用输入条),空着也看得出这是干什么的地方。

但真起一单之后暴露了更重的问题:占位在**说谎**。待处理角标写着 1,正文却
说「手上没有待办」—— 因为 ChatStream(643 行)与 WorkOrderReport(449 行)
早就写好、也从 @features/desk-task 导出了,却没有一处在用。占位不是缺内容,
是内容没接上。三处接回真渲染:

  待处理  → WorkOrderReport(工单正文、编号、派发时间、数据与分析)
  工作日志 → .jc-log 行,按归侧四类各一色(机器/人类/裁决/等世界)
  工作成果 → .jc-file 行,点开进 FileViewer;打开时右槽换「返回」
  对话区  → ChatStream,三个回传(说一句 / 答卡 / 答工单)在这里收口

顺带治了窄对话区的抗压:工作区优先保「五按钮 + 翻页」,对话区在 1280 下被
压到 212px,卡头标题 `flex:1` 没设 `min-width:0`,一挤就逐字折断成竖排单字。
降级顺序改成「状态芯片换行下沉、标题始终整词可读」—— 截断能让布局不崩,
但读的人不知道这是什么卡。不改任何一栏尺寸。

五闸:tsc ✓ eslint ✓ depcruise 373 模块 ✓ api:check 公开契约无变化 ✓ 481 测试 ✓
走查后清了运行数据(仓库自带 reset-runs,配置原样留着),换业务化主题重起一单。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-28 10:25:19 +08:00
co-authored by Claude Opus 5
parent 5619d86ce4
commit c59e73754b
4 changed files with 243 additions and 62 deletions
+64 -8
View File
@@ -8,12 +8,13 @@
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { Session, TaskCard, TaskDetail } from '@contracts'; import type { Session, TaskCard, TaskDetail, WorkOrderReply } from '@contracts';
import { import {
listApprovalsForMeRoute, listApprovalsForMeRoute,
listMessagesRoute, listMessagesRoute,
listTasksRoute, listTasksRoute,
logoutRoute, logoutRoute,
replyWorkOrderRoute,
sessionRoute, sessionRoute,
taskDetailRoute, taskDetailRoute,
} from '@contracts'; } from '@contracts';
@@ -25,6 +26,7 @@ import { ReportDesk } from '@features/desk-report';
import { Ico } from '@web-shared/Ico.tsx'; import { Ico } from '@web-shared/Ico.tsx';
import { call } from '@web-shared/typed-fetch.ts'; import { call } from '@web-shared/typed-fetch.ts';
import { useChatWidth } from '@web-shared/useChatWidth.ts'; import { useChatWidth } from '@web-shared/useChatWidth.ts';
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
import { SURFACE_META, canSeeSurface } from '@web-shared/gov/surfaces.ts'; import { SURFACE_META, canSeeSurface } from '@web-shared/gov/surfaces.ts';
import { queryKeys } from './query.ts'; import { queryKeys } from './query.ts';
@@ -91,6 +93,17 @@ export function AppShell() {
const me = session.data?.staff.name ?? '—'; const me = session.data?.staff.name ?? '—';
/**
* 工单回传 —— 视图区与对话区同一条口。
* 回传后按键失效、画面由服务端重投影:前端不自行推演卡与工单的状态(§9.2)。
*/
const replyOrder = useMutation({
mutationFn: (reply: WorkOrderReply) => call(replyWorkOrderRoute, { body: { reply } }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['desk'] });
},
});
return ( return (
<div className="app-shell"> <div className="app-shell">
<div className="app-body"> <div className="app-body">
@@ -186,29 +199,72 @@ export function AppShell() {
onPick={setTaskId} onPick={setTaskId}
onCollapse={() => setCollapsed(true)} onCollapse={() => setCollapsed(true)}
/> />
{/*
手上没任务时,三栏也得**站得住**:中栏与对话区各给自己的空态。
少一栏会让整个工位看着像坏了 —— 空态是常态,不是缺页。
*/}
{detail.data !== undefined ? ( {detail.data !== undefined ? (
<ViewRegion key={current} detail={detail.data} /> <ViewRegion
key={current}
detail={detail.data}
onReplyOrder={(reply) => replyOrder.mutate(reply)}
/>
) : ( ) : (
<main className="execution-panel"> <main className="execution-panel">
{tasks.isLoading ? (
<div className="jc-list-empty"> <div className="jc-list-empty">
<Ico name="list-checks" s={18} /> <Ico name="list-checks" s={18} />
{tasks.isLoading ? '载入中…' : '还没有落到你工位的任务'} 载入中…
</div> </div>
) : (
<EmptyState
ico="list-checks"
tone="brand"
title="手上没有任务"
desc="流程走到你负责的活动时,任务会连同办这件事要用的全部上下文一起送到这里 —— 待处理的事、往来记录、产出的文件都在一屏之内。"
hint="有新任务时左侧列表会亮角标"
/>
)}
</main> </main>
)} )}
{detail.data !== undefined && (
<i <i
className="jc-vsplit" className="jc-vsplit"
title="拖拽调整对话区宽度 · 双击复位" title="拖拽调整对话区宽度 · 双击复位"
onPointerDown={split.onSplitDown} onPointerDown={split.onSplitDown}
onDoubleClick={split.onSplitReset} onDoubleClick={split.onSplitReset}
/> />
)}
{detail.data !== undefined && (
<section className="session-chat"> <section className="session-chat">
<TaskChat key={current} /> {detail.data !== undefined ? (
</section> <TaskChat key={current} detail={detail.data} onLocate={() => undefined} />
) : (
<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">
<EmptyState
ico="messages-square"
tone="brand"
title="还没有可对话的任务"
desc="每一单任务都有自己的对话:AI 有结论、要派工、或者要你拿个主意,都在那一单里说 —— 所以得先有一单在手。"
/>
</div>
<div className="copilot-composer session-composer">
<div className="composer-row">
<textarea rows={2} disabled placeholder="选中一单任务后就能在这里交代" />
<button className="copilot-send" disabled>
<Ico name="send" s={14} /> 发送
</button>
</div>
</div>
</div>
)} )}
</section>
</div> </div>
) : module === 'approvals' ? ( ) : module === 'approvals' ? (
<ApprovalDesk /> <ApprovalDesk />
+28
View File
@@ -2922,3 +2922,31 @@
.proto-root .jc-tnav-btn.back svg { .proto-root .jc-tnav-btn.back svg {
transform: translateY(-2px); transform: translateY(-2px);
} }
/* ── 工作日志(补充,非原型)—— 一行一步,标明谁的产出 ────────────────── */
.proto-root .jc-log { display: flex; flex-direction: column; }
.proto-root .jc-log-row { display: flex; gap: 10px; align-items: baseline; padding: 8px 2px; }
.proto-root .jc-log-row + .jc-log-row { border-top: 1px solid var(--line-soft); }
.proto-root .jc-log-at { flex: none; font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-log-side { flex: none; font-size: 10.5px; font-weight: 700; padding: 1px 7px; border-radius: 999px; background: var(--soft); color: var(--ink-2); }
/* 归侧四类各一色:机器 / 人 / 人裁 / 世界等待 —— 一眼看出这一步是谁推的 */
.proto-root .jc-log-row.t-machine .jc-log-side { background: var(--brand-wash); color: var(--brand-deep); }
.proto-root .jc-log-row.t-human .jc-log-side { background: #e6f7f4; color: #0a6a64; }
.proto-root .jc-log-row.t-human-verdict .jc-log-side { background: #fff4e5; color: #de7802; }
.proto-root .jc-log-row.t-world-wait .jc-log-side { background: var(--soft); color: var(--ink-3); }
.proto-root .jc-log-t { flex: 1; min-width: 0; }
.proto-root .jc-log-t b { display: block; font-size: 12.5px; font-weight: 600; }
.proto-root .jc-log-t em { display: block; margin-top: 2px; font-style: normal; font-size: 11px; line-height: 1.6; color: var(--ink-3); }
/* 成果二级界面的返回键 —— 顶部页签不变,右槽换成它 */
.proto-root .jc-tnav-back { width: auto; padding: 0 9px; gap: 5px; font-size: 11.5px; font-weight: 600; }
/* ── 窄对话区的抗压(补充,非原型)────────────────────────────────────
对话区在小屏上会被压到 ~210px(工作区优先保「五按钮 + 翻页」,任务列表可收起让位)。
卡片原样搬过来是不抗压的:卡头标题 `flex: 1` 没设 `min-width: 0`,一挤就**逐字折断**
成竖排单字;按钮同理。这里只教卡片怎么变窄,不改任何一栏的尺寸。
降级的顺序是:先让状态芯片换行下沉(标题拿回整行),标题始终整词可读 —— 而不是把
「工单 · 执行」截成一个「工」字。截断能让布局不崩,但读的人不知道这是什么卡。 */
.proto-root .jc-fc-h { flex-wrap: wrap; row-gap: 3px; }
.proto-root .jc-fc-h b { min-width: 0; flex: 1 1 auto; }
.proto-root .jc-fc-open { white-space: nowrap; }
@@ -1,12 +1,44 @@
/** /**
* 对话区 —— 「对话流是**一切回环输入的唯一入口**」(OS 4.1)。 * 对话区 —— 「对话流是**一切回环输入的唯一入口**」(OS 4.1)。
* *
* 壳(`jc-tch` 题头 / 滚动区 / composer)照原型留着;气泡与卡族备在 `chat-bubbles.tsx`。 * 壳(`jc-tch` 题头 / 滚动区 / composer)由 `ChatStream` 自己带;这里只做取数与回传:
* 空态文案从用人的角度写 —— 空对话是这一单刚开始时的**常态**,不是缺陷。 * 说一句、答卡、答工单,回传后按键失效,画面由服务端重投影(前端零过程状态)。
*
* **有内容就渲染内容,没内容才落空态** —— 空对话是这一单刚开始时的常态,不是缺陷;
* 但一旦真有话,占位就变成了谎:角标说有、正文说没有,人会以为坏了。
*/ */
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { ChatCardReply, TaskDetail, WorkOrderReply } from '@contracts';
import { replyChatCardRoute, replyWorkOrderRoute, sayRoute } from '@contracts';
import { call } from '@web-shared/typed-fetch.ts';
import { Ico } from '@web-shared/Ico.tsx'; import { Ico } from '@web-shared/Ico.tsx';
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
import { ChatStream } from './chat-bubbles.tsx';
export function TaskChat() { export function TaskChat({ detail, onLocate }: { detail: TaskDetail; onLocate: (orderId: string) => void }) {
const queryClient = useQueryClient();
const taskId = detail.card.id;
/** 回传后一律重取任务投影 —— 卡与工单的状态由服务端说,前端不自行推演 */
const refresh = () => {
void queryClient.invalidateQueries({ queryKey: ['desk', 'task', taskId] });
void queryClient.invalidateQueries({ queryKey: ['desk', 'tasks'] });
};
const say = useMutation({
mutationFn: (text: string) => call(sayRoute, { params: { taskId }, body: { text } }),
onSuccess: refresh,
});
const replyCard = useMutation({
mutationFn: (reply: ChatCardReply) => call(replyChatCardRoute, { body: { reply } }),
onSuccess: refresh,
});
const replyOrder = useMutation({
mutationFn: (reply: WorkOrderReply) => call(replyWorkOrderRoute, { body: { reply } }),
onSuccess: refresh,
});
if (detail.chat.length === 0) {
return ( return (
<div className="jc-tch"> <div className="jc-tch">
<header className="jc-tch-head"> <header className="jc-tch-head">
@@ -15,31 +47,49 @@ export function TaskChat() {
</span> </span>
<b className="jc-tch-name">任务助理</b> <b className="jc-tch-name">任务助理</b>
<em className="jc-tch-tag">Agent</em> <em className="jc-tch-tag">Agent</em>
<span className="jc-tch-desc">盯着这单推进 · 有事随时说</span> <span className="jc-tch-desc">盯着这单推进</span>
</header> </header>
<div className="jc-tch-scroll"> <div className="jc-tch-scroll">
<div className="jc-empty t-brand"> <EmptyState
<span className="jc-empty-art"> ico="messages-square"
<i className="r1" /> tone="brand"
<i className="r2" /> title="还没有消息"
<span className="jc-empty-ico"> desc="AI 有结论、要派工、或者要你拿个主意,都会在这里说。你也可以随时交代两句。"
<Ico name="messages-square" s={26} /> />
</span>
</span>
<b>还没有消息</b>
<p>AI 有结论、要派工、或者要你拿个主意,都会在这里说。你也可以随时交代两句。</p>
</div> </div>
</div>
<div className="copilot-composer session-composer"> <div className="copilot-composer session-composer">
<div className="composer-row"> <div className="composer-row">
<textarea rows={2} disabled placeholder="有什么要补充或交代的?" /> <textarea
<button className="copilot-send" disabled> rows={2}
placeholder="有什么要补充或交代的?"
onKeyDown={(e) => {
if (e.key !== 'Enter' || e.shiftKey) return;
e.preventDefault();
const text = e.currentTarget.value.trim();
if (text !== '') {
say.mutate(text);
e.currentTarget.value = '';
}
}}
/>
<button className="copilot-send" disabled={say.isPending}>
<Ico name="send" s={14} /> 发送 <Ico name="send" s={14} /> 发送
</button> </button>
</div> </div>
</div> </div>
</div> </div>
); );
}
return (
<ChatStream
chat={detail.chat}
orders={detail.workOrders}
thinking={say.isPending}
onSay={(text) => say.mutate(text)}
onReplyCard={(reply) => replyCard.mutate(reply)}
onReplyOrder={(reply) => replyOrder.mutate(reply)}
onLocate={onLocate}
/>
);
} }
@@ -11,24 +11,31 @@
* 不写「这块还没做」—— 后者是开发视角,用户不关心,也看不懂。 * 不写「这块还没做」—— 后者是开发视角,用户不关心,也看不懂。
*/ */
import { useState } from 'react'; import { useState } from 'react';
import type { TaskDetail } from '@contracts'; import type { FileDoc, TaskDetail, WorkOrderReply } from '@contracts';
import { TERMINAL_STATE_LABEL } from '@contracts'; import { TERMINAL_STATE_LABEL } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx'; import { Ico } from '@web-shared/Ico.tsx';
import ScrollArea from '@web-shared/ScrollArea.tsx'; import ScrollArea from '@web-shared/ScrollArea.tsx';
import { EmptyState } from '@web-shared/desk/module-kit.tsx'; import { EmptyState } from '@web-shared/desk/module-kit.tsx';
import { ViewBlocks } from '@ui'; import { ViewBlocks } from '@ui';
import { WorkOrderReport } from './work-order-report.tsx';
import { FileViewer } from './FileViewer.tsx';
type Tab = 'open' | 'comms' | 'done' | 'wf' | 'log'; type Tab = 'open' | 'comms' | 'done' | 'wf' | 'log';
export type ViewRegionProps = { export type ViewRegionProps = {
readonly detail: TaskDetail; readonly detail: TaskDetail;
/** 工单回传 —— 由壳层接路由,视图区只管把人填的东西交出去 */
onReplyOrder: (reply: WorkOrderReply) => void;
}; };
export function ViewRegion({ detail }: ViewRegionProps) { export function ViewRegion({ detail, onReplyOrder }: ViewRegionProps) {
const [view, setView] = useState<Tab>('open'); const [view, setView] = useState<Tab>('open');
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
/** 表单值是**过程状态**,只活在这一次填写里:回传成功后画面由服务端重投影 */
const [values, setValues] = useState<Record<string, string>>({});
const [openFile, setOpenFile] = useState<FileDoc | undefined>(undefined);
const orders = detail.workOrders; const orders = detail.workOrders;
const openList = orders.filter((o) => o.done === undefined); const openList = orders.filter((o) => o.done === undefined);
@@ -140,7 +147,15 @@ export function ViewRegion({ detail }: ViewRegionProps) {
翻页**恒显示**:只有一件也出 1/1,两侧箭头灰显。 翻页**恒显示**:只有一件也出 1/1,两侧箭头灰显。
按件数隐藏会让点位条忽有忽无,右侧跟着跳 —— 页签条要定得住。 按件数隐藏会让点位条忽有忽无,右侧跟着跳 —— 页签条要定得住。
*/} */}
{view === 'open' && ( {/*
右槽两态:成果进二级界面时是「返回」,否则待处理位上是翻页。
顶部五个按钮**始终不变** —— 二级界面长在工作区内部,不是另一页。
*/}
{openFile !== undefined ? (
<button type="button" className="jc-tnav-btn jc-tnav-back" onClick={() => setOpenFile(undefined)}>
<Ico name="arrow-left" s={14} /> 返回
</button>
) : view === 'open' ? (
<span className="jc-tnav-pg" aria-label="翻页"> <span className="jc-tnav-pg" aria-label="翻页">
<button <button
type="button" type="button"
@@ -164,7 +179,7 @@ export function ViewRegion({ detail }: ViewRegionProps) {
<Ico name="chevron-right" s={15} /> <Ico name="chevron-right" s={15} />
</button> </button>
</span> </span>
)} ) : null}
</div> </div>
{/* key:换件 / 换页签即重挂载滚动区,回到正文顶部 */} {/* key:换件 / 换页签即重挂载滚动区,回到正文顶部 */}
@@ -174,7 +189,9 @@ export function ViewRegion({ detail }: ViewRegionProps) {
vpClassName="wf-body" vpClassName="wf-body"
inset={-6} inset={-6}
> >
{view === 'comms' ? ( {openFile !== undefined ? (
<FileViewer doc={openFile} />
) : view === 'comms' ? (
/* 占位:与外界的往来(邮件线程等)还没接进来 —— 明说没接,不装成「暂无数据」 */ /* 占位:与外界的往来(邮件线程等)还没接进来 —— 明说没接,不装成「暂无数据」 */
<EmptyState <EmptyState
ico="mail" ico="mail"
@@ -185,12 +202,27 @@ export function ViewRegion({ detail }: ViewRegionProps) {
) : view === 'wf' ? ( ) : view === 'wf' ? (
<WfDetail detail={detail} openN={openList.length} doneN={orders.length - openList.length} /> <WfDetail detail={detail} openN={openList.length} doneN={orders.length - openList.length} />
) : view === 'log' ? ( ) : view === 'log' ? (
detail.log.length > 0 ? (
<div className="jc-log">
{detail.log.map((line) => (
<div key={line.id} className={'jc-log-row t-' + line.side}>
<span className="jc-log-at jc-mono">{line.at.slice(5, 16).replace('T', ' ')}</span>
<span className="jc-log-side">{line.sideLabel}</span>
<span className="jc-log-t">
<b>{line.title}</b>
{line.summary !== undefined && <em>{line.summary}</em>}
</span>
</div>
))}
</div>
) : (
<EmptyState <EmptyState
ico="archive" ico="archive"
tone="brand" tone="brand"
title="还没有进展" title="还没有进展"
desc="AI 每推进一步都记在这里,并标明是谁的产出:AI 完成、我填的、我确认的、等外部。" desc="AI 每推进一步都记在这里,并标明是谁的产出:AI 完成、我填的、我确认的、等外部。"
/> />
)
) : view === 'done' ? ( ) : view === 'done' ? (
<EmptyState <EmptyState
ico="archive" ico="archive"
@@ -198,6 +230,21 @@ export function ViewRegion({ detail }: ViewRegionProps) {
title="还没有成果" title="还没有成果"
desc="这一单产出的报告与文件会归档在这里,一行一份、点开整篇看。" desc="这一单产出的报告与文件会归档在这里,一行一份、点开整篇看。"
/> />
) : cur !== undefined ? (
/*
有工单就把工单摆出来 —— 角标说有 1 件、正文说「手上没有待办」,那是占位在说谎。
办这张单要用的上下文(活动推的那一屏、依据、表单)由 WorkOrderReport 一并铺开。
*/
<WorkOrderReport
order={cur}
detail={detail}
typeLine={cur.mode === 'acceptance' ? '验收工单 · 终局唯放行' : '执行工单 · 作业指导书 + 表单'}
blocks={cur.mode === 'execution' ? (cur.blocks ?? detail.view) : detail.view}
{...(detail.viewHtml === undefined ? {} : { html: detail.viewHtml, srcTitle: detail.card.activity })}
values={values}
onField={(k, v) => setValues((old) => ({ ...old, [k]: v }))}
onReply={onReplyOrder}
/>
) : ( ) : (
<EmptyState <EmptyState
ico="list-checks" ico="list-checks"