Archived
用户:「在中央工作区修改了内容点击保存后,为什么会出现『AI 正在推进这单』的提示?」 因果链:件上按保存 → afterSave **先重推件、再叫醒任务**(「保存即铺格」,2026-08-29: 人选了芯片类型按保存,配方这才有机会把参数行铺出来;不叫醒格子永远不出现,08-30 走查在这儿卡死过)→ wake 把 runtime 拨成 running → 工作区的 running 判据排在 viewHtml 前面(2026-08-21:正在跑的这一拍要压过上一拍摆下的**陈旧**的件)→ 人刚填完按下保存, 屏上那份刚重推、含着他刚填的值的件被推进场景顶掉几秒。 08-21 那条规则的前提是「viewHtml 是上一次推的,可能陈旧」,而这条路上件比 running 还新,前提不成立;更要紧的是那一拍按「叫了人就等人」的闸(turn-runner,08-27) **根本不问模型**,而那一屏写着「看材料、写台账、调工具都由它来」——一件没发生, 界面在撒谎。 修:running 那一支加 `&& !holding`。判断收进纯函数 `awaitingHuman`(未办结工单 / 未选决策卡 / 未确认录入卡),与服务端 humanTool.pendingCards 说的是同一句话 —— **有未答的卡,服务端就不问模型,前端就不说 AI 在推进**,两侧同一条道理的两面。 没有任何卡在等人、AI 真在埋头算时,推进场景照旧摆(2026-08-21 要的正是它)。 6 条新测,四项各做变异验证(去掉工单项 / 决策卡项 / 录入卡项各红一条,恒真红两条 —— 恒真等于把推进场景整个废掉);pnpm gates 退出码 0(2493 测)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
1182 lines
56 KiB
TypeScript
1182 lines
56 KiB
TypeScript
/**
|
|
* 标题区 + 工作区 —— 自原型 `_components/ViewRegion.tsx` 平移(2026-07-26 定稿)。
|
|
*
|
|
* 「视图区**承担全部结构化交互**」:表单读写 + 过程成果的图形展示,全在这里完成;
|
|
* 决策不在这里 —— 它是对话区的选择题(AM 07)。
|
|
*
|
|
* 五页签:待处理 / 沟通记录 / 工作成果 / 任务详情 / 工作日志。
|
|
* DOM 与类名逐字照搬原型 —— 皮肤是原型的原样,自造类名等于没样式。
|
|
*
|
|
* **空态即常态**:文案从用人的角度写「你手上现在没有什么、将会有什么」,
|
|
* 不写「这块还没做」—— 后者是开发视角,用户不关心,也看不懂。
|
|
*/
|
|
import { useState } from 'react';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { markCommReadRoute, openAssetFileRoute, saveTableRoute } from '@contracts';
|
|
import { call } from '@web-shared/typed-fetch.ts';
|
|
import type { FileDoc, TaskDetail, WorkOrder, WorkOrderReply } from '@contracts';
|
|
import { TERMINAL_STATE_LABEL, isRejection } 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, ViewContainer } from '@ui';
|
|
import { pagerFace, pagerText } from './pager.ts';
|
|
import { awaitingHuman, pendingCardsOf } from './pending-cards.ts';
|
|
import type { ViewEdits, ViewSave } from '@ui';
|
|
import { GuideCard, WorkOrderReport } from './work-order-report.tsx';
|
|
import { FileViewer } from './FileViewer.tsx';
|
|
import { atFull, atMinute } from '@web-shared/at.ts';
|
|
import { Btn } from '@web-shared/Btn.tsx';
|
|
|
|
|
|
type Tab = 'open' | 'comms' | 'done' | 'wf' | 'log';
|
|
|
|
export type ViewRegionProps = {
|
|
readonly detail: TaskDetail;
|
|
/** 工单回传 —— 由壳层接路由,视图区只管把人填的东西交出去 */
|
|
onReplyOrder: (reply: WorkOrderReply) => void;
|
|
/**
|
|
* 填报值由**壳层持有** —— 不是这一份的局部状态。
|
|
*
|
|
* PRD 的口径:验收工单「AI 已填好、可编辑」,而**裁决在对话区的验收单上**。
|
|
* 编辑与裁决是同一件事的两半,却分在两个区里做 —— 缓冲只活在工作区里,
|
|
* 人在报告上改的值就到不了裁决那一刻:验收回传的 `values` 恒空,修订永远落不下来。
|
|
*/
|
|
readonly values: Readonly<Record<string, string>>;
|
|
onField: (key: string, value: string) => void;
|
|
/**
|
|
* 对话区点了「打开工单」→ 视图区跳转定位(PRD:locate)。
|
|
* `seq` 每点一次递增 —— 同一张卡点两次也要再跳一次(人就是想回到它)。
|
|
* 待办的跳「待处理」并翻到那一页;办结的跳「工作成果」并打开那份定稿报告。
|
|
*/
|
|
/*
|
|
* `orderId` 跳工单;`tab` 直接跳页签 —— 起单那句系统开场白的「查看邮件」用后者
|
|
* (它指的不是某张工单,是那封来件本身)。
|
|
*/
|
|
readonly locate?: { seq: number; orderId?: string; tab?: Tab } | null;
|
|
};
|
|
|
|
|
|
/**
|
|
* 件上改出来的行 → 回传载荷 —— **按台账地址对上工单声明的那张表**。
|
|
*
|
|
* 件里那几张可改的表各挂着 `data-edit="域.对象"`;工单的 `blocks[i]` 声明了
|
|
* `ledger`(写哪张表)与 `fields`(列在哪个位置)。这里按地址找到那一块,
|
|
* 再**照它的 `fields` 顺序**摆格子 —— 于是回传仍是老样子 `[列名, ...行]`,
|
|
* 服务端那道写回(`applyBoundTableEdits`)一个字都不用改。
|
|
*
|
|
* 按地址找而不是按位置找:件与卡是两份东西,位置对齐迟早会错开一位,
|
|
* 而错开一位的后果是把年用量写进目标价那一列 —— 那种错没人看得出来。
|
|
*/
|
|
function collectEdits(edits: ViewEdits, order: WorkOrder, onField: (k: string, v: string) => void): void {
|
|
const blocks = order.blocks ?? [];
|
|
for (const [address, rows] of Object.entries(edits)) {
|
|
const at = blocks.findIndex((b) => b.t === 'table' && b.ledger === address);
|
|
const block = blocks[at];
|
|
if (at < 0 || block === undefined || block.t !== 'table') continue;
|
|
const fields = block.fields ?? [];
|
|
if (fields.length === 0) continue;
|
|
const table = [block.cols, ...rows.map((row) => fields.map((f) => row[f] ?? ''))];
|
|
onField(`table:${at}`, JSON.stringify(table));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 右上角那对箭头 —— **翻件**与**翻页**共用这一个形状,靠 `mark` 区分。
|
|
* 两组同时出现时(多张卡各自多页)才摆那个字,单独出现时保持原样不占地方。
|
|
*/
|
|
/** 资料格式各用各的图标 —— 一眼看得出点开的是张表还是一份文档 */
|
|
const ICO_OF_FORMAT: Readonly<Record<string, string>> = {
|
|
xlsx: 'file-spreadsheet',
|
|
docx: 'file-type',
|
|
html: 'file-text',
|
|
md: 'file-text',
|
|
};
|
|
|
|
function Pager({
|
|
mark,
|
|
pos,
|
|
total,
|
|
onGo,
|
|
what,
|
|
}: {
|
|
mark?: string;
|
|
pos: number;
|
|
total: number;
|
|
onGo: (next: number) => void;
|
|
what: string;
|
|
}) {
|
|
return (
|
|
<span className="jc-tnav-pg" aria-label={what}>
|
|
<button
|
|
type="button"
|
|
className="jc-tnav-btn"
|
|
disabled={pos <= 0}
|
|
onClick={() => onGo(pos - 1)}
|
|
aria-label={`上一${what}`}
|
|
>
|
|
<Ico name="chevron-left" s={15} />
|
|
</button>
|
|
<b className="jc-tnav-n jc-mono">
|
|
{mark === undefined ? null : <i className="jc-tnav-mk">{mark}</i>}
|
|
{pagerText(pos, total)}
|
|
</b>
|
|
<button
|
|
type="button"
|
|
className="jc-tnav-btn"
|
|
disabled={pos >= total - 1}
|
|
onClick={() => onGo(pos + 1)}
|
|
aria-label={`下一${what}`}
|
|
>
|
|
<Ico name="chevron-right" s={15} />
|
|
</button>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: ViewRegionProps) {
|
|
const [view, setView] = useState<Tab>('open');
|
|
const [page, setPage] = useState(0);
|
|
/*
|
|
* **件内翻页**(2026-08-02 用户:「一个执行单可以派多个 HTML 待执行报告,
|
|
* 合理利用右上角的翻页和待处理的数字气泡」):件里带 `data-page` 的可见段
|
|
* 各算一页(A2 = 一颗待选型芯片一页)。单卡多页时,右上角那对箭头翻的是页。
|
|
*/
|
|
const [docPage, setDocPage] = useState(0);
|
|
const [docPages, setDocPages] = useState(1);
|
|
/** 工作成果:列表里点开的那份定稿报告 */
|
|
const [doneSel, setDoneSel] = useState<string | null>(null);
|
|
/**
|
|
* **定稿报告的件内翻页**(2026-08-21 用户:「工作成果里面点进一个工作成果也需要
|
|
* 这个按钮」)——待处理那份件的翻页只接在 `cur`/`docPage` 上,工作成果点进详情后
|
|
* 走的是另一套局面(`doneItem`),从前那对 `[data-page]` 页从没接过 `page`/`onPages`,
|
|
* 于是定稿报告里带多页的件(如一颗芯片一页的选型件)在成果详情里会**整份铺开**,
|
|
* 待处理那一屏能翻的页,归档后反而翻不了。与 `docPage`/`docPages` 分开一份状态,
|
|
* 免得两个局面共用一个页码,来回切换互相冲页。
|
|
*/
|
|
const [doneDocPage, setDoneDocPage] = useState(0);
|
|
const [doneDocPages, setDoneDocPages] = useState(1);
|
|
const [openFile, setOpenFile] = useState<FileDoc | undefined>(undefined);
|
|
/** 那一份取不回来时的原话 —— 摆在件上方,不弹窗、不静默 */
|
|
const [fileError, setFileError] = useState<string | undefined>(undefined);
|
|
/** 当下这一份的**原件**地址(`raw=1`)—— 屏上渲的是读回来的样子,下载给的是真字节 */
|
|
const [fileHref, setFileHref] = useState<string | undefined>(undefined);
|
|
|
|
const orders = detail.workOrders;
|
|
const openList = orders.filter((o) => o.done === undefined);
|
|
/* 未读条数 —— 页签那枚红气泡读的就是它;标记按钮按一下这个数才动 */
|
|
const unreadN = detail.comms.filter((m) => m.unread).length;
|
|
/**
|
|
* **`awaiting-event` 是「本回合挂起」的统称**,`decide`/`entry`/`listen` 三条路
|
|
* 都落这一个值 —— 而 decide/entry 发的是**对话区的卡**(chat_card),不进
|
|
* `work_order`,`openList` 因此照旧空。这一屏原样套用「不用盯着,轮到你时
|
|
* 工单会带着上下文出现在这里」就成了空话:那张卡已经在等,而且**永远不会**
|
|
* 出现在这一页(它压根不是工单)。界面不撒谎 —— 这里改成读一眼 `detail.chat`
|
|
* 里有没有未回的决策卡 / 录入卡,是就换一句指向对话区的实话。
|
|
*/
|
|
const pending = pendingCardsOf(detail.chat);
|
|
const pendingChatCard = pending.decision || pending.entry;
|
|
/* 这一单还有活等着人办吗 —— 推进场景要不要摆看它(缘由见 `awaitingHuman` 的头注) */
|
|
const holding = awaitingHuman({ openOrders: openList.length, cards: pending });
|
|
|
|
/*
|
|
* 标记已读 / 未读 —— 乐观更新交给失效:标完服务端广播 task 触点,
|
|
* 这一屏跟着重取。不在前端手写缓存(订阅层统一做,组件不自建过程状态)。
|
|
*/
|
|
const queryClient = useQueryClient();
|
|
/**
|
|
* 件上按下「保存」→ **落草稿层 + 让服务端按新数据重渲那一帧**。
|
|
*
|
|
* 不落库,人改的东西只活在浏览器里,刷新一次就没了;不重渲,由数据算出来的东西
|
|
* (类徽 A/B/C、分布条、结论横幅那条去向)会停在上一次渲染那一刻 ——
|
|
* 实测:人把三条料全改成 A 类,底下的去向还写着「去选型」。
|
|
* 落完重取任务投影,整份件跟着一起变。**工单不办结,放行门的位置没动。**
|
|
*/
|
|
/**
|
|
* **有行没落下就说出来**(2026-08-24 用户:「件上新增的那个客户,推到下一步就不见了」)。
|
|
* 主键那几格空着就凑不出钥匙、存不住;从前基座悄悄跳过、接口照回 ok ——
|
|
* 人看到「保存成功」,东西却不在。现在把没落下的行红字点名,他补上钥匙再存一次。
|
|
*/
|
|
const [saveWarn, setSaveWarn] = useState<string | undefined>(undefined);
|
|
|
|
const saveTable = useMutation({
|
|
mutationFn: (save: ViewSave) =>
|
|
call<{ ok: boolean; rows: number; unkeyed?: Array<{ hint: string }> }>(saveTableRoute, {
|
|
params: { taskId: detail.card.id },
|
|
body: {
|
|
tables: save.tables as Record<string, Array<Record<string, string>>>,
|
|
drops: save.drops as Record<string, Array<Record<string, string>>>,
|
|
},
|
|
}),
|
|
onSuccess: (out) => {
|
|
setSaveWarn(
|
|
out.unkeyed === undefined || out.unkeyed.length === 0
|
|
? undefined
|
|
: `有 ${out.unkeyed.length} 行没能存下 —— ${out.unkeyed.map((u) => u.hint).join(';')}`,
|
|
);
|
|
void queryClient.invalidateQueries({ queryKey: ['desk', 'task', detail.card.id] });
|
|
},
|
|
});
|
|
|
|
/*
|
|
* **点开一份资料文件** —— 件上点了资料名,这里去把那一份取回来。
|
|
*
|
|
* 为什么要跑一趟服务端而不在前端拼:那份文件是**真造出来的字节**
|
|
* (docx / xlsx 是 ZIP 包着 XML),前端拿到的是它读回来的样子 ——
|
|
* 屏上渲的与下载到的是同一份东西。取不到就如实弹一句,不摆一个空壳。
|
|
*/
|
|
const openAssetFile = useMutation({
|
|
mutationFn: (ref: { address: string; key: string }) => {
|
|
const qs = `address=${encodeURIComponent(ref.address)}&key=${encodeURIComponent(ref.key)}`;
|
|
setFileHref(`/api/desk/tasks/${detail.card.id}/file?${qs}&raw=1`);
|
|
return call<{ file: FileDoc; fileName: string; format: string }>(openAssetFileRoute, {
|
|
params: { taskId: detail.card.id },
|
|
query: { address: ref.address, key: ref.key },
|
|
});
|
|
},
|
|
onSuccess: (out) => setOpenFile(out.file),
|
|
onError: (error: unknown) => setFileError(error instanceof Error ? error.message : '这份资料打不开'),
|
|
});
|
|
|
|
/*
|
|
* 正在取的是哪一份 —— 先摘成两个普通值再用。
|
|
* 直接在 JSX 里写 `openAssetFile.isPending && openAssetFile.variables.key === …` 不行:
|
|
* 上面那一层三元已经按 `isPending` 分过岔,进到 else 分支里它被收窄成「不在办」,
|
|
* `variables` 整个不存在(TS 判 never)—— 同一个判断在同一棵树里只能问一次。
|
|
*/
|
|
const fetchingFileKey = openAssetFile.isPending ? openAssetFile.variables.key : undefined;
|
|
|
|
const markRead = useMutation({
|
|
mutationFn: (input: { commId: string; read: boolean }) =>
|
|
call(markCommReadRoute, { params: { commId: input.commId }, body: { read: input.read } }),
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['desk'] }),
|
|
});
|
|
/*
|
|
* **工作成果的「一行一份」不分是工单还是决策卡答出来的**——`decide` 是唯一能循环的
|
|
* 中间验收机制(如 E04 的经营计划审批),答完不产生工单行,报告快照走的是
|
|
* `detail.decidedDeliverables` 这条单独的口(见后端注释:混进 `workOrders`
|
|
* 会连累 `signed` 那处「`mode==='acceptance'` 恒是终局放行卡」的假设)。
|
|
* 只在这一屏(工作成果列表)合并显示,`orders`/`openList`/`signed` 一律不动。
|
|
*/
|
|
const doneList = [...orders.filter((o) => o.done !== undefined), ...detail.decidedDeliverables].sort((a, b) =>
|
|
a.at < b.at ? -1 : a.at > b.at ? 1 : 0,
|
|
);
|
|
/*
|
|
* **字签下了,但这一单还没收尾** —— 签完到收尾之间那一小段(合并 / 发口 / 落终态)。
|
|
* 认的是「有一张办结的验收单 + 还没有终态」:两者同时成立只有这一种局面。
|
|
*/
|
|
const signed =
|
|
detail.card.terminal === undefined &&
|
|
orders.some((o) => o.mode === 'acceptance' && o.done !== undefined && !isRejection(o.done.result));
|
|
const pos = Math.min(page, Math.max(0, openList.length - 1));
|
|
const cur = openList[pos];
|
|
const doneItem = doneSel === null ? undefined : doneList.find((o) => o.id === doneSel);
|
|
/** 成果详情里当下摆的是第几份 —— 翻「份」用它 */
|
|
/** 这一屏该出哪几组翻页(件 / 页 / 都没有) —— 判断抽在 `pager.ts`,有测 */
|
|
const face = pagerFace({ items: openList.length, pages: docPages });
|
|
|
|
/*
|
|
* 对话区点击 → 跳到工单所在页签与页位(渲染期调整 state,seen 保证幂等 —— PRD 同款)。
|
|
* 办结的跳成果详情;待办的跳待处理并翻到那一页。
|
|
*/
|
|
const [seenLoc, setSeenLoc] = useState(0);
|
|
if (locate != null && locate.seq !== seenLoc) {
|
|
setSeenLoc(locate.seq);
|
|
if (locate.tab !== undefined) {
|
|
setOpenFile(undefined);
|
|
setView(locate.tab);
|
|
setPage(0);
|
|
setDocPage(0);
|
|
setDoneSel(null);
|
|
}
|
|
// 决策卡答出来的成果没有工单行(见 doneList 那条注释),定位时一并找
|
|
const hit = orders.find((o) => o.id === locate.orderId) ?? detail.decidedDeliverables.find((o) => o.id === locate.orderId);
|
|
if (hit !== undefined) {
|
|
setOpenFile(undefined);
|
|
if (hit.done !== undefined) {
|
|
setView('done');
|
|
setDoneSel(hit.id);
|
|
setDoneDocPage(0);
|
|
} else {
|
|
setView('open');
|
|
setPage(Math.max(0, openList.findIndex((o) => o.id === locate.orderId)));
|
|
setDocPage(0);
|
|
}
|
|
} else if (locate.orderId !== undefined && locate.tab === undefined) {
|
|
/*
|
|
* 找不到那张单 —— 多半是审核卡(决策卡穿验收工单的衣服,2026-08-27)上的「验收工单」钮:
|
|
* 它没有工单行,件却在待处理那一屏(活动按剧本 `view` 推的)。至少把那一屏翻出来,
|
|
* 别让人点了没反应。
|
|
*/
|
|
setOpenFile(undefined);
|
|
setView('open');
|
|
setDocPage(0);
|
|
}
|
|
}
|
|
|
|
/** 换件 —— 页码跟着归零(上一件翻到第 3 页,下一件可能只有一页) */
|
|
const goItem = (next: number) => {
|
|
setPage(next);
|
|
setDocPage(0);
|
|
};
|
|
|
|
const go = (to: Tab) => {
|
|
setView(to);
|
|
setPage(0);
|
|
setDocPage(0);
|
|
setDoneSel(null);
|
|
setDoneDocPage(0);
|
|
};
|
|
|
|
const terminalLabel = detail.card.terminal === undefined ? undefined : TERMINAL_STATE_LABEL[detail.card.terminal];
|
|
|
|
return (
|
|
<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-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>
|
|
|
|
{/* ② 工作区 —— 页签与点位条**不滚**,只有正文滚 */}
|
|
<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>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={view === 'comms'}
|
|
className={'jc-tnav-tab' + (view === 'comms' ? ' on' : '')}
|
|
onClick={() => go('comms')}
|
|
>
|
|
<Ico name="mail" s={14} /> 沟通记录
|
|
{/* 未读气泡:红的,数字是**未读条数**,不是总条数 —— 它要回答「有没有我还没看的」 */}
|
|
{unreadN > 0 && <em className="jc-unread">{unreadN}</em>}
|
|
</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,两侧箭头灰显。
|
|
按件数隐藏会让点位条忽有忽无,右侧跟着跳 —— 页签条要定得住。
|
|
*/}
|
|
{/*
|
|
右槽两态:成果进二级界面时是「返回」,否则待处理位上是翻页。
|
|
顶部五个按钮**始终不变** —— 二级界面长在工作区内部,不是另一页。
|
|
*/}
|
|
{openFile !== undefined ? (
|
|
<button
|
|
type="button"
|
|
className="jc-tnav-btn jc-tnav-back"
|
|
onClick={() => {
|
|
setOpenFile(undefined);
|
|
setFileHref(undefined);
|
|
setFileError(undefined);
|
|
}}
|
|
>
|
|
<Ico name="arrow-left" s={14} /> 返回
|
|
</button>
|
|
) : view === 'done' && doneItem !== undefined ? (
|
|
/*
|
|
* 成果详情右上角摆两组翻页 + 返回列表:
|
|
* · **翻页**(2026-08-21):同一份带多页的件归了档,翻页的能力不该不见了;
|
|
* · 08-23 曾在这里加过「翻份」(在几份定稿之间切换),2026-08-27 用户定撤掉:
|
|
* 右上角那对箭头的意思是**这一份件里翻页**;换一份成果回列表点。
|
|
*/
|
|
<>
|
|
{/*
|
|
* **右上角只翻页,不翻份**(2026-08-27 用户:「这个东西不是让你在整个工作成果里面切换,
|
|
* 是让你在单个工单有多个页面的情况下切换的」)。08-23 加的「翻份」撤掉:换一份成果回列表点,
|
|
* 右上角那对箭头只在这一份件里翻页 —— 与待处理那一屏同一个意思。一页也照旧摆 1/1(定得住)。
|
|
*/}
|
|
<Pager pos={doneDocPage} total={doneDocPages} what="页" onGo={setDoneDocPage} />
|
|
<button type="button" className="jc-tnav-btn back" onClick={() => setDoneSel(null)}>
|
|
<Ico name="chevron-left" s={14} /> 返回列表
|
|
</button>
|
|
</>
|
|
) : view === 'open' ? (
|
|
/*
|
|
* **翻件与翻页各归各位**(2026-08-23 用户:「一次叫人不一定只有一个 html
|
|
* 模板所以需要翻页,我看现在这个 0/0 显示的不正常」)。从前两者二选一
|
|
* (`docPages > 1 && openList.length <= 1`):多张卡各自多页时**页就翻不动了**,
|
|
* 人只看得见每张卡的第一页。现在同时出,两组各带一个字区分。
|
|
* 一件都没有时(还在跑 / 在等对话区那张卡 / 已收尾)也不再摆「0/0」——
|
|
* 那不是一个数,是「这儿没有可翻的东西」,写「—」。
|
|
*/
|
|
<>
|
|
{face.items && (
|
|
<Pager
|
|
{...(face.pages ? { mark: '件' } : {})}
|
|
pos={pos}
|
|
total={openList.length}
|
|
what="件"
|
|
onGo={goItem}
|
|
/>
|
|
)}
|
|
{face.pages && (
|
|
<Pager
|
|
{...(face.items ? { mark: '页' } : {})}
|
|
pos={docPage}
|
|
total={docPages}
|
|
what="页"
|
|
onGo={setDocPage}
|
|
/>
|
|
)}
|
|
{face.solo && <Pager pos={pos} total={openList.length} what="件" onGo={goItem} />}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* key:换件 / 换页签即重挂载滚动区,回到正文顶部 */}
|
|
<ScrollArea
|
|
key={`${view}:${view === 'done' ? (doneSel ?? 'list') : (cur?.id ?? '-')}`}
|
|
className="jc-sa--fill"
|
|
vpClassName="wf-body"
|
|
inset={-6}
|
|
>
|
|
{openAssetFile.isPending ? (
|
|
/*
|
|
* **取件的那一段要说出来** —— 资料名是件上的一个链接,点它没有按钮可以变样;
|
|
* 那份文件又是服务端**现造**出来的字节(造 + 传,慢的有一两秒)。
|
|
* 从前这一段屏上一动不动,人只好再点一次 —— 于是同一份资料取两遍。
|
|
*/
|
|
<EmptyState ico="file-text" tone="brand" title="正在取这一份资料…" desc="文件是台账那一行现造出来的,造好就摆在这儿。" />
|
|
) : openFile !== undefined ? (
|
|
<FileViewer doc={openFile} href={fileHref} />
|
|
) : fileError !== undefined ? (
|
|
/* 那一份取不回来:**如实说是哪一句**(服务端原话),别把人留在原地猜 */
|
|
<EmptyState ico="file-text" tone="muted" title="这份资料打不开" desc={fileError} />
|
|
) : view === 'comms' ? (
|
|
/*
|
|
* 沟通记录 = 一单往来的**原件**。进来的那一封是开案的那份料 ——
|
|
* 人一点开这单就该看见「这单从哪来的」,不必等模型跑几拍再说。
|
|
*
|
|
* 附件那一行照抄基座读出来的说明:读出多少字 / 为什么读不出。
|
|
* **摆一个「已上传 ✓」是撒谎** —— 活动读不出内容的件,人得当场知道。
|
|
*/
|
|
detail.comms.length === 0 ? (
|
|
<EmptyState
|
|
ico="mail"
|
|
tone="muted"
|
|
title="这一单还没有往来记录"
|
|
desc="跟客户、供应商往来的邮件和消息会按单归到这里,连原件一起存:谈过什么、什么时候谈的,回头都查得到。"
|
|
/>
|
|
) : (
|
|
<div className="jc-comms">
|
|
{detail.comms.map((m) => (
|
|
<article key={m.id} className={'jc-comm t-' + m.direction + (m.unread ? ' unread' : '')}>
|
|
<header className="jc-comm-h">
|
|
<span className={'jc-comm-dir t-' + m.direction}>
|
|
<Ico name={m.direction === 'in' ? 'corner-down-left' : 'corner-up-right'} s={12} />
|
|
{m.direction === 'in' ? '收到' : '发出'}
|
|
</span>
|
|
<b className="jc-comm-sub">{m.subject}</b>
|
|
{/* 未读点:一眼扫得出哪几封还没看 —— 与右边那个按钮说的是同一件事 */}
|
|
{m.unread && <span className="jc-comm-new">未读</span>}
|
|
<span className="jc-comm-at jc-mono">{atMinute(m.at)}</span>
|
|
{/*
|
|
标记按钮 —— **气泡计数只跟着这一下走**。
|
|
不做「打开页签即已读」:那是替人宣布他看过了,而他可能只是路过。
|
|
反向也留着:看了一半想留个记号,标回未读。
|
|
*/}
|
|
<Btn
|
|
className={'jc-comm-mark' + (m.unread ? '' : ' done')}
|
|
busy={markRead.isPending && markRead.variables.commId === m.id}
|
|
aria-label={m.unread ? `把「${m.subject}」标为已读` : `把「${m.subject}」标回未读`}
|
|
onClick={() => markRead.mutate({ commId: m.id, read: m.unread })}
|
|
>
|
|
<Ico name={m.unread ? 'check' : 'rotate-ccw'} s={11} />
|
|
{m.unread ? '标为已读' : '标回未读'}
|
|
</Btn>
|
|
</header>
|
|
<div className="jc-comm-meta">
|
|
<span className="jc-mono">{m.channel}</span>
|
|
<span>{m.direction === 'in' ? '来自' : '发往'}</span>
|
|
<b>{m.party === '' ? '未署名' : m.party}</b>
|
|
{m.note !== '' && <em>{m.note}</em>}
|
|
</div>
|
|
{m.body !== '' && <pre className="jc-comm-body">{m.body}</pre>}
|
|
{m.attachments.length > 0 && (
|
|
<div className="jc-comm-att">
|
|
{m.attachments.map((a) => (
|
|
<span key={a.name} className={'jc-att' + (a.readable === true ? '' : ' blind')}>
|
|
<Ico name={a.readable === true ? 'file-text' : 'alert-triangle'} s={12} />
|
|
<b>{a.name}</b>
|
|
{a.size !== undefined && <i className="jc-mono">{(a.size / 1024).toFixed(0)} KB</i>}
|
|
{a.note !== undefined && <em>{a.note}</em>}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</article>
|
|
))}
|
|
</div>
|
|
)
|
|
) : view === 'wf' ? (
|
|
<WfDetail detail={detail} openN={openList.length} doneN={orders.length - openList.length} />
|
|
) : view === 'log' ? (
|
|
detail.log.length > 0 ? (
|
|
<div className="jc-log">
|
|
{detail.log.map((line) => (
|
|
/*
|
|
* **三色一档一意**(2026-08-02 用户定):负责人绿 · 智能体蓝 · 系统灰。
|
|
* 从前按归侧上色,而归侧有五种 —— 那是**度量**的分法
|
|
* (自主率要把「等外面」与「机器干的」分开);人扫这一屏只问
|
|
* 「这一步是我办的、AI 办的,还是流程自己走到的」。
|
|
*/
|
|
<LogRow key={line.id} line={line} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<EmptyState
|
|
ico="archive"
|
|
tone="brand"
|
|
title="还没有进展"
|
|
desc="AI 每推进一步都记在这里,并标明是谁的产出:AI 完成、我填的、我确认的、等外部。"
|
|
/>
|
|
)
|
|
) : view === 'done' ? (
|
|
doneItem !== undefined ? (
|
|
/*
|
|
* **定稿归档 = 办结那一刻的件快照,1:1 静态重放**(2026-08-02 用户:
|
|
* 「HTML UI、数据、内容、布局都要 1:1 地从待处理状态转换为静态报告
|
|
* 放到工作成果」—— 反复强调过)。
|
|
*
|
|
* 从前两条路都不对:验收单渲**当下**的任务视图(随后续推进还会变),
|
|
* 执行单干脆退回 WorkOrderReport 卡壳(题头/编号带/blocks)——
|
|
* 没有一样是人办结时看到的那份。现在办结那一刻基座把渲好的件
|
|
* 存进工单(`finalHtml`),这里原样静态渲它;老单没有快照的,
|
|
* 退最近一帧任务视图,再没有才退卡壳(历史兼容)。
|
|
*/
|
|
doneItem.finalHtml !== undefined || detail.viewHtml !== undefined ? (
|
|
<ViewContainer
|
|
html={doneItem.finalHtml ?? detail.viewHtml ?? ''}
|
|
title={`${detail.card.activity} · 定稿报告`}
|
|
page={doneDocPage}
|
|
onPages={setDoneDocPages}
|
|
onOpenFile={(ref) => {
|
|
setFileError(undefined);
|
|
openAssetFile.mutate(ref);
|
|
}}
|
|
/>
|
|
) : (
|
|
<article className="jc-mst-doc">
|
|
<WorkOrderReport
|
|
order={doneItem}
|
|
detail={detail}
|
|
typeLine={doneItem.mode === 'acceptance' ? '验收工单 · 定稿归档' : '执行工单 · 定稿归档'}
|
|
blocks={doneItem.blocks !== undefined && doneItem.blocks.length > 0 ? doneItem.blocks : detail.view}
|
|
values={{}}
|
|
onField={() => undefined}
|
|
onReply={() => undefined}
|
|
/>
|
|
</article>
|
|
)
|
|
) : doneList.length > 0 || detail.assetFiles.length > 0 ? (
|
|
/* 一行一份成果报告,点进去看整篇(PRD:jc-dvs 列表) */
|
|
<div className="jc-dvs">
|
|
{/*
|
|
* **产出的资料另起一摞**(2026-08-23 用户:「工作成果里也需要有产出的附件,
|
|
* 点击后可以预览」)。它与定稿报告不是一类东西:报告是「叫了人办的那件事
|
|
* 办完长什么样」,资料是**办出来的东西本身**(白皮书 / 对照表 / 落地页)——
|
|
* 混在一摞里,「这一单交出了几份成果」这个数就说不清了。
|
|
* 点开走的是件上点资料名的同一条路(同一个地址 + 主键)。
|
|
*/}
|
|
{detail.assetFiles.length > 0 && (
|
|
<>
|
|
<div className="jc-dvs-sub">
|
|
产出的资料
|
|
<em>{detail.assetFiles.length} 份</em>
|
|
{/* 没存档的是在办中的那几份 —— 如实说,别让人以为已经归了档 */}
|
|
{detail.assetFiles.every((f) => f.archived) ? null : <i>在办中 · 放行时存档</i>}
|
|
</div>
|
|
{detail.assetFiles.map((f) => (
|
|
<Btn
|
|
key={`${f.address}/${f.key}`}
|
|
className={'jc-dv jc-dv-row s-ok m-file f-' + f.format}
|
|
busy={fetchingFileKey === f.key}
|
|
onClick={() => {
|
|
setFileError(undefined);
|
|
openAssetFile.mutate({ address: f.address, key: f.key });
|
|
}}
|
|
>
|
|
<span className="jc-dv-h">
|
|
<span className="jc-dv-ico">
|
|
<Ico name={ICO_OF_FORMAT[f.format] ?? 'file-text'} s={16} />
|
|
</span>
|
|
<span className="jc-dv-t">
|
|
<em>
|
|
{f.name}
|
|
{f.at === undefined ? '' : ` · 存档 ${atMinute(f.at)}`}
|
|
</em>
|
|
<b>{f.title}</b>
|
|
</span>
|
|
<span className={'jc-dv-fmt f-' + f.format}>{f.format}</span>
|
|
<Ico name="chevron-right" s={15} />
|
|
</span>
|
|
</Btn>
|
|
))}
|
|
{doneList.length > 0 && <div className="jc-dvs-sub">定稿报告<em>{doneList.length} 份</em></div>}
|
|
</>
|
|
)}
|
|
{doneList.map((o) => {
|
|
const res = o.done?.result;
|
|
const bad = res !== undefined && isRejection(res);
|
|
return (
|
|
<button
|
|
key={o.id}
|
|
type="button"
|
|
/*
|
|
* **执行与验收两种颜色**(2026-08-02 用户定):一摞成果里
|
|
* 「我办过的那张单」与「我签过字的那一关」是两类东西 ——
|
|
* 同色排下来,人得逐行读标题才分得出。退回的仍压过颜色走警示色。
|
|
*/
|
|
className={
|
|
'jc-dv jc-dv-row ' + (bad ? 's-bad' : 's-ok') + (o.mode === 'acceptance' ? ' m-acc' : ' m-exe')
|
|
}
|
|
onClick={() => {
|
|
setDoneSel(o.id);
|
|
setDoneDocPage(0);
|
|
}}
|
|
>
|
|
<span className="jc-dv-h">
|
|
<span className="jc-dv-ico">
|
|
<Ico name={o.mode === 'acceptance' ? 'shield-check' : 'file-text'} s={16} />
|
|
</span>
|
|
<span className="jc-dv-t">
|
|
<em>
|
|
派发 {atMinute(o.at)}
|
|
{o.done !== undefined ? ` · 办结 ${atMinute(o.done.at)}` : ''}
|
|
</em>
|
|
<b>{o.title}</b>
|
|
</span>
|
|
<span className="jc-dv-st">
|
|
{bad ? '已退回' : o.mode === 'acceptance' ? '验收通过' : '已办结'}
|
|
</span>
|
|
<Ico name="chevron-right" s={15} />
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<EmptyState
|
|
ico="archive"
|
|
tone="brand"
|
|
title="还没有成果报告"
|
|
desc="办结一件工单,它就会定稿成一份可回看的报告,归档在这里 —— 一行一份、点开整篇看。"
|
|
hint="先去「待处理」把手上的事办完"
|
|
/>
|
|
)
|
|
) : detail.card.terminal !== undefined ? (
|
|
<EmptyState
|
|
ico="circle-check"
|
|
tone="ok"
|
|
title="这单已收尾"
|
|
desc="所有工单都定稿归档了。要回看哪一份,去「工作成果」一行一份点开整篇。"
|
|
/>
|
|
) : signed ? (
|
|
/*
|
|
* **签完到收尾之间那一小段**(2026-08-02 用户:「点完验收按钮后,
|
|
* 状态变化时交互上会闪一下中间态,视觉效果不好」)。
|
|
*
|
|
* 那一闪不是渲染毛病,是**真的中间态**:字签下去了,合并、发口、收尾还在跑。
|
|
* 从前这一段落在通用四态里 —— 待办清零 → 「都办完了」闪一下 → 再跳「已收尾」。
|
|
* 「都办完了」这句话在那一刻是对的,可它说的是**另一件事**(你手上没活了),
|
|
* 于是读起来像跳了一帧。
|
|
*
|
|
* 现在这一段有自己的话:**字已签下,正在落地**。它不是为了盖住中间态 ——
|
|
* 中间态本来就该说出来,只是要说对。
|
|
*/
|
|
<EmptyState
|
|
ico="hourglass"
|
|
tone="brand"
|
|
title="字已签下,正在落地"
|
|
desc="成果正在并进正式表、按去向发往下游。落完这一屏会换成收尾结果 —— 不用刷新。"
|
|
hint="通常几秒钟"
|
|
/>
|
|
) : detail.card.runtime === 'running' && !holding ? (
|
|
/*
|
|
* **AI 正在算这一拍,不是「已经有一份件摆着」**(2026-08-21 用户:
|
|
* 「AI 持续推进中时中间的页面不变成持续推进的页面了,而是卡在 html
|
|
* 模版页面」)——这是上面 `detail.viewHtml` 判据改动带出的回归:
|
|
* `viewHtml` 是**上一次**推的那一帧,活步收完之后引擎不主动清它
|
|
* (「一帧不推,让最后摆上去的那张留在档案里」),于是它在 AI 正埋头
|
|
* 算下一拍的这几秒里仍然「有货」——viewHtml 判据排在 running 前面,
|
|
* 空待处理就被这份**陈旧**的件抢先占住,推进中动画再也显示不出来。
|
|
* **正在跑的这一拍,永远比上一拍摆下的旧件优先**:running 判据挪到
|
|
* viewHtml 前面,陈旧的件只在「AI 这会儿不在算」时才配拿来顶上。
|
|
*
|
|
* **`&& !holding` 是 2026-08-31 补的**(用户:「在中央工作区修改了内容点击
|
|
* 保存后,为什么会出现『AI 正在推进这单』」):上面那段的前提是「viewHtml 是
|
|
* 上一次推的,可能陈旧」。而件上按保存走的 `afterSave` 是**先重推件、再叫醒
|
|
* 任务**(「保存即铺格」)—— 那条路上件比 `running` 还新,顶掉它没有道理;
|
|
* 且那一拍按「叫了人就等人」的闸根本不问模型,这一屏写的「看材料、写台账、
|
|
* 调工具都由它来」一件没发生。**有活等着人办就不摆推进场景**,判断收在
|
|
* `awaitingHuman` 里(与服务端 `humanTool.pendingCards` 同一句话)。
|
|
*/
|
|
<WorkingScene doneN={doneList.length} />
|
|
) : pending.entry ? (
|
|
/*
|
|
* **录入卡在等人时,工作区摆占位页,不摆件**(2026-08-28 用户看柴浩那单
|
|
* 「客户 123 不在客户档案里,请核对建档」定的)。建档那一步要人做的事全在
|
|
* 对话区那张卡上;这时 `viewHtml` 里躺着的是**上一拍**推的那份 HTML 件,
|
|
* 与建档无关 —— 摆着只会让人以为要在那份工单上办点什么。
|
|
* 这一支排在 viewHtml 之前、running 之后:AI 在算时照旧是推进场景。
|
|
* 决策卡不走这一支 —— 人要决定的对象常常就是件上那份稿,件得留着给他看。
|
|
*/
|
|
<EmptyState
|
|
ico="users"
|
|
tone="brand"
|
|
title="先把这份档案建起来"
|
|
desc={`AI 认不出这位客户,已在右边对话区发了一张「${pending.entryLedger ?? '档案'}」录入卡。核对、确认后档案落进台账,AI 接着往下推进 —— 这一步工作区没有要你看的件。`}
|
|
hint="录入在对话区那张卡上,不在这里"
|
|
/>
|
|
) : detail.viewHtml !== undefined ? (
|
|
/*
|
|
* **工作区只放那份件本身**(2026-08-01 用户定)。
|
|
*
|
|
* 从前这里是 `WorkOrderReport`:它自带题头、编号带、依据带、判据表、
|
|
* 章节头,再把活动推的那一屏**包在自己里面** —— 于是人看到的是
|
|
* 「一份报告里嵌着另一份报告」,两套版面语言叠在一起。用户原话:
|
|
* 「你用你自己的报告格式嵌套了一层我要的报告格式」。
|
|
*
|
|
* 现在分家:件归工作区(整篇铺开、可就地改),
|
|
* **判据与作业指导书跟着「要你办的这件事」走,落在对话区的卡上**;
|
|
* 提交那一下也在那儿 —— 编辑与提交分在两个区,缓冲由壳层持有(values)。
|
|
*
|
|
* **这一格判据从前是 `cur !== undefined`(挂在开着的工单上)**(2026-08-21
|
|
* 用户送来实测截图查出来的坑):`decide` 循环(如 E04 S4「经营计划审批」)
|
|
* 不开工单,活动照旧按 `view:'coverage-plan'` 推了一屏「E04-验收工单-01-
|
|
* 关系经营计划.html」——`detail.viewHtml` 明明有货,却因为外层先问
|
|
* 「有没有开着的工单」而永远问不到它,工作区落空态,人只能去对话区
|
|
* 翻找那份件在说什么。**件推没推,与工单开没开是两件事**——
|
|
* 判据换成直接问 `detail.viewHtml` 本身,`cur` 只在没有件、要退回卡壳
|
|
* 呈现,或收边传值(`onField`)时才用得上。
|
|
*/
|
|
<>
|
|
{/*
|
|
**有行没落下,红字摆在件的上方** —— 而不是让人以为存好了。
|
|
位置有意)在件之上:他刚按完保存,眼睛就在这一带。
|
|
*/}
|
|
{saveWarn !== undefined && (
|
|
<div className="jc-savewarn" role="alert">
|
|
<Ico name="info" s={14} />
|
|
<span>{saveWarn}</span>
|
|
</div>
|
|
)}
|
|
<ViewContainer
|
|
html={detail.viewHtml}
|
|
title={`${detail.card.activity} · 汇报报告`}
|
|
page={docPage}
|
|
onPages={setDocPages}
|
|
onOpenFile={(ref) => {
|
|
setFileError(undefined);
|
|
openAssetFile.mutate(ref);
|
|
}}
|
|
editable
|
|
onEdits={(save) => {
|
|
/*
|
|
* **落库按地址走** —— 件自己说了它是哪张表(`data-edit="域.对象"`),
|
|
* 原样送上去即可。从前借道当下那张卡的 `blocks[i]` 收列名,于是
|
|
* 卡上没有那一块时(熔断卡就没有)**保存一格都送不出去**,
|
|
* 而屏上照旧有闸、有编辑、有保存 —— 界面给了个落不了地的口子。
|
|
*
|
|
* 缓冲那一份仍按卡的形状收(`table:N`):卡上那张表由提交那一下带走,
|
|
* 与件上这一张是两条路,卡没有对应的块时它自然收不到东西,不影响落库。
|
|
* `decide` 循环没有开着的工单(`cur` 恒 undefined)时这条缓冲天然不适用,
|
|
* 落库(`saveTable.mutate`)本身不靠它,原样跳过即可。
|
|
*/
|
|
saveTable.mutate(save);
|
|
if (cur !== undefined) collectEdits(save.tables, cur, onField);
|
|
}}
|
|
/>
|
|
</>
|
|
) : cur !== undefined ? (
|
|
/* 活动没推件的(还没到推件那一拍、或包里就没有模版)才退回工单报告:那时工作区总不能是白的 */
|
|
<article className="jc-mst-doc">
|
|
<WorkOrderReport
|
|
order={cur}
|
|
detail={detail}
|
|
typeLine={cur.mode === 'acceptance' ? '验收工单 · 终局唯放行' : '执行工单 · 作业指导书 + 表单'}
|
|
blocks={cur.blocks !== undefined && cur.blocks.length > 0 ? cur.blocks : detail.view}
|
|
values={values}
|
|
onField={onField}
|
|
onReply={onReplyOrder}
|
|
/>
|
|
{cur.mode === 'execution' && <GuideCard order={cur} />}
|
|
</article>
|
|
) : (
|
|
/*
|
|
* 空待处理**四态分明** —— 状态如实,不许一句「没有待办」把四种局面糊成一种:
|
|
* 推进中 = AI 在自己转(动画场景,running 已在上面拦下);
|
|
* 等外部 = 环停着听回音;都办完了 = 你的活清零、单还在走;已收尾 = 一切定稿
|
|
* (terminal/signed 也已在上面拦下)。这里只剩 awaiting-event 的两条岔与
|
|
* 兜底两态。
|
|
*
|
|
* 「等外部」这一态下还有一条岔:decide/entry 挂起时 `runtime` 与真正等
|
|
* 外部回音是同一个值(`awaiting-event`),但那张卡在**对话区**等你,
|
|
* 不是在等世界回信 —— `pendingChatCard` 把这条岔认出来单独说话。
|
|
*/
|
|
detail.card.runtime === 'awaiting-event' && pendingChatCard ? (
|
|
<EmptyState
|
|
ico="hourglass"
|
|
tone="brand"
|
|
title="有一张卡在等你"
|
|
desc="这一步要你在对话区做个决定或补一笔档案 —— 待处理这一页只装执行 / 验收工单,那张卡走的是另一条路,去右边对话区看。"
|
|
hint="回完那张卡,AI 接着往下推进"
|
|
/>
|
|
) : detail.card.runtime === 'awaiting-event' ? (
|
|
<EmptyState
|
|
ico="hourglass"
|
|
tone="brand"
|
|
title="AI 在等一个外部回音"
|
|
desc="等的可能是一封回信、一批数据、或一个时点。回音一到它就接着推进;需要你出手时,工单会带着完整上下文出现在这里。"
|
|
hint="不用盯着 —— 轮到你时左侧列表会亮角标"
|
|
/>
|
|
) : doneList.length > 0 ? (
|
|
<EmptyState
|
|
ico="circle-check"
|
|
tone="ok"
|
|
title="都办完了"
|
|
desc="手上的工单清零。AI 接着往下推进,需要你执行或验收的,会第一时间送到这里。"
|
|
hint="工作成果里可以回看每一份定稿报告"
|
|
/>
|
|
) : (
|
|
<EmptyState
|
|
ico="list-checks"
|
|
tone="brand"
|
|
title="手上没有待办"
|
|
desc="需要你执行或验收的事会送到这里,带着办这件事要用的全部上下文 —— 不用你去别处找。"
|
|
hint="轮到你时会第一时间出现"
|
|
/>
|
|
)
|
|
)}
|
|
</ScrollArea>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
/* ═════════════════ AI 推进中 · 空工作区的工作场景 ═════════════════ */
|
|
|
|
/**
|
|
* AI 正在办这单、还没轮到人 —— 空待处理不是「没有东西」,是**有人在干活**。
|
|
* 涟漪说「环在转」,微光工作线说「报告正在一行行写出来」;
|
|
* 都是纯 CSS、只动 transform/opacity,prefers-reduced-motion 下静止但状态仍可读。
|
|
*/
|
|
/**
|
|
* 日志一行 —— **折叠时一行,展开才看细节**(2026-08-03 用户:「细节展开不够好」)。
|
|
*
|
|
* 这一屏的用法是**扫**:一眼看完这单走过哪几步、谁办的。所以密度不能破 ——
|
|
* 细节收进抽屉,想深究哪一条点哪一条。**没有细节的条目不给展开钮**:
|
|
* 摆一个点开是空的抽屉,比不给还糟(点一次才知道白点)。
|
|
*
|
|
* 展开不做动画高度:日志动辄几十条,一条一个过渡就是一屏都在抖。
|
|
*/
|
|
function LogRow({ line }: { line: TaskDetail['log'][number] }) {
|
|
const [open, setOpen] = useState(false);
|
|
const has = line.detail.length > 0;
|
|
return (
|
|
<div className={'jc-log-row t-' + line.who + (open ? ' on' : '')}>
|
|
<span className="jc-log-at jc-mono">{atMinute(line.at)}</span>
|
|
{/* 章摆的是**动作**(四字,与对话区同词),不是归侧 ——
|
|
归侧仍在 `t-${side}` 的配色里,度量也照旧靠它 */}
|
|
<span className="jc-log-side">{line.action}</span>
|
|
<span className="jc-log-t">
|
|
{has ? (
|
|
<button type="button" className="jc-log-more" onClick={() => setOpen(!open)} aria-expanded={open}>
|
|
<b>{line.title}</b>
|
|
<Ico name={open ? 'chevron-down' : 'chevron-right'} s={13} />
|
|
</button>
|
|
) : (
|
|
<b>{line.title}</b>
|
|
)}
|
|
{line.summary !== undefined && <em>{line.summary}</em>}
|
|
{/*
|
|
* **展开就是把话说完,不摆二级条目**(2026-08-03 用户定)。
|
|
*
|
|
* 从前展开出来是一摞「键:值」—— 那是把留痕的字段名搬到人面前,
|
|
* 人还得自己在脑子里把它们连成一句话。日志是给人读的:
|
|
* 该说的直接写成正文,一段说完;真有两三样要说的,一样一段。
|
|
*/}
|
|
{open && (
|
|
<div className="jc-log-body">
|
|
{line.detail.map((text, i) => (
|
|
<p key={i}>{text}</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function WorkingScene({ doneN }: { doneN: number }) {
|
|
return (
|
|
<div className="jc-working" aria-live="polite">
|
|
<span className="jc-working-art" aria-hidden>
|
|
<i className="rp" />
|
|
<i className="rp rp2" />
|
|
<span className="jc-working-ico">
|
|
<Ico name="bot" s={26} />
|
|
</span>
|
|
</span>
|
|
<b>AI 正在推进这单</b>
|
|
<p>
|
|
看材料、写台账、调工具都由它来;需要你执行或验收的,工单会带着完整上下文出现在这里。
|
|
{doneN > 0 ? `此前你办结的 ${doneN} 件都在「工作成果」里。` : ''}
|
|
</p>
|
|
<span className="jc-working-lines" aria-hidden>
|
|
<i />
|
|
<i />
|
|
<i />
|
|
</span>
|
|
<span className="jc-working-hint">
|
|
<i className="jc-pulse-dot" />
|
|
实时盯着 · 有进展这里立即更新
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ═════════════════ 任务详情:KPI 带 + 流程链 + 上下文档案 ═════════════════ */
|
|
|
|
function WSec({ ico, label, aside }: { ico: string; label: string; aside?: React.ReactNode }) {
|
|
return (
|
|
<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">起于 {atFull(detail.card.startedAt)}</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>
|
|
);
|
|
}
|
|
|
|
/* ═════════════════ 工单报告页 ═════════════════ */
|
|
|
|
|