Archived
资料文件真产出来了;E04/I01 补到验收线,顺手填了几个「墙拦住却不给路」的坑
**资料文件全链**(用户:审核资料这一步要真产出 word/excel/md/html,前端能渲、点资料名能开)
接手时这一步一个文件都产不出来 —— S3 剧本白纸黑字写着「file_ref 本 MVP 阶段可留空」。
文件是台账那一行的投影,不另存一份:
· 声明 `file:{titleColumn,bodyColumn,formatColumn}`,它同时是授权面(没声明就点不开)
· 现造真字节 —— docx/xlsx 是自己打的 OOXML 包(zip-write:store+CRC32,零依赖)
· 读回来渲 —— 解的就是造出来那份字节,渲得出来才证明文件是真的
· 件上 `data-file-of` → 工作区二级界面(Word 纸面/Excel 网格/渲好的 md/沙箱 html)+ 下载原件
放行落地前把每一份冻进 `sys.task_asset_file`(迁移 0029):归档件点开的必须是签字那一刻
那一份,而资料落在主数据上、试跑单不落正式表 —— 不存这一份,收工后一个都点不开。
工作成果里「产出的资料」自成一摞。Markdown 富文本渲染顺带销账(仍零依赖)。
**Harness**
· 画面按「这一拍办完之后」在办的那一步挑 —— 从前拿拍首的局面决定拍尾的事:
收掉 S4、发出 S5 的卡,而屏上还摆着 S4 的只读件,人无处可填(墙要配路第四次现形)
· 验收「重做」真能退回重做,且打回后台账没动就不许再提交(软硬两段,不锁死单子)
· 立监听的授权面是清单声明;发卡的授权面是调人预算,不是计划项
· 读工具同参重调有兜底;空转写身后还有行动就不废串;finish 的 doneWhen 按写完后的草稿算
**界面**
· 翻件与翻页各归各位(从前二选一,多卡多页时页翻不动);没得翻写「—」不写 0/0
· 工作成果详情能翻份;表宽超出能横滑(E04/I01 抄版式时把 overflow-x 抄丢了)
· 查看面灰底滚过一屏变白 —— 两条独立的路都治了(沙箱内不同色 / 弹性子项不长个)
· 测试台「取不到」不再当成「还活着」
**活动**
E04 关系覆盖、I01 品牌内容各自补到验收线:判据全读基座数、判据指向的东西摆上卡面、
样例满载且名字 ≤14 字;提交前要裁的判据搬进说明书(模型会压拍,压过之后只有说明书还在)。
验收:e2e i01 4/4、e04 3/3 全部 released,资料在办中渲得出、放行后存了档点得开;
门禁 typecheck/lint/depcruise/1628 测全绿。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { markCommReadRoute, saveTableRoute } from '@contracts';
|
||||
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';
|
||||
@@ -20,6 +20,7 @@ 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 type { ViewEdits, ViewSave } from '@ui';
|
||||
import { GuideCard, WorkOrderReport } from './work-order-report.tsx';
|
||||
import { FileViewer } from './FileViewer.tsx';
|
||||
@@ -78,6 +79,59 @@ function collectEdits(edits: ViewEdits, order: WorkOrder, onField: (k: string, v
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 右上角那对箭头 —— **翻件**与**翻页**共用这一个形状,靠 `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);
|
||||
@@ -90,12 +144,37 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
||||
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 pendingChatCard = detail.chat.some(
|
||||
(m) => (m.k === 'decision' && m.chosen === undefined) || (m.k === 'entry' && m.done === undefined),
|
||||
);
|
||||
|
||||
/*
|
||||
* 标记已读 / 未读 —— 乐观更新交给失效:标完服务端广播 task 触点,
|
||||
@@ -122,12 +201,41 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
||||
onSuccess: () => 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 : '这份资料打不开'),
|
||||
});
|
||||
|
||||
const markRead = useMutation({
|
||||
mutationFn: (input: { commId: string; read: boolean }) =>
|
||||
call(markCommReadRoute, { params: { commId: input.commId }, body: { read: input.read } }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['desk'] }),
|
||||
});
|
||||
const doneList = orders.filter((o) => o.done !== undefined);
|
||||
/*
|
||||
* **工作成果的「一行一份」不分是工单还是决策卡答出来的**——`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,
|
||||
);
|
||||
/*
|
||||
* **字签下了,但这一单还没收尾** —— 签完到收尾之间那一小段(合并 / 发口 / 落终态)。
|
||||
* 认的是「有一张办结的验收单 + 还没有终态」:两者同时成立只有这一种局面。
|
||||
@@ -138,6 +246,10 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
||||
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);
|
||||
/** 成果详情里当下摆的是第几份 —— 翻「份」用它 */
|
||||
const donePos = Math.max(0, doneList.findIndex((o) => o.id === doneSel));
|
||||
/** 这一屏该出哪几组翻页(件 / 页 / 都没有) —— 判断抽在 `pager.ts`,有测 */
|
||||
const face = pagerFace({ items: openList.length, pages: docPages });
|
||||
|
||||
/*
|
||||
* 对话区点击 → 跳到工单所在页签与页位(渲染期调整 state,seen 保证幂等 —— PRD 同款)。
|
||||
@@ -150,25 +262,37 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
||||
setOpenFile(undefined);
|
||||
setView(locate.tab);
|
||||
setPage(0);
|
||||
setDocPage(0);
|
||||
setDoneSel(null);
|
||||
}
|
||||
const hit = orders.find((o) => o.id === locate.orderId);
|
||||
// 决策卡答出来的成果没有工单行(见 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 换件 —— 页码跟着归零(上一件翻到第 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];
|
||||
@@ -278,64 +402,84 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
||||
顶部五个按钮**始终不变** —— 二级界面长在工作区内部,不是另一页。
|
||||
*/}
|
||||
{openFile !== undefined ? (
|
||||
<button type="button" className="jc-tnav-btn jc-tnav-back" onClick={() => setOpenFile(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 ? (
|
||||
<button type="button" className="jc-tnav-btn back" onClick={() => setDoneSel(null)}>
|
||||
<Ico name="chevron-left" s={14} /> 返回列表
|
||||
</button>
|
||||
/*
|
||||
* 成果详情右上角摆两组翻页 + 返回列表:
|
||||
* · **翻页**(2026-08-21):同一份带多页的件归了档,翻页的能力不该不见了;
|
||||
* · **翻份**(2026-08-23 用户:「工作成果点进一个工作成果也需要这个东西」)——
|
||||
* 从前点进详情只有「返回列表」:想看下一份得先退出去再点一次,
|
||||
* 而一单常有三五份定稿(执行单快照 / 验收单快照 / 决策卡快照),对着看是常事。
|
||||
* 换份时页码归零 —— 上一份翻到第 3 页,下一份可能只有一页。
|
||||
* 各自只在多于一个时现身;都只有一个就照旧只留「返回列表」。
|
||||
*/
|
||||
<>
|
||||
{doneList.length > 1 && (
|
||||
<Pager
|
||||
{...(doneDocPages > 1 ? { mark: '份' } : {})}
|
||||
pos={donePos}
|
||||
total={doneList.length}
|
||||
what="份成果"
|
||||
onGo={(next) => {
|
||||
const to = doneList[Math.max(0, Math.min(next, doneList.length - 1))];
|
||||
if (to !== undefined) setDoneSel(to.id);
|
||||
setDoneDocPage(0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{doneDocPages > 1 && (
|
||||
<Pager
|
||||
{...(doneList.length > 1 ? { mark: '页' } : {})}
|
||||
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' ? (
|
||||
/* 单卡多页时这对箭头翻**件里的页**(一颗芯片一页);多卡时照旧翻件 */
|
||||
docPages > 1 && openList.length <= 1 ? (
|
||||
<span className="jc-tnav-pg" aria-label="翻页">
|
||||
<button
|
||||
type="button"
|
||||
className="jc-tnav-btn"
|
||||
disabled={docPage === 0}
|
||||
onClick={() => setDocPage(docPage - 1)}
|
||||
aria-label="上一页"
|
||||
>
|
||||
<Ico name="chevron-left" s={15} />
|
||||
</button>
|
||||
<b className="jc-tnav-n jc-mono">
|
||||
{docPage + 1}/{docPages}
|
||||
</b>
|
||||
<button
|
||||
type="button"
|
||||
className="jc-tnav-btn"
|
||||
disabled={docPage >= docPages - 1}
|
||||
onClick={() => setDocPage(docPage + 1)}
|
||||
aria-label="下一页"
|
||||
>
|
||||
<Ico name="chevron-right" s={15} />
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span className="jc-tnav-pg" aria-label="翻页">
|
||||
<button
|
||||
type="button"
|
||||
className="jc-tnav-btn"
|
||||
disabled={pos === 0}
|
||||
onClick={() => setPage(pos - 1)}
|
||||
aria-label="上一件"
|
||||
>
|
||||
<Ico name="chevron-left" s={15} />
|
||||
</button>
|
||||
<b className="jc-tnav-n jc-mono">
|
||||
{openList.length === 0 ? 0 : pos + 1}/{openList.length}
|
||||
</b>
|
||||
<button
|
||||
type="button"
|
||||
className="jc-tnav-btn"
|
||||
disabled={pos >= openList.length - 1}
|
||||
onClick={() => setPage(pos + 1)}
|
||||
aria-label="下一件"
|
||||
>
|
||||
<Ico name="chevron-right" s={15} />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
/*
|
||||
* **翻件与翻页各归各位**(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>
|
||||
|
||||
@@ -347,7 +491,10 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
||||
inset={-6}
|
||||
>
|
||||
{openFile !== undefined ? (
|
||||
<FileViewer doc={openFile} />
|
||||
<FileViewer doc={openFile} href={fileHref} />
|
||||
) : fileError !== undefined ? (
|
||||
/* 那一份取不回来:**如实说是哪一句**(服务端原话),别把人留在原地猜 */
|
||||
<EmptyState ico="file-text" tone="muted" title="这份资料打不开" desc={fileError} />
|
||||
) : view === 'comms' ? (
|
||||
/*
|
||||
* 沟通记录 = 一单往来的**原件**。进来的那一封是开案的那份料 ——
|
||||
@@ -455,6 +602,12 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
||||
<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">
|
||||
@@ -469,9 +622,53 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
||||
/>
|
||||
</article>
|
||||
)
|
||||
) : doneList.length > 0 ? (
|
||||
) : 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) => (
|
||||
<button
|
||||
key={`${f.address}/${f.key}`}
|
||||
type="button"
|
||||
className={'jc-dv jc-dv-row s-ok m-file f-' + f.format}
|
||||
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>
|
||||
</button>
|
||||
))}
|
||||
{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);
|
||||
@@ -487,7 +684,10 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
||||
className={
|
||||
'jc-dv jc-dv-row ' + (bad ? 's-bad' : 's-ok') + (o.mode === 'acceptance' ? ' m-acc' : ' m-exe')
|
||||
}
|
||||
onClick={() => setDoneSel(o.id)}
|
||||
onClick={() => {
|
||||
setDoneSel(o.id);
|
||||
setDoneDocPage(0);
|
||||
}}
|
||||
>
|
||||
<span className="jc-dv-h">
|
||||
<span className="jc-dv-ico">
|
||||
@@ -518,7 +718,47 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
||||
hint="先去「待处理」把手上的事办完"
|
||||
/>
|
||||
)
|
||||
) : cur !== undefined ? (
|
||||
) : 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' ? (
|
||||
/*
|
||||
* **AI 正在算这一拍,不是「已经有一份件摆着」**(2026-08-21 用户:
|
||||
* 「AI 持续推进中时中间的页面不变成持续推进的页面了,而是卡在 html
|
||||
* 模版页面」)——这是上面 `detail.viewHtml` 判据改动带出的回归:
|
||||
* `viewHtml` 是**上一次**推的那一帧,活步收完之后引擎不主动清它
|
||||
* (「一帧不推,让最后摆上去的那张留在档案里」),于是它在 AI 正埋头
|
||||
* 算下一拍的这几秒里仍然「有货」——viewHtml 判据排在 running 前面,
|
||||
* 空待处理就被这份**陈旧**的件抢先占住,推进中动画再也显示不出来。
|
||||
* **正在跑的这一拍,永远比上一拍摆下的旧件优先**:running 判据挪到
|
||||
* viewHtml 前面,陈旧的件只在「AI 这会儿不在算」时才配拿来顶上。
|
||||
*/
|
||||
<WorkingScene doneN={doneList.length} />
|
||||
) : detail.viewHtml !== undefined ? (
|
||||
/*
|
||||
* **工作区只放那份件本身**(2026-08-01 用户定)。
|
||||
*
|
||||
@@ -531,79 +771,75 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
||||
* **判据与作业指导书跟着「要你办的这件事」走,落在对话区的卡上**;
|
||||
* 提交那一下也在那儿 —— 编辑与提交分在两个区,缓冲由壳层持有(values)。
|
||||
*
|
||||
* 活动没推件的(还没到推件那一拍、或包里就没有模版)才退回工单报告:
|
||||
* 那时工作区总不能是白的。
|
||||
* **这一格判据从前是 `cur !== undefined`(挂在开着的工单上)**(2026-08-21
|
||||
* 用户送来实测截图查出来的坑):`decide` 循环(如 E04 S4「经营计划审批」)
|
||||
* 不开工单,活动照旧按 `view:'coverage-plan'` 推了一屏「E04-验收工单-01-
|
||||
* 关系经营计划.html」——`detail.viewHtml` 明明有货,却因为外层先问
|
||||
* 「有没有开着的工单」而永远问不到它,工作区落空态,人只能去对话区
|
||||
* 翻找那份件在说什么。**件推没推,与工单开没开是两件事**——
|
||||
* 判据换成直接问 `detail.viewHtml` 本身,`cur` 只在没有件、要退回卡壳
|
||||
* 呈现,或收边传值(`onField`)时才用得上。
|
||||
*/
|
||||
detail.viewHtml !== undefined ? (
|
||||
<ViewContainer
|
||||
html={detail.viewHtml}
|
||||
title={`${detail.card.activity} · 汇报报告`}
|
||||
page={docPage}
|
||||
onPages={setDocPages}
|
||||
editable
|
||||
onEdits={(save) => {
|
||||
/*
|
||||
* **落库按地址走** —— 件自己说了它是哪张表(`data-edit="域.对象"`),
|
||||
* 原样送上去即可。从前借道当下那张卡的 `blocks[i]` 收列名,于是
|
||||
* 卡上没有那一块时(熔断卡就没有)**保存一格都送不出去**,
|
||||
* 而屏上照旧有闸、有编辑、有保存 —— 界面给了个落不了地的口子。
|
||||
*
|
||||
* 缓冲那一份仍按卡的形状收(`table:N`):卡上那张表由提交那一下带走,
|
||||
* 与件上这一张是两条路,卡没有对应的块时它自然收不到东西,不影响落库。
|
||||
*/
|
||||
saveTable.mutate(save);
|
||||
collectEdits(save.tables, cur, onField);
|
||||
}}
|
||||
<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}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
{cur.mode === 'execution' && <GuideCard order={cur} />}
|
||||
</article>
|
||||
) : (
|
||||
/*
|
||||
* 空待处理**四态分明** —— 状态如实,不许一句「没有待办」把四种局面糊成一种:
|
||||
* 推进中 = AI 在自己转(动画场景);等外部 = 环停着听回音;
|
||||
* 都办完了 = 你的活清零、单还在走;已收尾 = 一切定稿。
|
||||
* 推进中 = AI 在自己转(动画场景,running 已在上面拦下);
|
||||
* 等外部 = 环停着听回音;都办完了 = 你的活清零、单还在走;已收尾 = 一切定稿
|
||||
* (terminal/signed 也已在上面拦下)。这里只剩 awaiting-event 的两条岔与
|
||||
* 兜底两态。
|
||||
*
|
||||
* 「等外部」这一态下还有一条岔:decide/entry 挂起时 `runtime` 与真正等
|
||||
* 外部回音是同一个值(`awaiting-event`),但那张卡在**对话区**等你,
|
||||
* 不是在等世界回信 —— `pendingChatCard` 把这条岔认出来单独说话。
|
||||
*/
|
||||
detail.card.terminal !== undefined ? (
|
||||
<EmptyState
|
||||
ico="circle-check"
|
||||
tone="ok"
|
||||
title="这单已收尾"
|
||||
desc="所有工单都定稿归档了。要回看哪一份,去「工作成果」一行一份点开整篇。"
|
||||
/>
|
||||
) : signed ? (
|
||||
/*
|
||||
* **签完到收尾之间那一小段**(2026-08-02 用户:「点完验收按钮后,
|
||||
* 状态变化时交互上会闪一下中间态,视觉效果不好」)。
|
||||
*
|
||||
* 那一闪不是渲染毛病,是**真的中间态**:字签下去了,合并、发口、收尾还在跑。
|
||||
* 从前这一段落在通用四态里 —— 待办清零 → 「都办完了」闪一下 → 再跳「已收尾」。
|
||||
* 「都办完了」这句话在那一刻是对的,可它说的是**另一件事**(你手上没活了),
|
||||
* 于是读起来像跳了一帧。
|
||||
*
|
||||
* 现在这一段有自己的话:**字已签下,正在落地**。它不是为了盖住中间态 ——
|
||||
* 中间态本来就该说出来,只是要说对。
|
||||
*/
|
||||
detail.card.runtime === 'awaiting-event' && pendingChatCard ? (
|
||||
<EmptyState
|
||||
ico="hourglass"
|
||||
tone="brand"
|
||||
title="字已签下,正在落地"
|
||||
desc="成果正在并进正式表、按去向发往下游。落完这一屏会换成收尾结果 —— 不用刷新。"
|
||||
hint="通常几秒钟"
|
||||
title="有一张卡在等你"
|
||||
desc="这一步要你在对话区做个决定或补一笔档案 —— 待处理这一页只装执行 / 验收工单,那张卡走的是另一条路,去右边对话区看。"
|
||||
hint="回完那张卡,AI 接着往下推进"
|
||||
/>
|
||||
) : detail.card.runtime === 'running' ? (
|
||||
<WorkingScene doneN={doneList.length} />
|
||||
) : detail.card.runtime === 'awaiting-event' ? (
|
||||
<EmptyState
|
||||
ico="hourglass"
|
||||
|
||||
Reference in New Issue
Block a user