Archived
工作台空占位补齐 —— 有内容渲染内容,没内容才落空态
任务面原来缺两处占位:中栏是一行裸文字,对话区在没选中任务时**整栏不渲染**。 补齐后三栏恒在:中栏落设计空态「手上没有任务」,对话区给全壳(题头 + 空态 + 禁用输入条),空着也看得出这是干什么的地方。 但真起一单之后暴露了更重的问题:占位在**说谎**。待处理角标写着 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:
@@ -8,12 +8,13 @@
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Session, TaskCard, TaskDetail } from '@contracts';
|
||||
import type { Session, TaskCard, TaskDetail, WorkOrderReply } from '@contracts';
|
||||
import {
|
||||
listApprovalsForMeRoute,
|
||||
listMessagesRoute,
|
||||
listTasksRoute,
|
||||
logoutRoute,
|
||||
replyWorkOrderRoute,
|
||||
sessionRoute,
|
||||
taskDetailRoute,
|
||||
} from '@contracts';
|
||||
@@ -25,6 +26,7 @@ import { ReportDesk } from '@features/desk-report';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { call } from '@web-shared/typed-fetch.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 { queryKeys } from './query.ts';
|
||||
|
||||
@@ -91,6 +93,17 @@ export function AppShell() {
|
||||
|
||||
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 (
|
||||
<div className="app-shell">
|
||||
<div className="app-body">
|
||||
@@ -186,29 +199,72 @@ export function AppShell() {
|
||||
onPick={setTaskId}
|
||||
onCollapse={() => setCollapsed(true)}
|
||||
/>
|
||||
{/*
|
||||
手上没任务时,三栏也得**站得住**:中栏与对话区各给自己的空态。
|
||||
少一栏会让整个工位看着像坏了 —— 空态是常态,不是缺页。
|
||||
*/}
|
||||
{detail.data !== undefined ? (
|
||||
<ViewRegion key={current} detail={detail.data} />
|
||||
<ViewRegion
|
||||
key={current}
|
||||
detail={detail.data}
|
||||
onReplyOrder={(reply) => replyOrder.mutate(reply)}
|
||||
/>
|
||||
) : (
|
||||
<main className="execution-panel">
|
||||
<div className="jc-list-empty">
|
||||
<Ico name="list-checks" s={18} />
|
||||
{tasks.isLoading ? '载入中…' : '还没有落到你工位的任务'}
|
||||
</div>
|
||||
{tasks.isLoading ? (
|
||||
<div className="jc-list-empty">
|
||||
<Ico name="list-checks" s={18} />
|
||||
载入中…
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
ico="list-checks"
|
||||
tone="brand"
|
||||
title="手上没有任务"
|
||||
desc="流程走到你负责的活动时,任务会连同办这件事要用的全部上下文一起送到这里 —— 待处理的事、往来记录、产出的文件都在一屏之内。"
|
||||
hint="有新任务时左侧列表会亮角标"
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)}
|
||||
{detail.data !== undefined && (
|
||||
<i
|
||||
className="jc-vsplit"
|
||||
title="拖拽调整对话区宽度 · 双击复位"
|
||||
onPointerDown={split.onSplitDown}
|
||||
onDoubleClick={split.onSplitReset}
|
||||
/>
|
||||
)}
|
||||
{detail.data !== undefined && (
|
||||
<section className="session-chat">
|
||||
<TaskChat key={current} />
|
||||
</section>
|
||||
)}
|
||||
<i
|
||||
className="jc-vsplit"
|
||||
title="拖拽调整对话区宽度 · 双击复位"
|
||||
onPointerDown={split.onSplitDown}
|
||||
onDoubleClick={split.onSplitReset}
|
||||
/>
|
||||
<section className="session-chat">
|
||||
{detail.data !== undefined ? (
|
||||
<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>
|
||||
) : module === 'approvals' ? (
|
||||
<ApprovalDesk />
|
||||
|
||||
@@ -2922,3 +2922,31 @@
|
||||
.proto-root .jc-tnav-btn.back svg {
|
||||
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,45 +1,95 @@
|
||||
/**
|
||||
* 对话区 —— 「对话流是**一切回环输入的唯一入口**」(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 { EmptyState } from '@web-shared/desk/module-kit.tsx';
|
||||
import { ChatStream } from './chat-bubbles.tsx';
|
||||
|
||||
export function TaskChat() {
|
||||
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>
|
||||
export function TaskChat({ detail, onLocate }: { detail: TaskDetail; onLocate: (orderId: string) => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const taskId = detail.card.id;
|
||||
|
||||
<div className="jc-tch-scroll">
|
||||
<div className="jc-empty t-brand">
|
||||
<span className="jc-empty-art">
|
||||
<i className="r1" />
|
||||
<i className="r2" />
|
||||
<span className="jc-empty-ico">
|
||||
<Ico name="messages-square" s={26} />
|
||||
</span>
|
||||
/** 回传后一律重取任务投影 —— 卡与工单的状态由服务端说,前端不自行推演 */
|
||||
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 (
|
||||
<div className="jc-tch">
|
||||
<header className="jc-tch-head">
|
||||
<span className="jc-tch-mark">
|
||||
<Ico name="bot" s={15} />
|
||||
</span>
|
||||
<b>还没有消息</b>
|
||||
<p>AI 有结论、要派工、或者要你拿个主意,都会在这里说。你也可以随时交代两句。</p>
|
||||
<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}
|
||||
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} /> 发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
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 type { TaskDetail } from '@contracts';
|
||||
import type { FileDoc, TaskDetail, WorkOrderReply } from '@contracts';
|
||||
import { TERMINAL_STATE_LABEL } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
|
||||
import { ViewBlocks } from '@ui';
|
||||
import { WorkOrderReport } from './work-order-report.tsx';
|
||||
import { FileViewer } from './FileViewer.tsx';
|
||||
|
||||
|
||||
type Tab = 'open' | 'comms' | 'done' | 'wf' | 'log';
|
||||
|
||||
export type ViewRegionProps = {
|
||||
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 [page, setPage] = useState(0);
|
||||
/** 表单值是**过程状态**,只活在这一次填写里:回传成功后画面由服务端重投影 */
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [openFile, setOpenFile] = useState<FileDoc | undefined>(undefined);
|
||||
|
||||
const orders = detail.workOrders;
|
||||
const openList = orders.filter((o) => o.done === undefined);
|
||||
@@ -140,7 +147,15 @@ export function ViewRegion({ detail }: ViewRegionProps) {
|
||||
翻页**恒显示**:只有一件也出 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="翻页">
|
||||
<button
|
||||
type="button"
|
||||
@@ -164,7 +179,7 @@ export function ViewRegion({ detail }: ViewRegionProps) {
|
||||
<Ico name="chevron-right" s={15} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* key:换件 / 换页签即重挂载滚动区,回到正文顶部 */}
|
||||
@@ -174,7 +189,9 @@ export function ViewRegion({ detail }: ViewRegionProps) {
|
||||
vpClassName="wf-body"
|
||||
inset={-6}
|
||||
>
|
||||
{view === 'comms' ? (
|
||||
{openFile !== undefined ? (
|
||||
<FileViewer doc={openFile} />
|
||||
) : view === 'comms' ? (
|
||||
/* 占位:与外界的往来(邮件线程等)还没接进来 —— 明说没接,不装成「暂无数据」 */
|
||||
<EmptyState
|
||||
ico="mail"
|
||||
@@ -185,12 +202,27 @@ export function ViewRegion({ detail }: ViewRegionProps) {
|
||||
) : view === 'wf' ? (
|
||||
<WfDetail detail={detail} openN={openList.length} doneN={orders.length - openList.length} />
|
||||
) : view === 'log' ? (
|
||||
<EmptyState
|
||||
ico="archive"
|
||||
tone="brand"
|
||||
title="还没有进展"
|
||||
desc="AI 每推进一步都记在这里,并标明是谁的产出:AI 完成、我填的、我确认的、等外部。"
|
||||
/>
|
||||
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
|
||||
ico="archive"
|
||||
tone="brand"
|
||||
title="还没有进展"
|
||||
desc="AI 每推进一步都记在这里,并标明是谁的产出:AI 完成、我填的、我确认的、等外部。"
|
||||
/>
|
||||
)
|
||||
) : view === 'done' ? (
|
||||
<EmptyState
|
||||
ico="archive"
|
||||
@@ -198,6 +230,21 @@ export function ViewRegion({ detail }: ViewRegionProps) {
|
||||
title="还没有成果"
|
||||
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
|
||||
ico="list-checks"
|
||||
|
||||
Reference in New Issue
Block a user