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:
@@ -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