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:
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 产出的资料**存档** —— 放行那一刻冻住的那一份(2026-08-23 用户:
|
||||
* 「检查当前产出的附件是否存档,在工作成果里也需要有产出的附件,点击后可以预览」)。
|
||||
*
|
||||
* 存档之前的实情:文件是台账那一行现造的,而资料常落在**主数据**上,
|
||||
* 试跑单放行时主数据只留在活动缓存、不落正式表 —— 于是**放行之后一份都点不开**
|
||||
* (实测 404)。工作成果恰恰是收工之后才去看的地方,不存这一份,那一屏就是空的。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TaskId } from '@contracts';
|
||||
import { archiveAssetFiles, keyText } from '../asset-archive.ts';
|
||||
|
||||
const TASK = 't-1' as TaskId;
|
||||
|
||||
/** 记下发生过哪些写 —— SQL 不求值,只看「几笔、带了什么」 */
|
||||
function fakeDb() {
|
||||
const writes: string[] = [];
|
||||
return {
|
||||
writes,
|
||||
execute: async (query: unknown) => {
|
||||
// drizzle 的 sql`` 片段:把它的字面量与参数拼成一句好断言的字符串
|
||||
const q = query as { queryChunks?: unknown[] };
|
||||
writes.push(JSON.stringify(q.queryChunks ?? query).slice(0, 4000));
|
||||
return { rows: [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ids = (() => {
|
||||
let n = 0;
|
||||
return {
|
||||
next: () => {
|
||||
n += 1;
|
||||
return `id-${n}`;
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
const ASSET = {
|
||||
domain: 'content',
|
||||
object: 'asset',
|
||||
fields: [{ name: 'content_id', primaryKey: true }, { name: 'title' }, { name: 'body_md' }, { name: 'file_format' }],
|
||||
file: { titleColumn: 'title', bodyColumn: 'body_md', formatColumn: 'file_format' },
|
||||
};
|
||||
|
||||
describe('keyText —— 主键拼法必须稳定', () => {
|
||||
it('按列名排序拼,写进去与取出来才对得上', () => {
|
||||
expect(keyText({ b: '2', a: '1' })).toBe('a=1,b=2');
|
||||
expect(keyText({ a: '1', b: '2' })).toBe('a=1,b=2');
|
||||
});
|
||||
|
||||
it('单列主键就是一句 `列=值` —— 与件上 data-file 那一串同形状', () => {
|
||||
expect(keyText({ content_id: 'CT-2026-0001' })).toBe('content_id=CT-2026-0001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
|
||||
it('一行一份,存下来的是真字节(存几份就写几笔)', async () => {
|
||||
const db = fakeDb();
|
||||
const saved = await archiveAssetFiles({
|
||||
db,
|
||||
taskId: TASK,
|
||||
ids,
|
||||
declarations: [ASSET],
|
||||
rowsOfAddress: async () => [
|
||||
{ content_id: 'CT-1', title: '白皮书', body_md: '# 白皮书\n\n正文', file_format: 'docx' },
|
||||
{ content_id: 'CT-2', title: '对照表', body_md: '| a | b |\n| - | - |\n| 1 | 2 |', file_format: 'xlsx' },
|
||||
],
|
||||
});
|
||||
expect(saved).toBe(2);
|
||||
expect(db.writes).toHaveLength(2);
|
||||
expect(db.writes.join()).toContain('白皮书');
|
||||
});
|
||||
|
||||
it('**没正文的行不存** —— 一份空壳存进去,人点开只会看见一张白纸', async () => {
|
||||
const db = fakeDb();
|
||||
const saved = await archiveAssetFiles({
|
||||
db,
|
||||
taskId: TASK,
|
||||
ids,
|
||||
declarations: [ASSET],
|
||||
rowsOfAddress: async () => [{ content_id: 'CT-1', title: '还没写', body_md: ' ', file_format: 'md' }],
|
||||
});
|
||||
expect(saved).toBe(0);
|
||||
expect(db.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('**主键不全的行不存** —— 存了也按不回那一行,取不回来', async () => {
|
||||
const db = fakeDb();
|
||||
const saved = await archiveAssetFiles({
|
||||
db,
|
||||
taskId: TASK,
|
||||
ids,
|
||||
declarations: [ASSET],
|
||||
rowsOfAddress: async () => [{ content_id: '', title: 'T', body_md: '正文', file_format: 'md' }],
|
||||
});
|
||||
expect(saved).toBe(0);
|
||||
});
|
||||
|
||||
it('没声明 `file` 段的表整张跳过 —— 存档也认那道授权面', async () => {
|
||||
const db = fakeDb();
|
||||
const saved = await archiveAssetFiles({
|
||||
db,
|
||||
taskId: TASK,
|
||||
ids,
|
||||
declarations: [{ ...ASSET, file: undefined }],
|
||||
rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'md' }],
|
||||
});
|
||||
expect(saved).toBe(0);
|
||||
});
|
||||
|
||||
it('格式列填了个不认识的值:退 md,不整份丢掉', async () => {
|
||||
const db = fakeDb();
|
||||
const saved = await archiveAssetFiles({
|
||||
db,
|
||||
taskId: TASK,
|
||||
ids,
|
||||
declarations: [ASSET],
|
||||
rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'pdf' }],
|
||||
});
|
||||
expect(saved).toBe(1);
|
||||
expect(db.writes.join()).toContain('"md"');
|
||||
});
|
||||
|
||||
it('大多数活动不产资料 —— 一张带 file 段的表都没有时返回 0,不报错', async () => {
|
||||
const db = fakeDb();
|
||||
expect(
|
||||
await archiveAssetFiles({ db, taskId: TASK, ids, declarations: [], rowsOfAddress: async () => [] }),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* 资料文件现造 + 读回来 —— 两头合起来自证:**造出来的是真文件**。
|
||||
*
|
||||
* 断言的重点不是「函数返回了字节」,而是:
|
||||
* · docx / xlsx 是合法 ZIP,里面那几份 XML 一份不缺(Word / Excel 认的就是它们);
|
||||
* · 读回来的内容与写进去的一模一样 —— 渲得出来,才谈得上「前端能渲染」;
|
||||
* · 模型写的字全程转义 —— 正文里带 `<script>` 也只是几个字,不是标签。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
columnName,
|
||||
composeFile,
|
||||
inlineHtml,
|
||||
markdownBody,
|
||||
markdownSheetRows,
|
||||
parseMarkdown,
|
||||
safeFileName,
|
||||
} from '../file-compose.ts';
|
||||
import { docxBlocks, toFileDoc, xlsxRows } from '../file-doc.ts';
|
||||
import { readZip } from '../upload-extract.ts';
|
||||
|
||||
const MD = [
|
||||
'# 车规级 MCU 供货能力说明',
|
||||
'',
|
||||
'本文面向 **Itelma** 采购与供应链团队,说明我方在车规 MCU 上的供货保障。',
|
||||
'',
|
||||
'## 一、产能与库存',
|
||||
'',
|
||||
'- 常备安全库存:12 周',
|
||||
'- 二供已导入,切换周期 4 周',
|
||||
'',
|
||||
'## 二、价格与交期',
|
||||
'',
|
||||
'| 型号 | 交期(周) | 年用量 |',
|
||||
'| --- | --- | --- |',
|
||||
'| NE555P | 8 | 30000 |',
|
||||
'| DAG2400 | 12 | 25000 |',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'结论:可承诺 `98%` 的按期交付率。',
|
||||
].join('\n');
|
||||
|
||||
describe('parseMarkdown —— 认得出这几样,认不出的不吞字', () => {
|
||||
it('标题 / 段落 / 列表 / 表格 / 分隔线各归各位', () => {
|
||||
const blocks = parseMarkdown(MD);
|
||||
expect(blocks.map((b) => b.t)).toEqual(['h1', 'p', 'h2', 'ul', 'h2', 'table', 'hr', 'p']);
|
||||
});
|
||||
|
||||
it('表格的分隔行只说对齐,不算一行数据', () => {
|
||||
const table = parseMarkdown(MD).find((b) => b.t === 'table');
|
||||
expect(table).toBeDefined();
|
||||
if (table?.t !== 'table') throw new Error('不是表格块');
|
||||
expect(table.rows).toEqual([
|
||||
['型号', '交期(周)', '年用量'],
|
||||
['NE555P', '8', '30000'],
|
||||
['DAG2400', '12', '25000'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('认不出的行当段落原样留着 —— 宁可朴素也不吞内容', () => {
|
||||
const blocks = parseMarkdown('> 引用还不认\n\n普通段落');
|
||||
expect(blocks).toEqual([
|
||||
{ t: 'p', text: '> 引用还不认' },
|
||||
{ t: 'p', text: '普通段落' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('转义 —— 模型写的字进不了标签位', () => {
|
||||
it('正文里的 <script> 只是几个字', () => {
|
||||
const html = markdownBody('这里有 <script>alert(1)</script> 一段');
|
||||
expect(html).toContain('<script>');
|
||||
expect(html).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('先转义再上行内标记 —— 顺序反了就是把字当标签执行', () => {
|
||||
expect(inlineHtml('**粗** 与 `码` 与 <b>假标签</b>')).toBe(
|
||||
'<strong>粗</strong> 与 <code>码</code> 与 <b>假标签</b>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('md —— 原文即文件', () => {
|
||||
it('正文没有标题行就补一条:文件得自报家门', () => {
|
||||
const f = composeFile({ title: '供货能力说明', markdown: '正文直接开始', format: 'md' });
|
||||
expect(new TextDecoder().decode(f.bytes)).toBe('# 供货能力说明\n\n正文直接开始');
|
||||
expect(f.fileName).toBe('供货能力说明.md');
|
||||
});
|
||||
|
||||
it('正文自带标题就不再补', () => {
|
||||
const f = composeFile({ title: 'T', markdown: MD, format: 'md' });
|
||||
expect(new TextDecoder().decode(f.bytes).startsWith('# 车规级 MCU')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('html —— 整份文件,离线打开也是这个样子', () => {
|
||||
const f = composeFile({ title: '供货能力说明', markdown: MD, format: 'html' });
|
||||
const text = new TextDecoder().decode(f.bytes);
|
||||
|
||||
it('是一份完整文档:doctype + 自带样式 + 纸面容器', () => {
|
||||
expect(text.startsWith('<!doctype html>')).toBe(true);
|
||||
expect(text).toContain('<style>');
|
||||
expect(text).toContain('class="doc-paper"');
|
||||
});
|
||||
|
||||
it('表格是真表格,不是一段文字', () => {
|
||||
expect(text).toContain('<th>型号</th>');
|
||||
expect(text).toContain('<td>NE555P</td>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('docx —— 真 OOXML 包', () => {
|
||||
const f = composeFile({ title: '供货能力说明', markdown: MD, format: 'docx' });
|
||||
const zip = readZip(f.bytes);
|
||||
|
||||
it('该有的几份 XML 一份不缺(少一份 Word 就报文件损坏)', () => {
|
||||
expect([...zip.keys()].sort()).toEqual(
|
||||
['[Content_Types].xml', '_rels/.rels', 'word/_rels/document.xml.rels', 'word/document.xml', 'word/styles.xml'].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('MIME 与扩展名是 Word 那一套', () => {
|
||||
expect(f.fileName).toBe('供货能力说明.docx');
|
||||
expect(f.mime).toContain('wordprocessingml.document');
|
||||
});
|
||||
|
||||
it('读回来:标题、正文、列表、表格都在,层级没丢', () => {
|
||||
const xml = new TextDecoder().decode(zip.get('word/document.xml') ?? new Uint8Array());
|
||||
const blocks = docxBlocks(xml);
|
||||
expect(blocks[0]).toEqual({ t: 'h1', text: '供货能力说明' });
|
||||
expect(blocks.some((b) => b.t === 'h2' && b.text === '一、产能与库存')).toBe(true);
|
||||
const list = blocks.find((b) => b.t === 'ul');
|
||||
expect(list?.t === 'ul' ? list.items : []).toEqual(['常备安全库存:12 周', '二供已导入,切换周期 4 周']);
|
||||
// 表格按行摊成 p:列用「 | 」连
|
||||
expect(blocks.some((b) => b.t === 'p' && b.text === 'NE555P | 8 | 30000')).toBe(true);
|
||||
});
|
||||
|
||||
it('行内标记不带进 Word —— 留着 ** 反而像乱码', () => {
|
||||
const xml = new TextDecoder().decode(zip.get('word/document.xml') ?? new Uint8Array());
|
||||
expect(xml).toContain('Itelma');
|
||||
expect(xml).not.toContain('**Itelma**');
|
||||
});
|
||||
});
|
||||
|
||||
describe('xlsx —— 真 OOXML 包,表格原样成行', () => {
|
||||
const f = composeFile({ title: '交期对照表', markdown: MD, format: 'xlsx' });
|
||||
|
||||
it('该有的几份 XML 一份不缺', () => {
|
||||
expect([...readZip(f.bytes).keys()].sort()).toEqual(
|
||||
['[Content_Types].xml', '_rels/.rels', 'xl/_rels/workbook.xml.rels', 'xl/workbook.xml', 'xl/worksheets/sheet1.xml'].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('读回来:表格三行在,首行是表头', () => {
|
||||
const sheet = xlsxRows(f.bytes);
|
||||
expect(sheet?.sheet).toBe('交期对照表');
|
||||
const rows = sheet?.rows ?? [];
|
||||
expect(rows[0]).toEqual(['交期对照表']);
|
||||
expect(rows).toContainEqual(['型号', '交期(周)', '年用量']);
|
||||
expect(rows).toContainEqual(['NE555P', '8', '30000']);
|
||||
});
|
||||
|
||||
it('正文不丢:列表与段落各占一行', () => {
|
||||
const rows = markdownSheetRows('T', MD);
|
||||
expect(rows.some((r) => r[0] === '• 常备安全库存:12 周')).toBe(true);
|
||||
expect(rows.some((r) => (r[0] ?? '').startsWith('结论:可承诺'))).toBe(true);
|
||||
});
|
||||
|
||||
it('列号:0→A、25→Z、26→AA', () => {
|
||||
expect([columnName(0), columnName(25), columnName(26)]).toEqual(['A', 'Z', 'AA']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toFileDoc —— 前端拿到的就是这份文件读回来的样子', () => {
|
||||
const one = (format: 'md' | 'html' | 'docx' | 'xlsx') => {
|
||||
const f = composeFile({ title: '供货能力说明', markdown: MD, format });
|
||||
return toFileDoc({ name: f.fileName, title: '供货能力说明', format, bytes: f.bytes });
|
||||
};
|
||||
|
||||
it('四种格式各出各的查看形态', () => {
|
||||
expect(one('md')?.kind).toBe('markdown');
|
||||
expect(one('html')?.kind).toBe('html');
|
||||
expect(one('docx')?.kind).toBe('word');
|
||||
expect(one('xlsx')?.kind).toBe('excel');
|
||||
});
|
||||
|
||||
it('markdown 同时给原文与渲好的那一份(下载拿原文,屏上摆渲染版)', () => {
|
||||
const doc = one('md');
|
||||
if (doc?.kind !== 'markdown') throw new Error('不是 markdown');
|
||||
expect(doc.md).toContain('| NE555P | 8 | 30000 |');
|
||||
expect(doc.html).toContain('<td>NE555P</td>');
|
||||
});
|
||||
|
||||
it('**读不出来就返回 undefined** —— 不摆一个空壳假装渲染过', () => {
|
||||
const junk = new TextEncoder().encode('这不是 ZIP');
|
||||
expect(toFileDoc({ name: 'x.docx', title: 'x', format: 'docx', bytes: junk })).toBeUndefined();
|
||||
expect(toFileDoc({ name: 'x.xlsx', title: 'x', format: 'xlsx', bytes: junk })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('文件名', () => {
|
||||
it('路径分隔符这类字符换成下划线 —— 别让标题决定文件落在哪', () => {
|
||||
expect(safeFileName('a/b:c*d?"e<f>g|h', 'md')).toBe('a_b_c_d__e_f_g_h.md');
|
||||
});
|
||||
|
||||
it('标题整个是空白就退一个兜底名,不出「.docx」这种没名字的文件', () => {
|
||||
expect(safeFileName(' ', 'docx')).toBe('资料.docx');
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@
|
||||
* 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { gateAddressesOf, gateOf, rowChanged, writesOf } from '../routes/desk.ts';
|
||||
import { findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
|
||||
import { missingReadings } from '../wiring/engines.ts';
|
||||
|
||||
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
|
||||
@@ -68,6 +68,85 @@ describe('件上按保存:按地址收', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **人在件上加的那一行,主键得凑得齐** —— 凑不齐就被静默丢掉,而屏上那一行还在
|
||||
* (界面说加上了,台账里没有 —— 最坏的一类:它不报错)。
|
||||
*
|
||||
* 三类主键各有各的凑法(2026-08-23,审 I01 需求清单时逮到的):
|
||||
* · **件上没摆的那一列**(如一单一个的案号):本单现有行全表一致 → 就是它;
|
||||
* · **件上摆了的**:人填了就用他填的;
|
||||
* · **序号型(整数主键)**:件上摆着,可「加一行」那一下所有格子都被清空
|
||||
* (`ViewContainer.addRow` 如此)——各行序号本就互不相同,「全表一致」永远取不出,
|
||||
* 于是整行被丢。序号的语义就是「本轮内递增」,该由基座续:现有最大值 + 1。
|
||||
* I01 的需求清单主键正是 `round_ref + req_seq`,不补这一手,人加的需求就消失。
|
||||
*/
|
||||
describe('件上加行:主键凑得齐才落得下', () => {
|
||||
const KEYS = ['round_ref', 'req_seq'];
|
||||
const TYPES = { round_ref: 'text', req_seq: 'integer' };
|
||||
const DRAFTS = [
|
||||
{ key: { round_ref: 'CTB-1', req_seq: 1 } },
|
||||
{ key: { round_ref: 'CTB-2', req_seq: 2 } },
|
||||
];
|
||||
const SAME_ROUND = [
|
||||
{ key: { round_ref: 'CTB-1', req_seq: 1 } },
|
||||
{ key: { round_ref: 'CTB-1', req_seq: 2 } },
|
||||
];
|
||||
|
||||
it('老行:件上摆了序号,案号从全表一致里补', () => {
|
||||
expect(resolveRowKey({ keys: KEYS, cells: { req_seq: '2' }, drafts: SAME_ROUND, types: TYPES })).toEqual({
|
||||
round_ref: 'CTB-1',
|
||||
req_seq: '2',
|
||||
});
|
||||
});
|
||||
|
||||
it('**新增行:序号空着 → 基座续号(现有最大 + 1)**,不再静默丢掉', () => {
|
||||
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '案例集' }, drafts: SAME_ROUND, types: TYPES })).toEqual({
|
||||
round_ref: 'CTB-1',
|
||||
req_seq: '3',
|
||||
});
|
||||
});
|
||||
|
||||
it('空表上加第一行:序号从 1 起 —— 但案号取不出就还是不猜', () => {
|
||||
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '公司介绍' }, drafts: [], types: TYPES })).toBeUndefined();
|
||||
expect(
|
||||
resolveRowKey({ keys: KEYS, cells: { round_ref: 'CTB-9', content_type: '公司介绍' }, drafts: [], types: TYPES }),
|
||||
).toEqual({ round_ref: 'CTB-9', req_seq: '1' });
|
||||
});
|
||||
|
||||
/*
|
||||
* **只有一行时,那一行的序号不是「全表一致的常量」**(2026-08-23 浏览器实测逮到:
|
||||
* 人在只有一行的清单上加一行,新行拿到 req_seq=1,**当场把原来那一行覆盖掉** ——
|
||||
* 屏上两行、库里一行,而且内容还成了新的那份)。序号逐行不同是它的定义,
|
||||
* 一致只是行数太少的假象。
|
||||
*/
|
||||
it('**表里只有一行:加的新行续成 2,不覆盖原来那一行**', () => {
|
||||
const one = [{ key: { round_ref: 'CTB-1', req_seq: 1 } }];
|
||||
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '销售工具包' }, drafts: one, types: TYPES })).toEqual({
|
||||
round_ref: 'CTB-1',
|
||||
req_seq: '2',
|
||||
});
|
||||
});
|
||||
|
||||
it('老行照旧认得出来 —— 件上摆了序号(主键列必挂 data-col,它是行的钥匙)', () => {
|
||||
const one = [{ key: { round_ref: 'CTB-1', req_seq: 1 } }];
|
||||
expect(resolveRowKey({ keys: KEYS, cells: { req_seq: '1', scene: '展会' }, drafts: one, types: TYPES })).toEqual({
|
||||
round_ref: 'CTB-1',
|
||||
req_seq: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('案号本身取不出(一单跨了两个号)→ 不猜,如实跳过', () => {
|
||||
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '案例集' }, drafts: DRAFTS, types: TYPES })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('**文本主键不续号** —— 序号能续是因为它本就是「第几条」,PN 这种续不出来', () => {
|
||||
const textKeys = ['rfq_ref', 'pn'];
|
||||
const textTypes = { rfq_ref: 'text', pn: 'text' };
|
||||
const drafts = [{ key: { rfq_ref: 'R1', pn: 'A' } }, { key: { rfq_ref: 'R1', pn: 'B' } }];
|
||||
expect(resolveRowKey({ keys: textKeys, cells: { eau: '100' }, drafts, types: textTypes })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('提交闸:必填没填就交不了', () => {
|
||||
const gate = (rows: Array<Record<string, unknown>>) =>
|
||||
gateOf(missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]), [RECORD]);
|
||||
@@ -298,3 +377,64 @@ describe('自增主键:前缀-两位序号', () => {
|
||||
expect(gen([], {}, 4, ['seq'])).toEqual({ seq: '05' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('主键摆不全时认现有行:一格都没认出来的碎片不许命中随便第一行', () => {
|
||||
const drafts = [
|
||||
{ key: { round_ref: 'R1', contact_code: 'CU-0142-01' } },
|
||||
{ key: { round_ref: 'R1', contact_code: 'CU-0087-02' } },
|
||||
{ key: { round_ref: 'R1', contact_code: 'CU-0087-01' } },
|
||||
];
|
||||
const keys = ['round_ref', 'contact_code'];
|
||||
|
||||
it('至少一格对得上 → 命中那一行(正常路径)', () => {
|
||||
expect(findKnownRow(drafts, keys, { contact_code: 'CU-0087-02' })?.key).toEqual({
|
||||
round_ref: 'R1',
|
||||
contact_code: 'CU-0087-02',
|
||||
});
|
||||
});
|
||||
|
||||
it('**一格主键都没给的碎片**(如渲染多嵌一层导致 collect() 拆出的空键分组)→ 不命中第一行,回 undefined', () => {
|
||||
expect(findKnownRow(drafts, keys, {})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('给了但一个都对不上 → 同样不命中(不是「随便一行」,是「哪行都不是」)', () => {
|
||||
expect(findKnownRow(drafts, keys, { contact_code: 'CU-9999-01' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **点开一份资料文件时,那一行是怎么认出来的**(2026-08-23)。
|
||||
*
|
||||
* 件上传来的 `key` 是一串字符,而"哪一行"是主键说了算。这一段的要害是
|
||||
* **只认声明里的主键列** —— 不然一个精心构造的 key 就能把别的行当成"这一份资料"取走。
|
||||
*/
|
||||
describe('parseRowKey —— 件上传来的主键认哪一行', () => {
|
||||
it('单列主键:直接给值', () => {
|
||||
expect(parseRowKey('CT-2026-0001', ['content_id'])).toEqual({ content_id: 'CT-2026-0001' });
|
||||
});
|
||||
|
||||
it('多列主键:列=值 以逗号连,顺序无所谓', () => {
|
||||
expect(parseRowKey('req_seq=2,round_ref=BR-1', ['round_ref', 'req_seq'])).toEqual({
|
||||
round_ref: 'BR-1',
|
||||
req_seq: '2',
|
||||
});
|
||||
});
|
||||
|
||||
it('**声明里没有的列名一律不认** —— 别让一串 key 把别的行取走', () => {
|
||||
expect(parseRowKey('content_id=A,secret=1', ['content_id'])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('主键给不全:不认(宁可打不开,也不打开错的那一份)', () => {
|
||||
expect(parseRowKey('round_ref=BR-1', ['round_ref', 'req_seq'])).toBeUndefined();
|
||||
expect(parseRowKey('round_ref=BR-1,req_seq=', ['round_ref', 'req_seq'])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('多列主键却只给了个裸值:不认(猜不出它是哪一列)', () => {
|
||||
expect(parseRowKey('BR-1', ['round_ref', 'req_seq'])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('空串 / 没有主键列:不认', () => {
|
||||
expect(parseRowKey('', ['content_id'])).toBeUndefined();
|
||||
expect(parseRowKey('X', [])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -884,7 +884,13 @@ export function normalizeAction(object: unknown, enabled: EnabledSet): AgentActi
|
||||
* 这里只是不让一个词的叫法差异吃掉一整拍。
|
||||
*/
|
||||
const kind = String(raw['kind'] ?? raw['action'] ?? '');
|
||||
const itemId = String(raw['itemId'] ?? enabled.entries[0]?.id ?? '');
|
||||
/*
|
||||
* 项号兜底连**进行中**的项一起看(2026-08-23):从前只兜「当下可为」的第一项,
|
||||
* 而依赖断点那一拍它恰恰是空的 —— 兜出来的是空串,兜底卡于是带着空项号发出去,
|
||||
* 被引擎自己的门拦下(最后的活路自己被砌了墙)。引擎那一侧已改成「挂不上照发」,
|
||||
* 这里再挑得准一点:在场的项优先挂上,记账才对得上。
|
||||
*/
|
||||
const itemId = String(raw['itemId'] ?? enabled.entries[0]?.id ?? enabled.inProgress[0]?.id ?? '');
|
||||
|
||||
switch (kind) {
|
||||
case 'start':
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 产出的资料文件**存档** —— 放行那一刻把它冻住。
|
||||
*
|
||||
* 在办期间文件是台账那一行的投影(现造,见 `file-compose.ts`):台账改一格,文件跟着变,
|
||||
* 两处永远一致 —— 那时这样最好。**收工之后不该再变**:归档件说的是「他签字那一刻
|
||||
* 交出去的是什么」,台账后来被下一单改了,归档的那份不能跟着改口。
|
||||
*
|
||||
* 还有一条更硬的理由:资料常落在**主数据**上(如 `content.asset` 品牌内容库),
|
||||
* 而试跑单放行时主数据只留在活动缓存、不落正式表(免得试跑往正式库里塞真资产)——
|
||||
* 不存这一份,试跑单一放行,人在工作成果里就什么都点不开了。
|
||||
*
|
||||
* 一单一行一份,重复放行覆盖(不留两份说法不一的档)。
|
||||
*/
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { TaskId } from '@contracts';
|
||||
import { composeFile, isFileFormat, type FileFormat } from './file-compose.ts';
|
||||
|
||||
/** 存档里的一份 —— 列表用(不带字节) */
|
||||
export type ArchivedAssetFile = {
|
||||
readonly id: string;
|
||||
readonly address: string;
|
||||
readonly key: Record<string, string>;
|
||||
readonly title: string;
|
||||
readonly format: FileFormat;
|
||||
readonly name: string;
|
||||
readonly size: number;
|
||||
readonly at: string;
|
||||
};
|
||||
|
||||
type Db = { execute(query: unknown): Promise<unknown> };
|
||||
|
||||
function rowsOf<T>(result: unknown): T[] {
|
||||
if (Array.isArray(result)) return result as T[];
|
||||
const rows = (result as { rows?: unknown }).rows;
|
||||
return Array.isArray(rows) ? (rows as T[]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 主键 → 一个**稳定的**字符串(按列名排序后拼)。
|
||||
* 稳定是要害:存的时候与取的时候拼法不一样,那一行就永远对不上。
|
||||
*/
|
||||
export function keyText(key: Readonly<Record<string, unknown>>): string {
|
||||
return Object.keys(key)
|
||||
.sort()
|
||||
.map((k) => `${k}=${String(key[k] ?? '')}`)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
/** 这一单已存档的资料(不含字节) */
|
||||
export async function listArchived(db: Db, taskId: TaskId): Promise<ArchivedAssetFile[]> {
|
||||
const rows = rowsOf<{
|
||||
id: string;
|
||||
address: string;
|
||||
row_key: unknown;
|
||||
title: string;
|
||||
format: string;
|
||||
name: string;
|
||||
size: number;
|
||||
created_at: unknown;
|
||||
}>(
|
||||
await db.execute(sql`
|
||||
select id, address, row_key, title, format, name, size, created_at
|
||||
from sys.task_asset_file where task_id = ${taskId} order by created_at, name`),
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
address: r.address,
|
||||
key: (typeof r.row_key === 'object' && r.row_key !== null ? r.row_key : {}) as Record<string, string>,
|
||||
title: r.title,
|
||||
format: isFileFormat(r.format) ? r.format : 'md',
|
||||
name: r.name,
|
||||
size: Number(r.size ?? 0),
|
||||
at: r.created_at instanceof Date ? r.created_at.toISOString() : String(r.created_at),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 取存档里的那一份字节 —— 按 (单, 地址, 主键) 认 */
|
||||
export async function readArchived(
|
||||
db: Db,
|
||||
taskId: TaskId,
|
||||
address: string,
|
||||
key: Readonly<Record<string, string>>,
|
||||
): Promise<{ bytes: Uint8Array; title: string; format: FileFormat; name: string } | undefined> {
|
||||
const rows = rowsOf<{ bytes: unknown; title: string; format: string; name: string }>(
|
||||
await db.execute(sql`
|
||||
select bytes, title, format, name from sys.task_asset_file
|
||||
where task_id = ${taskId} and address = ${address} and row_key = ${JSON.stringify(key)}::jsonb
|
||||
limit 1`),
|
||||
);
|
||||
const one = rows[0];
|
||||
if (one === undefined) return undefined;
|
||||
const raw = one.bytes;
|
||||
/*
|
||||
* **必须拷一份成独立的 Uint8Array** —— pg 取回来的是 Node `Buffer`,
|
||||
* 而 Buffer 挂在一块**共享的 64KB 内存池**上:`buf.slice()` 是视图不是拷贝,
|
||||
* `.buffer` 拿到的是整个池子。直接交出去,一份 3KB 的文件会变成 65536 字节
|
||||
* (后面全是别人的残渣)—— 实测下载三份资料,三份都是 65536。
|
||||
* `Uint8Array.from` 按元素拷,新数组的 buffer 恰好这么长。
|
||||
*/
|
||||
const bytes =
|
||||
raw instanceof Uint8Array
|
||||
? Uint8Array.from(raw)
|
||||
: typeof raw === 'string'
|
||||
? Uint8Array.from(Buffer.from(raw, 'hex'))
|
||||
: undefined;
|
||||
if (bytes === undefined) return undefined;
|
||||
return { bytes, title: one.title, format: isFileFormat(one.format) ? one.format : 'md', name: one.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把这一单产出的资料**全部存一份**。放行落地之前调 —— 那时草稿还在、人的修订也已落回,
|
||||
* 存下来的正是他签字那一刻的样子。
|
||||
*
|
||||
* 返回存了几份;一份都没有(活动没声明 `file` 段、或一行都没写)时返回 0,不报错 ——
|
||||
* 大多数活动本来就不产资料文件。
|
||||
*/
|
||||
export async function archiveAssetFiles(input: {
|
||||
readonly db: Db;
|
||||
readonly taskId: TaskId;
|
||||
readonly ids: { next(): string };
|
||||
/** 活动清单声明的台账对象(带 `file` 段的才算) */
|
||||
readonly declarations: ReadonlyArray<{
|
||||
domain: string;
|
||||
object: string;
|
||||
fields: ReadonlyArray<{ name: string; primaryKey?: boolean }>;
|
||||
file?: { titleColumn: string; bodyColumn: string; formatColumn: string } | undefined;
|
||||
}>;
|
||||
/** 本单在该地址写下的行(正式版 + 本单草稿) */
|
||||
readonly rowsOfAddress: (address: { domain: string; object: string }) => Promise<Array<Record<string, unknown>>>;
|
||||
}): Promise<number> {
|
||||
let saved = 0;
|
||||
for (const declared of input.declarations) {
|
||||
const spec = declared.file;
|
||||
if (spec === undefined) continue;
|
||||
const keys = declared.fields.filter((f) => f.primaryKey === true).map((f) => f.name);
|
||||
if (keys.length === 0) continue;
|
||||
|
||||
const rows = await input.rowsOfAddress({ domain: declared.domain, object: declared.object });
|
||||
for (const row of rows) {
|
||||
const markdown = String(row[spec.bodyColumn] ?? '').trim();
|
||||
if (markdown === '') continue; // 没正文的行不是资料,别存一份空壳
|
||||
const key: Record<string, string> = {};
|
||||
for (const k of keys) key[k] = String(row[k] ?? '');
|
||||
if (Object.values(key).some((v) => v === '')) continue; // 主键不全的行认不出来,存了也取不回
|
||||
|
||||
const title = String(row[spec.titleColumn] ?? '').trim() || Object.values(key).join('-');
|
||||
const rawFormat = String(row[spec.formatColumn] ?? '').trim().toLowerCase();
|
||||
const format: FileFormat = isFileFormat(rawFormat) ? rawFormat : 'md';
|
||||
const composed = composeFile({ title, markdown, format });
|
||||
const address = `${declared.domain}.${declared.object}`;
|
||||
|
||||
await input.db.execute(sql`
|
||||
insert into sys.task_asset_file (id, task_id, address, row_key, title, format, name, bytes, size)
|
||||
values (${input.ids.next()}, ${input.taskId}, ${address}, ${JSON.stringify(key)}::jsonb,
|
||||
${title}, ${format}, ${composed.fileName}, ${Buffer.from(composed.bytes)}, ${composed.bytes.length})
|
||||
on conflict (task_id, address, row_key) do update set
|
||||
title = excluded.title, format = excluded.format, name = excluded.name,
|
||||
bytes = excluded.bytes, size = excluded.size, created_at = now()`);
|
||||
saved += 1;
|
||||
}
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export type AppConfig = {
|
||||
readonly seedPassword: string;
|
||||
};
|
||||
|
||||
const DEFAULT_DATABASE_URL = 'postgres://jcai:jcai@localhost:54329/jcai';
|
||||
const DEFAULT_DATABASE_URL = 'postgres://jcai:jcai@localhost:54329/jcai_wos';
|
||||
/** 开发缺省口令 —— 播种时在 stdout 明示,生产须以 `JCAI_SEED_PASSWORD` 覆盖 */
|
||||
const DEFAULT_SEED_PASSWORD = 'jcai-dev-2026';
|
||||
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* 资料文件**现造** —— 进「标题 + Markdown 正文 + 要哪种格式」,出真字节。
|
||||
*
|
||||
* 为什么是现造而不是另存一份文件:资料的正身是**内容**,而内容本来就落在台账里。
|
||||
* 再存一份字节,同一份事实两处各存一份 —— 改了台账那份文件就成了旧的,而没人看得出
|
||||
* 以哪份为准。现造的这一份永远与台账一致,下载拿到的又是真 .docx / .xlsx
|
||||
* (Word / Excel 打得开),两头都不将就。
|
||||
*
|
||||
* 不引第三方库 —— 与 `upload-extract.ts` 同一条路:docx / xlsx 本质是 ZIP 包着几份 XML,
|
||||
* 写一个只做 store(不压缩)的 ZIP 打包器就够了,为一件事拉进一整个文档库不值当。
|
||||
*
|
||||
* 这一份是**纯计算**:进字符串、出字节,不碰库不碰网,所以能逐条测。
|
||||
*/
|
||||
import { crc32Of, zipStore } from './zip-write.ts';
|
||||
|
||||
export const FILE_FORMATS = ['md', 'html', 'docx', 'xlsx'] as const;
|
||||
export type FileFormat = (typeof FILE_FORMATS)[number];
|
||||
|
||||
export const isFileFormat = (v: unknown): v is FileFormat =>
|
||||
typeof v === 'string' && (FILE_FORMATS as readonly string[]).includes(v);
|
||||
|
||||
/** 一份造好的文件 —— 字节 + 它该叫什么 + 该以什么 MIME 交出去 */
|
||||
export type ComposedFile = {
|
||||
readonly bytes: Uint8Array;
|
||||
readonly fileName: string;
|
||||
readonly mime: string;
|
||||
readonly format: FileFormat;
|
||||
};
|
||||
|
||||
/** 各格式该以什么 MIME 交出去 —— 存档那一路也认它(`desk.ts`) */
|
||||
export const MIME_OF: Record<FileFormat, string> = {
|
||||
md: 'text/markdown; charset=utf-8',
|
||||
html: 'text/html; charset=utf-8',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
|
||||
/* ==================== Markdown → 结构块 ==================== */
|
||||
|
||||
export type MdBlock =
|
||||
| { readonly t: 'h1' | 'h2' | 'h3' | 'p'; readonly text: string }
|
||||
/* ul / ol 各成一员 —— 合成 `'ul' | 'ol'` 一个成员时,判过这两个分支
|
||||
TS 也不会把它从联合里剔掉,后面取 `text` 就通不过 */
|
||||
| { readonly t: 'ul'; readonly items: readonly string[] }
|
||||
| { readonly t: 'ol'; readonly items: readonly string[] }
|
||||
| { readonly t: 'table'; readonly rows: ReadonlyArray<readonly string[]> }
|
||||
| { readonly t: 'hr' };
|
||||
|
||||
const TABLE_SPLIT = /\s*\|\s*/;
|
||||
|
||||
/** 一行表格 `| a | b |` → ['a','b'](首尾的竖线不算列) */
|
||||
function tableCells(line: string): string[] {
|
||||
const inner = line.trim().replace(/^\|/, '').replace(/\|$/, '');
|
||||
return inner.split(TABLE_SPLIT).map((c) => c.trim());
|
||||
}
|
||||
|
||||
const isTableLine = (line: string): boolean => line.trim().startsWith('|') && line.trim().endsWith('|');
|
||||
/** 分隔行 `|---|:--:|` —— 它只说对齐,不是数据 */
|
||||
const isTableRule = (line: string): boolean => isTableLine(line) && /^[\s|:-]+$/.test(line);
|
||||
|
||||
/**
|
||||
* Markdown 子集 → 块。**认的就是这几样**:标题(#/##/###)、无序列表(-/*)、
|
||||
* 有序列表(1.)、表格(GFM)、分隔线(---)、段落。
|
||||
*
|
||||
* 为什么只认一个子集:这些文件是给人看的资料(白皮书 / 规格书 / 对照表),
|
||||
* 上面这几样够写;认得越多、docx/xlsx 那一侧要对应的东西越多,而每多一样
|
||||
* 都是一处可能与前端渲染不一致的地方。**认不出的一律当段落原样保留**,不吞字。
|
||||
*/
|
||||
export function parseMarkdown(md: string): MdBlock[] {
|
||||
const lines = md.replace(/\r\n?/g, '\n').split('\n');
|
||||
const blocks: MdBlock[] = [];
|
||||
let para: string[] = [];
|
||||
|
||||
const flushPara = (): void => {
|
||||
if (para.length === 0) return;
|
||||
blocks.push({ t: 'p', text: para.join(' ').trim() });
|
||||
para = [];
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i] ?? '';
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed === '') {
|
||||
flushPara();
|
||||
continue;
|
||||
}
|
||||
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
|
||||
flushPara();
|
||||
blocks.push({ t: 'hr' });
|
||||
continue;
|
||||
}
|
||||
const heading = /^(#{1,3})\s+(.*)$/.exec(trimmed);
|
||||
if (heading !== null) {
|
||||
flushPara();
|
||||
const depth = (heading[1] ?? '#').length;
|
||||
blocks.push({ t: depth === 1 ? 'h1' : depth === 2 ? 'h2' : 'h3', text: (heading[2] ?? '').trim() });
|
||||
continue;
|
||||
}
|
||||
if (isTableLine(trimmed)) {
|
||||
flushPara();
|
||||
const rows: string[][] = [];
|
||||
while (i < lines.length && isTableLine(lines[i] ?? '')) {
|
||||
const one = lines[i] ?? '';
|
||||
if (!isTableRule(one)) rows.push(tableCells(one));
|
||||
i += 1;
|
||||
}
|
||||
i -= 1;
|
||||
if (rows.length > 0) blocks.push({ t: 'table', rows });
|
||||
continue;
|
||||
}
|
||||
if (/^[-*+]\s+/.test(trimmed)) {
|
||||
flushPara();
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i] ?? '')) {
|
||||
items.push((lines[i] ?? '').trim().replace(/^[-*+]\s+/, ''));
|
||||
i += 1;
|
||||
}
|
||||
i -= 1;
|
||||
blocks.push({ t: 'ul', items });
|
||||
continue;
|
||||
}
|
||||
if (/^\d+[.)]\s+/.test(trimmed)) {
|
||||
flushPara();
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i] ?? '')) {
|
||||
items.push((lines[i] ?? '').trim().replace(/^\d+[.)]\s+/, ''));
|
||||
i += 1;
|
||||
}
|
||||
i -= 1;
|
||||
blocks.push({ t: 'ol', items });
|
||||
continue;
|
||||
}
|
||||
para.push(trimmed);
|
||||
}
|
||||
flushPara();
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/* ==================== 行内:粗体 / 斜体 / 代码 ==================== */
|
||||
|
||||
export function escapeXml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** 行内标记 → HTML。**先转义再上标记** —— 顺序反了就是把模型写的字当标签执行 */
|
||||
export function inlineHtml(text: string): string {
|
||||
return escapeXml(text)
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
||||
}
|
||||
|
||||
/** 行内标记**去掉**(docx / xlsx 的文本格里不做富文本,留记号反而像乱码) */
|
||||
export function inlinePlain(text: string): string {
|
||||
return text
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||
.replace(/(^|[^*])\*([^*]+)\*/g, '$1$2');
|
||||
}
|
||||
|
||||
/* ==================== md → HTML(前端渲染也用这一份) ==================== */
|
||||
|
||||
/**
|
||||
* 正文骨架 —— 只吐这几种标签,内容全程转义:摆进沙箱是安全的,
|
||||
* 而且前端 Markdown 查看面与下载到的 .html 长得一模一样(同一段函数出的)。
|
||||
*/
|
||||
export function markdownBody(md: string): string {
|
||||
return parseMarkdown(md)
|
||||
.map((b) => {
|
||||
if (b.t === 'hr') return '<hr />';
|
||||
if (b.t === 'ul') return `<ul>${b.items.map((i) => `<li>${inlineHtml(i)}</li>`).join('')}</ul>`;
|
||||
if (b.t === 'ol') return `<ol>${b.items.map((i) => `<li>${inlineHtml(i)}</li>`).join('')}</ol>`;
|
||||
if (b.t === 'table') {
|
||||
const [head, ...rest] = b.rows;
|
||||
const thead =
|
||||
head === undefined ? '' : `<thead><tr>${head.map((c) => `<th>${inlineHtml(c)}</th>`).join('')}</tr></thead>`;
|
||||
const tbody = rest.map((r) => `<tr>${r.map((c) => `<td>${inlineHtml(c)}</td>`).join('')}</tr>`).join('');
|
||||
return `<div class="doc-tbl"><table>${thead}<tbody>${tbody}</tbody></table></div>`;
|
||||
}
|
||||
return `<${b.t}>${inlineHtml(b.text)}</${b.t}>`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** 纸面样式 —— 下载的 .html 与前端查看面共用,两处看着是同一份文件 */
|
||||
export const DOC_CSS = `
|
||||
:root { color-scheme: light; }
|
||||
body { margin: 0; background: #f4f5f7; }
|
||||
.doc-paper { max-width: 820px; margin: 0 auto; padding: 46px 56px 60px; background: #fff; color: #1f2329;
|
||||
font: 14px/1.85 "PingFang SC", "Microsoft YaHei", -apple-system, "Segoe UI", sans-serif; }
|
||||
.doc-paper h1 { font-size: 25px; line-height: 1.35; margin: 0 0 18px; padding-bottom: 12px; border-bottom: 2px solid #1f2329; }
|
||||
.doc-paper h2 { font-size: 18px; margin: 30px 0 10px; padding-left: 10px; border-left: 3px solid #3370ff; }
|
||||
.doc-paper h3 { font-size: 15px; margin: 22px 0 8px; color: #3d4046; }
|
||||
.doc-paper p { margin: 0 0 11px; }
|
||||
.doc-paper ul, .doc-paper ol { margin: 0 0 12px; padding-left: 22px; }
|
||||
.doc-paper li { margin: 3px 0; }
|
||||
.doc-paper hr { border: 0; border-top: 1px solid #dfe1e5; margin: 22px 0; }
|
||||
.doc-paper code { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px;
|
||||
background: #f2f3f5; padding: 1px 5px; border-radius: 4px; }
|
||||
.doc-paper strong { font-weight: 700; }
|
||||
.doc-tbl { overflow-x: auto; margin: 0 0 14px; }
|
||||
.doc-paper table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
.doc-paper th, .doc-paper td { border: 1px solid #dfe1e5; padding: 7px 10px; text-align: left; vertical-align: top; }
|
||||
.doc-paper th { background: #f7f8fa; font-weight: 700; }
|
||||
@media (max-width: 720px) { .doc-paper { padding: 26px 20px 34px; } }
|
||||
`.trim();
|
||||
|
||||
/** 整份 HTML 文件(下载的那一份 —— 自带样式,离线打开也是这个样子) */
|
||||
export function markdownHtmlDoc(title: string, md: string): string {
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${escapeXml(title)}</title>
|
||||
<style>${DOC_CSS}</style></head>
|
||||
<body><article class="doc-paper">${markdownBody(md)}</article></body></html>`;
|
||||
}
|
||||
|
||||
/* ==================== md → docx(真 OOXML) ==================== */
|
||||
|
||||
const DOCX_CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
||||
</Types>`;
|
||||
|
||||
const DOCX_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>`;
|
||||
|
||||
const DOCX_DOC_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
</Relationships>`;
|
||||
|
||||
/** 只定义用到的那几个段落样式 —— Word 靠 styleId 认标题层级(导航窗格据此出目录) */
|
||||
const DOCX_STYLES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:pPr><w:spacing w:after="240"/></w:pPr><w:rPr><w:b/><w:sz w:val="44"/></w:rPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:pPr><w:outlineLvl w:val="0"/><w:spacing w:before="280" w:after="120"/></w:pPr><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:pPr><w:outlineLvl w:val="1"/><w:spacing w:before="240" w:after="100"/></w:pPr><w:rPr><w:b/><w:sz w:val="26"/></w:rPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="ListParagraph"><w:name w:val="List Paragraph"/><w:pPr><w:ind w:left="420"/></w:pPr></w:style>
|
||||
</w:styles>`;
|
||||
|
||||
const wRun = (text: string): string =>
|
||||
`<w:r><w:t xml:space="preserve">${escapeXml(inlinePlain(text))}</w:t></w:r>`;
|
||||
|
||||
const wPara = (text: string, style?: string): string =>
|
||||
`<w:p>${style === undefined ? '' : `<w:pPr><w:pStyle w:val="${style}"/></w:pPr>`}${wRun(text)}</w:p>`;
|
||||
|
||||
/** 表格:一行一 `<w:tr>`,首行加底纹当表头 —— Word 里是真表格,能选能改 */
|
||||
function wTable(rows: ReadonlyArray<readonly string[]>): string {
|
||||
const cols = Math.max(...rows.map((r) => r.length), 1);
|
||||
const width = Math.floor(9000 / cols);
|
||||
const cell = (text: string, head: boolean): string =>
|
||||
`<w:tc><w:tcPr><w:tcW w:w="${width}" w:type="dxa"/>${head ? '<w:shd w:val="clear" w:fill="F2F3F5"/>' : ''}</w:tcPr>` +
|
||||
`<w:p>${head ? '<w:pPr><w:rPr><w:b/></w:rPr></w:pPr>' : ''}${wRun(text)}</w:p></w:tc>`;
|
||||
const body = rows
|
||||
.map(
|
||||
(r, ri) =>
|
||||
`<w:tr>${Array.from({ length: cols }, (_, ci) => cell(r[ci] ?? '', ri === 0)).join('')}</w:tr>`,
|
||||
)
|
||||
.join('');
|
||||
return (
|
||||
`<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/>` +
|
||||
`<w:tblBorders>${['top', 'left', 'bottom', 'right', 'insideH', 'insideV']
|
||||
.map((s) => `<w:${s} w:val="single" w:sz="4" w:color="DFE1E5"/>`)
|
||||
.join('')}</w:tblBorders></w:tblPr>${body}</w:tbl>`
|
||||
);
|
||||
}
|
||||
|
||||
export function markdownDocxXml(title: string, md: string): string {
|
||||
const body = parseMarkdown(md)
|
||||
.map((b) => {
|
||||
if (b.t === 'hr') return '<w:p><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="6" w:color="DFE1E5"/></w:pBdr></w:pPr></w:p>';
|
||||
if (b.t === 'ul') return b.items.map((i) => wPara(`• ${i}`, 'ListParagraph')).join('');
|
||||
if (b.t === 'ol') return b.items.map((i, n) => wPara(`${n + 1}. ${i}`, 'ListParagraph')).join('');
|
||||
if (b.t === 'table') return wTable(b.rows);
|
||||
if (b.t === 'h1') return wPara(b.text, 'Heading1');
|
||||
if (b.t === 'h2') return wPara(b.text, 'Heading2');
|
||||
if (b.t === 'h3') return wPara(b.text, 'Heading2');
|
||||
return wPara(b.text);
|
||||
})
|
||||
.join('');
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${wPara(title, 'Title')}${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134"/></w:sectPr></w:body></w:document>`;
|
||||
}
|
||||
|
||||
/* ==================== md → xlsx(真 OOXML) ==================== */
|
||||
|
||||
const XLSX_CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||
</Types>`;
|
||||
|
||||
const XLSX_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||
</Relationships>`;
|
||||
|
||||
const XLSX_WB_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
||||
</Relationships>`;
|
||||
|
||||
const xlsxWorkbook = (sheetName: string): string => `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<sheets><sheet name="${escapeXml(sheetName).slice(0, 31)}" sheetId="1" r:id="rId1"/></sheets></workbook>`;
|
||||
|
||||
/** 列号 0 → A,26 → AA */
|
||||
export function columnName(index: number): string {
|
||||
let n = index + 1;
|
||||
let out = '';
|
||||
while (n > 0) {
|
||||
const rem = (n - 1) % 26;
|
||||
out = String.fromCharCode(65 + rem) + out;
|
||||
n = Math.floor((n - 1) / 26);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格化:**表格块原样成行**,其余块各占一行(标题前空一行)——
|
||||
* 一份规格书导成 Excel,人要的是那几张对照表,而不是把正文丢掉。
|
||||
*/
|
||||
export function markdownSheetRows(title: string, md: string): string[][] {
|
||||
const rows: string[][] = [[title]];
|
||||
for (const b of parseMarkdown(md)) {
|
||||
if (b.t === 'hr') continue;
|
||||
if (b.t === 'table') {
|
||||
rows.push([]);
|
||||
for (const r of b.rows) rows.push(r.map((c) => inlinePlain(c)));
|
||||
continue;
|
||||
}
|
||||
if (b.t === 'ul') {
|
||||
for (const i of b.items) rows.push([`• ${inlinePlain(i)}`]);
|
||||
continue;
|
||||
}
|
||||
if (b.t === 'ol') {
|
||||
b.items.forEach((i, n) => rows.push([`${n + 1}. ${inlinePlain(i)}`]));
|
||||
continue;
|
||||
}
|
||||
if (b.t === 'h1' || b.t === 'h2' || b.t === 'h3') rows.push([]);
|
||||
rows.push([inlinePlain(b.text)]);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 值全部按 inlineStr 写 —— 不建共享串表:少一份要对齐的索引,少一处能错的地方 */
|
||||
function xlsxSheet(rows: ReadonlyArray<readonly string[]>): string {
|
||||
const body = rows
|
||||
.map((cells, ri) => {
|
||||
const tds = cells
|
||||
.map((v, ci) =>
|
||||
v === ''
|
||||
? ''
|
||||
: `<c r="${columnName(ci)}${ri + 1}" t="inlineStr"><is><t xml:space="preserve">${escapeXml(v)}</t></is></c>`,
|
||||
)
|
||||
.join('');
|
||||
return `<row r="${ri + 1}">${tds}</row>`;
|
||||
})
|
||||
.join('');
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${body}</sheetData></worksheet>`;
|
||||
}
|
||||
|
||||
/* ==================== 出口 ==================== */
|
||||
|
||||
/** 文件名里不许出现的字符(跨平台取交集),一律换成下划线 */
|
||||
export function safeFileName(title: string, format: FileFormat): string {
|
||||
const base = title.replace(/[\\/:*?"<>|\n\r\t]/g, '_').trim().slice(0, 80) || '资料';
|
||||
return `${base}.${format}`;
|
||||
}
|
||||
|
||||
export function composeFile(input: {
|
||||
readonly title: string;
|
||||
readonly markdown: string;
|
||||
readonly format: FileFormat;
|
||||
}): ComposedFile {
|
||||
const { title, markdown, format } = input;
|
||||
const enc = new TextEncoder();
|
||||
const common = { fileName: safeFileName(title, format), mime: MIME_OF[format], format };
|
||||
|
||||
if (format === 'md') {
|
||||
// 原文里没有标题行就补一条 —— 文件得自报家门(台账那一列是标题,正文常常直接从正文起)
|
||||
const head = /^\s*#\s+/.test(markdown) ? '' : `# ${title}\n\n`;
|
||||
return { ...common, bytes: enc.encode(head + markdown) };
|
||||
}
|
||||
if (format === 'html') return { ...common, bytes: enc.encode(markdownHtmlDoc(title, markdown)) };
|
||||
if (format === 'docx') {
|
||||
return {
|
||||
...common,
|
||||
bytes: zipStore([
|
||||
['[Content_Types].xml', enc.encode(DOCX_CONTENT_TYPES)],
|
||||
['_rels/.rels', enc.encode(DOCX_RELS)],
|
||||
['word/_rels/document.xml.rels', enc.encode(DOCX_DOC_RELS)],
|
||||
['word/styles.xml', enc.encode(DOCX_STYLES)],
|
||||
['word/document.xml', enc.encode(markdownDocxXml(title, markdown))],
|
||||
]),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
bytes: zipStore([
|
||||
['[Content_Types].xml', enc.encode(XLSX_CONTENT_TYPES)],
|
||||
['_rels/.rels', enc.encode(XLSX_RELS)],
|
||||
['xl/_rels/workbook.xml.rels', enc.encode(XLSX_WB_RELS)],
|
||||
['xl/workbook.xml', enc.encode(xlsxWorkbook(title))],
|
||||
['xl/worksheets/sheet1.xml', enc.encode(xlsxSheet(markdownSheetRows(title, markdown)))],
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
export { crc32Of };
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 造好的资料文件**读回来给前端渲染** —— 进字节,出 `FileDoc`。
|
||||
*
|
||||
* 为什么绕这一圈,而不是直接把 Markdown 原文丢给前端按格式摆:
|
||||
* **那样界面渲的就不是那份文件了**。人下载到的是 .docx,屏上看到的是另一条路
|
||||
* 拼出来的样子,两边哪天不一致谁也发现不了。这里读的就是 `composeFile` 出的那一份
|
||||
* 字节:docx 解出段落、xlsx 解出行列 —— 渲得出来,才证明那份文件是真的。
|
||||
*
|
||||
* 纯计算:进字节、出结构,不碰库不碰网。
|
||||
*/
|
||||
import type { FileDoc } from '@contracts';
|
||||
import { markdownHtmlDoc, type FileFormat } from './file-compose.ts';
|
||||
import { readZip, sharedStrings, sheetToRows, unescapeXml } from './upload-extract.ts';
|
||||
|
||||
/** docx 的一段:`<w:p>` 里所有 `<w:t>` 连起来就是这一段的文字 */
|
||||
function docxParagraphs(xml: string): Array<{ style: string; text: string }> {
|
||||
const out: Array<{ style: string; text: string }> = [];
|
||||
for (const p of xml.matchAll(/<w:p\b[^>]*>([\s\S]*?)<\/w:p>/g)) {
|
||||
const inner = p[1] ?? '';
|
||||
const style = /<w:pStyle\s+w:val="([^"]+)"/.exec(inner)?.[1] ?? '';
|
||||
const text = [...inner.matchAll(/<w:t\b[^>]*>([\s\S]*?)<\/w:t>/g)].map((t) => unescapeXml(t[1] ?? '')).join('');
|
||||
out.push({ style, text });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 查看面认得的块 —— 与 `FileDoc` 的 word 分支同形 */
|
||||
export type WordBlock = { t: 'h1' | 'h2' | 'p'; text: string } | { t: 'ul'; items: string[] };
|
||||
|
||||
/**
|
||||
* docx → 查看面的块。表格在 `<w:tbl>` 里,而查看面(`FileViewer` 的 word 分支)
|
||||
* 只认 h1/h2/p/ul —— 表格按行摊成 `p`(用「 | 」连列),**宁可朴素也不丢内容**。
|
||||
*/
|
||||
export function docxBlocks(xml: string): WordBlock[] {
|
||||
const blocks: WordBlock[] = [];
|
||||
let bullets: string[] = [];
|
||||
const flush = (): void => {
|
||||
if (bullets.length === 0) return;
|
||||
blocks.push({ t: 'ul', items: bullets });
|
||||
bullets = [];
|
||||
};
|
||||
|
||||
// 表格与段落按它们在文档里的先后走:一次扫过,谁先出现算谁
|
||||
for (const node of xml.matchAll(/<w:tbl\b[\s\S]*?<\/w:tbl>|<w:p\b[^>]*>[\s\S]*?<\/w:p>/g)) {
|
||||
const chunk = node[0];
|
||||
if (chunk.startsWith('<w:tbl')) {
|
||||
flush();
|
||||
for (const tr of chunk.matchAll(/<w:tr\b[^>]*>([\s\S]*?)<\/w:tr>/g)) {
|
||||
const cells = [...(tr[1] ?? '').matchAll(/<w:tc\b[^>]*>([\s\S]*?)<\/w:tc>/g)].map((tc) =>
|
||||
[...(tc[1] ?? '').matchAll(/<w:t\b[^>]*>([\s\S]*?)<\/w:t>/g)].map((t) => unescapeXml(t[1] ?? '')).join(''),
|
||||
);
|
||||
if (cells.some((c) => c !== '')) blocks.push({ t: 'p', text: cells.join(' | ') });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const [para] = docxParagraphs(chunk);
|
||||
if (para === undefined) continue;
|
||||
const { style, text } = para;
|
||||
if (text === '') continue;
|
||||
if (style === 'ListParagraph') {
|
||||
bullets.push(text.replace(/^[•\d]+[.、]?\s*/, ''));
|
||||
continue;
|
||||
}
|
||||
flush();
|
||||
if (style === 'Title' || style === 'Heading1') blocks.push({ t: 'h1', text });
|
||||
else if (style === 'Heading2') blocks.push({ t: 'h2', text });
|
||||
else blocks.push({ t: 'p', text });
|
||||
}
|
||||
flush();
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** xlsx → 行列(第一张表)。读的是**真包**:解不开就如实说,不编一张表出来 */
|
||||
export function xlsxRows(bytes: Uint8Array): { rows: string[][]; sheet: string } | undefined {
|
||||
const zip = readZip(bytes);
|
||||
if (zip.size === 0) return undefined;
|
||||
const dec = new TextDecoder('utf-8');
|
||||
const sharedRaw = zip.get('xl/sharedStrings.xml');
|
||||
const shared = sharedRaw === undefined ? [] : sharedStrings(dec.decode(sharedRaw));
|
||||
const first = [...zip.keys()].filter((k) => /^xl\/worksheets\/sheet\d+\.xml$/.test(k)).sort()[0];
|
||||
if (first === undefined) return undefined;
|
||||
const raw = zip.get(first);
|
||||
if (raw === undefined) return undefined;
|
||||
const wbRaw = zip.get('xl/workbook.xml');
|
||||
const sheet =
|
||||
wbRaw === undefined ? 'Sheet1' : unescapeXml(/<sheet\s+name="([^"]*)"/.exec(dec.decode(wbRaw))?.[1] ?? 'Sheet1');
|
||||
return { rows: sheetToRows(dec.decode(raw), shared), sheet };
|
||||
}
|
||||
|
||||
/**
|
||||
* 字节 → `FileDoc`。**读不出来就返回 undefined** —— 上层据此如实说
|
||||
* 「这份文件打不开」,不摆一个空壳假装渲染过(界面不撒谎)。
|
||||
*/
|
||||
export function toFileDoc(input: {
|
||||
readonly name: string;
|
||||
readonly title: string;
|
||||
readonly format: FileFormat;
|
||||
readonly bytes: Uint8Array;
|
||||
}): FileDoc | undefined {
|
||||
const { name, title, format, bytes } = input;
|
||||
const dec = new TextDecoder('utf-8');
|
||||
|
||||
if (format === 'html') return { kind: 'html', name, html: dec.decode(bytes) };
|
||||
if (format === 'md') {
|
||||
const md = dec.decode(bytes);
|
||||
// 原文与渲好的一起交:查看面摆渲染版,而「下载」拿到的仍是这段原文
|
||||
return { kind: 'markdown', name, md, html: markdownHtmlDoc(title, md) };
|
||||
}
|
||||
if (format === 'xlsx') {
|
||||
const sheet = xlsxRows(bytes);
|
||||
if (sheet === undefined) return undefined;
|
||||
return { kind: 'excel', name, sheet: sheet.sheet, rows: sheet.rows };
|
||||
}
|
||||
|
||||
const zip = readZip(bytes);
|
||||
const doc = zip.get('word/document.xml');
|
||||
if (doc === undefined) return undefined;
|
||||
const blocks = docxBlocks(dec.decode(doc));
|
||||
if (blocks.length === 0) return undefined;
|
||||
return { kind: 'word', name, blocks };
|
||||
}
|
||||
@@ -30,6 +30,8 @@ const RUN_TABLES = [
|
||||
*/
|
||||
'sys.task_comm',
|
||||
'sys.upload',
|
||||
// 产出的资料文件存档 —— 同样是跑出来的、同样没有指向 task 的外键
|
||||
'sys.task_asset_file',
|
||||
'sys.node_instance',
|
||||
// 全链 Trace:span 是运行痕,与工作流同生共死 —— 清运行数据不清它,观测面会挂着一堆孤儿
|
||||
'sys.trace_span',
|
||||
|
||||
+383
-36
@@ -5,26 +5,32 @@
|
||||
*/
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError, ChainNode, ChatMessage, Deliverable, FileDoc, TaskCard, TaskId, ViewBlock, WorkOrder } from '@contracts';
|
||||
import type { ApiError, ChainNode, ChatMessage, DecisionCard, Deliverable, FileDoc, TaskCard, TaskId, ViewBlock, WorkOrder } from '@contracts';
|
||||
import {
|
||||
answerMessageRoute,
|
||||
chatCardReplySchema,
|
||||
confirmTriggerPendingRoute,
|
||||
deliverableIdSchema,
|
||||
listMessagesRoute,
|
||||
markMessageReadRoute,
|
||||
listTasksRoute,
|
||||
markCommReadRoute,
|
||||
replyChatCardRoute,
|
||||
replyWorkOrderRoute,
|
||||
openAssetFileRoute,
|
||||
saveTableRoute,
|
||||
sayRoute,
|
||||
taskDetailRoute,
|
||||
staffIdSchema,
|
||||
taskIdSchema,
|
||||
workOrderIdSchema,
|
||||
workOrderReplySchema,
|
||||
} from '@contracts';
|
||||
import { ReplyRejectedError } from '@modules/human-tool';
|
||||
import { advanceOrder } from '@modules/process';
|
||||
import { keyText, listArchived, readArchived } from '../asset-archive.ts';
|
||||
import { composeFile, isFileFormat, MIME_OF, safeFileName, type FileFormat } from '../file-compose.ts';
|
||||
import { toFileDoc } from '../file-doc.ts';
|
||||
import { displayLog } from './log-lines.ts';
|
||||
import { pulse } from '../workers/pulse.ts';
|
||||
import type { Container } from '../container.ts';
|
||||
@@ -60,6 +66,70 @@ const merge = (into: WriteBack, one: WriteBack): void => {
|
||||
* 用它认行,不用它改行(改错一个字符就会凭空多出一行,踩过)。
|
||||
* 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。
|
||||
*/
|
||||
/**
|
||||
* **人在件上改的/加的这一行,主键怎么凑齐** —— 凑不齐就落不下去,而屏上那一行还在
|
||||
* (界面说加上了,台账里没有 —— 最坏的一类:它不报错)。三类主键三种凑法:
|
||||
*
|
||||
* ① 件上摆了、人也填了 → 就用他填的(主键只认行、不改行);
|
||||
* ② **件上压根没摆的**(A1 的 `rfq_ref`、I01 的 `round_ref`:一单一个,不占版面)——
|
||||
* 先按行认(`findKnownRow`:件上给了别的主键列就拿它去现有行里找),
|
||||
* 认不出再看这一列在本单现有行里是不是**全表一致**,是就是它;
|
||||
* 一单跨了两个号 → 取不出,如实跳过,不猜;
|
||||
* ③ **序号型(整数主键)**——「加一行」那一下所有格子都被清空
|
||||
* (`ViewContainer.addRow` 如此),而各行序号本就互不相同,②那条永远取不出,
|
||||
* 于是整行被丢(2026-08-23 审 I01 需求清单 `round_ref + req_seq` 时逮到)。
|
||||
* 序号的语义就是「本轮内第几条」,该由基座续:现有最大值 + 1,空表从 1 起。
|
||||
* **只续新增行**:老行的序号件上摆着、人填着,走①就定了。
|
||||
*/
|
||||
export function resolveRowKey(input: {
|
||||
keys: readonly string[];
|
||||
cells: Readonly<Record<string, string>>;
|
||||
drafts: ReadonlyArray<{ key: Record<string, unknown> }>;
|
||||
types: Readonly<Record<string, string>>;
|
||||
}): Record<string, string> | undefined {
|
||||
const { keys, cells, drafts, types } = input;
|
||||
const key: Record<string, string> = {};
|
||||
for (const k of keys) {
|
||||
const v = cells[k];
|
||||
if (v !== undefined && v.trim() !== '') key[k] = v.trim();
|
||||
}
|
||||
if (Object.keys(key).length === keys.length) return key;
|
||||
|
||||
const known = findKnownRow(drafts, keys, key);
|
||||
const isSeq = (column: string): boolean => types[column] === 'integer' || types[column] === 'number';
|
||||
/*
|
||||
* **序号型主键不走「全表一致」这条路**(2026-08-23 浏览器实测逮到的):
|
||||
* 表里只有一行时,那一行的序号在「全表一致」眼里就是个常量 —— 人加的新行会拿到
|
||||
* 同一个号,**当场覆盖掉原来那一行**(屏上两行,库里一行,而且内容还是新的那份)。
|
||||
* 序号逐行不同是它的定义,一致只是行数太少的假象;它该走下面的续号那一支。
|
||||
*/
|
||||
for (const k of keys) {
|
||||
if (key[k] !== undefined) continue;
|
||||
if (known === undefined && isSeq(k)) continue;
|
||||
const v =
|
||||
known === undefined
|
||||
? (() => {
|
||||
const seen = new Set(drafts.map((d) => String(d.key[k] ?? '')));
|
||||
const only = [...seen][0];
|
||||
return seen.size === 1 && only !== undefined && only !== '' ? only : undefined;
|
||||
})()
|
||||
: String(known.key[k] ?? '');
|
||||
if (v !== undefined && v !== '') key[k] = v;
|
||||
}
|
||||
if (Object.keys(key).length === keys.length) return key;
|
||||
|
||||
// ③ 续号:只对整数型主键,且这一行确实是新增的(前两条都没认出来)
|
||||
for (const k of keys) {
|
||||
if (key[k] !== undefined) continue;
|
||||
if (!isSeq(k)) continue;
|
||||
const used = drafts
|
||||
.map((d) => Number(d.key[k]))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
key[k] = String((used.length === 0 ? 0 : Math.max(...used)) + 1);
|
||||
}
|
||||
return Object.keys(key).length === keys.length ? key : undefined;
|
||||
}
|
||||
|
||||
async function writeEditedRows(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
@@ -71,30 +141,16 @@ async function writeEditedRows(
|
||||
const out = nothing();
|
||||
// 这一单现有的行 —— 三处要用:补主键(件上没摆全的那几列)、认出哪几行真改了、认出哪几行是新增
|
||||
const drafts = (await container.ledger.taskDrafts(taskId)).filter((d) => d.domain === domain && d.object === object);
|
||||
/*
|
||||
* **件上压根没摆的那几列主键**(A1 的 `rfq_ref` 就是:一单一个单号,不占版面)——
|
||||
* 本单现有行里那一列若全表一致,就是这一单的那个值,新增行照它填。
|
||||
* 不一致(一单跨了两个单号)就取不出来:那时新增行认不出自己是谁,如实跳过,不猜。
|
||||
*/
|
||||
const constant = (column: string): string | undefined => {
|
||||
const seen = new Set(drafts.map((d) => String(d.key[column] ?? '')));
|
||||
const only = [...seen][0];
|
||||
return seen.size === 1 && only !== undefined && only !== '' ? only : undefined;
|
||||
};
|
||||
|
||||
const types = Object.fromEntries(declared.fields.map((f) => [f.name, f.type]));
|
||||
for (const cells of lines) {
|
||||
/*
|
||||
* 主键**只用来认行,不参与改行**:人改了主键那一格等于换了一行,
|
||||
* 落下去就是凭空多一条(实测过同一颗料在表里成了两行)。
|
||||
* 表里没摆全主键的(如单号那一列不在件上),从草稿现有的行里补 —— 认不出就跳过。
|
||||
* 凑主键这一段抽成了纯函数 `resolveRowKey`(有测):三类主键三种凑法,
|
||||
* 尤其序号型那一手不补,人在件上加的行会被静默丢掉。
|
||||
*/
|
||||
const key: Record<string, string> = {};
|
||||
let complete = true;
|
||||
for (const k of keys) {
|
||||
const v = cells[k];
|
||||
if (v === undefined || v.trim() === '') complete = false;
|
||||
else key[k] = v;
|
||||
}
|
||||
const key = resolveRowKey({ keys, cells, drafts, types });
|
||||
/*
|
||||
* **人手改的值与 AI 写的值过同一道归型门**(`coerceToColumn`)。
|
||||
*
|
||||
@@ -113,22 +169,7 @@ async function writeEditedRows(
|
||||
if (coerced.kind === 'value') patch[name] = coerced.value;
|
||||
else if (coerced.kind === 'reject') out.bad.push(`${name}=${value}`);
|
||||
}
|
||||
if (!complete) {
|
||||
/*
|
||||
* 主键没摆全 —— 两种情形,先按行认,认不出再按列补:
|
||||
* · 老行:件上给了 PN,单号那一列没摆 → 拿 PN 去本单现有行里找,取它的单号;
|
||||
* · **新增行**:PN 是新的,现有行里一个都对不上 → 那几列取全表一致的那个值。
|
||||
* 少了后一半,人在件上加的行会**静默丢掉**:主键凑不齐,这里一 continue 就没了,
|
||||
* 而屏上那一行还在 —— 界面说加上了,台账里没有。
|
||||
*/
|
||||
const known = drafts.find((d) => keys.every((k) => key[k] === undefined || String(d.key[k]) === key[k]));
|
||||
for (const k of keys) {
|
||||
if (key[k] !== undefined) continue;
|
||||
const v = known === undefined ? constant(k) : String(known.key[k] ?? '');
|
||||
if (v !== undefined && v !== '') key[k] = v;
|
||||
}
|
||||
}
|
||||
if (Object.keys(key).length !== keys.length) continue;
|
||||
if (key === undefined) continue;
|
||||
const was = drafts.find((d) => keys.every((k) => String(d.key[k] ?? '') === key[k]));
|
||||
if (!rowChanged(was === undefined ? undefined : { ...was.key, ...was.patch }, patch)) continue;
|
||||
await container.ledger.draftWrite({ taskId, address: { domain, object }, key, patch });
|
||||
@@ -172,6 +213,27 @@ async function dropEditedRows(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* **主键摆不全时,认这一行是本单现有的哪一行**(2026-08-21 修死)。
|
||||
*
|
||||
* `key` 是这一格已经认出来的那几个主键值(可能一个都没有)。**一格都没认出来的碎片
|
||||
* 不许命中「随便找到的第一行」**——`.find()` 的谓词对着全 `undefined` 的 `key` 恒真,
|
||||
* 会把它错配给 `drafts` 里第一条,而那条八成风马牛不相及(手写的 radio/select 组
|
||||
* 因为渲染嵌套多一层,曾经被 `collect()` 拆成不带主键的碎片,现已在 `ViewContainer`
|
||||
* 那一侧修死——这里补的是第二道:即便未来哪天又出现类似的欠主键碎片,也不会被这个
|
||||
* `.find()` 悄悄按到错误的行上)。至少要有一格真对得上,不然交给调用方去按列补
|
||||
* (`constant`),对不上就整行跳过。
|
||||
*/
|
||||
export function findKnownRow<K extends Record<string, unknown>>(
|
||||
drafts: ReadonlyArray<{ key: K }>,
|
||||
keys: readonly string[],
|
||||
key: Readonly<Record<string, string>>,
|
||||
): { key: K } | undefined {
|
||||
return drafts.find(
|
||||
(d) => keys.some((k) => key[k] !== undefined) && keys.every((k) => key[k] === undefined || String(d.key[k]) === key[k]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* **这一行真改了吗** —— 件上按一次保存,整张表的格子都回传上来(它不知道人动了哪一格)。
|
||||
*
|
||||
@@ -318,6 +380,68 @@ async function landingOf(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一单产出的**资料文件**一摞 —— 工作成果里与定稿报告并列。
|
||||
*
|
||||
* 两个来源,**存档优先**:放行之后那一份已经冻住(归档件点开的必须是他签字那一刻
|
||||
* 的那一份);在办期间还没有存档,就照台账现列 —— 人在审的那几份也该看得见。
|
||||
* 认行一律 `address + key`,与件上 `data-file` 那一串同一形状:点开走同一条路。
|
||||
*/
|
||||
async function assetFilesOf(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
): Promise<Array<{ address: string; key: string; title: string; format: string; name: string; at?: string; archived: boolean }>> {
|
||||
const archived = await listArchived(container.db, taskId).catch(() => []);
|
||||
if (archived.length > 0) {
|
||||
return archived.map((a) => ({
|
||||
address: a.address,
|
||||
key: keyText(a.key),
|
||||
title: a.title,
|
||||
format: a.format,
|
||||
name: a.name,
|
||||
at: a.at,
|
||||
archived: true,
|
||||
}));
|
||||
}
|
||||
|
||||
const declarations = await declarationsOf(container, taskId);
|
||||
const out: Array<{ address: string; key: string; title: string; format: string; name: string; archived: boolean }> = [];
|
||||
for (const declared of declarations ?? []) {
|
||||
const spec = declared.file;
|
||||
if (spec === undefined) continue;
|
||||
const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name);
|
||||
if (keys.length === 0) continue;
|
||||
const page = await container.ledger
|
||||
.taskView(taskId, {
|
||||
address: { domain: declared.domain, object: declared.object },
|
||||
where: {},
|
||||
orderBy: [],
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
})
|
||||
.catch(() => ({ rows: [] as Array<Record<string, unknown>> }));
|
||||
for (const row of page.rows) {
|
||||
// 没正文的行不是资料 —— 摆出来点开只会落一句「还没产出」
|
||||
if (String(row[spec.bodyColumn] ?? '').trim() === '') continue;
|
||||
const key: Record<string, string> = {};
|
||||
for (const k of keys) key[k] = String(row[k] ?? '');
|
||||
if (Object.values(key).some((v) => v === '')) continue;
|
||||
const rawFormat = String(row[spec.formatColumn] ?? '').trim().toLowerCase();
|
||||
const format = isFileFormat(rawFormat) ? rawFormat : 'md';
|
||||
const title = String(row[spec.titleColumn] ?? '').trim() || Object.values(key).join('-');
|
||||
out.push({
|
||||
address: `${declared.domain}.${declared.object}`,
|
||||
key: keyText(key),
|
||||
title,
|
||||
format,
|
||||
name: safeFileName(title, format),
|
||||
archived: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 这一单归哪个活动、它声明过哪几张表 —— 授权面就是这份声明 */
|
||||
async function declarationsOf(container: Container, taskId: TaskId) {
|
||||
const task = await container.task.get(taskId).catch(() => undefined);
|
||||
@@ -326,6 +450,30 @@ async function declarationsOf(container: Container, taskId: TaskId) {
|
||||
return manifest?.declarations.ledger;
|
||||
}
|
||||
|
||||
/**
|
||||
* 件上传来的主键 → 一行的主键对。
|
||||
*
|
||||
* 两种写法:单列主键直接给值(`CT-2026-0001`),多列主键写 `列=值` 以 `,` 连
|
||||
* (`round_ref=BR-2026...,req_seq=1`)。**认的是声明里的主键列**,件上说的列名
|
||||
* 若不在其中一律不认 —— 不然一个精心构造的 key 就能把别的行当成"这一份资料"取走。
|
||||
*/
|
||||
export function parseRowKey(raw: string, keys: readonly string[]): Record<string, string> | undefined {
|
||||
if (keys.length === 0) return undefined;
|
||||
if (!raw.includes('=')) {
|
||||
const only = keys[0];
|
||||
return keys.length === 1 && only !== undefined && raw !== '' ? { [only]: raw } : undefined;
|
||||
}
|
||||
const out: Record<string, string> = {};
|
||||
for (const pair of raw.split(',')) {
|
||||
const at = pair.indexOf('=');
|
||||
if (at === -1) continue;
|
||||
const col = pair.slice(0, at).trim();
|
||||
if (!keys.includes(col)) return undefined;
|
||||
out[col] = pair.slice(at + 1).trim();
|
||||
}
|
||||
return keys.every((k) => out[k] !== undefined && out[k] !== '') ? out : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 就地修订留一条痕 —— 改了几行、加了几行、撤了哪几行、哪几格没归成型。
|
||||
*
|
||||
@@ -725,6 +873,43 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
return gate === undefined ? order : { ...order, gate };
|
||||
}),
|
||||
);
|
||||
/*
|
||||
* **决策卡答完的报告也算工作成果**(2026-08-21,`decisionCardSchema.finalHtml`)。
|
||||
*
|
||||
* `decide` 是唯一能循环的中间验收机制(`issueAcceptance` 一个任务只能发一次),
|
||||
* 而它不是工单 —— 答完那一刻没有 `sys.work_order` 行,工作成果那一屏从前只认
|
||||
* 工单,人在这张卡上通过的报告(如 E04 的经营计划)永远看不到。这里把答过的
|
||||
* 决策卡拼成同一形状的条目,**单独一个字段**,不并进 `workOrders`:
|
||||
* `mode:'acceptance'` 在别处(`ViewRegion.tsx` 的 `signed`)是「终局放行卡」的
|
||||
* 同义词——那道判断认的是「有一张办结的验收单 + 还没有终态」等于「字签下了正在落地」,
|
||||
* 前提是 `issueAcceptance` 只发一次、acceptance 恒是那一张。混进去会让 E04 一过
|
||||
* S4(中间点,离终态还有 S5/S6 一大截)就误亮「正在落地」。前端只在工作成果那一屏
|
||||
* 把两份列表并显,别处仍只读 `workOrders`。
|
||||
*
|
||||
* 三值口径(见 E04 case-model 的注释:`approve`/`reject`/`custom:` 开头)粗分
|
||||
* 「通过 = pass,别的都算驳回」,只影响列表上那一点点颜色,不影响任何机检或放行 ——
|
||||
* 那道门永远是 `issueAcceptance` 走的放行卡,这里只是回看用的一条历史记录。
|
||||
*/
|
||||
const decided: WorkOrder[] = cards
|
||||
.filter((c): c is DecisionCard => c.k === 'decision')
|
||||
.flatMap((c) => {
|
||||
const { chosen, finalHtml } = c;
|
||||
if (chosen === undefined || finalHtml === undefined) return [];
|
||||
return [
|
||||
{
|
||||
id: workOrderIdSchema.parse(c.id),
|
||||
at: c.at,
|
||||
title: c.ask,
|
||||
mode: 'acceptance' as const,
|
||||
basis: c.basis,
|
||||
deliverable: deliverableIdSchema.parse(c.id),
|
||||
criteria: [],
|
||||
fields: [],
|
||||
finalHtml,
|
||||
done: { at: chosen.at, by: chosen.by, result: chosen.value === 'approve' ? ('pass' as const) : ('redo' as const) },
|
||||
},
|
||||
];
|
||||
});
|
||||
/*
|
||||
* 沟通记录 —— 一单往来的原件。附件那一格照抄基座读出来的 `note`(读出多少字 /
|
||||
* 为什么读不出),**不改写**:界面上说「已上传 ✓」而活动其实看不见里面,是撒谎。
|
||||
@@ -839,6 +1024,8 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
results,
|
||||
comms,
|
||||
workOrders: gated,
|
||||
decidedDeliverables: decided,
|
||||
assetFiles: await assetFilesOf(container, taskId),
|
||||
chat: merged,
|
||||
view: pushed?.blocks !== undefined ? [...pushed.blocks] : view,
|
||||
...(pushed?.html === undefined ? {} : { viewHtml: pushed.html }),
|
||||
@@ -859,6 +1046,125 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
* **工单不办结**:保存只是保存。放行门的位置一点没变 ——
|
||||
* 写的仍是草稿层,合并进正式版依旧只发生在人签字那一刻。
|
||||
*/
|
||||
/*
|
||||
* **点开一份资料文件** —— 件上的资料名点下去,走到这里。
|
||||
*
|
||||
* 三道关,一道都不能省:
|
||||
* ① 会话(是不是这个人);
|
||||
* ② **授权面 = 活动清单的台账声明** —— 那张表没声明 `file` 段就不给,
|
||||
* 与「写看台账声明」「立监听看清单声明」同款;声明里没有的列名也不认;
|
||||
* ③ 行取的是**任务视图**(正式版 + 本单草稿)—— 在办的初稿本来就还在草稿层,
|
||||
* 只读正式表的话,人在审的这一份恰恰打不开。
|
||||
*
|
||||
* 拿到行之后**现造**:标题 + 正文 + 格式 → 真字节。`raw=1` 原样交出去(下载),
|
||||
* 否则再读回来成 `FileDoc` 给前端渲 —— 读得回来才证明那份文件是真的。
|
||||
*/
|
||||
app.get(openAssetFileRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
}
|
||||
const taskId = taskIdSchema.parse(c.req.param('taskId'));
|
||||
const address = c.req.query('address') ?? '';
|
||||
const rawKey = c.req.query('key') ?? '';
|
||||
const [domain = '', object = ''] = address.split('.');
|
||||
|
||||
/* 单没了就直说单没了 —— 不然下面那句会把「这一单不在了」说成「活动没声明」 */
|
||||
const task = await container.task.get(taskId).catch(() => undefined);
|
||||
if (task === undefined) {
|
||||
const body: ApiError = { code: 'not-found', message: '这一单已经不在了' };
|
||||
return c.json(body, 404);
|
||||
}
|
||||
const declarations = await declarationsOf(container, taskId);
|
||||
const declared = declarations?.find((d) => d.domain === domain && d.object === object);
|
||||
if (declared?.file === undefined) {
|
||||
const body: ApiError = {
|
||||
code: 'forbidden',
|
||||
message: `这个活动没有把「${address}」声明成资料文件(清单的 declarations.ledger[].file)——不声明就点不开`,
|
||||
};
|
||||
return c.json(body, 403);
|
||||
}
|
||||
|
||||
const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name);
|
||||
const key = parseRowKey(rawKey, keys);
|
||||
if (key === undefined) {
|
||||
const body: ApiError = { code: 'validation-failed', message: `认不出这一行:主键要给全 ${keys.join('、')}` };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
|
||||
/*
|
||||
* **先看存档** —— 放行那一刻这一份就冻住了(`asset-archive.ts`)。
|
||||
*
|
||||
* 顺序是有讲究的:归档件上点开的必须是**他签字那一刻的那一份**,
|
||||
* 而不是台账后来被下一单改成的样子。在办期间还没有存档,自然落到下面现造那条路。
|
||||
*/
|
||||
const archived = await readArchived(container.db, taskId, address, key).catch(() => undefined);
|
||||
if (archived !== undefined) {
|
||||
if (c.req.query('raw') === '1') {
|
||||
return c.body(archived.bytes.slice().buffer as ArrayBuffer, 200, {
|
||||
'content-type': MIME_OF[archived.format],
|
||||
'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(archived.name)}`,
|
||||
});
|
||||
}
|
||||
const doc = toFileDoc({ name: archived.name, title: archived.title, format: archived.format, bytes: archived.bytes });
|
||||
if (doc !== undefined) return c.json({ file: doc, fileName: archived.name, format: archived.format });
|
||||
}
|
||||
|
||||
const page = await container.ledger
|
||||
.taskView(taskId, { address: { domain, object }, where: {}, orderBy: [], limit: 500, offset: 0 })
|
||||
.catch(() => ({ rows: [] as Array<Record<string, unknown>> }));
|
||||
const row = page.rows.find((r) => keys.every((k) => String(r[k] ?? '') === key[k]));
|
||||
if (row === undefined) {
|
||||
/*
|
||||
* **收了工再来找不到,多半是试跑那条路**(2026-08-23 实测):
|
||||
* 资料这一行若落在**主数据**上,试跑单放行时它只留在活动缓存、不落正式表
|
||||
* (免得试跑往品牌内容库里塞真资产,见 ledger 的 `simulation` 那段注释)——
|
||||
* 于是放行前点得开、放行后点不开。正式单不受此限(放行合并进正式表,照旧查得到)。
|
||||
* 两种情形说清楚,别拿一句「可能已被撤掉」把人支去翻列表找一行根本不会在的东西。
|
||||
*/
|
||||
const body: ApiError = {
|
||||
code: 'not-found',
|
||||
message:
|
||||
task.terminal === undefined
|
||||
? '这一行不在了 —— 可能已被撤掉,回列表看一眼'
|
||||
: '这一单已经收工,查不到这一行了 —— 若它是试跑单,放行时主数据只留在活动缓存、不落正式表' +
|
||||
'(试跑不往正式库里塞真资产),这份资料在放行前查得到;正式单不受此限',
|
||||
};
|
||||
return c.json(body, 404);
|
||||
}
|
||||
|
||||
const text = (col: string): string => String(row[col] ?? '').trim();
|
||||
const title = text(declared.file.titleColumn) || keys.map((k) => key[k]).join('-');
|
||||
const markdown = text(declared.file.bodyColumn);
|
||||
const rawFormat = text(declared.file.formatColumn).toLowerCase();
|
||||
/* 格式列没填 / 填了个不认识的:退 md —— 有正文就该看得见,不因为一格没填就整份打不开 */
|
||||
const format: FileFormat = isFileFormat(rawFormat) ? rawFormat : 'md';
|
||||
if (markdown === '') {
|
||||
const body: ApiError = {
|
||||
code: 'not-found',
|
||||
message: `这一份还没有正文(${declared.file.bodyColumn} 是空的)—— 资料还没产出,不是打不开`,
|
||||
};
|
||||
return c.json(body, 404);
|
||||
}
|
||||
|
||||
const composed = composeFile({ title, markdown, format });
|
||||
if (c.req.query('raw') === '1') {
|
||||
return c.body(composed.bytes.slice().buffer as ArrayBuffer, 200, {
|
||||
'content-type': composed.mime,
|
||||
// 文件名走 RFC 5987:中文名在各家浏览器上才落得对
|
||||
'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(composed.fileName)}`,
|
||||
});
|
||||
}
|
||||
|
||||
const doc = toFileDoc({ name: composed.fileName, title, format, bytes: composed.bytes });
|
||||
if (doc === undefined) {
|
||||
const body: ApiError = { code: 'internal', message: `这份 ${format} 造出来了却读不回来 —— 已留痕,请报给开发` };
|
||||
return c.json(body, 500);
|
||||
}
|
||||
return c.json({ file: doc, fileName: composed.fileName, format });
|
||||
});
|
||||
|
||||
app.post(saveTableRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) {
|
||||
@@ -1012,6 +1318,17 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
...(card.also ?? []).map((g) => ({ ledger: g.ledger, key: g.key })),
|
||||
];
|
||||
const declarations = (await declarationsOf(container, cardTaskId)) ?? [];
|
||||
/*
|
||||
* **跨段共享主键**(2026-08-20 修死):卡面注释早就写着「两段的 customer_code
|
||||
* 都留空:人在第一段填了什么,第二段跟着是什么」,但代码从没兑现过这句话 ——
|
||||
* `also` 段的 `key` 只取卡面写死的字面占位(通常就是那个空字符串),从不看
|
||||
* 人刚在**上一段**填的值。建一位新客户时,公司档正确落了 `customer_code`
|
||||
* (它作为一个 `field` 进了 patch),联系人档却带着空字符串的 `customer_code`
|
||||
* 落库 —— 新联系人从落地那一刻起就没挂在这位客户名下,`autoKeys` 算
|
||||
* `contact_code` 时也因为拿不到前缀,生成裸序号(`01`)而不是
|
||||
* `CU-0201-01`。现在按段落定的主键值顺着传给后面的段。
|
||||
*/
|
||||
const sharedKey: Record<string, string> = {};
|
||||
for (const [at, group] of groups.entries()) {
|
||||
const [domain, object] = group.ledger.split('.');
|
||||
if (domain === undefined || object === undefined) continue;
|
||||
@@ -1024,8 +1341,19 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
}
|
||||
if (Object.keys(patch).length === 0) continue;
|
||||
const declared = declarations.find((l) => l.domain === domain && l.object === object);
|
||||
const key = { ...group.key, ...(await autoKeys(container, cardTaskId, declared, group.key)) };
|
||||
// 本段的非自增主键:这一段自己填的(patch)优先,没填就接上更早那段定下的值
|
||||
const ownKeyFields = (declared?.fields ?? []).filter((f) => f.primaryKey && f.auto !== true).map((f) => f.name);
|
||||
const given: Record<string, string> = { ...group.key };
|
||||
for (const name of ownKeyFields) {
|
||||
const value = patch[name] ?? sharedKey[name];
|
||||
if (value !== undefined && value !== '') given[name] = value;
|
||||
}
|
||||
const key = { ...given, ...(await autoKeys(container, cardTaskId, declared, given)) };
|
||||
await container.ledger.draftWrite({ taskId: cardTaskId, address: { domain, object }, key, patch });
|
||||
for (const name of ownKeyFields) {
|
||||
const value = key[name];
|
||||
if (value !== undefined && value !== '') sharedKey[name] = value;
|
||||
}
|
||||
}
|
||||
{
|
||||
/*
|
||||
@@ -1036,6 +1364,25 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
*/
|
||||
}
|
||||
}
|
||||
/*
|
||||
* **决策卡答完那一刻,把当时的件原样存进这张卡**(与工单同一条道理,见
|
||||
* `decisionCardSchema.finalHtml` 的注释)。`decide` 是唯一能循环的中间验收
|
||||
* 机制,E04 的经营计划审批(S4_plan_approval)就是靠它——没有这一段,
|
||||
* 人在这张卡上通过的报告永远进不了工作成果(那一屏从前只认工单)。
|
||||
* 快照失败不拦人答卡(留痕即可)。
|
||||
*/
|
||||
if (parsed.data.k === 'decision' && card.k === 'decision') {
|
||||
try {
|
||||
const shot = await container.interaction.viewOf(cardTaskId);
|
||||
if (shot?.html !== undefined) {
|
||||
await container.db.execute(
|
||||
sql`update sys.chat_card set payload = jsonb_set(payload, '{finalHtml}', ${JSON.stringify(shot.html)}::jsonb) where id = ${parsed.data.cardId}`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* 件还没推过就没有快照 —— 不拦人答卡 */
|
||||
}
|
||||
}
|
||||
/*
|
||||
* 人答完了就接着跑 —— **录入卡尤其要紧**:它是人按下确认那一刻,
|
||||
* 眼睛正盯着屏幕。等两轮轮询(实测 6.8 秒)那段时间对话区一动不动,
|
||||
|
||||
@@ -720,7 +720,19 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const taskId = taskIdSchema.parse(c.req.param('taskId'));
|
||||
const task = await container.task.get(taskId);
|
||||
/*
|
||||
* **「这一单没了」是 404,不是 500**(2026-08-23 用户:「推进完成了,但测试台还在
|
||||
* 运行中,没有停下来」查出来的后一半)。这一单被别处清掉(reset-runs / 另一台起单
|
||||
* 先销毁上一单)之后,这条路由抛的是普通 Error → 兜底成 `500 internal`。
|
||||
* 而 500 的意思是「服务器出毛病了,等会儿再来」——面板据此**继续举着上一次的状态
|
||||
* 每 2 秒重试**,屏上就成了「AWAITING-EVENT · 自动刷新」配一条「任务不存在」。
|
||||
* 状态码说错一格,前端就分不清「该松手」与「该等它回来」。
|
||||
*/
|
||||
const task = await container.task.get(taskId).catch(() => undefined);
|
||||
if (task === undefined) {
|
||||
const body: ApiError = { code: 'not-found', message: `这一单已经不在了(${taskId})` };
|
||||
return c.json(body, 404);
|
||||
}
|
||||
|
||||
const memory = await container.memory.window(taskId, 200);
|
||||
const drafts = await container.ledger.taskDrafts(taskId);
|
||||
@@ -1246,7 +1258,20 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
const faces = await Promise.all(
|
||||
cards.map(async (card) => {
|
||||
const manifest = await container.registry.manifestOf(card.activityId);
|
||||
type FieldSpec = { type?: string; items?: { type?: string }; title?: string; format?: string };
|
||||
type FieldSpec = {
|
||||
type?: string;
|
||||
items?: { type?: string };
|
||||
title?: string;
|
||||
format?: string;
|
||||
/** 枚举值 —— 声明了就该给下拉选,不该让人对着自由文本框猜合法值 */
|
||||
enum?: unknown[];
|
||||
/** 枚举的人读名(与 `enum` 同序;JSON Schema 无此标准关键字,借业界通行写法) */
|
||||
enumNames?: unknown[];
|
||||
/** JSON Schema 标准关键字:示例值,填不出合法值时给个能抄的样子 */
|
||||
examples?: unknown[];
|
||||
/** 非标准扩展:另一个字段等于某值才出现这一格(级联下拉那类场景) */
|
||||
showWhen?: { field?: string; equals?: string };
|
||||
};
|
||||
const data = manifest.contract.input.data as {
|
||||
required?: string[];
|
||||
properties?: Record<string, FieldSpec>;
|
||||
@@ -1256,6 +1281,17 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
/** 类型的人读写法 —— **数组写成 `T[]`**:声明得看得出是一个还是一串 */
|
||||
const typeOf = (spec: FieldSpec | undefined): string =>
|
||||
spec?.type === 'array' ? `${spec.items?.type ?? 'any'}[]` : (spec?.type ?? 'unknown');
|
||||
/** 枚举 → 下拉选项:有 `enumNames` 配对就给人读名,没有就拿枚举值本身当标签 */
|
||||
const optionsOf = (spec: FieldSpec | undefined): { value: string; label: string }[] | undefined => {
|
||||
if (!Array.isArray(spec?.enum) || spec.enum.length === 0) return undefined;
|
||||
return spec.enum.map((v, i) => ({ value: String(v), label: String(spec.enumNames?.[i] ?? v) }));
|
||||
};
|
||||
/** 示例值:JSON Schema `examples` 第一项,对象/数组类型序列化成能直接抄的 JSON 串 */
|
||||
const exampleOf = (spec: FieldSpec | undefined): string | undefined => {
|
||||
const first = spec?.examples?.[0];
|
||||
if (first === undefined) return undefined;
|
||||
return typeof first === 'string' ? first : JSON.stringify(first);
|
||||
};
|
||||
const portFields = (payload: unknown) => {
|
||||
const p = payload as { properties?: Record<string, FieldSpec> } | undefined;
|
||||
return Object.entries(p?.properties ?? {}).map(([name, spec]) => ({ name, type: typeOf(spec) }));
|
||||
@@ -1270,6 +1306,14 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
...(spec?.title === undefined ? {} : { label: spec.title }),
|
||||
// `format: 'file'` = 这一格收的是**真文件**,界面该给文件选择器而不是文本框
|
||||
...(spec?.format === undefined ? {} : { format: spec.format }),
|
||||
// 声明了枚举就给下拉选 —— 不该让人对着自由文本框猜合法值
|
||||
...(optionsOf(spec) === undefined ? {} : { options: optionsOf(spec) }),
|
||||
// 声明了示例就把它摆进占位提示,比裸类型名(`string`)更能抄
|
||||
...(exampleOf(spec) === undefined ? {} : { example: exampleOf(spec) }),
|
||||
// 声明了 showWhen 就只在另一格等于某值时才出现(级联下拉)
|
||||
...(spec?.showWhen?.field === undefined || spec.showWhen.equals === undefined
|
||||
? {}
|
||||
: { showWhen: { field: spec.showWhen.field, equals: spec.showWhen.equals } }),
|
||||
})),
|
||||
// 包自带的样例集:试跑一个活动时照它预填(对着空表单编,编出来的多半跑不通)
|
||||
samples: manifest.samples.map((sample) => ({ name: sample.name, input: sample.input })),
|
||||
|
||||
@@ -30,6 +30,8 @@ const REAL_PACKAGES = [
|
||||
'quote-pricing',
|
||||
'quote-negotiate',
|
||||
'quote-archive',
|
||||
'crm-relationship-coverage',
|
||||
'brand-content-toolkit',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -65,21 +67,6 @@ export async function installRealActivities(container: Container): Promise<void>
|
||||
// 冲突要如实报:静默吞掉的话,表结构和声明对不上而没人知道
|
||||
(conflicts.length === 0 ? '' : ` —— 合并冲突 ${conflicts.length} 处:${conflicts.map((c) => c.field).join('、')}`),
|
||||
);
|
||||
/*
|
||||
* 一位样例客户 —— 让「老客户认得出」那条路试得出来(见 `MASTER_SAMPLE_ROWS` 的注)。
|
||||
* 按主键写,重复播种是覆盖不是堆叠;写正式版(不是草稿层),
|
||||
* 因为 A1 第一步查的就是正式版 —— 草稿只有本任务自己看得见。
|
||||
*/
|
||||
for (const row of MASTER_SAMPLE_ROWS) {
|
||||
await container.ledger.systemWrite({
|
||||
address: { domain: row.address.domain, object: row.address.object },
|
||||
key: { ...row.key },
|
||||
values: { ...row.values },
|
||||
cause: { kind: 'governance', ref: 'seed:master-sample' },
|
||||
});
|
||||
}
|
||||
log(` 样例客户已就位:${MASTER_SAMPLE_ROWS[1]?.values.email ?? ''}(样例 2 用它试老客户那条路)`);
|
||||
|
||||
const root = activitiesRoot();
|
||||
|
||||
for (const dir of REAL_PACKAGES) {
|
||||
@@ -135,4 +122,25 @@ export async function installRealActivities(container: Container): Promise<void>
|
||||
log(` 活动包 ${dir} 未过安装两审:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* **样例客户 —— 装完全部活动包之后再播种**(2026-08-20 改序,踩过坑):
|
||||
* `MASTER_SAMPLE_ROWS` 里带着 E04 追加的列(`cust_type`/`decision_chain`…)——
|
||||
* 这些列要等 `crm-relationship-coverage` 装进 `REAL_PACKAGES` 循环、
|
||||
* 它的台账声明经 `applyDeclarations` 做过 `add-column` 之后才真的存在于物理表。
|
||||
* 原来放在装活动**之前**播种,种到这些新列时 Postgres 直接报
|
||||
* `column "cust_type" of relation "customer__profile" does not exist`——
|
||||
* 主数据物化(平台基线列)在活动之前没错,但样例数据要用到**活动追加的列**时,
|
||||
* 必须排在对应活动装完之后。按主键写,重复播种是覆盖不是堆叠;写正式版
|
||||
* (不是草稿层),因为 A1 第一步查的就是正式版 —— 草稿只有本任务自己看得见。
|
||||
*/
|
||||
for (const row of MASTER_SAMPLE_ROWS) {
|
||||
await container.ledger.systemWrite({
|
||||
address: { domain: row.address.domain, object: row.address.object },
|
||||
key: { ...row.key },
|
||||
values: { ...row.values },
|
||||
cause: { kind: 'governance', ref: 'seed:master-sample' },
|
||||
});
|
||||
}
|
||||
log(` 样例客户已就位:${MASTER_SAMPLE_ROWS[1]?.values.email ?? ''}(样例 2 用它试老客户那条路)`);
|
||||
}
|
||||
|
||||
@@ -175,6 +175,26 @@ export const MASTER_SAMPLE_ROWS = [
|
||||
payment_terms: 'EXW · CNY 含税',
|
||||
profile_tags: '项目制 · 按项目批量询价 · 认证要求严(AEC-Q200)',
|
||||
status: 'active',
|
||||
/*
|
||||
* E04(关系覆盖与关键人经营)字段 —— 2026-08-20 补齐,让「客户已经跑过 E04、
|
||||
* 找缺口」这条分支第一次能真被样例跑通(此前这几列全空,每轮都只能从零起草)。
|
||||
* decision_chain 只覆盖了 bt-01(采购层),bt-02/bt-03 是刻意留的缺口。
|
||||
*/
|
||||
cust_type: 'Tier1 OEM',
|
||||
coverage_score: 35,
|
||||
decision_chain: {
|
||||
nodes: [
|
||||
{ node: 'bt-01', btLevel: 'bt-01', required: true, contactCode: 'CU-0142-01', covered: true },
|
||||
{ node: 'bt-02', btLevel: 'bt-02', required: true, contactCode: '', covered: false },
|
||||
{ node: 'bt-03', btLevel: 'bt-03', required: true, contactCode: '', covered: false },
|
||||
],
|
||||
},
|
||||
interaction_timeline: {
|
||||
entries: [
|
||||
{ date: '2026-06-15', type: '邮件报价跟进', contactCode: 'CU-0142-01', note: 'MD14.1 项目 BOM 报价确认' },
|
||||
{ date: '2026-07-20', type: '电话沟通', contactCode: 'CU-0142-01', note: '交期与付款方式确认' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -187,6 +207,13 @@ export const MASTER_SAMPLE_ROWS = [
|
||||
title: '采购部',
|
||||
// **认人的钥匙**:老客户样例的来信就从这个邮箱发,A1 第一步照它认得出
|
||||
email: 'e.volkova@itelma.ru',
|
||||
// E04 字段 —— 对应上面 decision_chain 的 bt-01 节点
|
||||
role: '采购',
|
||||
decision_node: 'bt-01',
|
||||
influence: 'high',
|
||||
relationship: 3,
|
||||
comm_pref: 'Email',
|
||||
key_person: true,
|
||||
},
|
||||
},
|
||||
/*
|
||||
@@ -205,4 +232,116 @@ export const MASTER_SAMPLE_ROWS = [
|
||||
status: 'active',
|
||||
},
|
||||
},
|
||||
/*
|
||||
* **CU-0087** —— E04(关系覆盖与关键人经营)"关键人离职/调岗"样例专用夹具
|
||||
* (2026-08-20 补):此前这条样例引用的 CU-0087 在种子数据里根本不存在,
|
||||
* 是一处遗留 bug。这里种一位客户 + 两位联系人,decision_chain 的 bt-01
|
||||
* (采购层)当下由陈刚覆盖 —— 样例说的正是"这个人要走了,这个节点要重新覆盖"。
|
||||
*/
|
||||
{
|
||||
address: { domain: 'customer', object: 'profile' },
|
||||
key: { customer_code: 'CU-0087' },
|
||||
values: {
|
||||
customer_code: 'CU-0087',
|
||||
name: '深圳精密电子有限公司',
|
||||
grade: 'B',
|
||||
industry: '工业自动化',
|
||||
region: '中国·深圳',
|
||||
payment_terms: 'T/T 30 天',
|
||||
profile_tags: '批量稳定 · 价格敏感',
|
||||
status: 'active',
|
||||
cust_type: '模组厂',
|
||||
coverage_score: 55,
|
||||
decision_chain: {
|
||||
nodes: [
|
||||
{ node: 'bt-01', btLevel: 'bt-01', required: true, contactCode: 'CU-0087-01', covered: true },
|
||||
{ node: 'bt-02', btLevel: 'bt-02', required: true, contactCode: 'CU-0087-02', covered: true },
|
||||
{ node: 'bt-03', btLevel: 'bt-03', required: false, contactCode: '', covered: false },
|
||||
],
|
||||
},
|
||||
interaction_timeline: {
|
||||
entries: [{ date: '2026-05-10', type: '现场拜访', contactCode: 'CU-0087-01', note: '年度框架协议签署' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
address: { domain: 'customer', object: 'contact' },
|
||||
key: { customer_code: 'CU-0087', contact_code: 'CU-0087-01' },
|
||||
values: {
|
||||
customer_code: 'CU-0087',
|
||||
contact_code: 'CU-0087-01',
|
||||
name: 'Chen Gang',
|
||||
title: '采购部长',
|
||||
// **样例说的就是这个人**:「关键人离职/调岗」引用的正是这一位
|
||||
email: 'chen.gang@sz-precision.com',
|
||||
role: '采购',
|
||||
decision_node: 'bt-01',
|
||||
influence: 'high',
|
||||
relationship: 4,
|
||||
comm_pref: 'Email',
|
||||
key_person: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
address: { domain: 'customer', object: 'contact' },
|
||||
key: { customer_code: 'CU-0087', contact_code: 'CU-0087-02' },
|
||||
values: {
|
||||
customer_code: 'CU-0087',
|
||||
contact_code: 'CU-0087-02',
|
||||
name: 'Li Wei',
|
||||
title: '技术总监',
|
||||
email: 'li.wei@sz-precision.com',
|
||||
role: '技术决策人',
|
||||
decision_node: 'bt-02',
|
||||
influence: 'medium',
|
||||
relationship: 3,
|
||||
comm_pref: 'Email',
|
||||
key_person: false,
|
||||
},
|
||||
},
|
||||
/*
|
||||
* I01(品牌形象与销售工具包管理)读的 L-01 宏观市场台账 —— 两条,呼应 I01 的两条
|
||||
* 事件驱动样例(「进入俄罗斯市场」「E04 请求定向素材」),让「读市场信号 → 拟内容」
|
||||
* 这条分支第一次有真数据可读,不必每轮都读空手。
|
||||
*/
|
||||
{
|
||||
address: { domain: 'market', object: 'signal' },
|
||||
key: { signal_id: 'MS-2026-0001' },
|
||||
values: {
|
||||
signal_id: 'MS-2026-0001',
|
||||
signal_type: 'industry_trend',
|
||||
title: '俄罗斯车规电子本土化替代加速',
|
||||
content: '受供应链管制影响,俄罗斯本土车企与 Tier1 加快引入非欧美芯片供应商,车规级 AEC-Q200 认证成为准入门槛,采购决策周期缩短、更看重供货稳定性承诺。',
|
||||
source: '行业报告',
|
||||
signal_date: '2026-06-01',
|
||||
market_region: '俄罗斯',
|
||||
industry: '汽车电子',
|
||||
chip_category: 'MCU',
|
||||
importance: 4,
|
||||
impact_dir: 'opportunity',
|
||||
signal_status: 'evaluated',
|
||||
version: '2026-Q2',
|
||||
review_note: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
address: { domain: 'market', object: 'signal' },
|
||||
key: { signal_id: 'MS-2026-0002' },
|
||||
values: {
|
||||
signal_id: 'MS-2026-0002',
|
||||
signal_type: 'customer_market',
|
||||
title: 'Itelma 系客户新一轮车身控制器项目招标',
|
||||
content: 'Itelma 及同类俄罗斯 Tier1 客户密集启动车身控制器新项目招标,价值传递素材需突出认证齐全度与批量交付能力,系统方案白皮书是决策链高层最看重的材料形式。',
|
||||
source: '订阅',
|
||||
signal_date: '2026-07-10',
|
||||
market_region: '俄罗斯',
|
||||
industry: '汽车电子',
|
||||
chip_category: 'MCU',
|
||||
importance: 5,
|
||||
impact_dir: 'opportunity',
|
||||
signal_status: 'evaluated',
|
||||
version: '2026-Q2',
|
||||
review_note: '',
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -169,6 +169,84 @@ const PLACEHOLDER_TOOL = {
|
||||
connectionKeys: [] as string[],
|
||||
};
|
||||
|
||||
/**
|
||||
* E04 覆盖度重算 —— **纯工具**(活动私有作用域):决策链节点比对,同输入必同结论。
|
||||
* 出参键名与台账列同名(`coverage_score`),模型拿回结果直接 write,不必改名。
|
||||
*/
|
||||
const COVERAGE_SCORE_TOOL = {
|
||||
name: 'crm-relationship-coverage.recompute-score',
|
||||
displayName: '关系覆盖度重算',
|
||||
version: '1.1.0',
|
||||
description:
|
||||
'按决策链节点比对重算关系覆盖度:节点算已覆盖要同时满足有联系人对应/标了关键人/亲密度≥3。' +
|
||||
'出参给覆盖率与缺口节点列表,权重按 BT 层级(bt-03 高层 > bt-02 技术 > bt-01 采购)。' +
|
||||
'一次可查一批 —— 传 customers 数组,出参走 results(逐家客户各调一次会多截断一拍)。',
|
||||
input: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
customers: {
|
||||
type: 'array',
|
||||
description: '批量(优先):[{customerCode, decisionNodes:[{node,btLevel,required}], contacts:[{contactCode,decisionNode,keyPerson,influence,relationship}]}]',
|
||||
},
|
||||
customerCode: { type: 'string', description: '单条查询' },
|
||||
decisionNodes: { type: 'array', description: '单条:[{node, btLevel, required}]' },
|
||||
contacts: { type: 'array', description: '单条:[{contactCode, decisionNode, keyPerson, influence, relationship}]' },
|
||||
},
|
||||
},
|
||||
output: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
customerCode: { type: 'string' },
|
||||
coverageScore: { type: 'integer' },
|
||||
coveredNodes: { type: 'array' },
|
||||
gapNodes: { type: 'array' },
|
||||
results: { type: 'array', description: '批量查询的逐家结果:每项 {customerCode, coverageScore, coveredNodes, gapNodes}' },
|
||||
},
|
||||
},
|
||||
effect: 'read' as const,
|
||||
errors: [] as Array<{ code: string; retryable: boolean; message: string }>,
|
||||
idempotent: true,
|
||||
timeoutMs: 5_000,
|
||||
retries: 0,
|
||||
connectionKeys: [] as string[],
|
||||
pure: true,
|
||||
ownerActivityId: 'crm-relationship-coverage',
|
||||
};
|
||||
|
||||
/**
|
||||
* E04 关键人排序 —— **纯工具**(活动私有作用域):影响力 × 亲密度缺口,批量算。
|
||||
* 出参键名与台账列同名(`priority`/`relationship_gap`/`rank_score`),模型照抄即可。
|
||||
*/
|
||||
const RANK_KEY_PERSONS_TOOL = {
|
||||
name: 'crm-relationship-coverage.rank-key-persons',
|
||||
displayName: '关键人经营优先级排序',
|
||||
version: '1.0.0',
|
||||
description:
|
||||
'按影响力 × 亲密度缺口批量排序关键人 —— 传 contacts 数组,一次算完,不逐人临场拍。' +
|
||||
'出参给每人的亲密度缺口、排序分与优先级档(high/medium/low)。',
|
||||
input: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
contacts: { type: 'array', description: '[{contactCode, influence, relationship}]' },
|
||||
},
|
||||
},
|
||||
output: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ranked: { type: 'array', description: '[{contactCode, relationshipGap, rankScore, priority}],已按 rankScore 降序' },
|
||||
counts: { type: 'object' },
|
||||
},
|
||||
},
|
||||
effect: 'read' as const,
|
||||
errors: [] as Array<{ code: string; retryable: boolean; message: string }>,
|
||||
idempotent: true,
|
||||
timeoutMs: 5_000,
|
||||
retries: 0,
|
||||
connectionKeys: [] as string[],
|
||||
pure: true,
|
||||
ownerActivityId: 'crm-relationship-coverage',
|
||||
};
|
||||
|
||||
/** 白名单:逐槽位授权 —— 谁能调什么,治理台说了算,活动代码里看不到这张表 */
|
||||
const ALLOWLIST: Readonly<Record<string, readonly string[]>> = {
|
||||
// A1 已是真活动:白名单里摘掉占位工具 —— 它一在,模型就会拿它糊弄过去
|
||||
@@ -211,7 +289,7 @@ export async function seedMvp(container: Container): Promise<void> {
|
||||
log(`① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro${apiKey === undefined ? '(环境无密钥 —— 两条都是未绑定,去治理台补)' : ''}`);
|
||||
|
||||
/* ② 功能工具注册两审 + 逐槽位白名单 */
|
||||
for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL]) {
|
||||
for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL, COVERAGE_SCORE_TOOL, RANK_KEY_PERSONS_TOOL]) {
|
||||
const verdict = await container.toolbox.register({
|
||||
contract,
|
||||
samples: [
|
||||
@@ -238,7 +316,7 @@ export async function seedMvp(container: Container): Promise<void> {
|
||||
for (const [activityId, tools] of Object.entries(ALLOWLIST)) {
|
||||
await container.toolbox.setAllowlist(activityId, tools);
|
||||
}
|
||||
log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 2,逐活动授权)');
|
||||
log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 4,逐活动授权)');
|
||||
|
||||
/*
|
||||
* ③ 活动装入 —— 两批:
|
||||
|
||||
@@ -207,6 +207,85 @@ const landingRate: ToolHandler = async ({ input }) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* E04 覆盖度重算 —— **纯工具**:决策链节点比对,同输入必同结论。
|
||||
*
|
||||
* 节点算「已覆盖」要同时满足三条:该节点上有联系人对应(`decisionNode` 匹配)、
|
||||
* 那个人标了 `keyPerson=true`、亲密度 `relationship>=3`。少一条都算空白 ——
|
||||
* 认得这个人不等于覆盖了这个人。权重按 BT 层级(bt-03 高层 > bt-02 技术 > bt-01 采购)。
|
||||
*
|
||||
* **一次可查一批**(`customers` 数组):同一批候选客户逐一临场调,每调一次就多截断
|
||||
* 一拍(工具调用后同拍其余动作作废)——一单 N 家候选就要 N 拍,批量传入只需一拍。
|
||||
* 契约演进只增不破坏:仍收单条 `{customerCode,decisionNodes,contacts}` 走 legacy 路径,
|
||||
* 批量给 `customers` 时优先。
|
||||
*/
|
||||
const BT_WEIGHT: Record<string, number> = { 'bt-01': 1, 'bt-02': 2, 'bt-03': 3 };
|
||||
|
||||
type DecisionNode = { node: string; btLevel: string; required: boolean };
|
||||
type CoverageContact = { contactCode: string; decisionNode: string; keyPerson: boolean; influence: string; relationship: number };
|
||||
type CustomerCoverageInput = { customerCode: string; decisionNodes: DecisionNode[]; contacts: CoverageContact[] };
|
||||
|
||||
const scoreOne = (customerCode: string, nodes: DecisionNode[], contacts: CoverageContact[]) => {
|
||||
const required = nodes.filter((n) => n.required === true);
|
||||
const coveredNodes: string[] = [];
|
||||
const gapNodes: string[] = [];
|
||||
let weightTotal = 0;
|
||||
let weightCovered = 0;
|
||||
for (const n of required) {
|
||||
const weight = BT_WEIGHT[n.btLevel] ?? 1;
|
||||
weightTotal += weight;
|
||||
const covered = contacts.some((c) => c.decisionNode === n.node && c.keyPerson === true && c.relationship >= 3);
|
||||
if (covered) {
|
||||
coveredNodes.push(n.node);
|
||||
weightCovered += weight;
|
||||
} else {
|
||||
gapNodes.push(n.node);
|
||||
}
|
||||
}
|
||||
const coverageScore = weightTotal === 0 ? 0 : Math.round((weightCovered / weightTotal) * 100);
|
||||
return { customerCode, coverageScore, coveredNodes, gapNodes };
|
||||
};
|
||||
|
||||
const recomputeScore: ToolHandler = async ({ input }) => {
|
||||
const batch = input['customers'];
|
||||
if (Array.isArray(batch)) {
|
||||
const results = (batch as CustomerCoverageInput[]).map((c) =>
|
||||
scoreOne(c.customerCode, Array.isArray(c.decisionNodes) ? c.decisionNodes : [], Array.isArray(c.contacts) ? c.contacts : []),
|
||||
);
|
||||
return { ok: true, output: { results } };
|
||||
}
|
||||
const nodes = Array.isArray(input['decisionNodes']) ? (input['decisionNodes'] as DecisionNode[]) : [];
|
||||
const contacts = Array.isArray(input['contacts']) ? (input['contacts'] as CoverageContact[]) : [];
|
||||
return { ok: true, output: scoreOne(String(input['customerCode'] ?? ''), nodes, contacts) };
|
||||
};
|
||||
|
||||
/**
|
||||
* E04 关键人排序 —— **纯工具**:影响力 × 亲密度缺口,批量算,不逐人临场拍。
|
||||
*
|
||||
* `relationshipGap = 5 - relationship`(亲密度越低缺口越大);
|
||||
* `rankScore = 影响力权重 × relationshipGap`;阈值 `>=9→high, >=4→medium, else→low`
|
||||
* (业务可调常量,不是这一单的临场判断)。
|
||||
*/
|
||||
const INFLUENCE_WEIGHT: Record<string, number> = { high: 3, medium: 2, low: 1 };
|
||||
|
||||
const rankKeyPersons: ToolHandler = async ({ input }) => {
|
||||
const contacts = Array.isArray(input['contacts'])
|
||||
? (input['contacts'] as Array<{ contactCode: string; influence: string; relationship: number }>)
|
||||
: [];
|
||||
const ranked = contacts
|
||||
.map((c) => {
|
||||
const weight = INFLUENCE_WEIGHT[c.influence] ?? 1;
|
||||
const relationshipGap = Math.max(0, 5 - Number(c.relationship ?? 0));
|
||||
const rankScore = weight * relationshipGap;
|
||||
const priority = rankScore >= 9 ? 'high' : rankScore >= 4 ? 'medium' : 'low';
|
||||
return { contactCode: c.contactCode, relationshipGap, rankScore, priority };
|
||||
})
|
||||
.sort((a, b) => b.rankScore - a.rankScore);
|
||||
const counts = { high: 0, medium: 0, low: 0 } as Record<string, number>;
|
||||
for (const r of ranked) counts[r.priority] = (counts[r.priority] ?? 0) + 1;
|
||||
return { ok: true, output: { ranked, counts } };
|
||||
};
|
||||
|
||||
/** 出站发送 —— 写类:过程中登记为动作意向,放行后由系统执行落地(OS 7.3) */
|
||||
const mailSend: ToolHandler = async ({ input, connection }) => {
|
||||
// 运行时只拿得到申报过的连接目标;真发信由 channel 的出站面承担
|
||||
@@ -272,4 +351,6 @@ export const FIRST_PARTY_TOOLS: ReadonlyArray<readonly [string, ToolHandler]> =
|
||||
['quote-archive.landing-rate', landingRate],
|
||||
['mail.send', mailSend],
|
||||
['slot.placeholder', slotPlaceholder],
|
||||
['crm-relationship-coverage.recompute-score', recomputeScore],
|
||||
['crm-relationship-coverage.rank-key-persons', rankKeyPersons],
|
||||
];
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
* 就能解;为一件读表的活拉进一整个电子表格库,是拿依赖换五十行代码。
|
||||
*
|
||||
* 这一份是**纯计算**:进字节、出文本,不碰库不碰网,所以能逐条测。
|
||||
*
|
||||
* 其中 `readZip` / `sheetToRows` 这几件**对外**:资料文件那一侧(`file-doc.ts`)
|
||||
* 要把造好的 docx / xlsx 再读回来渲染 —— 读的这一套只该有一份实现,
|
||||
* 两处各写一份迟早在某个边角上分叉(而分叉的症状是「下载能开、界面渲不出」)。
|
||||
*/
|
||||
import { inflateRawSync } from 'node:zlib';
|
||||
|
||||
@@ -56,7 +60,7 @@ function cap(text: string, how: string): Extracted {
|
||||
* 只支持 store(0)与 deflate(8)两种压缩法 —— xlsx 出厂就这两种。
|
||||
* 认不出的条目跳过而不是抛:一份表里混了一条读不了的条目,不该让整份表读不出来。
|
||||
*/
|
||||
function readZip(bytes: Uint8Array): Map<string, Uint8Array> {
|
||||
export function readZip(bytes: Uint8Array): Map<string, Uint8Array> {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const files = new Map<string, Uint8Array>();
|
||||
|
||||
@@ -104,7 +108,7 @@ function readZip(bytes: Uint8Array): Map<string, Uint8Array> {
|
||||
}
|
||||
|
||||
/** XML 实体还原 —— 只有这五个是 XML 预定义的 */
|
||||
function unescapeXml(s: string): string {
|
||||
export function unescapeXml(s: string): string {
|
||||
return s
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
@@ -115,7 +119,7 @@ function unescapeXml(s: string): string {
|
||||
}
|
||||
|
||||
/** 共享字符串表:`<si>` 一条一个字符串,里面可能被拆成多个 `<t>`(富文本) */
|
||||
function sharedStrings(xml: string): string[] {
|
||||
export function sharedStrings(xml: string): string[] {
|
||||
return [...xml.matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) =>
|
||||
[...(m[1] ?? '').matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((t) => unescapeXml(t[1] ?? '')).join(''),
|
||||
);
|
||||
@@ -135,7 +139,7 @@ function columnOf(ref: string): number {
|
||||
* 单元格三种取值:`t="s"` 查共享串表、`t="inlineStr"` 取内联 `<t>`、其余取 `<v>` 原样
|
||||
* (数字、日期序列号都在这一类)。日期不转换:转错了比不转更糟,原值至少可以追。
|
||||
*/
|
||||
function sheetToRows(xml: string, shared: readonly string[]): string[][] {
|
||||
export function sheetToRows(xml: string, shared: readonly string[]): string[][] {
|
||||
const rows: string[][] = [];
|
||||
for (const rowMatch of xml.matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)) {
|
||||
const cells: string[] = [];
|
||||
|
||||
@@ -37,6 +37,7 @@ import type { TriggerService } from '@modules/trigger';
|
||||
import { evaluate as evalSfeel, evaluateCondition, evaluateUnaryTest } from '@sfeel';
|
||||
import type { StaffId, TaskId } from '@contracts';
|
||||
import { cardIdSchema, staffIdSchema } from '@contracts';
|
||||
import { archiveAssetFiles } from '../asset-archive.ts';
|
||||
import { createAgentRunner } from '../agent-runner.ts';
|
||||
import { createMastraAgentRunner } from '../mastra-agent-runner.ts';
|
||||
import type { Clock, IdGenerator } from '../runtime.ts';
|
||||
@@ -673,6 +674,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
const owner = task === undefined ? undefined : await deps.org.getStaff(task.ownerId).catch(() => undefined);
|
||||
const ownerLabel = owner === undefined ? '' : `${owner.name} · ${owner.position.name}`;
|
||||
const origin = task?.origin === 'testbed' ? '试跑起单' : '通道入站';
|
||||
/*
|
||||
* **这一单的任务号** —— 工单模版卡头「关联任务」那一格(E04 目标件 §6.2 点名的
|
||||
* 共用动态位)。与归属签同理:任务号是基座的事实,活动写不出也不该写;
|
||||
* 全长 UUID 如实给,摆多长由模版自己的样式定(截断是排版,不是数据)。
|
||||
*/
|
||||
const taskRef = task === undefined ? '' : String(taskId);
|
||||
/*
|
||||
* **这一单算出来要走哪一口** —— 报告上那张分支判定表要标出命中的是哪一行。
|
||||
*
|
||||
@@ -703,6 +710,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
'case:openedAt': openedAt,
|
||||
'case:origin': origin,
|
||||
'case:owner': ownerLabel,
|
||||
'case:taskId': taskRef,
|
||||
...route,
|
||||
...own,
|
||||
}),
|
||||
@@ -797,7 +805,22 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
const declared = manifest.declarations.ledger;
|
||||
// 工具调用是**留痕里的事实**:判据要说「亲查过行情」,得有一边不是活动自报的
|
||||
const trail = await deps.memory.window(taskId, 400);
|
||||
return {
|
||||
/*
|
||||
* **开案时刻要真的在 Agent 的作用域里**(2026-08-22,E04 实测逼出来的):
|
||||
* 说明书写着「round_ref = case:openedAt 按 YYYYMMDDHHmmss 格式化(引擎注入的
|
||||
* 开案时刻)」,而这个键从前只注进**视图渲染**那一侧,Agent 的任务视图里没有 ——
|
||||
* 模型只能自己编;正式表里一旦有别单的案号,它就照抄(A2 治过的「从台账借号」
|
||||
* 同款),测试台里所有单于是共用同一个号,跨单主键相撞、上一单的正式行渗进
|
||||
* 下一单的读面。给全到秒(视图那份只到分,是给人看的;这份是给铸主键用的)。
|
||||
*/
|
||||
const two = (n: number): string => String(n).padStart(2, '0');
|
||||
const opened = new Date(task.startedAt);
|
||||
const openedAt = Number.isNaN(opened.getTime())
|
||||
? ''
|
||||
: `${opened.getFullYear()}-${two(opened.getMonth() + 1)}-${two(opened.getDate())} ` +
|
||||
`${two(opened.getHours())}:${two(opened.getMinutes())}:${two(opened.getSeconds())}`;
|
||||
const enriched: Record<string, unknown> = {
|
||||
'case:openedAt': openedAt,
|
||||
...scope,
|
||||
...rowReadings(mine, declared),
|
||||
...rowKeyList(mine, declared),
|
||||
@@ -806,6 +829,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
...toolCallReadings(trail, manifest.declarations.tools),
|
||||
...mine,
|
||||
};
|
||||
return enriched;
|
||||
},
|
||||
/**
|
||||
* 本活动声明过的台账对象 —— 清单 `declarations.ledger` 就是这份名单。
|
||||
@@ -955,6 +979,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
});
|
||||
return { id: listener.id };
|
||||
},
|
||||
/** 立监听的授权面 —— 活动清单声明过的那几种(见 port.ts 的注释) */
|
||||
async declared(taskId: TaskId) {
|
||||
const { activityId } = await taskContext.of(taskId);
|
||||
const manifest = await deps.registry.manifestOf(activityId);
|
||||
return manifest.declarations.listeners.map((l) => ({ eventType: l.eventType, keyFilter: l.keyFilter }));
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -2094,6 +2124,43 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
* 在一张验收单上改几十行是另一件事。
|
||||
*/
|
||||
await applyRevisions(input.taskId);
|
||||
/*
|
||||
* **产出的资料文件在这儿冻住** —— 修订刚落回草稿层、合并还没发生:
|
||||
* 存下来的正是他签字那一刻的样子(与工单的 `finalHtml` 快照同一个道理)。
|
||||
*
|
||||
* 为什么非存不可,而不是回头照台账现造:资料常落在**主数据**上,
|
||||
* 而试跑单放行时主数据只留在活动缓存、不落正式表 —— 不存这一份,
|
||||
* 试跑单一放行,人在工作成果里就什么都点不开了。正式单则是另一头的理由:
|
||||
* 台账后来被下一单改了,归档件不该跟着改口。
|
||||
* 存不下来不拦放行(资料是成果的一份,不是放行的前提):记一条痕,人看得见。
|
||||
*/
|
||||
try {
|
||||
const manifest = await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId);
|
||||
const saved = await archiveAssetFiles({
|
||||
db,
|
||||
taskId: input.taskId,
|
||||
ids,
|
||||
declarations: manifest.declarations.ledger,
|
||||
rowsOfAddress: async (address) =>
|
||||
(
|
||||
await deps.ledger.taskView(input.taskId, { address, where: {}, orderBy: [], limit: 500, offset: 0 })
|
||||
).rows as Array<Record<string, unknown>>,
|
||||
});
|
||||
if (saved > 0) {
|
||||
await memoryRecorder.append({
|
||||
taskId: input.taskId,
|
||||
kind: 'ledger-write' as never,
|
||||
title: `产出的资料已存档:${saved} 份(工作成果里点得开)`,
|
||||
detail: { assetFiles: saved },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await memoryRecorder.append({
|
||||
taskId: input.taskId,
|
||||
kind: 'result',
|
||||
title: `资料存档没做成:${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
}
|
||||
const outcome = await deps.release.land({
|
||||
taskId: input.taskId,
|
||||
ownerId: input.ownerId,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 最小 ZIP 打包器 —— docx / xlsx 就是 ZIP 包着几份 XML,写它才写得出真文件。
|
||||
*
|
||||
* 只做 **store(不压缩,method 0)**:合法的 ZIP,Word / Excel / macOS 归档工具都认。
|
||||
* 为什么不压:压缩要 deflate + 一套长度对账,而这些 XML 至多几十 KB ——
|
||||
* 省下来的那几 KB 换一处能错的地方,不划算。将来真要压,把 `method` 与数据段一起换即可。
|
||||
*
|
||||
* 读的那一侧在 `upload-extract.ts`(`readZip`),两边合起来能自证:
|
||||
* 造出来的包读得回来,读回来的内容与进去的一模一样。
|
||||
*/
|
||||
|
||||
/** CRC-32(IEEE 多项式 0xEDB88320)—— ZIP 每条目都要校验和,写错了解压工具会报损坏 */
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i += 1) {
|
||||
let c = i;
|
||||
for (let k = 0; k < 8; k += 1) c = (c & 1) === 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[i] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
export function crc32Of(bytes: Uint8Array): number {
|
||||
let c = 0xffffffff;
|
||||
for (const b of bytes) c = (CRC_TABLE[(c ^ b) & 0xff] ?? 0) ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
type Entry = readonly [name: string, bytes: Uint8Array];
|
||||
|
||||
/**
|
||||
* 打包。条目顺序即包内顺序 —— `[Content_Types].xml` 习惯放第一条(不少读取器认这个)。
|
||||
*
|
||||
* 时间戳一律写 1980-01-01(DOS 时间的下限):同样的入参出同样的字节,
|
||||
* 这样「文件变没变」就是内容的事,不掺一个每次都不同的时间 —— 也让测试断得住。
|
||||
*/
|
||||
export function zipStore(entries: readonly Entry[]): Uint8Array {
|
||||
const enc = new TextEncoder();
|
||||
const locals: Uint8Array[] = [];
|
||||
const centrals: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const [name, data] of entries) {
|
||||
const nameBytes = enc.encode(name);
|
||||
const crc = crc32Of(data);
|
||||
|
||||
const local = new Uint8Array(30 + nameBytes.length + data.length);
|
||||
const lv = new DataView(local.buffer);
|
||||
lv.setUint32(0, 0x04034b50, true); // 本地头签名
|
||||
lv.setUint16(4, 20, true); // 需要 2.0 才能解
|
||||
lv.setUint16(6, 0x0800, true); // 位标志:文件名是 UTF-8
|
||||
lv.setUint16(8, 0, true); // method 0 = store
|
||||
lv.setUint16(10, 0, true); // 时:0
|
||||
lv.setUint16(12, 33, true); // 日:1980-01-01
|
||||
lv.setUint32(14, crc, true);
|
||||
lv.setUint32(18, data.length, true); // 压缩后大小 = 原大小(store)
|
||||
lv.setUint32(22, data.length, true);
|
||||
lv.setUint16(26, nameBytes.length, true);
|
||||
lv.setUint16(28, 0, true); // 无 extra
|
||||
local.set(nameBytes, 30);
|
||||
local.set(data, 30 + nameBytes.length);
|
||||
locals.push(local);
|
||||
|
||||
const central = new Uint8Array(46 + nameBytes.length);
|
||||
const cv = new DataView(central.buffer);
|
||||
cv.setUint32(0, 0x02014b50, true); // 中央目录签名
|
||||
cv.setUint16(4, 20, true); // 造它的版本
|
||||
cv.setUint16(6, 20, true);
|
||||
cv.setUint16(8, 0x0800, true);
|
||||
cv.setUint16(10, 0, true);
|
||||
cv.setUint16(12, 0, true);
|
||||
cv.setUint16(14, 33, true);
|
||||
cv.setUint32(16, crc, true);
|
||||
cv.setUint32(20, data.length, true);
|
||||
cv.setUint32(24, data.length, true);
|
||||
cv.setUint16(28, nameBytes.length, true);
|
||||
cv.setUint32(42, offset, true); // 本地头在包里的偏移
|
||||
central.set(nameBytes, 46);
|
||||
centrals.push(central);
|
||||
|
||||
offset += local.length;
|
||||
}
|
||||
|
||||
const centralSize = centrals.reduce((n, c) => n + c.length, 0);
|
||||
const eocd = new Uint8Array(22);
|
||||
const ev = new DataView(eocd.buffer);
|
||||
ev.setUint32(0, 0x06054b50, true);
|
||||
ev.setUint16(8, entries.length, true); // 本卷条目数
|
||||
ev.setUint16(10, entries.length, true); // 总条目数
|
||||
ev.setUint32(12, centralSize, true);
|
||||
ev.setUint32(16, offset, true); // 中央目录起点
|
||||
|
||||
const total = offset + centralSize + eocd.length;
|
||||
const out = new Uint8Array(total);
|
||||
let p = 0;
|
||||
for (const part of [...locals, ...centrals, eocd]) {
|
||||
out.set(part, p);
|
||||
p += part.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -262,6 +262,68 @@ describe('data-edit / data-col:可改格子的输入孪生件', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('手写的下拉孪生件:当下的值要标 selected', () => {
|
||||
const SELECT_ROW =
|
||||
'<tbody data-rows="rs" data-edit="d.o"><tr><td><select class="jc-cell" data-col="depth">' +
|
||||
'<option value=""></option><option value="deep">深度</option><option value="standard">标准</option>' +
|
||||
'<option value="light">轻度</option></select></td></tr></tbody>';
|
||||
|
||||
it('当下的值标 selected —— 人一进编辑态就看得见 AI 填了什么', () => {
|
||||
const out = renderView(SELECT_ROW, { rs: [{ depth: 'standard' }] });
|
||||
expect(out).toContain('<option value="standard" selected>标准</option>');
|
||||
});
|
||||
|
||||
it('值匹配到具体那个 option,不是随便一个', () => {
|
||||
const out = renderView(SELECT_ROW, { rs: [{ depth: 'light' }] });
|
||||
expect(out).toContain('<option value="light" selected>轻度</option>');
|
||||
expect(out).toContain('<option value="deep">深度</option>');
|
||||
expect(out).toContain('<option value="standard">标准</option>');
|
||||
});
|
||||
|
||||
it('没有值 —— 照旧留白,不选任何一项(没答=不改的规矩不能被这条改掉)', () => {
|
||||
const out = renderView(SELECT_ROW, { rs: [{}] });
|
||||
expect(out).not.toContain('selected');
|
||||
});
|
||||
|
||||
it('值不在选项里 —— 原样留白,不硬凑一个', () => {
|
||||
const out = renderView(SELECT_ROW, { rs: [{ depth: 'unknown-code' }] });
|
||||
expect(out).not.toContain('selected');
|
||||
});
|
||||
|
||||
it('没标 data-edit 的表(归档件)一样标好 selected —— 静态归档也该如实呈现当下的值', () => {
|
||||
const out = renderView(SELECT_ROW.replace(' data-edit="d.o"', ''), { rs: [{ depth: 'deep' }] });
|
||||
expect(out).toContain('<option value="deep" selected>深度</option>');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 手写的多行文本孪生件 —— E04 执行回执的「结果摘要 / 客户反馈」照目标件是 textarea
|
||||
* (一句话装不下「见到的关键人、谈了什么、达成什么」)。与下拉同款:
|
||||
* 渲染时把当下的值填进正文,人一进编辑态看得见 AI/上一轮填了什么。
|
||||
*/
|
||||
describe('手写的多行文本孪生件:当下的值进正文', () => {
|
||||
const TEXTAREA_ROW =
|
||||
'<tbody data-rows="rs" data-edit="d.o"><tr><td>' +
|
||||
'<textarea class="jc-cell" data-col="result_summary" placeholder="谈了什么"></textarea>' +
|
||||
'</td></tr></tbody>';
|
||||
|
||||
it('当下的值进正文 —— 人一进编辑态就看得见已填的内容', () => {
|
||||
const out = renderView(TEXTAREA_ROW, { rs: [{ result_summary: '见到了产品 VP,达成样品测试意向' }] });
|
||||
expect(out).toContain('data-col="result_summary" placeholder="谈了什么">见到了产品 VP,达成样品测试意向</textarea>');
|
||||
});
|
||||
|
||||
it('没有值 —— 正文真空着,placeholder 才显得出来', () => {
|
||||
const out = renderView(TEXTAREA_ROW, { rs: [{}] });
|
||||
expect(out).toContain('placeholder="谈了什么"></textarea>');
|
||||
});
|
||||
|
||||
it('值要转义 —— 正文里混进闭合标签就是逃逸', () => {
|
||||
const out = renderView(TEXTAREA_ROW, { rs: [{ result_summary: '</textarea><script>alert(1)</script>' }] });
|
||||
expect(out).not.toContain('<script>');
|
||||
expect(out).toContain('</textarea>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('渲染两遍不叠加 —— 交出去的是干净 HTML', () => {
|
||||
it('第二遍渲染不会再长一个 input(属性已被消费)', () => {
|
||||
const once = renderView('<tbody data-rows="rs" data-edit="d.o"><tr><td><span data-slot="pn" data-col="pn"></span></td></tr></tbody>', {
|
||||
@@ -278,3 +340,37 @@ describe('渲染两遍不叠加 —— 交出去的是干净 HTML', () => {
|
||||
expect(out).not.toMatch(/<script|\son[a-z]+=/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data-file-of:这一格点开是一份资料文件', () => {
|
||||
const TPL =
|
||||
'<tbody data-rows="mine:content.asset"><tr>' +
|
||||
'<td><span data-file-of="content_id" data-slot="title"></span></td>' +
|
||||
'</tr></tbody>';
|
||||
|
||||
it('换成认得出行的 data-file="地址/主键值",原属性被消费掉', () => {
|
||||
const out = renderView(TPL, {
|
||||
'mine:content.asset': [{ content_id: 'CT-2026-0001', title: '供货能力说明' }],
|
||||
});
|
||||
expect(out).toContain('data-file="content.asset/CT-2026-0001"');
|
||||
expect(out).not.toContain('data-file-of');
|
||||
expect(out).toContain('供货能力说明');
|
||||
});
|
||||
|
||||
it('**地址去掉 `mine:` 前缀** —— 那是取值的路径,不是台账地址', () => {
|
||||
const out = renderView(TPL, { 'mine:content.asset': [{ content_id: 'A', title: 'T' }] });
|
||||
expect(out).toContain('data-file="content.asset/A"');
|
||||
});
|
||||
|
||||
it('那一列还空着:整个属性去掉 —— 没有文件的行不该长得像能点开', () => {
|
||||
const out = renderView(TPL, { 'mine:content.asset': [{ title: '还没编号' }] });
|
||||
expect(out).not.toContain('data-file');
|
||||
expect(out).toContain('还没编号');
|
||||
});
|
||||
|
||||
it('主键值里的引号被转义 —— 一行数据撑不开属性', () => {
|
||||
const out = renderView(TPL, { 'mine:content.asset': [{ content_id: 'A" onclick="x', title: 'T' }] });
|
||||
// 引号成了 ",那一串整个还在 data-file 的值里面 —— 没有第二个属性冒出来
|
||||
expect(out).toContain('data-file="content.asset/A" onclick="x"');
|
||||
expect(out).not.toContain('onclick="x"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -162,16 +162,44 @@ export function renderRows(html: string, scope: Readonly<Record<string, unknown>
|
||||
* 是的话每一行多渲一份输入孪生件,并把行号钉在行上(写回时按它对行)。
|
||||
*/
|
||||
const editable = /\bdata-edit\s*=\s*"([^"]+)"/.exec(raw) !== null;
|
||||
/*
|
||||
* 这一组行还是**一份份资料文件**吗 —— 由行路径决定地址(`mine:content.asset`
|
||||
* → `content.asset`)。行里带 `data-file-of="列名"` 的元素,渲染时换成
|
||||
* `data-file="地址/主键值"`:点它就是点开那一份文件(外层接住,见 ViewContainer)。
|
||||
* 模版这一侧照旧只做取值,「点了去哪」不在这份 HTML 里。
|
||||
*/
|
||||
const address = path.replace(/^mine:/, '');
|
||||
const body = rows
|
||||
.map((row, index) => {
|
||||
const one = (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>;
|
||||
return renderTemplate(uniqueNames(editable ? twinInputs(inner, index) : inner, index), one);
|
||||
const twinned = editable ? twinInputs(inner, index) : inner;
|
||||
return renderTemplate(uniqueNames(textareaCurrent(selectCurrent(fileLinks(twinned, one, address), one), one), index), one);
|
||||
})
|
||||
.join('');
|
||||
return `${open}${body}</${tag}>`;
|
||||
});
|
||||
}
|
||||
|
||||
/** `data-file-of="列名"` —— 这一格点开是一份资料文件,文件认哪一行由该列的值定 */
|
||||
const FILE_OF = /\s*\bdata-file-of\s*=\s*"([^"]+)"/;
|
||||
|
||||
/**
|
||||
* 把 `data-file-of` 换成一个**认得出行的** `data-file="地址/主键值"`,并消费掉原属性。
|
||||
*
|
||||
* 值取不到(那一列还空着)就**整个属性去掉** —— 没有文件的行不该长得像能点开,
|
||||
* 点下去落一句「打不开」比一开始就不给点更糟(界面不撒谎)。
|
||||
*/
|
||||
export function fileLinks(inner: string, row: Readonly<Record<string, unknown>>, address: string): string {
|
||||
return inner.replace(/<([a-zA-Z][\w-]*)\b([^>]*?\bdata-file-of\s*=\s*"[^"]+"[^>]*)>/g, (all, tag: string, attrs: string) => {
|
||||
const col = FILE_OF.exec(attrs)?.[1];
|
||||
if (col === undefined) return all;
|
||||
const value = displayValue(row[col]);
|
||||
const rest = attrs.replace(FILE_OF, '');
|
||||
if (value === '' || value === '—') return `<${tag}${rest}>`;
|
||||
return `<${tag}${rest} data-file="${escapeHtml(address)}/${escapeHtml(value)}">`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* **行内表单组名唯一化** —— 行模版里手写的 radio 组(如 A2 替代等级三选)带着
|
||||
* 固定 `name`;行复制 N 份后同名 radio 在整份文档里成了一组,第 2 页点一下
|
||||
@@ -211,6 +239,49 @@ function twinInputs(inner: string, rowIndex: number): string {
|
||||
});
|
||||
}
|
||||
|
||||
/** 手写的下拉孪生件:`<select … data-col="depth">…</select>` */
|
||||
const SELECT_CELL = /<select\b[^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*>[\s\S]*?<\/select>/g;
|
||||
|
||||
/**
|
||||
* **下拉的孪生件要带上当下的值**(2026-08-21 用户实测截图:「点了编辑后直接就变白了」)。
|
||||
*
|
||||
* 文本格子的孪生件(`twinInputs`)一开始就带着 `value=`,人一进编辑态看到的是
|
||||
* 「AI 已经填了什么」;手写的 `<select>`(完成状态 / 关键人变动 / 覆盖深度这类枚举格)
|
||||
* 从前一直是净的 `<option value="">`,没人标 `selected`——同一行里,文本格子回显着
|
||||
* 原值,紧挨着的下拉却空白一片,像是这一格的数据丢了。
|
||||
*
|
||||
* 找到 `value` 与当下取值相等的那个 `<option>`,补一个 `selected`。**没有值就照旧留白**
|
||||
* (原样交给「没答=不改」那条规矩——这里只补「有值就该看得见」,不改「没答不算话」的语义:
|
||||
* 人如果确实没碰这一格,交出去的还是当下这个值,原样写回等于没变)。
|
||||
*/
|
||||
function selectCurrent(inner: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
return inner.replace(SELECT_CELL, (block, col: string) => {
|
||||
const raw = resolveSlot(scope, col);
|
||||
if (raw === undefined || raw === null || raw === '') return block;
|
||||
const value = String(raw).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const optionRe = new RegExp(`(<option\\b[^>]*\\bvalue\\s*=\\s*"${value}"[^>]*?)(/?>)`);
|
||||
return optionRe.test(block) ? block.replace(optionRe, (_m, attrs: string, close: string) => `${attrs} selected${close}`) : block;
|
||||
});
|
||||
}
|
||||
|
||||
/** 手写的多行文本孪生件:`<textarea … data-col="result_summary">…</textarea>` */
|
||||
const TEXTAREA_CELL = /<textarea\b([^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*)>[\s\S]*?<\/textarea>/g;
|
||||
|
||||
/**
|
||||
* **多行文本的孪生件要带上当下的值**(2026-08-22,E04 执行回执):「结果摘要 / 客户反馈」
|
||||
* 照目标件是 textarea(一句话装不下「见到的关键人、谈了什么、达成什么」),
|
||||
* 而 textarea 的值在**正文**不在 `value=` 属性 —— `twinInputs` 那条 input 路填不了它。
|
||||
* 与 `selectCurrent` 同款:值填进正文,没有值就真空着(placeholder 才显得出来)。
|
||||
* 正文位插值照文本位那四个字符转义 —— 混进 `</textarea>` 就是逃逸。
|
||||
*/
|
||||
function textareaCurrent(inner: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
return inner.replace(TEXTAREA_CELL, (all, attrs: string, col: string) => {
|
||||
const raw = resolveSlot(scope, col);
|
||||
if (raw === undefined || raw === null || raw === '') return all;
|
||||
return `<textarea${attrs}>${escapeHtml(displayValue(raw))}</textarea>`;
|
||||
});
|
||||
}
|
||||
|
||||
/** 先展开行,再填标量槽 —— 顺序不能反,否则行内槽位会被外层作用域填掉 */
|
||||
export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
return renderTemplate(renderRows(html, scope), scope);
|
||||
|
||||
@@ -104,10 +104,26 @@
|
||||
`mayCompleteCase` 只对 release 为真,`mayTerminateCase` 只对 harness / owner / governance 为真
|
||||
- **提交放行 = 发验收卡**(2026-07-28 三修①):submit / 判齐(autoCompleteReady)
|
||||
不再返回 `submitted` 空转 —— 发**验收卡**、立监听、在途验收入状态信封
|
||||
(`loop_state.pending`,迁移 0015);判齐重入不发第二张卡;裁决到达后由下一拍
|
||||
定终局:pass → `finished` + `released: true`(选口随放行带回),reject → 不放行
|
||||
(`loop_state.pending`,迁移 0015);判齐重入不发第二张卡;裁决到达后由下一拍分岔:
|
||||
pass → `finished` + `released: true`(选口随放行带回);**非 pass = 打回重做,
|
||||
不是终局**(2026-08-22 修死:从前非 pass 也走 `finished` 分支,task 层把
|
||||
「finished 未放行」重新挂成 awaiting-human —— 人已经说了「重做」,却再没有事件
|
||||
叫醒它,一单就此卡死)——意见(`PendingAcceptance.comment`,onEvent 从裁决载荷
|
||||
捕入信封)以「验收被打回重做:…」进轨迹,在途验收清掉,**同一拍照常问模型**;
|
||||
刚被打回的那一拍不走判齐快道(否则判齐恒真的活动会在意见没人读时原样再发一张卡),
|
||||
模型修订后重新 submit,新发的验收卡才是下一次裁决的对象
|
||||
- **onEvent 只记不跑**(三修②):事件到达只登记 occur / 收裁决入信封,存快照即返;
|
||||
推进(可能含一次模型决策)统一发生在 tick —— steps/activeMs/tokens 一个预算口径
|
||||
- **任务轨迹喂回叙事面**(三修③):`memory.window(taskId)` 的最近轨迹进
|
||||
【本任务最近轨迹】段,窗口大小 = 活动声明的 `harness.memoryWindow`,Agent 每拍不失忆
|
||||
- **驳回是迁移不是状态**:`reenter` 以 repetitionRule 新实例重新入场
|
||||
- **打回重做之后,没改过东西就不许再提交**(2026-08-23):`raiseAcceptance` 一处把关,
|
||||
模型 submit 与判齐快道两条路共用 —— 打回之后台账没动过(写/撤都算)就拒;
|
||||
软硬两段:头一次拒并指路(改台账 / 发卡问清楚要改什么),已因此拒过一次再提交就放行
|
||||
(墙要配路,不锁死单子)。起因:打回那一刻案例模型的项常常全是 `Completed`,
|
||||
模型除了原样重新 submit 无事可做,6 秒就把同一份成果递回来
|
||||
- **立监听的授权面是活动清单的 `declarations.listeners`**(2026-08-23):
|
||||
`ListenerRegistrar.declared(taskId)` 给出声明过的那几种,没声明过的类型当场拒。
|
||||
与「写的授权面是台账声明」「调工具的授权面是白名单」同一条 ——
|
||||
立了没人叫醒的监听不是等待,是失联(实测:模型在依赖断点上临场立 data 监听,
|
||||
而那个活动一个监听都没声明,单子永久挂在 `awaiting-event`)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
canTransitionItem,
|
||||
IllegalTransitionError,
|
||||
initialRuntime,
|
||||
markWorldTouched,
|
||||
mayCompleteCase,
|
||||
mayTerminateCase,
|
||||
navigate,
|
||||
@@ -134,6 +135,22 @@ describe('生命周期 —— 语义忠实,引擎不得私改迁移拓扑', () =
|
||||
const next = reenter(runtime, 'T1');
|
||||
expect(next.items.find((i) => i.id === 'T1')).toEqual({ id: 'T1', state: 'Available', instance: 2 });
|
||||
});
|
||||
|
||||
describe('markWorldTouched —— finish 前的证据(AM 12.2 原理一)', () => {
|
||||
it('标一个项 —— 其余项原样不动', () => {
|
||||
const model = parseCmmn(XML);
|
||||
const next = markWorldTouched(initialRuntime(model), 'T1');
|
||||
expect(next.items.find((i) => i.id === 'T1')?.worldTouched).toBe(true);
|
||||
expect(next.items.find((i) => i.id !== 'T1')?.worldTouched).toBeUndefined();
|
||||
});
|
||||
|
||||
it('驳回重新入场是新实例 —— 标记不带过去,得重新证明真触达过', () => {
|
||||
const model = parseCmmn(XML);
|
||||
const touched = markWorldTouched(initialRuntime(model), 'T1');
|
||||
const next = reenter(touched, 'T1');
|
||||
expect(next.items.find((i) => i.id === 'T1')?.worldTouched).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('终局唯放行与 Harness —— AI 不得产生终局迁移', () => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TaskId } from '@contracts';
|
||||
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns } from '../internal/usecase/turn-runner.ts';
|
||||
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash } from '../internal/usecase/turn-runner.ts';
|
||||
import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts';
|
||||
import type { PendingAcceptance } from '../port.ts';
|
||||
import type { CaseModel } from '../internal/domain/cmmn-parse.ts';
|
||||
@@ -37,6 +37,12 @@ type Harness = {
|
||||
readonly pushed: string[];
|
||||
/** 每次推帧时「已写下几行」—— 帧推在行动之前的话,它是 0 */
|
||||
readonly writesAtPush: number[];
|
||||
/** 这一拍写下的全部留痕 —— 断言「被拒/无事发生」走这里 */
|
||||
readonly appended: Array<{ kind: string; title: string }>;
|
||||
/** 真被调到的工具名 —— 断言「拦没拦住调用」走这里 */
|
||||
readonly toolCalls: string[];
|
||||
/** 真立起来的监听 —— 断言「拦没拦住立监听」走这里 */
|
||||
readonly listened: Array<{ eventType: string }>;
|
||||
envelope: { model: unknown; parsed: CaseModel; runtime: CaseRuntime; pending?: PendingAcceptance };
|
||||
};
|
||||
|
||||
@@ -59,6 +65,12 @@ function harness(
|
||||
writeNoop?: boolean;
|
||||
/** 撤行时草稿层里根本没有那一行(主键写法对不上) */
|
||||
dropMisses?: boolean;
|
||||
/** doneWhen 表达式求值的桩(不给就恒真,同旧行为) */
|
||||
sfeelCondition?: (expr: string, scope: Record<string, unknown>) => boolean;
|
||||
/** 动态任务视图:按「此刻已写下几行」现算 —— 测「先写后 finish 的 doneWhen 用当下值」要它 */
|
||||
viewFn?: (written: Harness['written']) => Record<string, unknown>;
|
||||
/** 这个活动清单声明过哪些监听(不给就当它声明了 data —— 老测试照跑) */
|
||||
declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>;
|
||||
} = {},
|
||||
): Harness {
|
||||
const issued: Harness['issued'] = [];
|
||||
@@ -68,6 +80,9 @@ function harness(
|
||||
const dropped: Harness['dropped'] = [];
|
||||
const pushed: string[] = [];
|
||||
const writesAtPush: number[] = [];
|
||||
const appended: Harness['appended'] = [];
|
||||
const toolCalls: string[] = [];
|
||||
const listened: Array<{ eventType: string }> = [];
|
||||
const h: Harness = {
|
||||
issued,
|
||||
saves,
|
||||
@@ -76,6 +91,9 @@ function harness(
|
||||
dropped,
|
||||
pushed,
|
||||
writesAtPush,
|
||||
appended,
|
||||
toolCalls,
|
||||
listened,
|
||||
runner: undefined as never,
|
||||
envelope: undefined as never,
|
||||
};
|
||||
@@ -93,9 +111,9 @@ function harness(
|
||||
},
|
||||
load: async () => h.envelope as never,
|
||||
},
|
||||
sfeel: { condition: () => true, expression: () => undefined } as never,
|
||||
sfeel: { condition: over.sfeelCondition ?? (() => true), expression: () => undefined } as never,
|
||||
data: {
|
||||
view: async () => over.scope ?? {},
|
||||
view: async () => (over.viewFn === undefined ? (over.scope ?? {}) : over.viewFn(written)),
|
||||
writable: async () => [
|
||||
{ domain: 'quote_intake', object: 'record', label: '统一受理记录', keys: ['pn'], fields: [{ name: 'eau', label: 'EAU' }] },
|
||||
],
|
||||
@@ -120,7 +138,10 @@ function harness(
|
||||
},
|
||||
} as never,
|
||||
tools: {
|
||||
invoke: async () => ({ ok: true }),
|
||||
invoke: async (input: { tool: string }) => {
|
||||
toolCalls.push(input.tool);
|
||||
return { ok: true };
|
||||
},
|
||||
isWriteEffect: async () => false,
|
||||
registerIntent: async () => undefined,
|
||||
busOf: async () => [
|
||||
@@ -137,9 +158,17 @@ function harness(
|
||||
return { id: `card-${issued.length}` };
|
||||
},
|
||||
} as never,
|
||||
listeners: { register: async () => ({ id: 'lsn-1' }) } as never,
|
||||
listeners: {
|
||||
register: async (input: { eventType: string }) => {
|
||||
listened.push({ eventType: input.eventType });
|
||||
return { id: 'lsn-1' };
|
||||
},
|
||||
declared: async () => over.declaredListeners ?? [{ eventType: 'data', keyFilter: {} }],
|
||||
} as never,
|
||||
memory: {
|
||||
append: async () => undefined,
|
||||
append: async (entry: { kind: string; title: string }) => {
|
||||
appended.push({ kind: entry.kind, title: entry.title });
|
||||
},
|
||||
loadActivityMemory: async () => [],
|
||||
window: async () => over.trail ?? [],
|
||||
} as never,
|
||||
@@ -212,11 +241,166 @@ describe('提交放行 —— 终局唯放行,卡只发一张', () => {
|
||||
expect(h.saves.at(-1)?.pending).toBeNull();
|
||||
});
|
||||
|
||||
it('裁决 reject → finished 不放行', async () => {
|
||||
const h = harness({ pending: { acceptanceCardId: 'card-9', verdict: 'reject' } });
|
||||
/*
|
||||
* **裁决非 pass = 打回重做,不是终局**(2026-08-22,柴浩实测:E04 一单点了「重做」,
|
||||
* 引擎把在途验收消费成 null、任务重新挂回 awaiting-human —— 人已经说了话,
|
||||
* 却再没有任何事件会把它叫醒,一单就此卡死)。重做的语义是「带着意见退回去再干一遍」:
|
||||
* 意见用人话进轨迹、在途验收清掉,**同一拍照常问模型** —— 模型带着意见改成果、
|
||||
* 改完重新 submit,新发的那张验收卡才是下一次裁决的对象。
|
||||
*/
|
||||
it('裁决 redo(非 pass)→ 不终局:意见进轨迹、清在途验收、同一拍照常问模型', async () => {
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
runtime: OPEN_RUNTIME,
|
||||
pending: { acceptanceCardId: 'card-9', verdict: 'reject', comment: '重做一次,优先级排得不对' },
|
||||
action: { kind: 'write', itemId: 'i1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 2 } }] },
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(outcome.status).toBe('finished');
|
||||
expect(outcome.released).toBe(false);
|
||||
expect(outcome.status).not.toBe('finished');
|
||||
expect(h.decided).toHaveLength(1);
|
||||
// 在途验收第一时间清掉 —— 留着它,下一拍还会按「已裁决」再走一遍终局
|
||||
expect(h.saves[0]?.pending).toBeNull();
|
||||
expect(h.appended.some((e) => e.title.includes('打回重做') && e.title.includes('重做一次,优先级排得不对'))).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* **打回重做之后,一个字没改就再提交 = 原样递回,当场拒**(2026-08-22,柴浩实测:
|
||||
* 点「重做」写了「重做一下」,6 秒后同一份成果原样又递上来 —— 三条裁量判据重判一遍、
|
||||
* 台账一个字没动。人看见的是「我说了重做,它当没听见」)。
|
||||
* 案例模型的项这时全是 Completed,模型除了重新 submit 无事可做 —— **措辞治不了的结构治**:
|
||||
* 提交这道口子自己看一眼「打回之后改过东西没有」,没改就不放行。
|
||||
* 与「重发已答过的卡」同族的软硬两段:头一次拒(把路指明:改台账、或问清楚要改什么),
|
||||
* 已经拒过一次还坚持原样提交就放行 —— 人还能再打回,不至于把单子锁死。
|
||||
*/
|
||||
const REDO_NOTICE = { kind: 'result', title: '验收被打回重做:优先级排得不对' };
|
||||
const WROTE = { kind: 'action', title: '写草稿:crm_coverage.plan 1 行(1 行有变化)', detail: { ledger: 'crm_coverage.plan' } };
|
||||
const REFUSED = { kind: 'result', title: '提交放行被拒:人打回了这一份成果,而打回之后台账一个字没改' };
|
||||
|
||||
it('打回后零修改就 submit:当场拒,不发新验收卡', async () => {
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, trail: [REDO_NOTICE] });
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(0);
|
||||
expect(h.appended.some((e) => e.title.startsWith('行动被拒') && e.title.includes('一个字没改'))).toBe(true);
|
||||
});
|
||||
|
||||
it('打回后改过台账再 submit:照常发卡', async () => {
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, trail: [REDO_NOTICE, WROTE] });
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(1);
|
||||
expect(outcome.status).toBe('awaiting');
|
||||
});
|
||||
|
||||
it('已经因此拒过一次:再提交就放行 —— 墙要配路,不把单子锁死', async () => {
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, trail: [REDO_NOTICE, REFUSED] });
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('判齐快道同一道闸:打回后没改,引擎也不替模型原样再递一次', async () => {
|
||||
// READY_MODEL 判齐恒真 —— 这条路不经模型,从前它会在意见没人读时自己再发一张卡
|
||||
const h = harness({ trail: [REDO_NOTICE] });
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(0);
|
||||
expect(outcome.status).toBe('running');
|
||||
expect(h.appended.some((e) => e.title.startsWith('提交放行被拒'))).toBe(true);
|
||||
});
|
||||
|
||||
it('没有打回过的常路:提交照常,这道闸不出手', async () => {
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, trail: [WROTE] });
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('判齐恒真的局面里收到 redo:这一拍不走判齐快道重发卡 —— 先让模型看见意见', async () => {
|
||||
// READY_MODEL 必做项全无 + autoComplete → 判齐恒真;从前这条快道会不问模型直接再发一张卡
|
||||
const h = harness({
|
||||
pending: { acceptanceCardId: 'card-9', verdict: 'reject' },
|
||||
action: { kind: 'write', itemId: 'i1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 2 } }] },
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.decided).toHaveLength(1);
|
||||
// 这一拍引擎没有自己发新验收卡(模型看完意见后自己 submit 才发)
|
||||
expect(h.issued).toHaveLength(0);
|
||||
// 没写意见也要说明白:让模型自己复核,不是让它猜
|
||||
expect(h.appended.some((e) => e.title.includes('打回重做'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **发卡的授权面是调人预算,不是计划项**(2026-08-23,E04 回归实测撞出来的):
|
||||
* 依赖断点那一拍「当下可为」是空的,归一层给兜底卡兜出来的 `itemId` 就是空串,
|
||||
* 于是引擎自己造的那张「需要你判断下一步」被自己的门拦下 ——
|
||||
* **最后的活路自己被拦住,这一单就真没路了**(墙要配路,而这里连路本身都砌了墙)。
|
||||
* 与「写的授权面是台账声明」「调工具的授权面是白名单」同族:项号只是记账,
|
||||
* 挂不上照发,留痕注明。人工激活那道 worldTouched 门不受影响 ——
|
||||
* 挂不上就没标到任何项上,那道门照旧要求「真给这一项发过卡」。
|
||||
*/
|
||||
describe('发卡 —— 项号挂不上不拦', () => {
|
||||
const CARD = { kind: 'human', itemId: '', card: { title: '需要你判断下一步', ask: '接着走哪一项?' }, workOrder: 'execution' };
|
||||
|
||||
it('项号为空:卡照发,留痕注明挂不上', async () => {
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: CARD });
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(1);
|
||||
expect(outcome.status).toBe('awaiting');
|
||||
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
|
||||
expect(h.appended.some((e) => e.title.includes('未挂到计划项'))).toBe(true);
|
||||
});
|
||||
|
||||
it('项号编的:同样照发 —— 卡是给人的,不该因为记账挂不上就发不出去', async () => {
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: { ...CARD, itemId: '我编的项' } });
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('项号挂得上:照旧挂上并点亮(worldTouched 那道门要的就是这个)', async () => {
|
||||
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: { ...CARD, itemId: 'GATED' } });
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(1);
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.worldTouched).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **立监听的授权面是活动清单的 `declarations.listeners`**(2026-08-23,柴浩实测:
|
||||
* E04 一单在 S4 审批过后卡在依赖断点 —— 模型没写 `plan_approved_total` 那面旗,
|
||||
* S5 的入场哨兵永不成立;它于是临场「立一个 data 监听等执行回执」,而 E04 声明的
|
||||
* 监听是**空的**、这一单也不会有任何 data 事件进来,单子就此永久挂在 awaiting-event,
|
||||
* 15 分钟一动不动)。与「写的授权面是台账声明」「调工具的授权面是白名单」同一条:
|
||||
* **没声明过的监听立不得** —— 立了也没人来叫醒它,那不是等待,是失联。
|
||||
*/
|
||||
describe('立监听 —— 授权面是清单声明', () => {
|
||||
const LISTEN = { kind: 'listen', itemId: 'i1', eventType: 'data', keyFilter: { kind: 'receipt' }, deadline: 'P3D' };
|
||||
|
||||
it('活动一个监听都没声明:当场拒,并指出该走哪条路', async () => {
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: LISTEN, declaredListeners: [] });
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(h.listened).toHaveLength(0);
|
||||
expect(outcome.status).toBe('running');
|
||||
expect(h.appended.some((e) => e.title.startsWith('行动被拒') && e.title.includes('没声明'))).toBe(true);
|
||||
});
|
||||
|
||||
it('声明过这一类:照立不拦', async () => {
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
runtime: OPEN_RUNTIME,
|
||||
action: LISTEN,
|
||||
declaredListeners: [{ eventType: 'data', keyFilter: { kind: 'receipt' } }],
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(h.listened).toHaveLength(1);
|
||||
expect(outcome.status).toBe('awaiting');
|
||||
});
|
||||
|
||||
it('声明的是别的类(只声明了 time,却要立 data):照样拒', async () => {
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
runtime: OPEN_RUNTIME,
|
||||
action: LISTEN,
|
||||
declaredListeners: [{ eventType: 'time', keyFilter: { kind: 'deadline' } }],
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.listened).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,6 +418,12 @@ describe('onEvent —— 只记不跑', () => {
|
||||
expect(h.envelope.pending).toEqual({ acceptanceCardId: 'card-9' });
|
||||
expect(h.decided).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('redo 裁决连意见一起落进信封 —— 重做那一拍模型要看得见人写了什么', async () => {
|
||||
const h = harness({ pending: { acceptanceCardId: 'card-9' } });
|
||||
await h.runner.onEvent(TASK, { kind: 'human', ref: 'card-9', payload: { verdict: 'redo', comment: '重做一次' } });
|
||||
expect(h.envelope.pending).toEqual({ acceptanceCardId: 'card-9', verdict: 'reject', comment: '重做一次' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('叙事面 —— 任务自身轨迹喂回', () => {
|
||||
@@ -605,6 +795,131 @@ describe('空转的写当场拒', () => {
|
||||
expect(outcome.status).toBe('running');
|
||||
expect(h.written).toHaveLength(1);
|
||||
});
|
||||
|
||||
/*
|
||||
* **空转写身后还有别的行动:无事发生,串不废**(2026-08-22,E04 活单熔断实测):
|
||||
* 模型每拍计划「写记账旗 + 发 S5 执行卡」两个动作的串 —— 旗的值没变,第 1 个动作
|
||||
* 被拒,而「被拒停在谁那儿」把整串弃权,排在后面的发卡从没执行过;连续五拍如此,
|
||||
* 熔断交人。提示词的 world-touch 事实行与拒回的 blockedOnWorld 指路**都在**,
|
||||
* 模型照样先写旗 —— 措辞已到头,结构治(设计律第六条):值没变的写什么也没改,
|
||||
* 后面的行动前提未变,不该陪葬。
|
||||
*/
|
||||
it('空转写身后还有行动:无事发生不废串 —— 排在后面的发卡照发', async () => {
|
||||
const h = harness({
|
||||
parsed: STAGE_MODEL,
|
||||
runtime: READY_RUNTIME2,
|
||||
actions: [
|
||||
{ kind: 'write', itemId: 'FREE', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] },
|
||||
{ kind: 'human', itemId: 'GATED', card: { title: '执行回执工单', ask: '请执行并回填' }, workOrder: 'execution' },
|
||||
],
|
||||
writeNoop: true,
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(h.issued.filter((c) => c.workOrder === 'execution')).toHaveLength(1);
|
||||
expect(outcome.status).toBe('awaiting');
|
||||
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
|
||||
// 无事发生也要说出来 —— 模型下一拍得看见「值已在台账里」这个事实
|
||||
expect(h.appended.some((e) => e.kind === 'observation' && e.title.includes('写了等于没写'))).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* **调工具的授权面是工具白名单,不是计划项**(2026-08-22,E04 实测:模型调
|
||||
* rank-key-persons 带了个空 itemId,被「没给计划项号」拒回,下一拍补上项号
|
||||
* 原样重调 —— 那次拒回什么都没保护,白烧一拍。与写行动同款:挂不上不拦,
|
||||
* 留痕注明,记账挂项是尽力而为,不是门。
|
||||
*/
|
||||
it('调工具项号为空/挂不上:照调不拦 —— 授权面是工具白名单,不是计划项', async () => {
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
runtime: OPEN_RUNTIME,
|
||||
action: { kind: 'tool', itemId: '', tool: 'crm.history-price-read', input: { pns: ['A'] } },
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(h.toolCalls).toContain('crm.history-price-read');
|
||||
expect(outcome.status).toBe('running');
|
||||
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
|
||||
});
|
||||
|
||||
/*
|
||||
* **同一份入参的读工具,原地重调是空转**(2026-08-22,E04 实测:S6 写回前模型把
|
||||
* recompute-score 用一字不差的入参连调 10 次 —— 分数没照它预期涨,就反复重调赌变卦;
|
||||
* 出参每次原样摆在轨迹里它照调不误,26 次调用烧穿预算的前科在案,而 stalledTurns
|
||||
* 只数「行动被拒」,成功的重调没有任何兜底)。与「重发已答过的卡」同族的软硬两段:
|
||||
* 重调的头几次照调、出参标题携带点名警告(不算被拒,模型不撞墙);第 5 次identical
|
||||
* 才拒(真卡死了,熔断兜底要看得见)。写过/撤过之后世界变了,计数随写清零。
|
||||
*/
|
||||
it('同一份入参的重调:出参标题带点名警告 —— 不拒,但话要说到', async () => {
|
||||
const CALL = { kind: 'tool', itemId: 'i1', tool: 'crm.history-price-read', input: { pns: ['A'] } };
|
||||
const echo = {
|
||||
kind: 'result',
|
||||
title: 'crm.history-price-read 返回',
|
||||
detail: { output: { ok: 1 }, inputHash: toolInputHash({ pns: ['A'] }) },
|
||||
};
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
runtime: OPEN_RUNTIME,
|
||||
action: CALL,
|
||||
trail: [echo, { kind: 'observation', title: '这一拍在往下办事' }, { ...echo }],
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.toolCalls).toContain('crm.history-price-read');
|
||||
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
|
||||
expect(h.appended.some((e) => e.kind === 'result' && e.title.includes('第 3 次调用') && e.title.includes('别再调了'))).toBe(true);
|
||||
});
|
||||
|
||||
it('同一份入参连调第 5 次(期间没写过东西):当场拒 —— 真卡死了,熔断要看得见', async () => {
|
||||
const CALL = { kind: 'tool', itemId: 'i1', tool: 'crm.history-price-read', input: { pns: ['A'] } };
|
||||
const echo = () => ({
|
||||
kind: 'result',
|
||||
title: 'crm.history-price-read 返回',
|
||||
detail: { output: { ok: 1 }, inputHash: toolInputHash({ pns: ['A'] }) },
|
||||
});
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
runtime: OPEN_RUNTIME,
|
||||
action: CALL,
|
||||
trail: [echo(), echo(), echo(), echo()],
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.toolCalls).toHaveLength(0);
|
||||
expect(h.appended.some((e) => e.kind === 'result' && e.title.startsWith('行动被拒'))).toBe(true);
|
||||
});
|
||||
|
||||
it('两次识别之间写过台账:世界变了,重调照放行', async () => {
|
||||
const CALL = { kind: 'tool', itemId: 'i1', tool: 'crm.history-price-read', input: { pns: ['A'] } };
|
||||
const echo = {
|
||||
kind: 'result',
|
||||
title: 'crm.history-price-read 返回',
|
||||
detail: { output: { ok: 1 }, inputHash: toolInputHash({ pns: ['A'] }) },
|
||||
};
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
runtime: OPEN_RUNTIME,
|
||||
action: CALL,
|
||||
trail: [echo, { ...echo }, { kind: 'action', title: '写草稿:d.o 1 行(1 行有变化)', detail: { ledger: 'd.o' } }],
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.toolCalls).toContain('crm.history-price-read');
|
||||
});
|
||||
|
||||
it('入参不同的重调不算原地打转:照放行', async () => {
|
||||
const CALL = { kind: 'tool', itemId: 'i1', tool: 'crm.history-price-read', input: { pns: ['B'] } };
|
||||
const echo = {
|
||||
kind: 'result',
|
||||
title: 'crm.history-price-read 返回',
|
||||
detail: { output: { ok: 1 }, inputHash: toolInputHash({ pns: ['A'] }) },
|
||||
};
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: CALL, trail: [echo, { ...echo }] });
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.toolCalls).toContain('crm.history-price-read');
|
||||
});
|
||||
|
||||
/* 串尾/独苗的空转写仍当场拒 —— 熔断检测(stalledTurns 数「行动被拒」)靠它兜底 */
|
||||
it('空转写是独苗:照旧当场拒,熔断检测看得见', async () => {
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: WRITE, writeNoop: true });
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.appended.some((e) => e.kind === 'result' && e.title.startsWith('行动被拒'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('宣告完成', () => {
|
||||
@@ -639,16 +954,111 @@ describe('宣告完成', () => {
|
||||
* 模型就是学不稳。人工激活真正的分量在引擎那一侧:不替它启动、不替人收 ——
|
||||
* 那两条各有各的测钉着(哨兵不自动激活 / 自动收步跳过人工激活项)。
|
||||
*/
|
||||
it('标了人工激活的项:模型显式动它 = 启动它,就地过 Active 再收', async () => {
|
||||
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('GATED') });
|
||||
it('标了人工激活的项:已经真触达过世界,模型显式动它 = 启动它,就地过 Active 再收', async () => {
|
||||
// worldTouched 模拟「更早的拍已经发过卡 / 立过监听」——这里只测「不必再补一次 start」那半条
|
||||
const h = harness({
|
||||
parsed: STAGE_MODEL,
|
||||
runtime: { ...READY_RUNTIME2, items: READY_RUNTIME2.items.map((i) => (i.id === 'GATED' ? { ...i, worldTouched: true } : i)) } as never,
|
||||
action: finish('GATED'),
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Completed');
|
||||
});
|
||||
|
||||
/*
|
||||
* **触达世界的项,没真触达过就不许 finish**(2026-08-21,E04 实测逼出来的:
|
||||
* S5_execute_receipt 从没给人发过执行回执工单,模型直接 finish 掉了它,
|
||||
* 人根本没被问过完成状态,台账拿空回执重算了覆盖度)。`manualActivation`
|
||||
* 声明的正是「完成与否不该由数据形状推出来」——如果 finish 不查有没有真的
|
||||
* human/entry/decide/listen 之一发生过,这条声明形同虚设,模型可以随口
|
||||
* 编一个「完成」。
|
||||
*/
|
||||
it('标了人工激活的项:从没真触达过世界,finish 被拒——不许凭空宣布人已经办完了', async () => {
|
||||
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('GATED') });
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(outcome.status).toBe('running');
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Available');
|
||||
});
|
||||
|
||||
it('编出来的项收不了', async () => {
|
||||
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('我编的项') });
|
||||
expect((await h.runner.runTurn(TASK)).status).toBe('running');
|
||||
});
|
||||
|
||||
/*
|
||||
* **声明了 doneWhen 的项,算出来是假就不许 finish**(2026-08-22,E04 实测逼出来的:
|
||||
* S1 该写两行候选客户,模型只写成一行就直接 finish——doneWhen 明明为假,这一步照样
|
||||
* 被记成「完成」,漏掉的那颗客户从此在这一单里凭空消失,一路悄悄漏到 S5 提交闸才第一次
|
||||
* 被拦下)。声明 doneWhen 就是说「这件事完没完可以算出来」,算出来是假,finish 就该被拒——
|
||||
* 不是让模型自己判断,是让基座去核。
|
||||
*/
|
||||
it('声明了 doneWhen 的项:算出来是假,finish 被拒——不许凭一句话绕过基座的判断', async () => {
|
||||
const h = harness({
|
||||
parsed: STAGE_MODEL,
|
||||
runtime: READY_RUNTIME2,
|
||||
action: finish('FREE'),
|
||||
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'some_rows > 0 and some_missing = 0' }],
|
||||
sfeelCondition: () => false,
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(outcome.status).toBe('running');
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Available');
|
||||
});
|
||||
|
||||
it('声明了 doneWhen 的项:算出来是真,finish 正常收', async () => {
|
||||
const h = harness({
|
||||
parsed: STAGE_MODEL,
|
||||
runtime: READY_RUNTIME2,
|
||||
action: finish('FREE'),
|
||||
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'some_rows > 0 and some_missing = 0' }],
|
||||
sfeelCondition: () => true,
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
|
||||
});
|
||||
|
||||
/*
|
||||
* **同一串里先写后 finish,doneWhen 要按写完之后的草稿算**(2026-08-22,E04 实测:
|
||||
* S1 一拍连做「写候选行 + finish」——写已经落进草稿,finish 却按拍首快照判 doneWhen
|
||||
* 为假被拒;下一拍引擎又用同一份数据自动收步。同一份数据两个裁决,每单白吃一次被拒,
|
||||
* 而「先写后 finish 一拍连做」正是提示词鼓励的省拍打法,不该被快照误伤。
|
||||
*/
|
||||
it('同一串先写后 finish:doneWhen 按写完后的草稿算,不按拍首快照', async () => {
|
||||
const h = harness({
|
||||
parsed: STAGE_MODEL,
|
||||
runtime: READY_RUNTIME2,
|
||||
actions: [
|
||||
{ kind: 'write', itemId: 'FREE', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] },
|
||||
{ kind: 'finish', itemId: 'FREE', rationale: '行已写齐' },
|
||||
],
|
||||
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'record_rows > 0' }],
|
||||
viewFn: (written) => ({ record_rows: written.length }),
|
||||
sfeelCondition: (_expr, scope) => Number(scope['record_rows'] ?? 0) > 0,
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
|
||||
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
|
||||
});
|
||||
|
||||
it('doneWhen 表达式算错了(抛错)—— 不拖垮这一拍,原样交给模型的 finish 判断', async () => {
|
||||
const h = harness({
|
||||
parsed: STAGE_MODEL,
|
||||
runtime: READY_RUNTIME2,
|
||||
action: finish('FREE'),
|
||||
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: '这不是一个合法表达式(((' }],
|
||||
sfeelCondition: () => {
|
||||
throw new Error('写坏了');
|
||||
},
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
|
||||
});
|
||||
|
||||
it('没声明 doneWhen 的项:finish 不受这道新门槛影响,照旧收', async () => {
|
||||
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('FREE'), sfeelCondition: () => false });
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -873,6 +1283,37 @@ describe('画面不再静默丢帧', () => {
|
||||
expect(h.writesAtPush).toEqual([2]);
|
||||
});
|
||||
|
||||
/*
|
||||
* **这一拍把上一步收了、下一步的卡也发了 —— 画面得跟着走到下一步**
|
||||
* (2026-08-23 E04 实测:S4 计划审批收掉、S5 执行卡当场发出,而工作区还摆着
|
||||
* 审批那份只读件。人拿到的卡写着「把回执填到件上」,屏上一个可填的格子都没有,
|
||||
* 提交闸如实拦住,他无处可填 —— 墙拦住却不给路)。
|
||||
* 病根与 `liveScope` 那条同源:**拿拍首的局面去决定拍尾的事**。
|
||||
*/
|
||||
it('一串行动把案子推到下一步 → 推的是下一步的画面,不是拍首那一步的', async () => {
|
||||
const TWO: CaseModel = {
|
||||
name: '两步',
|
||||
autoComplete: true,
|
||||
items: [
|
||||
{ id: 'i1', name: '第一步', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
|
||||
{ id: 'i2', name: '第二步', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
|
||||
],
|
||||
sentries: [],
|
||||
} as never;
|
||||
const h = harness({
|
||||
parsed: TWO,
|
||||
runtime: { items: [{ id: 'i1', state: 'Active' }, { id: 'i2', state: 'Available' }] } as never,
|
||||
templates: ['step1-view', 'step2-view'],
|
||||
steps: [
|
||||
{ item: 'i1', intent: '办第一步', guide: '做法', writes: [], reads: [], tools: [], view: 'step1-view' },
|
||||
{ item: 'i2', intent: '办第二步', guide: '做法', writes: [], reads: [], tools: [], view: 'step2-view' },
|
||||
],
|
||||
actions: [{ kind: 'finish', itemId: 'i1' }],
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.pushed).toEqual(['step2-view']);
|
||||
});
|
||||
|
||||
it('活步都收完 → 一帧都不推,最后摆上去的那一张(放行页)留在档案里', async () => {
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
|
||||
@@ -76,6 +76,17 @@ export type ItemRuntime = {
|
||||
readonly instance: number;
|
||||
/** 语义性前提待裁量评估的标记 */
|
||||
readonly awaitingJudgement?: boolean;
|
||||
/**
|
||||
* **这一实例期间,AI 有没有真的触达过世界**(human / entry / decide / listen 之一)。
|
||||
*
|
||||
* 只给「触达世界」的项(`manualActivation` = true)当门槛用:那类项完成与否
|
||||
* 天生**不该由数据形状推出来**(AM 12.2 的原理一),唯一的证据是「真发过一张卡
|
||||
* 或真立过一次监听」。没有这个字段之前,`finish` 只看生命周期迁移合不合法 ——
|
||||
* 模型可以在从没给人发过卡的情况下直接宣告某个人工激活项「完成」,
|
||||
* 人的那一步就此被悄悄跳过(2026-08-21 用户实测:E04 S5 执行回执从未派单,
|
||||
* 覆盖度收口直接用了空回执重算,人根本没被问过)。
|
||||
*/
|
||||
readonly worldTouched?: boolean;
|
||||
};
|
||||
|
||||
export type CaseRuntime = {
|
||||
@@ -342,6 +353,11 @@ export function transitionItem(runtime: CaseRuntime, itemId: string, to: ItemSta
|
||||
return { ...runtime, items: runtime.items.map((i) => (i.id === itemId ? { ...i, state: to } : i)) };
|
||||
}
|
||||
|
||||
/** human / entry / decide / listen 真发生过一次 —— 见 `ItemRuntime.worldTouched` 的注释 */
|
||||
export function markWorldTouched(runtime: CaseRuntime, itemId: string): CaseRuntime {
|
||||
return { ...runtime, items: runtime.items.map((i) => (i.id === itemId ? { ...i, worldTouched: true } : i)) };
|
||||
}
|
||||
|
||||
/** repetitionRule:驳回是**迁移不是状态** —— 相关计划项以新实例重新入场 */
|
||||
export function reenter(runtime: CaseRuntime, itemId: string): CaseRuntime {
|
||||
return {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
criterionKey,
|
||||
readingsOf,
|
||||
initialRuntime,
|
||||
markWorldTouched,
|
||||
navigate,
|
||||
occur,
|
||||
publishEnabled,
|
||||
@@ -139,6 +140,27 @@ function compact(value: unknown, limit: number = TRAIL_PAYLOAD_LIMIT): string {
|
||||
* 非法行动、每拍被拓扑拒回来,人在界面上只看到「进行中」一动不动 ——
|
||||
* 那不是「在办」,是卡死了。Harness 的活就是在这时候把它交回给人。
|
||||
*/
|
||||
/**
|
||||
* 工具入参的短哈希 —— 「同一份入参重调」检查的账。
|
||||
* 存整份入参不行(轨迹要进提示词,一份 contacts 数组就是几百字);djb2 足够认同一性,
|
||||
* 碰撞的代价只是一次多余的拦截提示,不是数据错误。
|
||||
*/
|
||||
/** 「验收被打回重做」那条痕的抬头 —— 写它与找它的是同一个常量,改一处两处都跟着改 */
|
||||
const REDO_MARK = '验收被打回重做';
|
||||
/**
|
||||
* 「打回之后没改就再提交」那次拒回里的记号句 —— 软硬两段靠它数第二次。
|
||||
* 认的是**句子**不是抬头:同一条拒回经两条路留痕,抬头各不相同
|
||||
* (模型 submit 那条是「行动被拒:…」,判齐快道那条是「提交放行被拒:…」)。
|
||||
*/
|
||||
const REFUSE_MARK = '打回之后台账一个字没改';
|
||||
|
||||
export function toolInputHash(input: unknown): string {
|
||||
const text = JSON.stringify(input) ?? '';
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < text.length; i += 1) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
|
||||
return `${text.length}:${(hash >>> 0).toString(16)}`;
|
||||
}
|
||||
|
||||
export function stalledTurns(trail: ReadonlyArray<{ kind: string; title: string }>): number {
|
||||
let count = 0;
|
||||
for (let i = trail.length - 1; i >= 0; i -= 1) {
|
||||
@@ -348,6 +370,13 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* **项号挂不上**(没给 / 编的一个不存在的号)—— 与「这一步已经办完了」是两件事:
|
||||
* 前者只是记账没对上(写、调工具、发卡都照办,留痕注明),后者是真墙(别往办完的事上派活)。
|
||||
* 分成两种错误型,调用方才好各按各的处置;混成一句,要么该拦的不拦,要么该放的不放。
|
||||
*/
|
||||
class ItemUnattachableError extends Error {}
|
||||
|
||||
/**
|
||||
* 干活的那一下:把计划项推到 Active,再由调用方收成 Completed。
|
||||
*
|
||||
@@ -372,7 +401,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
.map((i) => i.id);
|
||||
// 空字符串不是「不存在的项」,是**没给项号** —— 两件事说成一句,读的人只能猜
|
||||
const what = itemId.trim() === '' ? '没给计划项号' : `计划项不存在:${itemId}`;
|
||||
throw new Error(`${what}(可选:${legal.join('、') || '无'})`);
|
||||
throw new ItemUnattachableError(`${what}(可选:${legal.join('、') || '无'})`);
|
||||
}
|
||||
/*
|
||||
* 父阶段没开着就不许在里面干活 —— 不然 CMMN 的阶段门是装饰。
|
||||
@@ -498,6 +527,26 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
taskId: TaskId,
|
||||
rawAction: AgentAction,
|
||||
runtime: CaseRuntime,
|
||||
/**
|
||||
* **这一项声明的 doneWhen,现在算出来是真是假**(undefined = 没声明,或算不出来)。
|
||||
* 只有 `finish` 用得上——`doneWhen` 从前只管「引擎自己收要不要收」,没管过
|
||||
* 「模型自己 finish 让不让」:一个没有 manualActivation 的普通步骤,模型随时能
|
||||
* 显式 finish 掉它,doneWhen 是真是假完全不影响这条路(2026-08-22 实测:
|
||||
* S1 该写两行候选客户,模型只写成一行就直接 finish,doneWhen 明明为假,
|
||||
* 这一步照样被记成「完成」)。**声明了 doneWhen 就是说「这件事完没完是可以算出来的」**——
|
||||
* 那就不该被模型一句「我 finish 了」绕过去,机检只读基座数得出来的数这条铁律,
|
||||
* 第五次被同一类洞违反(前四次是自报的行数/去向哨兵/入场哨兵/收步判据)。
|
||||
*/
|
||||
doneWhenOf?: (itemId: string) => boolean | undefined,
|
||||
/**
|
||||
* **这个行动身后同一串里还有没排到的行动**(2026-08-22,E04 活单熔断实测)。
|
||||
* 空转写(值一行都没变)从前一律当场拒,而「被拒停在谁那儿」把整串弃权 ——
|
||||
* 模型每拍计划「写记账旗 + 发执行卡」,旗的值没变,排在后面的发卡从没执行过,
|
||||
* 连拒五拍熔断。值没变的写**什么也没改**,后面行动的前提未变,不该陪葬:
|
||||
* 有后续就降级成「无事发生」放行整串;没有后续(串尾/独苗)仍拒 ——
|
||||
* 熔断检测(stalledTurns 数「行动被拒」)靠它兜底,纯空转的单不能悄无声息。
|
||||
*/
|
||||
hasFollowUp = false,
|
||||
): Promise<{
|
||||
runtime: CaseRuntime;
|
||||
status: TurnOutcome['status'];
|
||||
@@ -615,10 +664,56 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
}
|
||||
|
||||
case 'tool': {
|
||||
// 先验迁移再动手:不合法的行动不该在世界上留下痕迹(登记意向、调工具都算)
|
||||
const { parsed: toolModel } = await loaded(taskId);
|
||||
// 站上场即可 —— **不收场**:一件事常要几个动作才交得出成果,收了就再也动不了它
|
||||
const working = readyToWork(toolModel, runtime, action.itemId);
|
||||
/*
|
||||
* **同一份入参的读工具,原地重调是空转**(2026-08-22,E04 实测:S6 写回前模型把
|
||||
* recompute-score 用一字不差的入参连调 10 次 —— 分数没照它预期涨,就反复重调
|
||||
* 赌变卦;出参每次原样摆在轨迹里,它照调不误。26 次调用烧穿 500k 预算的前科在案,
|
||||
* 而 stalledTurns 只数「行动被拒」,成功的重调它看不见 —— 没有任何兜底)。
|
||||
* 头两次照调;期间**没有任何写/撤动作**(世界没变,读出来的只会一样)的第 3 次
|
||||
* identical 调用当场拒 —— 拒回是模型反应最好的通道,也归熔断兜底管。
|
||||
* 写过之后计数清零:世界变了,重调是合法的。
|
||||
*/
|
||||
let sameCalls = 0;
|
||||
if (!(await deps.tools.isWriteEffect(action.tool))) {
|
||||
const hash = toolInputHash(action.input);
|
||||
const recent = await deps.memory.window(taskId, STALL_WINDOW);
|
||||
for (let i = recent.length - 1; i >= 0; i -= 1) {
|
||||
const entry = recent[i]!;
|
||||
if (entry.kind === 'action' && typeof entry.detail?.['ledger'] === 'string') break; // 写/撤过,世界变了
|
||||
if (entry.kind === 'result' && entry.title.startsWith(`${action.tool} 返回`) && entry.detail?.['inputHash'] === hash) {
|
||||
sameCalls += 1;
|
||||
}
|
||||
}
|
||||
/*
|
||||
* 与「重发已答过的卡」同族的软硬两段:重调的头几次**照调**(它是本地纯函数,
|
||||
* 调用本身不贵,贵的是拍)但出参标题携带点名警告 —— 不算被拒,模型不撞墙;
|
||||
* 到第 5 次identical 才拒(那时是真卡死了,熔断兜底要看得见它)。
|
||||
*/
|
||||
if (sameCalls >= 4) {
|
||||
throw new Error(
|
||||
`${action.tool} 用同一份入参已经调过 ${sameCalls} 次,出参就在你的轨迹里,而这期间台账一个字没动 —— ` +
|
||||
'它是按入参算数的工具,同样的输入永远同样的结论,再调一遍不会变卦。' +
|
||||
'算出来的数与你预想的不一样,先检查入参是不是漏了字段;确认无误就**按这个数往下办**' +
|
||||
'(该写的表写上、该收的步收掉),别再调了。',
|
||||
);
|
||||
}
|
||||
}
|
||||
/*
|
||||
* **调工具的授权面是工具白名单,不是计划项**(2026-08-22,E04 实测):
|
||||
* 模型调 rank-key-persons 带了个空 itemId,被「没给计划项号」拒回,
|
||||
* 下一拍补上项号原样重调 —— 那次拒回什么都没保护,白烧一拍。
|
||||
* 与写行动同款(「写的授权面是台账声明」):itemId 只是记账,
|
||||
* 挂不上(空串/项不存在/已收掉)照调不拦,留痕注明;记账挂项尽力而为。
|
||||
*/
|
||||
let working = runtime;
|
||||
let unattached: string | undefined;
|
||||
try {
|
||||
// 站上场即可 —— **不收场**:一件事常要几个动作才交得出成果,收了就再也动不了它
|
||||
working = readyToWork(toolModel, runtime, action.itemId);
|
||||
} catch (error) {
|
||||
unattached = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
// 写类工具登记为动作意向 —— 触达世界的动作集中在放行时落地
|
||||
if (await deps.tools.isWriteEffect(action.tool)) {
|
||||
await deps.tools.registerIntent({
|
||||
@@ -651,12 +746,19 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'result',
|
||||
title: `${action.tool} ${result.ok ? '返回' : '失败'}`,
|
||||
detail: { output: result.output, error: result.error?.message },
|
||||
title:
|
||||
`${action.tool} ${result.ok ? '返回' : '失败'}` +
|
||||
(unattached === undefined ? '' : ` —— 未挂到计划项(${unattached})`) +
|
||||
// 重调的警告写在出参标题上 —— 它随轨迹进下一拍的上下文,正是模型看的地方
|
||||
(sameCalls === 0 || !result.ok
|
||||
? ''
|
||||
: ` —— 这是同一份入参的第 ${sameCalls + 1} 次调用,出参与上次一字不差:别再调了,按这个数往下办`),
|
||||
// inputHash 是「同一份入参重调」检查的账 —— 存短哈希,不存整份入参(轨迹要进提示词)
|
||||
detail: { output: result.output, error: result.error?.message, inputHash: toolInputHash(action.input) },
|
||||
});
|
||||
return {
|
||||
// 失败才动状态(Failed 是拓扑里的一档);成了就留在 Active 等 Agent 宣告完成
|
||||
runtime: result.ok ? working : transitionItem(working, action.itemId, 'Failed'),
|
||||
// 失败才动状态(Failed 是拓扑里的一档;没挂上项就没有项可标);成了就留在 Active 等 Agent 宣告完成
|
||||
runtime: result.ok || unattached !== undefined ? working : transitionItem(working, action.itemId, 'Failed'),
|
||||
status: 'running',
|
||||
};
|
||||
}
|
||||
@@ -676,7 +778,20 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* T_release —— 每一拍的补写都被「要先 start」拒回来,连拒五拍熔断交人,
|
||||
* 而那几行数据本身合法合规。活没干完却没有项可挂,是记账问题,不是授权问题。
|
||||
*/
|
||||
if (action.rows.length === 0) throw new Error('写台账要给行:rows 为空,这一拍什么也没发生');
|
||||
/*
|
||||
* **空 rows 的拒回要点名地址,不能只说「什么也没发生」**(2026-08-21 实测:
|
||||
* E04 一拍连发三个写,前两个成了,第三个 crm_coverage.ledger_update 带着空
|
||||
* rows 被拒——下一拍模型没有回头补那一张,反倒把前两张已经成功的表原样
|
||||
* 重写,连拒五拍熔断)。这张表这一拍**一行都没写**,不是「写错了要改」,
|
||||
* 是「压根没写」——把地址钉在拒回里,下一拍才有目标可对,不会去改别的表。
|
||||
*/
|
||||
if (action.rows.length === 0) {
|
||||
throw new Error(
|
||||
`写台账要给行:${action.address} 这一下 rows 是空的,这一拍什么也没发生 —— ` +
|
||||
`这张表这一拍一行都没写(不是内容错了要改,是压根没写),下一拍别去动别的表` +
|
||||
'(尤其别把已经写成功的表重写一遍,那只会被当空转再拒一次),回来把这张表真正的行写上。',
|
||||
);
|
||||
}
|
||||
const at = action.address.indexOf('.');
|
||||
if (at <= 0 || at === action.address.length - 1) {
|
||||
throw new Error(`台账地址要写成「域.对象」:收到 ${action.address || '(空)'}`);
|
||||
@@ -719,11 +834,44 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* 「换一件事做」四个字不够指方向(2026-08-02 实测:A2 一单连撞四拍,
|
||||
* 每拍都把同一行原样再写一遍)—— 拒回的话是模型下一拍的行动指南,
|
||||
* 得说清「这些值**已经在**台账里」意味着什么:多半是该办下一件事了。
|
||||
*
|
||||
* **「该 finish 的项」这半句在一种局面下会把模型指回死路**(2026-08-21
|
||||
* 实测:E04 finish 被 worldTouched 那道新门拒了之后,模型没去发卡,
|
||||
* 反倒把同一行 verdict 原样重写了五拍,连拒到熔断)——它其实卡在
|
||||
* 「有一项标了人工激活,却还没真给人发过卡」,这句泛泛的指路对它不管用。
|
||||
* 查一眼当下有没有这样的项,点名指过去:该发的是卡,不是再写一遍这张表。
|
||||
*/
|
||||
const blockedOnWorld = model.items.find((i) => {
|
||||
if (i.manualActivation !== true) return false;
|
||||
const state = runtime.items.find((r) => r.id === i.id);
|
||||
return (state?.state === 'Enabled' || state?.state === 'Active') && state.worldTouched !== true;
|
||||
});
|
||||
/*
|
||||
* **身后还有行动就不拒:无事发生,串照走**(2026-08-22,E04 活单熔断实测)。
|
||||
* 模型每拍计划「写记账旗 + 发 S5 执行卡」—— 旗的值没变,这里一拒整串弃权,
|
||||
* 排在后面的发卡从没执行过;提示词的 world-touch 事实行与下面 blockedOnWorld
|
||||
* 的指路**都在**,它照样先写旗:措辞已到头,结构治。值没变的写什么也没改,
|
||||
* 后面行动的前提未变,不该陪葬。留痕照说(模型下一拍要看见「值已在台账里」),
|
||||
* 局面原样交回 —— 无事发生不站上场(与 start 已开项、重发已答卡同族)。
|
||||
*/
|
||||
if (hasFollowUp) {
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
title: `写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同 —— 无事发生,这一串后面的行动照走`,
|
||||
detail: { internal: true, ledger: action.address, itemId: action.itemId },
|
||||
});
|
||||
return { runtime, status: 'running' };
|
||||
}
|
||||
throw new Error(
|
||||
`写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同 —— ` +
|
||||
`这些值**已经在台账里了**,不必再写。该办的多半是下一件事:还没写的列/表、` +
|
||||
`该 finish 的项,或按剧本走下一步;把同样的值再写一遍只会再被拒一次`,
|
||||
`该 finish 的项,或按剧本走下一步;把同样的值再写一遍只会再被拒一次` +
|
||||
(blockedOnWorld === undefined
|
||||
? ''
|
||||
: ` —— 这一单当下卡在 ${blockedOnWorld.id}(标了人工激活,这一单里还没真给它发过卡、` +
|
||||
'也没为它立过监听):别再往这张表上写了,去照说明书发那一张卡(human/entry/decide)' +
|
||||
'或立一次监听(listen),这才是当下唯一能往下走的路。'),
|
||||
);
|
||||
}
|
||||
await deps.memory.append({
|
||||
@@ -875,7 +1023,30 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
}
|
||||
}
|
||||
const { parsed: cardModel } = await loaded(taskId);
|
||||
const advanced = readyToWork(cardModel, runtime, action.itemId);
|
||||
/*
|
||||
* **发卡的授权面是调人预算,不是计划项**(2026-08-23,E04 回归实测撞出来的):
|
||||
* 依赖断点那一拍「当下可为」是空的,归一层给兜底卡兜出来的 itemId 就是空串 ——
|
||||
* 于是引擎自己造的那张「需要你判断下一步」被自己的门拦下。
|
||||
* **最后的活路自己被砌了墙,这一单就真没路了。**
|
||||
* 与「写的授权面是台账声明」「调工具的授权面是白名单」同族:项号只是记账,
|
||||
* 挂不上照发、留痕注明。人工激活那道 worldTouched 门不受影响 ——
|
||||
* 挂不上就没标到任何项上,那道门照旧要求「真给这一项发过卡」。
|
||||
*/
|
||||
let advanced = runtime;
|
||||
let cardUnattached: string | undefined;
|
||||
try {
|
||||
advanced = readyToWork(cardModel, runtime, action.itemId);
|
||||
} catch (error) {
|
||||
// 「这一步已经办完/停用」是真墙,原样抛回去(拒回话术会把模型指向当下那一步)
|
||||
if (!(error instanceof ItemUnattachableError)) throw error;
|
||||
cardUnattached = error.message;
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
title: `发卡未挂到计划项(${cardUnattached}) —— 卡照发,记账挂不上不拦人`,
|
||||
detail: { internal: true, card: action.card },
|
||||
});
|
||||
}
|
||||
const card = await deps.cards.issue({
|
||||
taskId,
|
||||
ownerId: context.ownerId,
|
||||
@@ -889,7 +1060,11 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
keyFilter: { cardId: card.id },
|
||||
deadline: 'P3D',
|
||||
});
|
||||
return { runtime: advanced, status: 'awaiting' };
|
||||
// 挂不上就没有项可标 —— worldTouched 只记在真挂上的那一项上,那道门因此仍然诚实
|
||||
return {
|
||||
runtime: cardUnattached === undefined ? markWorldTouched(advanced, action.itemId) : advanced,
|
||||
status: 'awaiting',
|
||||
};
|
||||
}
|
||||
|
||||
case 'ask': {
|
||||
@@ -932,7 +1107,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
keyFilter: { cardId: entryCard.id },
|
||||
deadline: 'P3D',
|
||||
});
|
||||
return { runtime: entryReady, status: 'awaiting' };
|
||||
return { runtime: markWorldTouched(entryReady, action.itemId), status: 'awaiting' };
|
||||
}
|
||||
|
||||
case 'decide': {
|
||||
@@ -992,10 +1167,31 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
keyFilter: { cardId: card.id },
|
||||
deadline: 'P3D',
|
||||
});
|
||||
return { runtime: advanced, status: 'awaiting' };
|
||||
return { runtime: markWorldTouched(advanced, action.itemId), status: 'awaiting' };
|
||||
}
|
||||
|
||||
case 'listen': {
|
||||
/*
|
||||
* **立监听的授权面是活动清单的 `declarations.listeners`**(2026-08-23 加,
|
||||
* 柴浩实测逼出来的:E04 一单卡在依赖断点 —— 模型没写那面记账旗、下一步的哨兵
|
||||
* 永不成立,它于是临场「立个 data 监听等回执」;而 E04 一个监听都没声明、
|
||||
* 这一单也不会有任何 data 事件进来,单子就此挂在 awaiting-event 15 分钟一动不动)。
|
||||
*
|
||||
* **立了没人叫醒的监听不是等待,是失联** —— 与「写的授权面是台账声明」
|
||||
* 「调工具的授权面是白名单」同一条纪律。拒回同时把两条真路指出来:
|
||||
* 要等人办事就发执行工单;卡在断点就回去补那个没写的数。
|
||||
*/
|
||||
const declared = await deps.listeners.declared(taskId);
|
||||
if (!declared.some((d) => d.eventType === action.eventType)) {
|
||||
const kinds = declared.map((d) => d.eventType).join('、');
|
||||
throw new Error(
|
||||
`这个活动没声明过 ${action.eventType} 类监听(声明过的:${kinds || '一个都没有'})—— ` +
|
||||
'立了也没人会来叫醒它,这一单会一直挂着。要等人去办事就**发执行工单**(human,' +
|
||||
'人在工作区的件上把回音填回来);当下要是没有项可干(依赖断点),' +
|
||||
'多半是某个入场前提的数还没写(看「干完的判据」那几行读数,常见的是某面记账旗漏了),' +
|
||||
'回去把那一笔补上,门自己就开了。',
|
||||
);
|
||||
}
|
||||
// AI 不能调用世界:对世界只有两种合法姿势 —— 触达它(动作)与等它(监听)
|
||||
await deps.listeners.register({
|
||||
taskId,
|
||||
@@ -1009,7 +1205,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
title: `立监听:${action.eventType}(时限 ${action.deadline})`,
|
||||
detail: action.keyFilter,
|
||||
});
|
||||
return { runtime, status: 'awaiting' };
|
||||
return { runtime: markWorldTouched(runtime, action.itemId), status: 'awaiting' };
|
||||
}
|
||||
|
||||
case 'asked': {
|
||||
@@ -1057,6 +1253,44 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
});
|
||||
return { runtime, status: 'running' };
|
||||
}
|
||||
/*
|
||||
* **触达世界的项,没真触达过就不许 finish**(2026-08-21 用户实测逼出来的:
|
||||
* E04 的 S5_execute_receipt 从未派发执行回执工单,模型一拍之内就把它
|
||||
* finish 掉了,人根本没被问过完成状态/结果摘要,收口那一步拿空回执重算了
|
||||
* 覆盖度)。`manualActivation = true` 声明的正是「这一项完成与否**不该由
|
||||
* 数据形状推出来**」(AM 12.2 原理一)——可 `finish` 从前只查生命周期迁移
|
||||
* 合不合法,从没查过「人真的被问过吗」。凭空写的完成不是完成,是编造。
|
||||
*
|
||||
* 门槛只认 `worldTouched`(见 lifecycle.ts):human / entry / decide / listen
|
||||
* 四个「触达世界」的动作之一真的针对这一项执行过,才算数——不管是这一拍刚发的,
|
||||
* 还是更早的拍留下的(reject 之后重发也算,标记不会中途被清)。
|
||||
*/
|
||||
const item = finishModel.items.find((i) => i.id === action.itemId);
|
||||
if (item?.manualActivation === true && runtime.items.find((i) => i.id === action.itemId)?.worldTouched !== true) {
|
||||
throw new Error(
|
||||
`${action.itemId} 标了人工激活(触达世界),但这一单里从没给它发过人类工具的卡、` +
|
||||
'也没为它立过监听 —— finish 不能凭空宣布「人已经办完了」。先照说明书发一张' +
|
||||
'卡(human/entry/decide)或立一次监听(listen),真收到回音再 finish;' +
|
||||
'如果这一步其实不需要人(说明书写错了),那是活动包的声明问题,不是这一拍能绕过去的。',
|
||||
);
|
||||
}
|
||||
/*
|
||||
* **声明了 doneWhen,就不许模型自己一句话绕过去**(2026-08-22 实测:E04 S1
|
||||
* 该写两行候选客户,模型只写成了一行就直接 finish——doneWhen 明明为假,
|
||||
* 这一步照样被记成「完成」,漏掉的那颗客户从此在这一单里凭空消失,
|
||||
* 一路悄悄漏到 S5 提交闸才第一次被拦下)。`doneWhen` 是「这件事完没完可以
|
||||
* 算出来」的声明,算出来是假,finish 就该被拒——不是让模型自己判断,
|
||||
* 是让基座去核。算不出来(表达式写坏了)或压根没声明,不拦,原样交给模型判断。
|
||||
*/
|
||||
const doneWhenNow = doneWhenOf?.(action.itemId);
|
||||
if (doneWhenNow === false) {
|
||||
throw new Error(
|
||||
`${action.itemId} 声明了 doneWhen(完没完可以算出来),但现在算出来是假 —— ` +
|
||||
'不能凭这一下 finish 说它已经办完了。回头看这一步该写的东西是不是漏了一部分' +
|
||||
'(常见的是:该写 N 行只写了 M 行,或某一行缺了必填列),补齐了 doneWhen 自然为真,' +
|
||||
'基座会自己收,不必你再 finish。',
|
||||
);
|
||||
}
|
||||
const ready = readyToWork(finishModel, runtime, action.itemId);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
@@ -1099,6 +1333,34 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
/** 验收报告上让人当场改的那几个值(带 AI 拟值与落回地址)—— 见 submit 行动的注释 */
|
||||
fields?: ReadonlyArray<{ key: string; label: string; ai?: string; ledger?: string }>,
|
||||
): Promise<{ runtime: CaseRuntime; status: TurnOutcome['status'] }> {
|
||||
/*
|
||||
* **人打回了这一份成果,改都没改就不许再递上来**(2026-08-22,柴浩实测:点「重做」
|
||||
* 写了「重做一下」,6 秒后同一份成果原样又递回 —— 三条裁量判据重判一遍、台账一个字没动。
|
||||
* 人看见的是「我说了重做,它当没听见」)。
|
||||
*
|
||||
* 根子在局面:打回那一刻案例模型的项全是 Completed,模型除了重新 submit 无事可做,
|
||||
* 拒回文案与说明书都劝不动它(**措辞治不了的结构治**)。所以由提交这道唯一的出口
|
||||
* 自己看一眼:打回之后台账动过没有(写 / 撤都算)——没动就不放行。
|
||||
*
|
||||
* 软硬两段(与「重发已答过的卡」「同一份入参重调」同族):头一次拒,把路指明;
|
||||
* 已经因此拒过一次还坚持原样提交就放行 —— 人还能再打回,墙要配路,不把单子锁死。
|
||||
*/
|
||||
const window = await deps.memory.window(taskId, STALL_WINDOW);
|
||||
let lastRedo = -1;
|
||||
for (const [at, entry] of window.entries()) if (entry.title.startsWith(REDO_MARK)) lastRedo = at;
|
||||
if (lastRedo >= 0) {
|
||||
const after = window.slice(lastRedo + 1);
|
||||
const revised = after.some((e) => e.kind === 'action' && typeof e.detail?.['ledger'] === 'string');
|
||||
const refusedBefore = after.some((e) => e.kind === 'result' && e.title.includes(REFUSE_MARK));
|
||||
if (!revised && !refusedBefore) {
|
||||
throw new Error(
|
||||
`人打回了这一份成果,而${REFUSE_MARK} —— 原样再递上去,` +
|
||||
'他看见的还是刚被他打回的那一份。先按他的意见改台账(write 覆盖写 / drop 撤行都使得),' +
|
||||
'改完再提交;他没写具体意见的话,自己把成果复核一遍、至少改对一处,' +
|
||||
'或者发一张卡问清楚他要改什么,别原样重提。',
|
||||
);
|
||||
}
|
||||
}
|
||||
const context = await deps.tasks.of(taskId);
|
||||
/*
|
||||
* **发验收卡之前先把放行那一页摆好**(2026-07-31)。
|
||||
@@ -1191,22 +1453,43 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
const pending = (await loaded(taskId)).pending;
|
||||
|
||||
/*
|
||||
* 在途验收先于一切:裁决到了按它定终局(released 与否都是终局,不再问 Agent);
|
||||
* 没到就继续等 —— 不重发卡、不空跑模型。
|
||||
* 在途验收先于一切:没裁就继续等(不重发卡、不空跑模型);裁了按 verdict 分岔:
|
||||
* · `pass` → 终局(released),不再问 Agent;
|
||||
* · **非 pass = 打回重做,不是终局**(2026-08-22,柴浩实测:从前这里也走终局分支,
|
||||
* task 层把「finished 未放行」重新挂成 awaiting-human —— 人已经说了「重做」,
|
||||
* 却再没有任何事件会叫醒它,一单就此卡死)。重做 = 带着意见退回去再干一遍:
|
||||
* 意见用人话进轨迹、在途验收清掉,**这一拍照常往下问模型** ——
|
||||
* 模型修订台账草稿后重新 submit,新发的验收卡才是下一次裁决的对象。
|
||||
*/
|
||||
let redone = false;
|
||||
if (pending !== undefined) {
|
||||
if (pending.verdict === undefined) {
|
||||
return { runtime, enabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
||||
}
|
||||
if (pending.verdict === 'pass') {
|
||||
await deps.state.save(taskId, model, parsed, runtime, null);
|
||||
return {
|
||||
runtime,
|
||||
enabled,
|
||||
navigation: { kind: 'converged' },
|
||||
status: 'finished',
|
||||
released: true,
|
||||
...(pending.selectedPorts === undefined ? {} : { selectedPorts: pending.selectedPorts }),
|
||||
};
|
||||
}
|
||||
redone = true;
|
||||
await deps.state.save(taskId, model, parsed, runtime, null);
|
||||
return {
|
||||
runtime,
|
||||
enabled,
|
||||
navigation: { kind: 'converged' },
|
||||
status: 'finished',
|
||||
released: pending.verdict === 'pass',
|
||||
...(pending.selectedPorts === undefined ? {} : { selectedPorts: pending.selectedPorts }),
|
||||
};
|
||||
const comment = pending.comment?.trim() ?? '';
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'result',
|
||||
title: `${REDO_MARK}${comment === '' ? '(人没写具体意见 —— 自己把成果复核一遍,至少一处实质性修订)' : `:${comment}`}`,
|
||||
detail: {
|
||||
hint:
|
||||
'按意见修订台账草稿(write 覆盖写 / drop 撤行都使得),改完重新提交放行(submit)——' +
|
||||
'新发的验收卡才是下一次裁决的对象;别原样重提,那只会再被打回一次。',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -1215,8 +1498,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* 提交要过**落地预检**那道门,预检不过会抛(必交付物缺、意向不可落地、他单改了同一字段)。
|
||||
* 抛了不能让回合崩:推进器会一遍遍重来,每遍再撞一次同一道门。
|
||||
* 正确做法与「行动被拒」一致 —— 拒绝 + 留痕 + 把理由喂回下一拍,让 Agent 自己去补。
|
||||
*
|
||||
* **刚被打回重做的那一拍不走这条快道**:判齐恒真的活动会在意见没人读的情况下
|
||||
* 立刻再发一张一模一样的验收卡 —— 人看见的是「我说了重做,它原样又递上来了」。
|
||||
* 这一拍先交给模型(意见就在轨迹里),它改完自己 submit。
|
||||
*/
|
||||
if (autoCompleteReady(parsed, runtime)) {
|
||||
if (autoCompleteReady(parsed, runtime) && !redone) {
|
||||
try {
|
||||
const raised = await raiseAcceptance(taskId, runtime, '必做项全部完成,无进行中项');
|
||||
return { runtime: raised.runtime, enabled, navigation: { kind: 'converged' }, status: raised.status };
|
||||
@@ -1431,11 +1718,33 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
const current =
|
||||
liveStep ??
|
||||
[...stateEnabled.inProgress, ...stateEnabled.entries].map((e) => stepOf(e.id)).find((x) => x !== undefined);
|
||||
/*
|
||||
* **触达世界的项,「有没有真的问过人」是一个数,不是一句要模型自己去轨迹里找的话**
|
||||
* (2026-08-21 用户实测逼出来的:E04 S5_execute_receipt 的 guide 已经写了
|
||||
* 「动手之前先在轨迹里找 [card-reply]」,finish 被拒的理由也点名了同一件事,
|
||||
* 模型两次都视而不见,一路把同一行 verdict 重写到连拒五拍熔断)。
|
||||
* 与「干完的判据」同一条治法:**能算的不问人、也不指望模型自己算**——
|
||||
* worldTouched(见 lifecycle.ts)在引擎这一侧本来就是现成的布尔,直接摆成一句
|
||||
* 事实,模型只需要读,不需要从一堆轨迹文本里自己判断「这条回复是不是答的是这一项」。
|
||||
*/
|
||||
const currentTouch = ((): string => {
|
||||
if (current === undefined) return '';
|
||||
const declared = parsed.items.find((i) => i.id === current.item);
|
||||
if (declared?.manualActivation !== true) return '';
|
||||
const touched = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
|
||||
return touched
|
||||
? '这一步标了人工激活(触达世界):这一单里已经真的给它发过卡或立过监听——人已经被问过了,' +
|
||||
'照下面「人交回之后」那一段办。'
|
||||
: '这一步标了人工激活(触达世界):这一单里从没给它发过卡(human/entry/decide)、' +
|
||||
'也没为它立过监听(listen)——**没有别的路,唯一合法的动作是先发那一张卡或立那次监听**,' +
|
||||
'写台账、finish 都还轮不到,写了也会被拒。';
|
||||
})();
|
||||
const staged =
|
||||
current === undefined
|
||||
? narrative
|
||||
: [
|
||||
`【当前这一步 · ${current.item}】${current.intent}`,
|
||||
currentTouch,
|
||||
current.guide,
|
||||
current.writes.length === 0 ? '' : `这一步要写的台账:${current.writes.join('、')}`,
|
||||
current.tools.length === 0 ? '' : `这一步要调的工具:${current.tools.join('、')}`,
|
||||
@@ -1630,6 +1939,15 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
let saved = false;
|
||||
let framed = false;
|
||||
let lastAction: AgentAction | undefined;
|
||||
/*
|
||||
* **finish 的 doneWhen 要按「此刻」的草稿算,不是拍首快照**(2026-08-22,E04 实测):
|
||||
* 「同一串先写后 finish 一拍连做」是提示词鼓励的省拍打法,而 doneWhen 若按拍首的
|
||||
* `scope` 求值,刚落进草稿的那几行看不见 —— finish 被「算出来是假」误拒,
|
||||
* 下一拍引擎又用同一份数据自动收步:同一份数据两个裁决,每单白吃一次被拒。
|
||||
* 写/撤落笔后标脏,轮到 finish 时才重取一次任务视图(不是每写一笔就取)。
|
||||
*/
|
||||
let liveScope = scope;
|
||||
let scopeStale = false;
|
||||
|
||||
for (let index = 0; index < planned.length; index += 1) {
|
||||
const action = planned[index]!;
|
||||
@@ -1646,7 +1964,27 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
if (action.kind === 'start' && parsed.items.find((i) => i.id === action.itemId) === undefined) {
|
||||
throw new ForbiddenTerminalError('owner');
|
||||
}
|
||||
applied = await applyAction(taskId, action, currentRuntime);
|
||||
if (action.kind === 'finish' && scopeStale) {
|
||||
liveScope = await deps.data.view(taskId);
|
||||
scopeStale = false;
|
||||
}
|
||||
applied = await applyAction(
|
||||
taskId,
|
||||
action,
|
||||
currentRuntime,
|
||||
(itemId) => {
|
||||
const step = stepOf(itemId);
|
||||
if (step?.doneWhen === undefined || step.doneWhen.trim() === '') return undefined;
|
||||
try {
|
||||
return deps.sfeel.condition(step.doneWhen, liveScope);
|
||||
} catch {
|
||||
// 谓词写坏了不该拖垮整拍(同 1436 行的口径):算不出来就不拦
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
index < planned.length - 1,
|
||||
);
|
||||
if (action.kind === 'write' || action.kind === 'drop') scopeStale = true;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
await deps.memory.append({
|
||||
@@ -1698,10 +2036,36 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* 兜一张 `case-overview`(`showView` 再退到它声明的第一张)。
|
||||
* 写了剧本却挑不出 —— 活步都收完了 —— 就一帧不推。
|
||||
*/
|
||||
const frame = wanted !== undefined && wanted !== '' ? wanted : steps.length === 0 ? 'case-overview' : undefined;
|
||||
/*
|
||||
* **挑画面要按「这一拍办完之后」在办的那一步**(2026-08-23,E04 S4→S5 实测)。
|
||||
*
|
||||
* `liveStep` 是**拍首**算的,而一串行动常常把案子往前推一步:那一拍收掉 S4
|
||||
* (经营计划审批)、当场发出 S5 的执行卡,画面却仍按 S4 推 —— 人打开工作台,
|
||||
* 卡上写着「把回执填到件上」,屏上摆的是审批那份只读件,**一个可填的格子都没有**;
|
||||
* 提交闸如实拦住「缺完成状态、结果摘要、关键人变动」,而他无处可填。
|
||||
* 这是设计律第一条「墙要配路」的又一次现形,病根与 `liveScope` 那条同源:
|
||||
* **拿拍首的局面去决定拍尾的事**。推之前按当下的局面重算一次。
|
||||
*
|
||||
* 只在真的算出了一步时才顶掉 `wanted` —— 活步都收完那种情形照旧走老路
|
||||
* (`framed` 或一帧不推),免得把人刚签过的放行页冲掉。
|
||||
*/
|
||||
if (scopeStale) {
|
||||
liveScope = await deps.data.view(taskId);
|
||||
scopeStale = false;
|
||||
}
|
||||
const settledNow = new Set(
|
||||
currentRuntime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id),
|
||||
);
|
||||
const enabledNow = publishEnabled(parsed, currentRuntime, liveScope, changedRefs(liveScope), deps.sfeel.condition, []);
|
||||
const stepNow = [...enabledNow.inProgress, ...enabledNow.entries]
|
||||
.filter((e) => !settledNow.has(e.id))
|
||||
.map((e) => stepOf(e.id))
|
||||
.find((x) => x !== undefined);
|
||||
const wantedNow = stepNow?.view !== undefined && stepNow.view !== '' ? stepNow.view : wanted;
|
||||
const frame = wantedNow !== undefined && wantedNow !== '' ? wantedNow : steps.length === 0 ? 'case-overview' : undefined;
|
||||
if (!framed && frame !== undefined && frame !== '') {
|
||||
try {
|
||||
const shown = await showView(taskId, frame, currentRuntime, liveStep?.view);
|
||||
const shown = await showView(taskId, frame, currentRuntime, stepNow?.view ?? liveStep?.view);
|
||||
if (!shown.pushed) {
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
@@ -1742,9 +2106,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
if (event.kind === 'human' && pending?.acceptanceCardId === event.ref) {
|
||||
const verdict = event.payload['verdict'] === 'pass' ? ('pass' as const) : ('reject' as const);
|
||||
const ports = event.payload['selectedPorts'];
|
||||
// 打回重做时人写的意见也要落进信封 —— 重做那一拍模型看的就是它
|
||||
const comment = event.payload['comment'];
|
||||
nextPending = {
|
||||
...pending,
|
||||
verdict,
|
||||
...(typeof comment === 'string' && comment.trim() !== '' ? { comment: comment.trim() } : {}),
|
||||
...(typeof ports === 'object' && ports !== null ? { selectedPorts: ports as Record<string, string> } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,11 +130,18 @@ export type ToolGateway = {
|
||||
|
||||
/**
|
||||
* 在途验收(提交放行的持久痕):卡发出即记,裁决到达再记 verdict ——
|
||||
* 判齐重入不再发第二张卡;恢复的那一拍据 verdict 定终局(released 与否)。
|
||||
* 判齐重入不再发第二张卡。恢复的那一拍按 verdict 分岔:
|
||||
* `pass` 才是终局(released);**非 pass = 打回重做,不是终局** ——
|
||||
* 意见(comment)用人话进轨迹、在途验收清掉,同一拍照常问模型,
|
||||
* 模型改完重新提交,新发的验收卡才是下一次裁决的对象(2026-08-22,
|
||||
* 从前非 pass 只把任务重新挂成 awaiting-human,人说了「重做」却再没有
|
||||
* 任何事件叫醒它,一单就此卡死 —— 柴浩实测)。
|
||||
*/
|
||||
export type PendingAcceptance = {
|
||||
readonly acceptanceCardId: string;
|
||||
readonly verdict?: 'pass' | 'reject';
|
||||
/** 打回重做时人写的意见 —— 空着就是点了固定按钮没写话 */
|
||||
readonly comment?: string;
|
||||
readonly selectedPorts?: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -209,6 +216,15 @@ export type ListenerRegistrar = {
|
||||
keyFilter: Record<string, string>;
|
||||
deadline: string;
|
||||
}): Promise<{ id: string }>;
|
||||
/**
|
||||
* **这个活动声明过哪几种监听** —— 立监听的授权面(2026-08-23 加)。
|
||||
*
|
||||
* 与「写的授权面是台账声明」「调工具的授权面是白名单」同一条:没声明过的监听立不得。
|
||||
* 起因是实测的一次永久挂起(E04):模型在依赖断点上临场立了个 data 监听等回执,
|
||||
* 而那个活动 `declarations.listeners` 是空的、这一单也不会有任何 data 事件进来 ——
|
||||
* 单子就此挂在 `awaiting-event` 一动不动。**立了没人叫醒的监听不是等待,是失联。**
|
||||
*/
|
||||
declared(taskId: TaskId): Promise<ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,7 +27,6 @@ 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';
|
||||
|
||||
type ModuleKey = 'tasks' | 'approvals' | 'messages' | 'minutes' | 'report';
|
||||
@@ -153,49 +152,33 @@ export function AppShell() {
|
||||
);
|
||||
})}
|
||||
<div className="jc-rail-foot">
|
||||
{/*
|
||||
另两面的入口 —— 按授权亮灯:没有治理授权的人根本看不见,不给点了才说没权。
|
||||
两枚并排、只留章不留名:轨底还得放身份钮,竖排三件会把左轨撑出滚动条(实测)。
|
||||
*/}
|
||||
{(canSeeSurface(session.data, 'design') || canSeeSurface(session.data, 'ops')) && (
|
||||
<div className="jc-nav-user" style={{ flexDirection: 'row', gap: 6, paddingTop: 0 }}>
|
||||
{canSeeSurface(session.data, 'design') && (
|
||||
<a
|
||||
className="jc-nav-ava"
|
||||
href={SURFACE_META.design.path}
|
||||
title="设计中心 —— 定义企业怎么运转"
|
||||
style={{ width: 26, height: 26, fontSize: 11, background: 'linear-gradient(140deg,#4954e6,#232b6e)' }}
|
||||
>
|
||||
设
|
||||
</a>
|
||||
)}
|
||||
{canSeeSurface(session.data, 'ops') && (
|
||||
<a
|
||||
className="jc-nav-ava"
|
||||
href={SURFACE_META.ops.path}
|
||||
title="运营中心 —— 看企业今天跑得怎样"
|
||||
style={{ width: 26, height: 26, fontSize: 11, background: 'linear-gradient(140deg,#12b886,#0b7a5c)' }}
|
||||
>
|
||||
运
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 轨底身份即登出入口 —— 会话在服务端,点它是真作废,不只是清本地 */}
|
||||
<button
|
||||
type="button"
|
||||
className="jc-nav-user"
|
||||
title={`${me}(本工作台所有者)· 点击登出`}
|
||||
disabled={logout.isPending}
|
||||
onClick={() => logout.mutate()}
|
||||
>
|
||||
{/* 轨底只报身份,不再兼职登出入口 —— 退出登录挪到顶栏,免得点头像误触登出 */}
|
||||
<div className="jc-nav-user" title={`${me}(本工作台所有者)`}>
|
||||
<span className="jc-nav-ava">{me.slice(0, 1)}</span>
|
||||
<span className="jc-nav-name">{me}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{module === 'tasks' ? (
|
||||
<div className="app-main">
|
||||
<header className="jc-topbar">
|
||||
<span className="jc-topbar-title">{NAV_MODULES.find((m) => m.key === module)?.label ?? ''}</span>
|
||||
<div className="jc-topbar-user">
|
||||
<span className="jc-topbar-name">{me}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="jc-topbar-logout"
|
||||
disabled={logout.isPending}
|
||||
onClick={() => logout.mutate()}
|
||||
>
|
||||
<Ico name="log-out" s={14} />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="app-main-body">
|
||||
{module === 'tasks' ? (
|
||||
<div className={'workspace' + (collapsed ? ' jc-side-collapsed' : '')} style={split.style}>
|
||||
{collapsed && (
|
||||
<button
|
||||
@@ -320,6 +303,8 @@ export function AppShell() {
|
||||
) : (
|
||||
<ReportDesk />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -476,7 +476,7 @@
|
||||
.proto-root .jc-sup:focus { outline: 0; border-color: var(--brand); box-shadow: 0 0 0 3px rgba(51,112,255,.12); }
|
||||
.proto-root .jc-sup:disabled { background: #f3f4f6; color: #5c626b; }
|
||||
|
||||
/* ===== 左侧一体化导航轨(无顶栏):品牌 → 四大模块 → 轨底动作 + 登录者身份 ===== */
|
||||
/* ===== 左侧一体化导航轨:品牌 → 四大模块 → 轨底身份(登出已挪去顶栏,见下)===== */
|
||||
.proto-root .app-nav { padding: 14px 10px 12px; }
|
||||
.proto-root .jc-rail-brand { text-align: center; font-size: 10px; font-weight: 800; letter-spacing: .14em; color: var(--ink-3); margin: -4px 0 6px; }
|
||||
.proto-root .app-nav button { min-height: 58px; transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); }
|
||||
@@ -489,6 +489,18 @@
|
||||
.proto-root .jc-nav-ava { width: 32px; height: 32px; border-radius: 999px; display: grid; place-items: center; flex: 0 0 auto; line-height: 1; color: #fff; font-size: 13px; font-weight: 800; background: linear-gradient(140deg, #2bc7d4, #0e9fd8); box-shadow: 0 2px 6px rgba(14, 159, 216, .35); }
|
||||
.proto-root .jc-nav-name { font-size: 10px; font-weight: 600; color: var(--ink-3); }
|
||||
|
||||
/* ===== 顶栏:当前模块名 + 身份 + 退出登录 —— 与左轨互不重叠,登出只有这一处入口 ===== */
|
||||
.proto-root .app-main { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
||||
.proto-root .app-main-body { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
||||
.proto-root .app-main-body > * { flex: 1 1 auto; min-width: 0; min-height: 0; }
|
||||
.proto-root .jc-topbar { flex: 0 0 auto; height: 56px; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; background: #fff; border-bottom: 1px solid var(--line); }
|
||||
.proto-root .jc-topbar-title { font-size: 14px; font-weight: 700; color: var(--ink); }
|
||||
.proto-root .jc-topbar-user { display: flex; align-items: center; gap: 12px; }
|
||||
.proto-root .jc-topbar-name { font-size: 13px; color: var(--ink-3); }
|
||||
.proto-root .jc-topbar-logout { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; border: 1px solid var(--line); border-radius: 8px; background: #fff; color: var(--ink-2); font-size: 13px; font-weight: 600; cursor: pointer; transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); }
|
||||
.proto-root .jc-topbar-logout:hover { background: var(--danger-soft); color: var(--danger); border-color: #f3c9c6; }
|
||||
.proto-root .jc-topbar-logout:disabled { opacity: .6; cursor: default; }
|
||||
|
||||
/* 工作清单头:渐变图标点缀 + 两级筛选 */
|
||||
.proto-root .jc-wl-head { display: flex; align-items: center; justify-content: flex-start; gap: 10px; }
|
||||
.proto-root .jc-wl-head > div { flex: 1; min-width: 0; text-align: left; }
|
||||
@@ -909,16 +921,50 @@
|
||||
.proto-root .fileview-name svg { color: var(--ink-3); flex: none; }
|
||||
.proto-root .fileview-name strong { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.proto-root .fileview-kind { margin-left: auto; flex: none; font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 999px; }
|
||||
/* 下载原件 —— 屏上渲的是读回来的样子,这条给的是真字节(.docx 用 Word 打得开) */
|
||||
.proto-root .fileview-dl { display: inline-flex; align-items: center; gap: 5px; flex: none; height: 28px; padding: 0 11px;
|
||||
border: 1px solid var(--line); border-radius: 8px; background: #fff; color: var(--ink-2);
|
||||
font-size: 12px; font-weight: 600; text-decoration: none; transition: all var(--t-fast); }
|
||||
.proto-root .fileview-dl:hover { border-color: var(--brand-soft); background: var(--brand-wash); color: var(--brand); }
|
||||
.proto-root .fileview-dl svg { flex: none; }
|
||||
.proto-root .fileview-kind.k-markdown { color: var(--brand-deep); background: var(--brand-wash); }
|
||||
.proto-root .fileview-kind.k-excel { color: var(--ok); background: var(--ok-soft); }
|
||||
.proto-root .fileview-kind.k-word { color: var(--st-auto); background: var(--st-auto-soft); }
|
||||
.proto-root .fileview-kind.k-pdf { color: var(--danger); background: var(--danger-soft); }
|
||||
.proto-root .fileview-body { min-height: 0; padding: 26px 22px; background: radial-gradient(120% 80% at 50% 0%, #eef1f6 0%, #e8ecf1 100%); }
|
||||
/*
|
||||
* **书桌要跟着纸一样长**(2026-08-23 用户第二次报「灰底滚动后变白」,附截图)。
|
||||
*
|
||||
* 这一格是 `.fileview`(`height:100%`)的弹性子项,而弹性子项默认 `flex: 0 1 auto`:
|
||||
* 它**不长个**,高度被容器(= 看得见的那一屏)封住。里面那张纸比一屏长时,
|
||||
* 纸溢出这个盒子继续往下排,而**背景只画在盒子里** —— 于是滚到一屏之外,
|
||||
* 纸两侧的灰书桌没了,底下是面板自己的白。上一轮只治了走沙箱的 markdown / html
|
||||
* (那两档的底色画在 iframe 里),word / excel 这两档是父文档直接渲的,漏在外面。
|
||||
*
|
||||
* `flex: 1 0 auto`:不许被压(`shrink:0` → 盒子至少和内容一样高,背景铺到底),
|
||||
* 内容比一屏短时又能长满(`grow:1` → 不会在下方留一条白边)。
|
||||
*/
|
||||
.proto-root .fileview-body { flex: 1 0 auto; min-height: 0; padding: 26px 22px; background: radial-gradient(120% 80% at 50% 0%, #eef1f6 0%, #e8ecf1 100%); }
|
||||
|
||||
/* 文档纸张(word:居中纸张) */
|
||||
.proto-root .doc-paper { max-width: 760px; margin: 0 auto; background: #fff; border: 1px solid var(--line-soft); border-radius: 8px; box-shadow: 0 16px 44px rgba(31, 35, 41, .14), 0 2px 6px rgba(31, 35, 41, .06); padding: 44px 52px; }
|
||||
/* markdown:铺满中间区全渲染,无窄栏/灰边/纸张感 */
|
||||
.proto-root .fileview.k-markdown .fileview-body { background: #fff; padding: 0; }
|
||||
/*
|
||||
* **Markdown 与 HTML 走沙箱之后,外层这一格要跟里面同色**(2026-08-23 用户:
|
||||
* 「渲染页面向下翻的时候背景的灰色在下半截会变成白色」)。
|
||||
*
|
||||
* 这条从前是 `background:#fff; padding:0` —— 那时 markdown 是一整块 `<pre>` 纸面铺满,
|
||||
* 白是对的。现在里面是一份**自带灰底 + 居中白纸**的文档(与下载到的 .html 同一份),
|
||||
* iframe 高度按内容算:内容比这一屏矮时,底下那截露出来的就是外层这块白 ——
|
||||
* 人往下翻,灰底在半路变成白色。同色(与文档自己的 #f4f5f7 一致)之后接缝就没了。
|
||||
*
|
||||
* 也不再给内边距:那份文档自己排的版(纸面居中、两侧留白),外层再套一层边距
|
||||
* 等于两套版面语言叠着。excel / word / pdf 那三种仍走上面的渐变书桌 —— 它们没有自己的底。
|
||||
*/
|
||||
.proto-root .fileview.k-markdown .fileview-body,
|
||||
.proto-root .fileview.k-html .fileview-body { background: #f4f5f7; padding: 0; }
|
||||
/* 内容比这一屏矮时,让沙箱把这一格铺满 —— 灰底一路到底,不在中途断开 */
|
||||
.proto-root .fileview.k-markdown .jc-view-frame,
|
||||
.proto-root .fileview.k-html .jc-view-frame { min-height: 100%; }
|
||||
.proto-root .doc-paper.doc-full { max-width: none; margin: 0; min-height: 100%; border: 0; border-radius: 0; box-shadow: none; padding: 28px 40px; }
|
||||
.proto-root .word-paper h1 { font-size: 22px; font-weight: 800; color: var(--ink); margin: 0 0 14px; text-align: center; }
|
||||
.proto-root .word-paper h2 { font-size: 16px; font-weight: 700; color: var(--ink); margin: 20px 0 8px; }
|
||||
@@ -2132,6 +2178,24 @@
|
||||
.proto-root .jc-dv { padding: 12px 14px; background: #fff; border: 1px solid var(--cardline); border-left: 3px solid var(--line); border-radius: 11px; box-shadow: var(--card-shadow); }
|
||||
.proto-root .jc-dv.s-ok { border-left-color: var(--ok); }
|
||||
.proto-root .jc-dv.s-live { border-left-color: var(--brand); }
|
||||
|
||||
/* ── 产出的资料(与定稿报告分两摞,2026-08-23)────────────────────────
|
||||
小标题只是分组条,不是卡片:它要看着比行「轻」,不然一摞里出现三种同重量的块 */
|
||||
.proto-root .jc-dvs-sub { display: flex; align-items: baseline; gap: 8px; margin: 4px 0 -2px; padding: 0 2px;
|
||||
font-size: 11.5px; font-weight: 800; letter-spacing: .04em; color: var(--ink-3); }
|
||||
.proto-root .jc-dvs-sub em { font-style: normal; font-weight: 700; color: var(--ink-4); }
|
||||
.proto-root .jc-dvs-sub i { margin-left: auto; font-style: normal; font-weight: 600; color: var(--ink-4); }
|
||||
/* 资料行按格式上色(与件上的格式徽同一套颜色) */
|
||||
.proto-root .jc-dv.m-file { border-left-color: var(--ink-4); }
|
||||
.proto-root .jc-dv.m-file.f-docx { border-left-color: #2b5fd9; }
|
||||
.proto-root .jc-dv.m-file.f-xlsx { border-left-color: #1f7a45; }
|
||||
.proto-root .jc-dv.m-file.f-html { border-left-color: #b25e15; }
|
||||
.proto-root .jc-dv-fmt { flex: none; margin-left: auto; padding: 3px 9px; border-radius: 999px;
|
||||
font-size: 11px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase;
|
||||
background: var(--soft); color: var(--ink-3); }
|
||||
.proto-root .jc-dv-fmt.f-docx { background: #eaf1ff; color: #2b5fd9; }
|
||||
.proto-root .jc-dv-fmt.f-xlsx { background: #e9f7ee; color: #1f7a45; }
|
||||
.proto-root .jc-dv-fmt.f-html { background: #fdf0e6; color: #b25e15; }
|
||||
.proto-root .jc-dv.s-bad { border-left-color: var(--danger); }
|
||||
.proto-root .jc-dv-h { display: flex; align-items: flex-start; gap: 10px; }
|
||||
.proto-root .jc-dv-ico { flex: none; width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; color: var(--ink-4); background: var(--soft); }
|
||||
@@ -2842,6 +2906,8 @@
|
||||
.proto-root .jc-tnav-btn:active:not(:disabled) { transform: scale(.95); }
|
||||
.proto-root .jc-tnav-btn:disabled { opacity: .35; cursor: default; }
|
||||
.proto-root .jc-tnav-n { min-width: 34px; text-align: center; font-size: 11px; font-weight: 800; color: var(--ink-2); }
|
||||
/* 两组翻页同时在场时(多张卡各自多页)各带一个字,单独在场时不摆 —— 一个数不必解释自己 */
|
||||
.proto-root .jc-tnav-mk { font-style: normal; font-weight: 700; color: var(--ink-3); margin-right: 3px; }
|
||||
.proto-root .jc-tnav-btn.back { width: auto; padding: 0 14px 0 10px; font-size: 12.5px; font-weight: 700; line-height: 1; white-space: nowrap; }
|
||||
.proto-root .jc-tnav-btn svg { flex: none; display: block; }
|
||||
/* 窄栏:页签藏图标省宽,保持一排 */
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 工作区右上角的翻页 —— 2026-08-23 用户三连指出的那几处。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pagerFace, pagerText } from '../internal/pager.ts';
|
||||
|
||||
describe('pagerText —— 没有可翻的东西就别摆数字', () => {
|
||||
it('**一件都没有:写「—」,不写 0/0**(0 分之 0 不是一个数)', () => {
|
||||
expect(pagerText(0, 0)).toBe('—');
|
||||
});
|
||||
|
||||
it('只有一件:照旧 1/1(点位条要定得住,不能忽有忽无)', () => {
|
||||
expect(pagerText(0, 1)).toBe('1/1');
|
||||
});
|
||||
|
||||
it('多件:报当下这一件', () => {
|
||||
expect(pagerText(1, 3)).toBe('2/3');
|
||||
});
|
||||
|
||||
it('页码越界(件数变少了而页码还停在后面):夹到最后一件,不报一个不存在的号', () => {
|
||||
expect(pagerText(5, 2)).toBe('2/2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagerFace —— 翻件与翻页各归各位', () => {
|
||||
it('一件一页:两组都不出,只留占位', () => {
|
||||
expect(pagerFace({ items: 1, pages: 1 })).toEqual({ items: false, pages: false, solo: true });
|
||||
});
|
||||
|
||||
it('多件单页:只出翻件', () => {
|
||||
expect(pagerFace({ items: 3, pages: 1 })).toEqual({ items: true, pages: false, solo: false });
|
||||
});
|
||||
|
||||
it('单件多页:只出翻页(A2 一颗待选型芯片一页)', () => {
|
||||
expect(pagerFace({ items: 1, pages: 4 })).toEqual({ items: false, pages: true, solo: false });
|
||||
});
|
||||
|
||||
it('**多件各自多页:两组同时出** —— 从前二选一,页就翻不动了', () => {
|
||||
expect(pagerFace({ items: 2, pages: 3 })).toEqual({ items: true, pages: true, solo: false });
|
||||
});
|
||||
|
||||
it('一件都没有:两组都不出,占位那一格由 pagerText 写「—」', () => {
|
||||
expect(pagerFace({ items: 0, pages: 1 })).toEqual({ items: false, pages: false, solo: true });
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ const KIND: Readonly<Record<FileDoc['kind'], { label: string; icon: string }>> =
|
||||
* 二级界面**长在工作区内部**:顶部四个页签始终不变,
|
||||
* 「返回」由页签条右槽给出(见 ViewRegion)—— 所以这里的标题栏不带返回钮,只报文件本身。
|
||||
*/
|
||||
export function FileViewer({ doc }: { doc: FileDoc }) {
|
||||
export function FileViewer({ doc, href }: { doc: FileDoc; href?: string | undefined }) {
|
||||
const k = KIND[doc.kind];
|
||||
return (
|
||||
<div className={'fileview k-' + doc.kind}>
|
||||
@@ -31,15 +31,33 @@ export function FileViewer({ doc }: { doc: FileDoc }) {
|
||||
<strong>{doc.name}</strong>
|
||||
</div>
|
||||
<span className={'fileview-kind k-' + doc.kind}>{k.label}</span>
|
||||
{/*
|
||||
**拿得走才算文件**(2026-08-23):屏上这一份是读回来渲的,而这条链接给的是
|
||||
原字节 —— .docx 用 Word 打得开、.xlsx 用 Excel 打得开。没有下载口的话,
|
||||
「产出了 Word」这句话就只能靠界面自称。取不到 href 时不摆这个钮(不给死链)。
|
||||
*/}
|
||||
{href !== undefined && (
|
||||
<a className="fileview-dl" href={href} download={doc.name}>
|
||||
<Ico name="download" s={13} /> 下载原件
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="fileview-body">
|
||||
{doc.kind === 'html' && <ViewContainer html={doc.html} title={doc.name} />}
|
||||
{doc.kind === 'markdown' && (
|
||||
// 富文本渲染(GFM 表格 / 内联 SVG)尚未接入 —— 先以原文纸面呈现,不假装渲染过(见 debt)
|
||||
<article className="doc-paper doc-full">
|
||||
<pre className="md">{doc.md}</pre>
|
||||
</article>
|
||||
)}
|
||||
{doc.kind === 'markdown' &&
|
||||
/*
|
||||
* **渲好的那一份由基座给**(`html`,2026-08-23):它与下载到的 .html 是
|
||||
* 同一段函数出的,两处看着是同一份文件;渲染走沙箱,与别的 HTML 件同一条路
|
||||
* (前端拼 DOM 就得 `dangerouslySetInnerHTML`,那是把模型写的字注进父文档)。
|
||||
* `html` 缺席仍退回原文纸面 —— **不假装渲染过**。
|
||||
*/
|
||||
(doc.html === undefined ? (
|
||||
<article className="doc-paper doc-full">
|
||||
<pre className="md">{doc.md}</pre>
|
||||
</article>
|
||||
) : (
|
||||
<ViewContainer html={doc.html} title={doc.name} />
|
||||
))}
|
||||
{doc.kind === 'excel' && <ExcelView doc={doc} />}
|
||||
{doc.kind === 'word' && <WordView doc={doc} />}
|
||||
{doc.kind === 'pdf' && <iframe className="pdf-frame" src={doc.src} title={doc.name} />}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 工作区右上角那对箭头**此刻在翻什么** —— 纯函数,好让「界面不撒谎」这条有测可钉。
|
||||
*
|
||||
* 这一屏有两种「翻」,语义不同,不该抢同一个位置(2026-08-23 用户三连指出):
|
||||
* · **翻件**:待处理里有好几张工单,一张一件;
|
||||
* · **翻页**:一张工单的件里有好几页(`[data-page]`,如 A2 一颗待选型芯片一页)——
|
||||
* 「一次叫人不一定只有一个 HTML」说的就是它。
|
||||
* 从前两者二选一(`docPages > 1 && openList.length <= 1`):**多张卡各自多页时,
|
||||
* 页就翻不动了** —— 人只能看见每张卡的第一页。现在两组各归各位,同时出。
|
||||
*
|
||||
* 还有个数字的事:一件都没有时从前照旧摆一个「0/0」。**0 分之 0 不是一个数**,
|
||||
* 它是「这儿没有可翻的东西」——那就别拿数字冒充,写「—」。
|
||||
*/
|
||||
|
||||
/** 两组翻页此刻各出不出 —— 都不出时给一个占位(点位条要定得住,不能忽有忽无) */
|
||||
export function pagerFace(input: { items: number; pages: number }): {
|
||||
readonly items: boolean;
|
||||
readonly pages: boolean;
|
||||
readonly solo: boolean;
|
||||
} {
|
||||
const items = input.items > 1;
|
||||
const pages = input.pages > 1;
|
||||
return { items, pages, solo: !items && !pages };
|
||||
}
|
||||
|
||||
/** 页码文案 —— 没有可翻的东西不摆「0/0」 */
|
||||
export function pagerText(pos: number, total: number): string {
|
||||
if (total <= 0) return '—';
|
||||
return `${Math.min(pos, total - 1) + 1}/${total}`;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* **取不到不是活着**(2026-08-23,用户:「推进完成了,但测试台还在运行中,没有停下来」)。
|
||||
*
|
||||
* 实测现场:面板钉着一个**已经不存在的任务**(别处 reset-runs 把它清了),屏上同时说着
|
||||
* 两句相反的话 —— 上面「AWAITING-EVENT · 自动刷新 2s」灯还亮着、钮还是「停止仿真」,
|
||||
* 下面一条橙带「取数失败 —— 任务不存在:fda365fc…」。病根在两处:
|
||||
* ① `q.data` 在取数失败后保留**上一次成功**的那份,而那份的 `terminal` 是 undefined,
|
||||
* 于是 `live` 恒真:灯常亮、2s 一直对着一个死号打;
|
||||
* ② `s === undefined ? 'live'` 这个兜底本身就在撒谎 —— 还没取到 ≠ 正在跑。
|
||||
*
|
||||
* 这几条钉的就是「界面不撒谎」:服务端说这一单没了,面板就得当场松手,
|
||||
* 别再举着上一次的状态说它在跑。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { observeFace } from '../internal/observe-face.ts';
|
||||
|
||||
describe('observeFace —— 面板此刻该怎么说', () => {
|
||||
it('在跑:灯活、会呼吸、报 runtime、还在自动刷新', () => {
|
||||
const face = observeFace({ data: { runtime: 'running' } });
|
||||
expect(face).toMatchObject({ lamp: 'live', breathe: true, word: 'running', gone: false, poll: true });
|
||||
});
|
||||
|
||||
it('等人:灯是 wait,照旧算活着(它在等,不是没了)', () => {
|
||||
const face = observeFace({ data: { runtime: 'awaiting-human' } });
|
||||
expect(face.lamp).toBe('wait');
|
||||
expect(face.gone).toBe(false);
|
||||
expect(face.poll).toBe(true);
|
||||
});
|
||||
|
||||
it('跑完:终态词上位,灯灭、不呼吸、不再轮询', () => {
|
||||
const face = observeFace({ data: { runtime: 'terminal', terminal: 'released' } });
|
||||
expect(face).toMatchObject({ lamp: 'done', breathe: false, gone: false, poll: false });
|
||||
expect(face.word).toBe('放行完成');
|
||||
});
|
||||
|
||||
/* ↓ 这三条是这次修的正主 */
|
||||
it('**任务不存在(404):当场认没了** —— 不再说在跑、不再呼吸、停止轮询', () => {
|
||||
const face = observeFace({ data: { runtime: 'awaiting-event' }, error: { status: 404 } });
|
||||
expect(face.gone).toBe(true);
|
||||
expect(face.breathe).toBe(false);
|
||||
expect(face.poll).toBe(false);
|
||||
expect(face.lamp).toBe('bad');
|
||||
expect(face.word).toBe('这一单已不在了');
|
||||
});
|
||||
|
||||
it('别的取数失败(500/断网):不当「没了」,但也不许说在跑', () => {
|
||||
const face = observeFace({ data: { runtime: 'running' }, error: { status: 500 } });
|
||||
expect(face.gone).toBe(false);
|
||||
expect(face.breathe).toBe(false);
|
||||
expect(face.word).toBe('取数失败');
|
||||
// 断网是暂时的 —— 照旧轮询,回来了自己接上
|
||||
expect(face.poll).toBe(true);
|
||||
});
|
||||
|
||||
it('还没取到:说「取数中」,不说在跑(灯不亮呼吸)', () => {
|
||||
const face = observeFace({});
|
||||
expect(face.word).toBe('取数中');
|
||||
expect(face.breathe).toBe(false);
|
||||
expect(face.gone).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { TERMINAL_STATE_LABEL, testActivityStateRoute, testRunActivityRoute, testStopRoute, uploadFileRoute } from '@contracts';
|
||||
import { testActivityStateRoute, testRunActivityRoute, testStopRoute, uploadFileRoute } from '@contracts';
|
||||
import type { UploadedFile } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
@@ -22,6 +22,7 @@ import { useGovActivities, useGovStaff } from '@web-shared/gov/gov-api.ts';
|
||||
import { QueryGate } from '@web-shared/gov/primitives.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import { DraftGroups } from './drafts.tsx';
|
||||
import { observeFace } from './observe-face.ts';
|
||||
|
||||
type Parsed<S> = S extends { parse: (data: unknown) => infer O } ? O : never;
|
||||
export type ActivityState = Parsed<typeof testActivityStateRoute.output>;
|
||||
@@ -89,17 +90,61 @@ export function ActivityRun({
|
||||
});
|
||||
|
||||
if (taskId !== undefined) {
|
||||
return <ActivityObserve taskId={taskId} strip={strip} stopping={stop.isPending} onStop={() => stop.mutate(taskId)} />;
|
||||
return (
|
||||
<ActivityObserve
|
||||
taskId={taskId}
|
||||
strip={strip}
|
||||
stopping={stop.isPending}
|
||||
onStop={() => stop.mutate(taskId)}
|
||||
/* 服务端说这一单没了:不调销毁接口(没有可销毁的),只让上层松手、重取「当下挂着哪一单」 */
|
||||
onGone={onStopped}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const fields = orderFields(picked?.contract.inputFields ?? [], picked?.contract.requiredInputs ?? []);
|
||||
// 必填缺一即不可起 —— 门口这道校验与后端同一口径(契约的 required)
|
||||
const missing = (picked?.contract.inputFields ?? [])
|
||||
.filter((f) => f.required && (data[f.name] ?? '').trim() === '')
|
||||
.map((f) => f.label ?? f.name);
|
||||
const allFields = orderFields(picked?.contract.inputFields ?? [], picked?.contract.requiredInputs ?? []);
|
||||
// 藏起来的格子不算数 —— 没露面的必填(如未选「事件驱动」时的「事件类型」)不该拦起单
|
||||
const fields = allFields.filter((f) => isVisible(f, data));
|
||||
const missing = fields.filter((f) => f.required && (data[f.name] ?? '').trim() === '').map((f) => f.label ?? f.name);
|
||||
const fireable = picked !== undefined && missing.length === 0;
|
||||
const ai = staff.find((s) => s.name === 'AI');
|
||||
|
||||
/*
|
||||
* 改一格顺手清掉因此藏起来的那几格 —— 触发方式从「事件驱动」切回「月度例行」,
|
||||
* 「事件类型」那格就该跟着消失;它已经填的值若留在 state 里,人切回来又看得见,
|
||||
* 也会被 `fire()` 悄悄当成这一单的入参一起交上去,而界面上早就看不见它了。
|
||||
*
|
||||
* 改的若是一格枚举选择(如「触发方式」「事件类型」),选完顺手看这组枚举组合
|
||||
* 唯不唯一对得上一条样例 —— 唯一就把那条样例其余格子的内容套进来当默认值
|
||||
* (「月度例行」/「事件驱动+某事件类型」各自的默认客户与说明,不用另外去点样例按钮)。
|
||||
* 不唯一(比如刚选了「事件驱动」,「事件类型」还没选)就不动,留给人接着选。
|
||||
*/
|
||||
const setField = (name: string, value: string): void => {
|
||||
setData((prev) => {
|
||||
const clear = (obj: Record<string, string>): void => {
|
||||
for (const f of allFields) if (!isVisible(f, obj)) delete obj[f.name];
|
||||
};
|
||||
let next: Record<string, string> = { ...prev, [name]: value };
|
||||
clear(next);
|
||||
const changed = allFields.find((f) => f.name === name);
|
||||
if (picked !== undefined && changed?.options !== undefined) {
|
||||
const idx = matchingSample(picked, next, allFields);
|
||||
/*
|
||||
* **整单换成这条样例,不是把它的字段叠上去** —— 样例的 `input` 本就是稀疏的
|
||||
* (「月度例行」样例只有 `trigger` 一个键),叠加式的 `{...next, ...sampleValues}`
|
||||
* 加得上样例给的值,却清不掉样例没提的值:从「重大商机」切回「月度例行」,
|
||||
* 客户与说明会照抄上一条样例的内容原样卡在框里不消失。样例匹配到的这一刻,
|
||||
* 它就该是这一单入参唯一的真相,照 sampleValues 整份重写。
|
||||
*/
|
||||
if (idx !== undefined) {
|
||||
next = sampleValues(picked, idx);
|
||||
clear(next); // 套模版也可能改变可见性,按同一条规矩再清一遍
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
* 起单前**按契约声明归型**:表单格子里全是文本,而契约说得清哪个字段是数组、哪个是对象
|
||||
* (`attachments: string[]`)。不归型就把 `["BOM.xlsx"]` 这串字面量当字符串送进去,
|
||||
@@ -108,8 +153,11 @@ export function ActivityRun({
|
||||
*/
|
||||
const fire = (card: ActivityCard): void => {
|
||||
const typeOf = new Map(card.contract.inputFields.map((f) => [f.name, f.type]));
|
||||
const visibleNames = new Set(fields.map((f) => f.name));
|
||||
const shaped: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(data)) {
|
||||
// 藏起来的格子不该跟着交上去 —— 与 `setField` 的清理是同一条规矩,这里再兜一道底
|
||||
if (!visibleNames.has(key)) continue;
|
||||
if (raw.trim() === '') continue;
|
||||
const type = typeOf.get(key) ?? 'string';
|
||||
if (!type.endsWith('[]') && type !== 'object') {
|
||||
@@ -197,23 +245,36 @@ export function ActivityRun({
|
||||
<FilePicker
|
||||
label={f.label ?? f.name}
|
||||
value={data[f.name] ?? ''}
|
||||
onChange={(next) => setData((prev) => ({ ...prev, [f.name]: next }))}
|
||||
onChange={(next) => setField(f.name, next)}
|
||||
onError={setError}
|
||||
/>
|
||||
) : f.options !== undefined && f.options.length > 0 ? (
|
||||
<select
|
||||
className="tb-pp-i"
|
||||
value={data[f.name] ?? ''}
|
||||
onChange={(e) => setField(f.name, e.target.value)}
|
||||
>
|
||||
<option value="">{f.required ? '请选择…' : '(不填)'}</option>
|
||||
{f.options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : isLong(f) ? (
|
||||
<textarea
|
||||
className="tb-pp-i"
|
||||
rows={5}
|
||||
value={data[f.name] ?? ''}
|
||||
placeholder={f.type}
|
||||
onChange={(e) => setData((prev) => ({ ...prev, [f.name]: e.target.value }))}
|
||||
placeholder={f.example ?? f.type}
|
||||
onChange={(e) => setField(f.name, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="tb-pp-i"
|
||||
value={data[f.name] ?? ''}
|
||||
placeholder={f.type}
|
||||
onChange={(e) => setData((prev) => ({ ...prev, [f.name]: e.target.value }))}
|
||||
placeholder={f.example ?? f.type}
|
||||
onChange={(e) => setField(f.name, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
@@ -301,8 +362,36 @@ type InputField = {
|
||||
label?: string | undefined;
|
||||
/** `file` = 这一格收真文件(契约里的 `format`)—— 界面给文件选择器 */
|
||||
format?: string | undefined;
|
||||
/** 声明了枚举(契约里的 `enum`)—— 界面给下拉选,不给自由文本框猜合法值 */
|
||||
options?: { value: string; label: string }[] | undefined;
|
||||
/** 示例值(契约里的 `examples` 第一项)—— 占位提示用,比裸类型名能抄 */
|
||||
example?: string | undefined;
|
||||
/** 声明了 → 只在另一格(`showWhen.field`)当下等于 `showWhen.equals` 时才出现(级联下拉) */
|
||||
showWhen?: { field: string; equals: string } | undefined;
|
||||
};
|
||||
|
||||
/** 这一格该不该露面 —— 没声明 `showWhen` 恒可见,声明了就看那另一格当下的值 */
|
||||
function isVisible(f: InputField, data: Readonly<Record<string, string>>): boolean {
|
||||
return f.showWhen === undefined || (data[f.showWhen.field] ?? '') === f.showWhen.equals;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前选中的这组枚举字段(有 `options` 且可见的那几格,如「触发方式」「事件类型」)
|
||||
* 如果唯一对应清单里的一条样例,回它的下标 —— 正是这条样例把其余格子的默认内容定下来。
|
||||
* 不唯一(比如选了「事件驱动」但「事件类型」还没选,四条候选样例都对不上)就回
|
||||
* `undefined`,不瞎填一条。这是通用能力:任何"选项 A 决定用哪套默认数据"的活动
|
||||
* 都靠它自动套模版,不用专门写一次性逻辑。
|
||||
*/
|
||||
function matchingSample(card: ActivityCard, data: Readonly<Record<string, string>>, allFields: readonly InputField[]): number | undefined {
|
||||
const enumFields = allFields.filter((f) => f.options !== undefined && f.options.length > 0 && isVisible(f, data));
|
||||
if (enumFields.length === 0) return undefined;
|
||||
const hit = card.contract.samples
|
||||
.map((s, i) => ({ s, i }))
|
||||
.filter(({ s }) => enumFields.every((f) => String((s.input as Record<string, unknown>)[f.name] ?? '') === (data[f.name] ?? '')))
|
||||
.map(({ i }) => i);
|
||||
return hit.length === 1 ? hit[0] : undefined;
|
||||
}
|
||||
|
||||
/** 长文本:正文、对象、数组 —— 一行输入框装不下的东西 */
|
||||
|
||||
/* ==================== 真文件那一格 ==================== */
|
||||
@@ -484,14 +573,6 @@ function Row({ label, values, empty }: { label: string; values: readonly string[
|
||||
|
||||
/* ==================== 观测态 ==================== */
|
||||
|
||||
const RUNTIME_LAMP: Readonly<Record<string, 'live' | 'wait' | 'done' | 'bad'>> = {
|
||||
running: 'live',
|
||||
'awaiting-human': 'wait',
|
||||
'awaiting-event': 'wait',
|
||||
'landing-blocked': 'bad',
|
||||
terminal: 'done',
|
||||
};
|
||||
|
||||
/**
|
||||
* 单活动试跑的观测面 —— 一屏看穿这一单。
|
||||
*
|
||||
@@ -504,11 +585,14 @@ function ActivityObserve({
|
||||
strip,
|
||||
stopping,
|
||||
onStop,
|
||||
onGone,
|
||||
}: {
|
||||
taskId: string;
|
||||
strip: (input: { lamp: 'idle' | 'live' | 'wait' | 'done' | 'bad'; breathe: boolean; word: string; detail?: ReactNode; right?: ReactNode }) => ReactNode;
|
||||
stopping: boolean;
|
||||
onStop: () => void;
|
||||
/** 服务端说这一单没了 —— 面板松手(别再钉着一个死号轮询) */
|
||||
onGone: () => void;
|
||||
}): ReactNode {
|
||||
const q = useQuery({
|
||||
queryKey: ['gov', 'testbed', 'activity', taskId],
|
||||
@@ -519,17 +603,33 @@ function ActivityObserve({
|
||||
* 轮询留着是为了推送漏掉的那种情形(断线、别的进程推进的单)。
|
||||
* 到终态就停:已经不动的东西没必要一直问。
|
||||
*/
|
||||
refetchInterval: (query) => (query.state.data?.terminal === undefined ? 2000 : false),
|
||||
/*
|
||||
* **取不到不是活着**(2026-08-23):从前这里只看 `data.terminal`,而取数失败时
|
||||
* `data` 保留的是上一次成功的那份、`terminal` 恒 undefined —— 于是对着一个
|
||||
* 已经不存在的任务 2s 一直打,屏上还举着「AWAITING-EVENT · 自动刷新」。
|
||||
* 现在由 `observeFace` 一处判断该不该继续问(404 与终态都不必再问)。
|
||||
*/
|
||||
refetchInterval: (query) =>
|
||||
observeFace({ data: query.state.data, error: query.state.error as { status?: number } | null ?? undefined }).poll
|
||||
? 2000
|
||||
: false,
|
||||
// 任务没了就别重试 —— 重试只是把同一句「不存在」再问三遍
|
||||
retry: (count, error) => (error as { status?: number } | undefined)?.status !== 404 && count < 2,
|
||||
});
|
||||
const s = q.data;
|
||||
const live = s !== undefined && s.terminal === undefined;
|
||||
const face = observeFace({ data: q.data, ...(q.error === null ? {} : { error: q.error as { status?: number } }) });
|
||||
const live = face.breathe;
|
||||
/* 服务端说没了:松手一次(回装配台或切到当下那一单)—— 不在渲染里直接改父状态 */
|
||||
useEffect(() => {
|
||||
if (face.gone) onGone();
|
||||
}, [face.gone, onGone]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{strip({
|
||||
lamp: s === undefined ? 'live' : (RUNTIME_LAMP[s.runtime] ?? 'live'),
|
||||
breathe: live,
|
||||
word: s?.terminal === undefined ? (s?.runtime ?? '取数中') : (TERMINAL_STATE_LABEL[s.terminal] ?? s.terminal),
|
||||
lamp: face.lamp,
|
||||
breathe: face.breathe,
|
||||
word: face.word,
|
||||
detail: (
|
||||
<>
|
||||
{s?.title ?? taskId.slice(0, 8)}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 观测面此刻**该怎么说** —— 纯函数,好让「界面不撒谎」这条有测可钉。
|
||||
*
|
||||
* 起因(2026-08-23 用户:「推进完成了,但测试台还在运行中,没有停下来」):
|
||||
* 面板钉着一个已经不存在的任务(别处把运行数据清了),屏上同时说两句相反的话 ——
|
||||
* 上面「AWAITING-EVENT · 自动刷新 2s」灯亮着、钮写「停止仿真」,
|
||||
* 下面一条橙带「取数失败 —— 任务不存在」。两处都错在同一个念头:
|
||||
* **把「取不到」当成了「还活着」**。
|
||||
* · `q.data` 取数失败后保留上一次成功的那份,那份 `terminal` 是 undefined → 恒判在跑;
|
||||
* · `s === undefined ? 'live'` 的兜底 —— 还没取到 ≠ 正在跑。
|
||||
*
|
||||
* 现在分三种局面说话:**没了**(服务端 404,当场松手、停轮询)、
|
||||
* **取数失败**(断网/500,不说在跑,但照旧轮询等它回来)、**取到了**(照实说)。
|
||||
*/
|
||||
import { TERMINAL_STATE_LABEL } from '@contracts';
|
||||
|
||||
/** 灯:活 / 等 / 完 / 坏 —— 与 `RUNTIME_LAMP` 同一套词 */
|
||||
export type Lamp = 'idle' | 'live' | 'wait' | 'done' | 'bad';
|
||||
|
||||
const RUNTIME_LAMP: Readonly<Record<string, Lamp>> = {
|
||||
running: 'live',
|
||||
'awaiting-human': 'wait',
|
||||
'awaiting-event': 'wait',
|
||||
'landing-blocked': 'bad',
|
||||
terminal: 'done',
|
||||
};
|
||||
|
||||
export type ObserveFace = {
|
||||
readonly lamp: Lamp;
|
||||
/** 呼吸灯 —— 只有真在跑才亮;取不到、没了、跑完了都不亮 */
|
||||
readonly breathe: boolean;
|
||||
readonly word: string;
|
||||
/** 服务端说这一单不存在了 —— 面板据此松手(回装配台或切到当下那一单) */
|
||||
readonly gone: boolean;
|
||||
/** 还要不要 2s 轮询 —— 终态与「没了」都不必再问 */
|
||||
readonly poll: boolean;
|
||||
};
|
||||
|
||||
export function observeFace(input: {
|
||||
data?: { runtime: string; terminal?: string | undefined } | undefined;
|
||||
error?: { status?: number | undefined } | undefined;
|
||||
}): ObserveFace {
|
||||
// ① 服务端明说没有这一单 —— 不是一次普通取数失败,是这一单已经不在了
|
||||
if (input.error?.status === 404) {
|
||||
return { lamp: 'bad', breathe: false, word: '这一单已不在了', gone: true, poll: false };
|
||||
}
|
||||
// ② 取数失败(断网 / 500):不知道它现在怎么样,那就别替它说「在跑」;轮询留着等它回来
|
||||
if (input.error !== undefined) {
|
||||
return { lamp: 'bad', breathe: false, word: '取数失败', gone: false, poll: true };
|
||||
}
|
||||
// ③ 还没取到:说「取数中」,灯不呼吸 —— 没取到不等于在跑
|
||||
if (input.data === undefined) {
|
||||
return { lamp: 'idle', breathe: false, word: '取数中', gone: false, poll: true };
|
||||
}
|
||||
const { runtime, terminal } = input.data;
|
||||
const live = terminal === undefined;
|
||||
return {
|
||||
lamp: RUNTIME_LAMP[runtime] ?? 'live',
|
||||
breathe: live,
|
||||
word: live ? runtime : ((TERMINAL_STATE_LABEL as Readonly<Record<string, string>>)[terminal] ?? terminal),
|
||||
gone: false,
|
||||
poll: live,
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export class ApiCallError extends Error {
|
||||
|
||||
type CallOptions = {
|
||||
readonly params?: Readonly<Record<string, string>>;
|
||||
/** 查询串 —— 路由契约里声明了 `query` 的走这里(值 undefined 的键不发) */
|
||||
readonly query?: Readonly<Record<string, string | undefined>>;
|
||||
readonly body?: unknown;
|
||||
};
|
||||
|
||||
@@ -26,8 +28,16 @@ function resolvePath(path: string, params: Readonly<Record<string, string>> = {}
|
||||
return path.replace(/:([A-Za-z_][\w]*)/g, (_, key: string) => encodeURIComponent(params[key] ?? ''));
|
||||
}
|
||||
|
||||
/** `?a=1&b=2` —— 没有查询参数时连问号都不加 */
|
||||
function queryString(query: Readonly<Record<string, string | undefined>> = {}): string {
|
||||
const usp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(query)) if (v !== undefined) usp.set(k, v);
|
||||
const s = usp.toString();
|
||||
return s === '' ? '' : `?${s}`;
|
||||
}
|
||||
|
||||
export async function call<T>(route: RouteContract, options: CallOptions = {}): Promise<T> {
|
||||
const response = await fetch(resolvePath(route.path, options.params), {
|
||||
const response = await fetch(resolvePath(route.path, options.params) + queryString(options.query), {
|
||||
method: route.method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
|
||||
Reference in New Issue
Block a user