diff --git a/apps/web/src/app/app-shell.tsx b/apps/web/src/app/app-shell.tsx
index af1a7ad..efd9743 100644
--- a/apps/web/src/app/app-shell.tsx
+++ b/apps/web/src/app/app-shell.tsx
@@ -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 (
@@ -186,29 +199,72 @@ export function AppShell() {
onPick={setTaskId}
onCollapse={() => setCollapsed(true)}
/>
+ {/*
+ 手上没任务时,三栏也得**站得住**:中栏与对话区各给自己的空态。
+ 少一栏会让整个工位看着像坏了 —— 空态是常态,不是缺页。
+ */}
{detail.data !== undefined ? (
-
+
replyOrder.mutate(reply)}
+ />
) : (
-
-
- {tasks.isLoading ? '载入中…' : '还没有落到你工位的任务'}
-
+ {tasks.isLoading ? (
+
+
+ 载入中…
+
+ ) : (
+
+ )}
)}
- {detail.data !== undefined && (
-
- )}
- {detail.data !== undefined && (
-
- )}
+
+
+ {detail.data !== undefined ? (
+ undefined} />
+ ) : (
+
+
+
+
+
+ 任务助理
+ Agent
+ 开一单即可对话
+
+
+
+
+
+
+ )}
+
) : module === 'approvals' ? (
diff --git a/apps/web/src/app/styles/app.css b/apps/web/src/app/styles/app.css
index 9c3270d..98e6d5c 100644
--- a/apps/web/src/app/styles/app.css
+++ b/apps/web/src/app/styles/app.css
@@ -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; }
diff --git a/apps/web/src/features/desk-task/internal/TaskChat.tsx b/apps/web/src/features/desk-task/internal/TaskChat.tsx
index 5692ac0..2ab7285 100644
--- a/apps/web/src/features/desk-task/internal/TaskChat.tsx
+++ b/apps/web/src/features/desk-task/internal/TaskChat.tsx
@@ -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 (
-
-
-
-
-
- 任务助理
- Agent
- 盯着这单推进 · 有事随时说
-
+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 (
+
say.mutate(text)}
+ onReplyCard={(reply) => replyCard.mutate(reply)}
+ onReplyOrder={(reply) => replyOrder.mutate(reply)}
+ onLocate={onLocate}
+ />
);
}
diff --git a/apps/web/src/features/desk-task/internal/ViewRegion.tsx b/apps/web/src/features/desk-task/internal/ViewRegion.tsx
index 6d043f6..e9d181a 100644
--- a/apps/web/src/features/desk-task/internal/ViewRegion.tsx
+++ b/apps/web/src/features/desk-task/internal/ViewRegion.tsx
@@ -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('open');
const [page, setPage] = useState(0);
+ /** 表单值是**过程状态**,只活在这一次填写里:回传成功后画面由服务端重投影 */
+ const [values, setValues] = useState>({});
+ const [openFile, setOpenFile] = useState(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 ? (
+
+ ) : view === 'open' ? (
- )}
+ ) : null}
{/* key:换件 / 换页签即重挂载滚动区,回到正文顶部 */}
@@ -174,7 +189,9 @@ export function ViewRegion({ detail }: ViewRegionProps) {
vpClassName="wf-body"
inset={-6}
>
- {view === 'comms' ? (
+ {openFile !== undefined ? (
+
+ ) : view === 'comms' ? (
/* 占位:与外界的往来(邮件线程等)还没接进来 —— 明说没接,不装成「暂无数据」 */
) : view === 'log' ? (
-
+ detail.log.length > 0 ? (
+
+ {detail.log.map((line) => (
+
+ {line.at.slice(5, 16).replace('T', ' ')}
+ {line.sideLabel}
+
+ {line.title}
+ {line.summary !== undefined && {line.summary}}
+
+
+ ))}
+
+ ) : (
+
+ )
) : view === 'done' ? (
+ ) : cur !== undefined ? (
+ /*
+ 有工单就把工单摆出来 —— 角标说有 1 件、正文说「手上没有待办」,那是占位在说谎。
+ 办这张单要用的上下文(活动推的那一屏、依据、表单)由 WorkOrderReport 一并铺开。
+ */
+
setValues((old) => ({ ...old, [k]: v }))}
+ onReply={onReplyOrder}
+ />
) : (