Archived
E04 修到零被拒线,harness 补七刀:跨进程租约与广播桥、原子写、引用墙、对话区按序号排
活动侧(E04 2.1.0 → 2.1.9,I01 1.2.2): - 覆盖闭合成为机检:基座新增 distinct 读数,「计划盖住的客户数 = 候选行数」钉进 S3 收步 / S4 哨兵 / 放行 L2;人加的候选客户不再被整单无视 - 「关键人待识别」占位句式全链退役:查无联系人先发数据录入卡收人;契约新增 refersTo, 计划/回执的 contact_code 只许引用真实存在的联系人(安装期核引用目标) - 台账更新确认摆全落库面:客户档案/联系人档案两张落库预览(touched: 数据源, 正式现值 + 草稿叠加),可补改、可加行(自增主键由基座续号);姓名升必填 - 更新明细:update_key 位置键 + 「同一处只留一条」L2;依据写人话不带机器键; 标题旁不摆单号;判据与文案不夹字段名;行标识去掉单标识那一段 - 回执骨架 = 计划行数钉进 S3 收步与 S4 哨兵;重做分支不许删计划凑数 - 阶段刻度对齐文档 7 步;起单样例预填满 基座侧: - 两个后端连同一个库:task-claim 跨进程租约(迁移 0030)+ Postgres LISTEN/NOTIFY 广播桥;对话区按任务内记忆序号排,不按墙上时钟;并卡「刚说过话」按记忆序列判紧邻 - 多行 write 整批先验后写(dryRun),被拒 = 一行都没落 - 决策卡意见与本单草稿字面比对,归属以观察进轨迹(note-anchors) - 括号栈补「补闭括号」两形(少一个 } / 行对象没关直接跟下一行) - 件上加行:空着的自增主键直进续号,不从别的行借号;未落下的行点名回给前端 - 人答复落库那一刻就把 runtime 翻 running(task.wake),两块同帧变脸 - 试跑单可落真库(land_master,迁移 0031,默认开);数据域按活动分组; JSON 列不再是 [object Object];按钮键感 + 在办进度条 + 防抖 - 报价链 A1–A7 从系统卸载(只卸不删源码),种子只装 E04/I01 契约变更(api-report 已刷新):FieldDeclaration.distinct / refersTo, objectDeclaration.file,landMaster,unkeyed,assetFiles,ledgerByActivityRoute。 1700 测 / typecheck / lint / depcruise / api:check 绿。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 广播的跨进程桥 —— **别的后端动了同一张单,这边的浏览器也得知道**。
|
||||
*
|
||||
* 2026-08-24 用户报的第三件事:「一直卡在 AI 推进中,刷新后才发现已经推完了」。
|
||||
* 推送本身没坏 —— 坏在 `createBroadcaster()` 是进程内的一个 Set:那会儿本机与
|
||||
* 局域网另一台各跑一个后端连同一个库,单子被那一台推着走,这一台的浏览器一个字没收到。
|
||||
*
|
||||
* 这里钉两条:①自己推的顺手 NOTIFY 出去;②**别人发的才在本地转发,自己发的不收** ——
|
||||
* 不然一条推送在两个进程之间来回弹。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { bridgeBroadcaster, TOUCH_CHANNEL } from '../broadcast-bridge.ts';
|
||||
import { createBroadcaster } from '../broadcast.ts';
|
||||
|
||||
/** 假连接池:记下 NOTIFY 出去的载荷,并留一个口子把「别人的通知」塞进来 */
|
||||
function fakePool() {
|
||||
const notified: Array<{ channel: string; payload: string }> = [];
|
||||
let onNotification: ((m: { channel: string; payload?: string }) => void) | undefined;
|
||||
const client = {
|
||||
on(event: string, handler: (arg: never) => void) {
|
||||
if (event === 'notification') onNotification = handler as never;
|
||||
},
|
||||
query: async () => ({ rows: [] }),
|
||||
release: () => undefined,
|
||||
};
|
||||
return {
|
||||
notified,
|
||||
/** 模拟别的进程(或自己)发来一条 */
|
||||
deliver(wire: Record<string, unknown> | string) {
|
||||
// 字符串原样送 —— 「载荷坏了」那条用例要的是真·不是 JSON 的字节
|
||||
onNotification?.({ channel: TOUCH_CHANNEL, payload: typeof wire === 'string' ? wire : JSON.stringify(wire) });
|
||||
},
|
||||
get armed() {
|
||||
return onNotification !== undefined;
|
||||
},
|
||||
pool: {
|
||||
query: async (_sql: string, params: unknown[]) => {
|
||||
notified.push({ channel: String(params[0]), payload: String(params[1]) });
|
||||
return { rows: [] };
|
||||
},
|
||||
connect: async () => client,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const settle = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
describe('bridgeBroadcaster', () => {
|
||||
it('本地推一条 → 顺手 NOTIFY 到库上(带着「谁发的」)', async () => {
|
||||
const fake = fakePool();
|
||||
const bus = createBroadcaster();
|
||||
bridgeBroadcaster(fake.pool as never, bus);
|
||||
bus.publish({ kind: 'task', id: 't-1', reason: 'advanced' });
|
||||
await settle();
|
||||
|
||||
expect(fake.notified).toHaveLength(1);
|
||||
expect(fake.notified[0]?.channel).toBe(TOUCH_CHANNEL);
|
||||
const wire = JSON.parse(fake.notified[0]?.payload ?? '{}') as Record<string, unknown>;
|
||||
expect(wire['id']).toBe('t-1');
|
||||
expect(typeof wire['from']).toBe('string'); // 认「谁发的」靠它
|
||||
});
|
||||
|
||||
it('**别的进程发来的,在本地转发一次** —— 这正是那一台推进、这一台刷新的那条路', async () => {
|
||||
const fake = fakePool();
|
||||
const bus = createBroadcaster();
|
||||
bridgeBroadcaster(fake.pool as never, bus);
|
||||
await settle();
|
||||
expect(fake.armed).toBe(true);
|
||||
|
||||
const got: string[] = [];
|
||||
bus.subscribe((t) => got.push(`${t.kind}:${t.id}:${t.reason}`));
|
||||
fake.deliver({ kind: 'task', id: 't-9', reason: 'advanced', from: '别的机器' });
|
||||
expect(got).toEqual(['task:t-9:advanced']);
|
||||
});
|
||||
|
||||
it('**自己发的不再收** —— 否则一条推送在两个进程之间来回弹', async () => {
|
||||
const fake = fakePool();
|
||||
const bus = createBroadcaster();
|
||||
bridgeBroadcaster(fake.pool as never, bus);
|
||||
bus.publish({ kind: 'task', id: 't-1', reason: 'advanced' });
|
||||
await settle();
|
||||
const self = (JSON.parse(fake.notified[0]?.payload ?? '{}') as { from?: string }).from;
|
||||
|
||||
const got: string[] = [];
|
||||
bus.subscribe((t) => got.push(t.id));
|
||||
fake.deliver({ kind: 'task', id: 't-1', reason: 'advanced', from: self });
|
||||
expect(got).toEqual([]);
|
||||
});
|
||||
|
||||
it('载荷坏了只丢这一条,连接照旧活着', async () => {
|
||||
const fake = fakePool();
|
||||
const bus = createBroadcaster();
|
||||
bridgeBroadcaster(fake.pool as never, bus);
|
||||
await settle();
|
||||
const got: string[] = [];
|
||||
bus.subscribe((t) => got.push(t.id));
|
||||
|
||||
expect(() => fake.deliver('这不是 JSON')).not.toThrow();
|
||||
// 是 JSON 但形状不对的也得丢掉 —— 频道全库公用,谁都能往里发一句话
|
||||
expect(() => fake.deliver('"光秃秃一个字符串"')).not.toThrow();
|
||||
expect(() => fake.deliver({ 没有: 'kind 与 id' })).not.toThrow();
|
||||
fake.deliver({ kind: 'task', id: 't-3', reason: 'advanced', from: '别处' });
|
||||
expect(got).toEqual(['t-3']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 对话区按任务内序号排(2026-08-24):两台后端交替推同一单,时钟一偏,
|
||||
* 上一拍的话就排到下一拍后面 —— 序号与时钟无关,拿它排。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { orderChat } from '../chat-order.ts';
|
||||
|
||||
const H = [
|
||||
{ id: 'm1', seq: 1, kind: 'narration', at: '2026-08-24T10:00:30.000Z' }, // 另一台机器,钟快 30s
|
||||
{ id: 'c1', seq: 2, kind: 'card-sent', at: '2026-08-24T10:00:30.100Z', detail: { id: 'card-A' } },
|
||||
{ id: 'm2', seq: 3, kind: 'narration', at: '2026-08-24T10:00:05.000Z' }, // 本机,钟准
|
||||
{ id: 'h1', seq: 4, kind: 'human-input', at: '2026-08-24T10:00:06.000Z', detail: { id: 'say-1' } },
|
||||
];
|
||||
|
||||
describe('orderChat', () => {
|
||||
it('**时钟倒着、序号顺着:按序号排** —— 上一拍的话不再跑到下一拍后面', () => {
|
||||
const items = [
|
||||
{ id: 'm2', at: '2026-08-24T10:00:05.000Z', text: '下一拍' },
|
||||
{ id: 'm1', at: '2026-08-24T10:00:30.000Z', text: '上一拍' },
|
||||
];
|
||||
expect(orderChat(items, H).map((i) => i.text)).toEqual(['上一拍', '下一拍']);
|
||||
});
|
||||
|
||||
it('卡按它的 card-sent 落点排;人的自由输入按 human-input 落点排', () => {
|
||||
const items = [
|
||||
{ id: 'say-1', at: '2026-08-24T10:00:06.000Z', text: '人说' },
|
||||
{ id: 'card-A', at: '2026-08-24T10:00:30.050Z', text: '卡' },
|
||||
{ id: 'm1', at: '2026-08-24T10:00:30.000Z', text: '话' },
|
||||
];
|
||||
expect(orderChat(items, H).map((i) => i.text)).toEqual(['话', '卡', '人说']);
|
||||
});
|
||||
|
||||
it('找不到落点的老数据按时刻插位,不炸', () => {
|
||||
const items = [
|
||||
{ id: 'zzz', at: '2026-08-24T10:00:05.500Z', text: '老' },
|
||||
{ id: 'm2', at: '2026-08-24T10:00:05.000Z', text: '下一拍' },
|
||||
{ id: 'm1', at: '2026-08-24T10:00:30.000Z', text: '上一拍' },
|
||||
];
|
||||
// 老条目的时刻在 m2(seq 3)之后、h1 之前 → 插到 seq 3 那一档,时刻再排在 m2 之后
|
||||
expect(orderChat(items, H).map((i) => i.text)).toEqual(['上一拍', '下一拍', '老']);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
* 所以归型放在写进草稿那一刻:归得了就归,归不了当场拒(理由喂回下一拍)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { coerceToColumn, localStamp, missingReadings, rowReadings, rowKeyList, tallyReadings, toolCallReadings } from '../wiring/engines.ts';
|
||||
import { coerceToColumn, distinctReadings, localStamp, missingReadings, rowLabel, rowReadings, rowKeyList, tallyReadings, toolCallReadings, touchedRows } from '../wiring/engines.ts';
|
||||
|
||||
describe('空值 = 这一列没有值(不写)', () => {
|
||||
it('空串 / 全空白 / null / undefined 一律不写 —— 「原文没写的不要补」的数据面落点', () => {
|
||||
@@ -381,3 +381,108 @@ describe('工具调用读数', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 不同值计数 `域_对象_distinct_<列>` —— 覆盖闭合判据的落点(2026-08-24 柴浩实测:
|
||||
* 人在覆盖范围确认卡上加了一家客户,计划行数照样 > 0,「盖没盖住每一家」没有数说得出来)。
|
||||
*/
|
||||
describe('distinctReadings —— 这一列有几个不同的值,基座数', () => {
|
||||
const DECL = [
|
||||
{
|
||||
domain: 'crm_coverage',
|
||||
object: 'plan',
|
||||
fields: [{ name: 'customer_code', distinct: true }, { name: 'contact_code' }],
|
||||
},
|
||||
];
|
||||
|
||||
it('计划行数 ≠ 客户数:一家客户几位关键人就几行,distinct 数的是客户', () => {
|
||||
const rows = [
|
||||
{ customer_code: 'CU-0142', contact_code: 'CU-0142-01' },
|
||||
{ customer_code: 'CU-0142', contact_code: 'CU-0142-02' },
|
||||
{ customer_code: 'cu-1111', contact_code: 'cu-1111-00' },
|
||||
];
|
||||
expect(distinctReadings({ 'mine:crm_coverage.plan': rows }, DECL)).toEqual({
|
||||
crm_coverage_plan_distinct_customer_code: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('没标 distinct 的列不出读数;标了的零行也铺 0 —— 未绑定的名字参与比较求值为假', () => {
|
||||
const out = distinctReadings({ 'mine:crm_coverage.plan': [] }, DECL);
|
||||
expect(out).toEqual({ crm_coverage_plan_distinct_customer_code: 0 });
|
||||
});
|
||||
|
||||
it('空值不算一种 —— 「没写」不是一个取值', () => {
|
||||
const rows = [{ customer_code: 'CU-0142' }, { customer_code: '' }, { customer_code: ' ' }, {}];
|
||||
expect(distinctReadings({ 'mine:crm_coverage.plan': rows }, DECL)).toEqual({
|
||||
crm_coverage_plan_distinct_customer_code: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `touched:域.对象` —— 落库预览的数据源(2026-08-24 柴浩实测:绑 mine: 的话
|
||||
* Itelma 既有的客户类型/行业整片空白,像是数据丢了)。
|
||||
*/
|
||||
describe('touchedRows —— 本轮碰过的行,以叠加后全貌摆出来', () => {
|
||||
const merged = [
|
||||
{ customer_code: 'CU-0142', name: 'Itelma', cust_type: 'Tier1 OEM', industry: '车规电子' },
|
||||
{ customer_code: 'CU-0087', name: '深圳精密', cust_type: '模组厂' },
|
||||
{ customer_code: 'CU-2039', name: '测试11111' },
|
||||
];
|
||||
|
||||
it('按草稿键挑行:碰过的行带着正式现值的全部列,没碰的行不出现', () => {
|
||||
const out = touchedRows(merged, [{ customer_code: 'CU-0142' }, { customer_code: 'CU-2039' }]);
|
||||
expect(out).toHaveLength(2);
|
||||
// 关键在这一列:草稿里没写过 cust_type,叠加行里必须有 —— 这正是回显的来源
|
||||
expect((out[0] as Record<string, unknown>)['cust_type']).toBe('Tier1 OEM');
|
||||
expect((out[1] as Record<string, unknown>)['name']).toBe('测试11111');
|
||||
});
|
||||
|
||||
it('草稿新增的行(正式表没有)也在 —— 叠加视图本就含它;键对不上的丢弃不报错', () => {
|
||||
const withNew = [...merged, { customer_code: 'CU-NEW', contact_code: 'CU-NEW-01', name: '新人' }];
|
||||
const out = touchedRows(withNew, [
|
||||
{ customer_code: 'CU-NEW', contact_code: 'CU-NEW-01' },
|
||||
{ customer_code: '查无此人', contact_code: 'X' },
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect((out[0] as Record<string, unknown>)['name']).toBe('新人');
|
||||
});
|
||||
|
||||
it('数值键与字符串键按字面等值比(pg 回来的是 number,草稿键常是 string)', () => {
|
||||
const out = touchedRows([{ seq: 3, name: '三号' }], [{ seq: '3' }]);
|
||||
expect(out).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 摆给人看的行标识去掉单标识(2026-08-25 用户:「COV-…/CU-0142-01 只保留斜杠右边」)。
|
||||
*/
|
||||
describe('rowLabel —— 主键去掉单号那一段', () => {
|
||||
it('单据表:round_ref + contact_code → 只留 contact_code', () => {
|
||||
expect(rowLabel({ round_ref: 'COV-20260825002227', contact_code: 'CU-0142-01' }, 'round_ref')).toBe('CU-0142-01');
|
||||
});
|
||||
|
||||
it('主数据表没有单标识:全部保留', () => {
|
||||
expect(rowLabel({ customer_code: 'CU-0142', contact_code: 'CU-0142-01' }, undefined)).toBe('CU-0142/CU-0142-01');
|
||||
});
|
||||
|
||||
it('主键只有单号:去完一段都不剩就退回整段,不摆空串', () => {
|
||||
expect(rowLabel({ round_ref: 'COV-1' }, 'round_ref')).toBe('COV-1');
|
||||
});
|
||||
|
||||
it('缺项名单走同一把尺 —— 红带 / 提交闸 / 拒回都不再带单号', () => {
|
||||
const out = missingReadings({ 'mine:crm_coverage.receipt': [{ round_ref: 'COV-1', contact_code: 'CU-0142-01' }] }, [
|
||||
{
|
||||
domain: 'crm_coverage',
|
||||
object: 'receipt',
|
||||
caseKey: 'round_ref',
|
||||
fields: [
|
||||
{ name: 'round_ref', primaryKey: true },
|
||||
{ name: 'contact_code', primaryKey: true },
|
||||
{ name: 'completion_status', primaryKey: false, required: true, label: '完成状态' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(out['crm_coverage_receipt_missing_keys']).toBe('CU-0142-01 缺完成状态');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 驳回意见的**归属比对** —— 2026-08-24 柴浩实测:意见「测试111建议动作改成拜访CTO」,
|
||||
* 计划表里有一行 customer_name=测试111,模型却把改动落到了 Itelma 那一行。
|
||||
* 归属是纯字符串包含,引擎数得出来的事实别让模型从没标点的话里自己抠。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { noteAnchors, noteAnchorTitle } from '../note-anchors.ts';
|
||||
|
||||
const row = (object: string, key: Record<string, string>, patch: Record<string, unknown>) => ({
|
||||
domain: 'crm_coverage',
|
||||
object,
|
||||
key,
|
||||
patch,
|
||||
});
|
||||
|
||||
const PLAN = [
|
||||
row('plan', { round_ref: 'COV-1', contact_code: 'CU-0142-01' }, { customer_name: 'Itelma', contact_name: 'Ekaterina Volkova', action: '拜访采购' }),
|
||||
row('plan', { round_ref: 'COV-1', contact_code: 'cu-1234-00' }, { customer_name: '测试111', contact_name: '关键人待识别', action: '首访建档' }),
|
||||
];
|
||||
|
||||
describe('noteAnchors —— 意见按字面点名对上了哪几行', () => {
|
||||
it('**实测那一单**:意见里的「测试111」恰是一行的客户名 —— 命中它,不命中 Itelma', () => {
|
||||
const hits = noteAnchors('测试111建议动作改成拜访CTO', PLAN);
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0]).toContain('cu-1234-00');
|
||||
expect(hits[0]).toContain('测试111');
|
||||
});
|
||||
|
||||
it('点的是编码也认得(主键值同样参与比对)', () => {
|
||||
const hits = noteAnchors('CU-0142-01 的动作太保守,改激进些', PLAN);
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0]).toContain('CU-0142-01');
|
||||
});
|
||||
|
||||
it('一行都没点名 → 空数组(没有依据的时候不说话)', () => {
|
||||
expect(noteAnchors('整体排序不合理,重新排一遍', PLAN)).toEqual([]);
|
||||
});
|
||||
|
||||
it('纯数字与短值不算点名 —— 「111」「01」撞上纯属巧合', () => {
|
||||
const rows = [row('plan', { round_ref: 'COV-1', contact_code: 'X' }, { seq: '111', tag: '高' })];
|
||||
expect(noteAnchors('测试111建议动作改成拜访CTO', rows)).toEqual([]);
|
||||
});
|
||||
|
||||
it('命中太多行(> 5)当没命中 —— 什么都命中的提示是噪音', () => {
|
||||
const rows = Array.from({ length: 6 }, (_, i) =>
|
||||
row('plan', { round_ref: 'COV-1', contact_code: `C-${i}` }, { customer_name: 'Itelma' }),
|
||||
);
|
||||
expect(noteAnchors('Itelma 的动作都改一下', rows)).toEqual([]);
|
||||
});
|
||||
|
||||
it('同一行几格都命中时只出一次,展示最长的那个名字', () => {
|
||||
const rows = [row('plan', { round_ref: 'COV-1', contact_code: 'C-1' }, { customer_name: '测试111', short: '测试' })];
|
||||
const hits = noteAnchors('测试111建议动作改成拜访CTO', rows);
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0]).toContain('「测试111」');
|
||||
});
|
||||
|
||||
it('观察全文带行动指南:先认归属、点名的行缺什么改这一行自己的内容', () => {
|
||||
const title = noteAnchorTitle(['crm_coverage.plan COV-1/cu-1234-00(命中「测试111」)']);
|
||||
expect(title).toContain('先认这几行的归属');
|
||||
expect(title).toContain('不是把改动挪到别的行头上');
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,30 @@ describe('件上加行:主键凑得齐才落得下', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('**空着的自增列不许从别的行借号** —— 加的新联系人不再覆盖该客户已有的草稿行(2026-08-24)', () => {
|
||||
// 人加一行:customer_code 填了、contact_code(auto)在件上但空着;该客户已有一行草稿联系人
|
||||
const contactDrafts = [{ key: { customer_code: 'CU-E2E-ADD', contact_code: 'CU-E2E-ADD-01' } }];
|
||||
const out = resolveRowKey({
|
||||
keys: ['customer_code', 'contact_code'],
|
||||
cells: { customer_code: 'CU-E2E-ADD', contact_code: '', name: '测试CTO王工' },
|
||||
drafts: contactDrafts,
|
||||
types: { customer_code: 'text', contact_code: 'text' },
|
||||
autos: ['contact_code'],
|
||||
});
|
||||
// 不许把 -01 借来当自己的钥匙(那是覆盖别人);认不出 → 交给 mint 那条路续号
|
||||
expect(out).toBeUndefined();
|
||||
// 编码真填了的(编辑既有行)照旧认得出
|
||||
expect(
|
||||
resolveRowKey({
|
||||
keys: ['customer_code', 'contact_code'],
|
||||
cells: { customer_code: 'CU-E2E-ADD', contact_code: 'CU-E2E-ADD-01', name: '改名' },
|
||||
drafts: contactDrafts,
|
||||
types: { customer_code: 'text', contact_code: 'text' },
|
||||
autos: ['contact_code'],
|
||||
}),
|
||||
).toEqual({ customer_code: 'CU-E2E-ADD', contact_code: 'CU-E2E-ADD-01' });
|
||||
});
|
||||
|
||||
it('**新增行:序号空着 → 基座续号(现有最大 + 1)**,不再静默丢掉', () => {
|
||||
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '案例集' }, drafts: SAME_ROUND, types: TYPES })).toEqual({
|
||||
round_ref: 'CTB-1',
|
||||
@@ -438,3 +462,61 @@ describe('parseRowKey —— 件上传来的主键认哪一行', () => {
|
||||
expect(parseRowKey('X', [])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **保存说成功,就得真的都存下了**(2026-08-24 用户实测:
|
||||
* 「用编辑新增了一个客户,推进到下一步直接就把我手动添加的这一行遗漏掉了」)。
|
||||
*
|
||||
* 主键那几格空着就凑不出钥匙 —— 从前基座一句 `continue` 悄悄跳过,接口照回 `{ok:true}`:
|
||||
* 人看到「保存成功」,东西却不在,而提交闸也拦不住(一行根本没写进台账,谈何缺列)。
|
||||
* 现在没落下的行要点名说出来,且**只点他填得了的那几列**。
|
||||
*/
|
||||
describe('落不下的行要点名 —— 不许悄悄丢', () => {
|
||||
const keys = ['round_ref', 'customer_code'];
|
||||
|
||||
it('主键那一格空着 → 认不出这一行(resolveRowKey 交白卷)', () => {
|
||||
expect(
|
||||
resolveRowKey({
|
||||
keys,
|
||||
cells: { customer_code: '', customer_name: '忘了填编号的客户' },
|
||||
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
|
||||
types: { round_ref: 'text', customer_code: 'text' },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('主键填了 → 案号由「全表一致」补齐,行认得出来', () => {
|
||||
expect(
|
||||
resolveRowKey({
|
||||
keys,
|
||||
cells: { customer_code: 'CU-9527', customer_name: '人工加的客户' },
|
||||
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
|
||||
types: { round_ref: 'text', customer_code: 'text' },
|
||||
}),
|
||||
).toEqual({ customer_code: 'CU-9527', round_ref: 'COV-1' });
|
||||
});
|
||||
|
||||
it('**表里只有一行时也不许「继承」老行的编码** —— 那会当场把老行覆盖掉', () => {
|
||||
// 件上摆着 customer_code、人却空着:一行的表在「全表一致」眼里像个常量,
|
||||
// 从前新行会拿到 CU-0142(屏上两行、库里一行,而且内容还是新的那份)
|
||||
expect(
|
||||
resolveRowKey({
|
||||
keys,
|
||||
cells: { customer_code: '', customer_name: '新客户' },
|
||||
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
|
||||
types: { round_ref: 'text', customer_code: 'text' },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('件上没摆的那把钥匙(案号)照旧由基座按全表一致补 —— 那是他碰不到的列', () => {
|
||||
expect(
|
||||
resolveRowKey({
|
||||
keys,
|
||||
cells: { customer_code: 'CU-9527' },
|
||||
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
|
||||
types: { round_ref: 'text', customer_code: 'text' },
|
||||
}),
|
||||
).toEqual({ customer_code: 'CU-9527', round_ref: 'COV-1' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,6 +272,8 @@ function restPart(scope: Record<string, unknown>, options?: { withoutAttachments
|
||||
const rest = Object.entries(scope).filter(
|
||||
([k]) =>
|
||||
!k.startsWith('mine:') &&
|
||||
// touched: 是给落库预览画面的(叠加后行),内容与 mine/整表重叠 —— 不进提示词
|
||||
!k.startsWith('touched:') &&
|
||||
!k.startsWith('focus:') &&
|
||||
!(options?.withoutAttachments === true && k === 'attachments'),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 广播的**跨进程桥** —— 别的后端动了同一张单,这边的浏览器也得知道。
|
||||
*
|
||||
* 起因(2026-08-24 用户报的第三件事):「一直卡在 AI 推进中,刷新后才发现是已经推完了、
|
||||
* 需要人类确认了」。查下来推送本身是好的 —— 可 `createBroadcaster()` 是**进程内**的
|
||||
* 一个 Set:谁推进的,只有谁自己的 SSE 客户端收得到。而那会儿本机与局域网另一台
|
||||
* 各跑着一个后端连同一个库(同一批症状的另一面,见 `workers/task-claim.ts`):
|
||||
* 单子被那一台推着走,状态在库里早变了,**这一台的浏览器一个字都没收到** ——
|
||||
* 界面就停在「AI 推进中」,直到人手动刷新。
|
||||
*
|
||||
* 桥怎么搭:Postgres 自带的 `LISTEN/NOTIFY` —— 不引消息队列、不加部署件。
|
||||
* · 本进程 publish 一条 → 顺手 `NOTIFY`(带上进程标识);
|
||||
* · 一条**专用连接**常年 `LISTEN`,收到别的进程发来的就在本地 publish 一次。
|
||||
* **自己发的自己不再收**(靠进程标识认),不然一条推送在两台之间来回弹。
|
||||
*
|
||||
* 桥断了不影响业务:推送本就是「告诉在线浏览器去重取」的提示,丢一条最坏是
|
||||
* 那一屏晚两秒(前端还有轮询兜底)。所以这里所有错误都只记一行,不往上抛。
|
||||
*/
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import type { Broadcaster, Touch } from './broadcast.ts';
|
||||
|
||||
/** 频道名 —— 全库一个就够,载荷里带着 kind/id,订阅方自己分 */
|
||||
export const TOUCH_CHANNEL = 'jcai_touch';
|
||||
|
||||
/** 这一次进程的身份 —— 用来认「这条是我自己发的」,不必稳定、不必持久 */
|
||||
const SELF = `${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
type Wire = Touch & { readonly from: string };
|
||||
|
||||
/**
|
||||
* 把本地广播接到库上。返回 `stop()`;起不来不抛 —— 单进程照常跑,只是没有跨进程那一段。
|
||||
*
|
||||
* `notify` 用参数化的 `pg_notify` 而不是拼 `NOTIFY` 语句:载荷里有中文与引号,
|
||||
* 拼字符串迟早出事(而这条路上的字符串全来自业务)。
|
||||
*/
|
||||
export function bridgeBroadcaster(pool: Pool, broadcaster: Broadcaster): { stop(): void } {
|
||||
let listener: PoolClient | undefined;
|
||||
let stopped = false;
|
||||
|
||||
/** 本地 → 库:自己推的顺手 NOTIFY 一条(不 await,广播不该拖慢业务) */
|
||||
const unsubscribe = broadcaster.subscribe((touch) => {
|
||||
if (stopped) return;
|
||||
const wire: Wire = { ...touch, from: SELF };
|
||||
void pool
|
||||
.query('select pg_notify($1, $2)', [TOUCH_CHANNEL, JSON.stringify(wire)])
|
||||
.catch((error: unknown) => {
|
||||
process.stderr.write(`[broadcast-bridge] notify 失败:${error instanceof Error ? error.message : String(error)}\n`);
|
||||
});
|
||||
});
|
||||
|
||||
/** 库 → 本地:一条专用连接常年 LISTEN,收到别人的就在本地 publish */
|
||||
const connect = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
listener = client;
|
||||
client.on('notification', (message) => {
|
||||
if (message.channel !== TOUCH_CHANNEL || message.payload === undefined) return;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(message.payload);
|
||||
/*
|
||||
* **形状不对就丢** —— 频道是全库公用的,谁都能往里 NOTIFY 一句话。
|
||||
* 不校形状的话,一条 `"hello"` 会被当成 touch 广播出去:
|
||||
* 前端拿到 `kind: undefined` 谁也失效不了,还白跑一趟(界面不撒谎那一条,
|
||||
* 对推送同样算数)。
|
||||
*/
|
||||
if (typeof parsed !== 'object' || parsed === null) return;
|
||||
const wire = parsed as Partial<Wire>;
|
||||
if (typeof wire.kind !== 'string' || typeof wire.id !== 'string') return;
|
||||
// **自己发的自己不收** —— 否则一条推送在进程之间来回弹
|
||||
if (wire.from === SELF) return;
|
||||
broadcaster.publish({ kind: wire.kind as Touch['kind'], id: wire.id, reason: wire.reason ?? 'remote' });
|
||||
} catch {
|
||||
/* 载荷根本不是 JSON:丢这一条,不拖垮连接 */
|
||||
}
|
||||
});
|
||||
client.on('error', (error) => {
|
||||
process.stderr.write(`[broadcast-bridge] 连接断了,5 秒后重连:${error.message}\n`);
|
||||
client.release();
|
||||
listener = undefined;
|
||||
setTimeout(() => void connect(), 5000).unref();
|
||||
});
|
||||
await client.query(`listen ${TOUCH_CHANNEL}`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`[broadcast-bridge] 接不上,5 秒后重试:${error instanceof Error ? error.message : String(error)}\n`);
|
||||
setTimeout(() => void connect(), 5000).unref();
|
||||
}
|
||||
};
|
||||
void connect();
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
unsubscribe();
|
||||
listener?.release();
|
||||
listener = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* **对话区按任务内序号排,不按墙上时钟**(2026-08-24 柴浩:「上一个步骤的消息跑到这一个
|
||||
* 步骤的后面」)。
|
||||
*
|
||||
* 对话流 = 时间线(AI 发言 / 系统事件,来自任务记忆)+ 对话卡族(录入卡 / 决策卡),
|
||||
* 从前合并时按 `at` 字符串排序。同一台后端上它们出自同一只时钟,没问题;
|
||||
* 可两台后端连同一个库交替推同一张单时(本机 + 局域网另一台,当天实测的部署形态),
|
||||
* 各自的时钟差上几秒,上一拍的话就排到了下一拍的后面 —— 「有时候乱」正是这个形状。
|
||||
*
|
||||
* 任务记忆的 `seq` 是任务内单调的(取 max+1,与时钟无关):每条对话项都能找到它在记忆里
|
||||
* 的落点 —— AI/系统项本身就是记忆条目;卡对应 `card-sent` 那条(detail.id);
|
||||
* 人的自由输入对应 `human-input` 那条(detail.id)。找不到落点的(老数据)退回按
|
||||
* 「它的时刻之前最后一条记忆的序号」插位 —— 仍是一个总序,只是精度回到时钟。
|
||||
*/
|
||||
export type OrderEntry = {
|
||||
readonly id: string;
|
||||
readonly seq: number;
|
||||
readonly kind: string;
|
||||
readonly at: string;
|
||||
readonly detail?: Readonly<Record<string, unknown>> | undefined;
|
||||
};
|
||||
|
||||
export function orderChat<T extends { readonly id: string; readonly at: string }>(
|
||||
items: readonly T[],
|
||||
history: readonly OrderEntry[],
|
||||
): T[] {
|
||||
const byEntryId = new Map<string, number>();
|
||||
const byLinkedId = new Map<string, number>();
|
||||
for (const e of history) {
|
||||
byEntryId.set(e.id, e.seq);
|
||||
const linked = e.detail?.['id'];
|
||||
if (typeof linked === 'string' && (e.kind === 'card-sent' || e.kind === 'human-input')) {
|
||||
if (!byLinkedId.has(linked)) byLinkedId.set(linked, e.seq);
|
||||
}
|
||||
}
|
||||
const sortedByAt = [...history].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
|
||||
const seqBefore = (at: string): number => {
|
||||
let seq = 0;
|
||||
for (const e of sortedByAt) {
|
||||
if (e.at <= at) seq = Math.max(seq, e.seq);
|
||||
else break;
|
||||
}
|
||||
return seq;
|
||||
};
|
||||
const keyed = items.map((item, index) => ({
|
||||
item,
|
||||
index,
|
||||
seq: byEntryId.get(item.id) ?? byLinkedId.get(item.id) ?? seqBefore(item.at),
|
||||
}));
|
||||
keyed.sort((a, b) => a.seq - b.seq || (a.item.at < b.item.at ? -1 : a.item.at > b.item.at ? 1 : 0) || a.index - b.index);
|
||||
return keyed.map((k) => k.item);
|
||||
}
|
||||
@@ -83,6 +83,8 @@ import type { AppConfig } from './config.ts';
|
||||
import { createIdGenerator, systemClock } from './runtime.ts';
|
||||
import { createSecretBox, loadSecretKey } from './secret-box.ts';
|
||||
import { createBroadcaster } from './broadcast.ts';
|
||||
import { noteAnchors, noteAnchorTitle } from './note-anchors.ts';
|
||||
import { bridgeBroadcaster } from './broadcast-bridge.ts';
|
||||
import type { Broadcaster } from './broadcast.ts';
|
||||
|
||||
export type Container = {
|
||||
@@ -188,6 +190,12 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
process.stderr.write(`[pg] 空闲连接异常(已剔除,下次取用自动重连):${error.message}\n`);
|
||||
});
|
||||
const db = drizzle(pool);
|
||||
/*
|
||||
* **广播接到库上** —— 多个后端连同一个库时,别的进程动了同一张单,
|
||||
* 这边的浏览器也得知道(不然就停在「AI 推进中」直到人手动刷新,2026-08-24 用户实测)。
|
||||
* 单进程部署时它只是空转一条 LISTEN 连接,没有副作用。
|
||||
*/
|
||||
const bridge = bridgeBroadcaster(pool, broadcaster);
|
||||
const ids = createIdGenerator();
|
||||
const secrets = createSecretBox(loadSecretKey());
|
||||
|
||||
@@ -259,10 +267,18 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
simulation: {
|
||||
isSimulated: async (taskId) => {
|
||||
const res = await pool.query<{ simulated: boolean }>(
|
||||
/*
|
||||
* **勾了「落真库」的试跑单不算试跑** —— 这道判断只服务一件事:
|
||||
* 放行合并时主数据要不要挡下来(见 `ledger-service`)。
|
||||
* 2026-08-24 用户要的正是这个:演示时要看见 `customer.contact` 的亲密度真的变了,
|
||||
* 而不是单据里写着「3 → 10」、主数据还是 3。默认仍然挡 —— 破例得有人按一下。
|
||||
*/
|
||||
`select exists (
|
||||
select 1 from sys.task t
|
||||
left join sys.workflow w on w.id = t.workflow_id
|
||||
where t.id = $1 and (t.origin = 'testbed' or w.source->>'ref' = 'testbed')
|
||||
where t.id = $1
|
||||
and (t.origin = 'testbed' or w.source->>'ref' = 'testbed')
|
||||
and coalesce(t.land_master, false) = false
|
||||
) as simulated`,
|
||||
[taskId],
|
||||
);
|
||||
@@ -408,11 +424,31 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
append: async (input) =>
|
||||
void (await memory.append({
|
||||
...input,
|
||||
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
|
||||
})),
|
||||
append: async (input) => {
|
||||
const stamp = { contractVersion: '1', businessModelVersion: '1', skillVersions: [] };
|
||||
await memory.append({ ...input, stamp });
|
||||
/*
|
||||
* **驳回意见点了谁,基座当场比对好摆进轨迹**(2026-08-24 柴浩实测:意见写
|
||||
* 「测试111建议动作改成拜访CTO」,模型把改动落到了 Itelma 那一行)。
|
||||
* 归属是纯字符串包含 —— 引擎有现成事实的,不让模型从一句没标点的话里自己抠。
|
||||
* 观察挂了不拦回传:它是给下一拍的路标,不是这一笔的门。
|
||||
*/
|
||||
if (input.kind === 'card-reply') {
|
||||
const detail = (input.detail ?? {}) as { note?: unknown; value?: unknown };
|
||||
const custom = typeof detail.value === 'string' && detail.value.startsWith('custom:') ? detail.value.slice('custom:'.length) : '';
|
||||
const note = typeof detail.note === 'string' && detail.note.trim() !== '' ? detail.note : custom;
|
||||
if (note.trim() !== '') {
|
||||
try {
|
||||
const hits = noteAnchors(note, await ledger.taskDrafts(input.taskId));
|
||||
if (hits.length > 0) {
|
||||
await memory.append({ taskId: input.taskId, kind: 'observation', title: noteAnchorTitle(hits), stamp });
|
||||
}
|
||||
} catch {
|
||||
/* 比对失败只是少一条路标 */
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
events: {
|
||||
publishHumanEvent: async (input) => {
|
||||
@@ -703,6 +739,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
report: deskElements.report,
|
||||
trace,
|
||||
async close() {
|
||||
bridge.stop();
|
||||
await pool.end();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* **驳回意见点了谁 —— 按字面比对,基座认得出来的归属别让模型猜**(2026-08-24 柴浩实测)。
|
||||
*
|
||||
* 那一单的意见原话是「测试111建议动作改成拜访CTO」,计划表里明明有一行
|
||||
* `customer_name = 测试111`;模型复述意见时把「测试111」整个丢掉(它把这三个字
|
||||
* 读成了「测试」+ 杂音),只剩「建议动作改成拜访CTO」,然后按自己的推理挑了
|
||||
* Itelma 的那一行去改 —— 人看到的是「我说了改测试111,它改到了 Itelma」。
|
||||
*
|
||||
* 治法与 E04 的 worldTouched 那一课同款:**指南让模型判的事,引擎有现成事实的,
|
||||
* 一律改成读事实**。「意见里那个名字对上台账哪一行」是纯字符串包含,机器一趟就
|
||||
* 数得出来;比对结果以观察进轨迹,落在 card-reply 旁边 —— 模型下一拍读意见时,
|
||||
* 归属已经是摆好的事实,不必再从一句没有标点的话里自己抠实体。
|
||||
*
|
||||
* 只做**字面比对**,不做任何语义猜测:
|
||||
* · 格子的值(字符串、去空白后 ≥ 3 字、非纯数字)是意见原文的子串 → 这一行被点名;
|
||||
* · 一行都没对上 → 没有这条观察(没有依据的时候不说话);
|
||||
* · 对上太多行(> 5)→ 也不说 —— 什么都命中的提示不是提示,是噪音。
|
||||
*/
|
||||
|
||||
/** 本单草稿层的一行 —— key 与 patch 合起来就是这一行现有的格子 */
|
||||
export type DraftRow = {
|
||||
readonly domain: string;
|
||||
readonly object: string;
|
||||
readonly key: Readonly<Record<string, unknown>>;
|
||||
readonly patch: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
/** 一行至少要这么长的值才算「点名」—— 短于此的子串(如「高」「01」)碰上纯属巧合 */
|
||||
const MIN_ANCHOR = 3;
|
||||
/** 命中行数封顶 —— 超过就当没命中(意见里带了个太常见的词) */
|
||||
const MAX_HITS = 5;
|
||||
|
||||
/**
|
||||
* 意见按字面点名对上了哪几行。返回人读的行描述(空数组 = 别出声)。
|
||||
*
|
||||
* 每行取**命中的值里最长的那个**当展示(「测试111」比「111」更像人点的名);
|
||||
* 行的身份用 `域.对象 主键值` 说 —— 与拒回里主键名单同一口径。
|
||||
*/
|
||||
export function noteAnchors(note: string, rows: ReadonlyArray<DraftRow>): string[] {
|
||||
const text = note.trim();
|
||||
if (text.length < MIN_ANCHOR) return [];
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const cells: Record<string, unknown> = { ...row.key, ...row.patch };
|
||||
let best = '';
|
||||
for (const value of Object.values(cells)) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const v = value.trim();
|
||||
if (v.length < MIN_ANCHOR) continue;
|
||||
if (/^\d+$/.test(v)) continue; // 纯数字撞上「2026」「111」这类年份/序号,不算点名
|
||||
if (!text.includes(v)) continue;
|
||||
if (v.length > best.length) best = v;
|
||||
}
|
||||
if (best === '') continue;
|
||||
const id = `${row.domain}.${row.object}|${JSON.stringify(row.key)}`;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push(`${row.domain}.${row.object} ${Object.values(row.key).map(String).join('/')}(命中「${best}」)`);
|
||||
}
|
||||
return out.length > MAX_HITS ? [] : out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 命中之后那条观察的全文 —— **它是模型下一拍的行动指南**(与拒回话术同一条纪律):
|
||||
* 只说归属这个事实与「别改到没点名的行上」,不替模型决定改什么。
|
||||
*/
|
||||
export function noteAnchorTitle(hits: readonly string[]): string {
|
||||
return (
|
||||
`意见按字面点名对上了本单写下的这几行:${hits.join('、')} —— ` +
|
||||
'要按意见改行,先认这几行的归属;点名的行缺什么(比如还没有那样的联系人),' +
|
||||
'改的是这一行自己的内容,不是把改动挪到别的行头上'
|
||||
);
|
||||
}
|
||||
+179
-26
@@ -42,21 +42,45 @@ function rowsOf<T>(result: unknown): T[] {
|
||||
return Array.isArray(rows) ? (rows as T[]) : [];
|
||||
}
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
import { coerceToColumn, missingReadings } from '../wiring/engines.ts';
|
||||
import { coerceToColumn, missingReadings, rowLabel } from '../wiring/engines.ts';
|
||||
import { orderChat } from '../chat-order.ts';
|
||||
|
||||
|
||||
/** 一张表收上来的样子:`{ 台账列名: 值 }`,一行一条 —— 主键列在里面,但只用来认行 */
|
||||
type EditedRow = Readonly<Record<string, string>>;
|
||||
|
||||
/** 写回的收成:改了几行、新增几行、撤了几行、哪几格归不了型 */
|
||||
type WriteBack = { rows: number; added: number; dropped: string[]; bad: string[] };
|
||||
type WriteBack = {
|
||||
rows: number;
|
||||
added: number;
|
||||
/**
|
||||
* **人新增的是哪几行**(主键,2026-08-24 柴浩实测补):从前只有个数「新增 1 行」——
|
||||
* 模型看不见这一屏,只看得见轨迹;不点名,它就守着自己记忆里的「识别出 1 家」
|
||||
* 把人加的那家从头无视到尾(计划/回执/收口一行都没它)。撤行早就点名了
|
||||
* (「撤掉的是人定的,别再写回去」),加行是同一条道理的另一半。
|
||||
*/
|
||||
addedKeys: string[];
|
||||
dropped: string[];
|
||||
bad: string[];
|
||||
/**
|
||||
* **认不出是哪一行、因而没落下的那几行**(2026-08-24 用户实测:
|
||||
* 「用编辑新增了一个客户,推进到下一步直接就把我手动添加的这一行遗漏掉了」)。
|
||||
*
|
||||
* 从前这里是一句 `continue` —— 行悄悄没了,而接口照旧回 `{ok:true}`:
|
||||
* 人看到「保存成功」,东西却不在。**保存说成功,就得真的都存下了**;
|
||||
* 存不下的必须点名说出来(是哪张表、缺哪几列钥匙),让他补得上。
|
||||
*/
|
||||
unkeyed: Array<{ address: string; keys: string[]; hint: string }>;
|
||||
};
|
||||
|
||||
const nothing = (): WriteBack => ({ rows: 0, added: 0, dropped: [], bad: [] });
|
||||
const nothing = (): WriteBack => ({ rows: 0, added: 0, addedKeys: [], dropped: [], bad: [], unkeyed: [] });
|
||||
const merge = (into: WriteBack, one: WriteBack): void => {
|
||||
into.rows += one.rows;
|
||||
into.added += one.added;
|
||||
into.addedKeys.push(...one.addedKeys);
|
||||
into.dropped.push(...one.dropped);
|
||||
into.bad.push(...one.bad);
|
||||
into.unkeyed.push(...one.unkeyed);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -86,6 +110,8 @@ export function resolveRowKey(input: {
|
||||
cells: Readonly<Record<string, string>>;
|
||||
drafts: ReadonlyArray<{ key: Record<string, unknown> }>;
|
||||
types: Readonly<Record<string, string>>;
|
||||
/** 声明了 `auto` 的主键列 —— 见下面那条「空着的自增列不许从别的行借号」 */
|
||||
autos?: readonly string[];
|
||||
}): Record<string, string> | undefined {
|
||||
const { keys, cells, drafts, types } = input;
|
||||
const key: Record<string, string> = {};
|
||||
@@ -95,7 +121,16 @@ export function resolveRowKey(input: {
|
||||
}
|
||||
if (Object.keys(key).length === keys.length) return key;
|
||||
|
||||
const known = findKnownRow(drafts, keys, key);
|
||||
/*
|
||||
* **空着的自增列不是身份线索,是「给我续个号」**(2026-08-24 确认屏补录联系人逼出):
|
||||
* 人加的新行联系人编码本来就该空着(自增列不问人),可 findKnownRow 拿着只有
|
||||
* customer_code 的半把钥匙,会把这一行错配到该客户**已有的草稿联系人行**上 ——
|
||||
* 编码从那行借来,「新增」当场变「覆盖」:模型刚建的那位被人加的这位顶掉,
|
||||
* 痕还写着「改了 1 行」。声明了 auto 的列,件上摆着却空着 → 一律不许从别的行
|
||||
* 借值,留给续号那条路(writeEditedRows 的 mint)。
|
||||
*/
|
||||
const barred = new Set((input.autos ?? []).filter((k) => k in cells && (cells[k] ?? '').trim() === ''));
|
||||
const known = barred.size > 0 ? undefined : findKnownRow(drafts, keys, key);
|
||||
const isSeq = (column: string): boolean => types[column] === 'integer' || types[column] === 'number';
|
||||
/*
|
||||
* **序号型主键不走「全表一致」这条路**(2026-08-23 浏览器实测逮到的):
|
||||
@@ -105,7 +140,19 @@ export function resolveRowKey(input: {
|
||||
*/
|
||||
for (const k of keys) {
|
||||
if (key[k] !== undefined) continue;
|
||||
if (barred.has(k)) continue;
|
||||
if (known === undefined && isSeq(k)) continue;
|
||||
/*
|
||||
* **件上摆着、他却空着的那一列,不许替他编一个值**(2026-08-24 用户实测的第二面)。
|
||||
*
|
||||
* 「全表一致」这条路是给**件上压根没摆**的主键用的(如案号 `round_ref`:
|
||||
* 一单一号,全表当然一致,基座补它天经地义)。可如果那一列就在件上、人能填、
|
||||
* 他没填 —— 补出来的值是**替他决定这一行是谁**:表里恰好只有一行时,
|
||||
* 新加的那行会拿到老行的客户编码,**当场把老行覆盖掉**(屏上两行,库里一行)。
|
||||
* 序号型主键 2026-08-23 已经因为同一个原因单独绕开过 —— 那是这条规律的一个特例,
|
||||
* 现在按「他填得了吗」统一判:填得了就必须他填,基座只补他碰不到的那几列。
|
||||
*/
|
||||
if (known === undefined && k in cells) continue;
|
||||
const v =
|
||||
known === undefined
|
||||
? (() => {
|
||||
@@ -133,14 +180,24 @@ export function resolveRowKey(input: {
|
||||
async function writeEditedRows(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
declared: { domain: string; object: string; fields: ReadonlyArray<{ name: string; type: string; primaryKey: boolean }> },
|
||||
declared: { domain: string; object: string; caseKey?: string | undefined; fields: ReadonlyArray<{ name: string; type: string; primaryKey: boolean; auto?: boolean | undefined }> },
|
||||
lines: readonly EditedRow[],
|
||||
): Promise<WriteBack> {
|
||||
const { domain, object } = declared;
|
||||
const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name);
|
||||
const out = nothing();
|
||||
// 这一单现有的行 —— 三处要用:补主键(件上没摆全的那几列)、认出哪几行真改了、认出哪几行是新增
|
||||
// 本单草稿行 —— 补主键用(件上没摆全的那几列按它认)
|
||||
const drafts = (await container.ledger.taskDrafts(taskId)).filter((d) => d.domain === domain && d.object === object);
|
||||
/*
|
||||
* **「真改了吗」要对着叠加后的行比,不能只对草稿**(2026-08-24 落库预览逼出来的):
|
||||
* 预览回显的是正式现值 + 草稿的叠加 —— 人一格没动按下保存,回传里带着的是回显值;
|
||||
* 只对草稿比的话,草稿里没有的那几列(如 Itelma 既有的客户类型)全算「改了」,
|
||||
* 痕上写「改了 2 行」而人一个字没动(日志撒谎),还平白多出一笔草稿。
|
||||
* 「这一行是不是新增」同理:正式表里有、草稿里没有的行,不是新增。
|
||||
*/
|
||||
const page = await container.ledger
|
||||
.taskView(taskId, { address: { domain, object }, where: {}, orderBy: [], limit: 500, offset: 0 })
|
||||
.catch(() => ({ rows: [] as Array<Record<string, unknown>> }));
|
||||
const types = Object.fromEntries(declared.fields.map((f) => [f.name, f.type]));
|
||||
for (const cells of lines) {
|
||||
/*
|
||||
@@ -150,7 +207,13 @@ async function writeEditedRows(
|
||||
* 凑主键这一段抽成了纯函数 `resolveRowKey`(有测):三类主键三种凑法,
|
||||
* 尤其序号型那一手不补,人在件上加的行会被静默丢掉。
|
||||
*/
|
||||
const key = resolveRowKey({ keys, cells, drafts, types });
|
||||
const key = resolveRowKey({
|
||||
keys,
|
||||
cells,
|
||||
drafts,
|
||||
types,
|
||||
autos: declared.fields.filter((f) => f.primaryKey && f.auto === true).map((f) => f.name),
|
||||
});
|
||||
/*
|
||||
* **人手改的值与 AI 写的值过同一道归型门**(`coerceToColumn`)。
|
||||
*
|
||||
@@ -169,12 +232,73 @@ async function writeEditedRows(
|
||||
if (coerced.kind === 'value') patch[name] = coerced.value;
|
||||
else if (coerced.kind === 'reject') out.bad.push(`${name}=${value}`);
|
||||
}
|
||||
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;
|
||||
if (key === undefined) {
|
||||
const filled = Object.entries(cells).filter(([, v]) => v.trim() !== '');
|
||||
/*
|
||||
* **自增主键空着不是「他没填」,是「不用他填」**(2026-08-24 用户在台账更新确认
|
||||
* 屏上补录联系人逼出来的):声明标了 `auto` 的主键(联系人编码)本就不该问人 ——
|
||||
* 录入卡那条路 2026-08-02 就立了这条规矩,件上「加一行」是同一件事的另一个入口。
|
||||
* 其余主键他都填了、缺的恰好全是 auto 列 → 由基座按声明的续号规则铸
|
||||
* (同组其余主键值-两位序号,序号数正式版 + 本单草稿,与录入卡同一段 autoKeys)。
|
||||
*/
|
||||
const autoCols = keys.filter((k) => declared.fields.find((f) => f.name === k)?.auto === true);
|
||||
const blankKeys = keys.filter((k) => (cells[k] ?? '').trim() === '');
|
||||
const mintable =
|
||||
filled.length > 0 &&
|
||||
autoCols.length > 0 &&
|
||||
blankKeys.length > 0 &&
|
||||
blankKeys.every((k) => autoCols.includes(k)) &&
|
||||
keys.filter((k) => !autoCols.includes(k)).every((k) => (cells[k] ?? '').trim() !== '');
|
||||
if (mintable) {
|
||||
const given = Object.fromEntries(keys.filter((k) => !autoCols.includes(k)).map((k) => [k, (cells[k] ?? '').trim()]));
|
||||
const minted = await autoKeys(container, taskId, declared as LedgerDeclaration, given);
|
||||
const newKey = { ...given, ...minted };
|
||||
if (keys.every((k) => (newKey[k] ?? '') !== '')) {
|
||||
await container.ledger.draftWrite({ taskId, address: { domain, object }, key: newKey, patch });
|
||||
out.added += 1;
|
||||
out.addedKeys.push(rowLabel(newKey, declared.caseKey));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
/*
|
||||
* **认不出这一行,就得说出来** —— 从前这里是一句 `continue`:人在件上加了一行、
|
||||
* 按了保存、接口回 `{ok:true}`,而那一行根本没落下(2026-08-24 用户实测)。
|
||||
* 键凑不齐只有一种情形:主键那几格他没填(件上摆着、标着必填,但空着)。
|
||||
* 空行不算 —— 那是「加一行」点出来还没动的骨架,人自己也知道它是空的。
|
||||
*/
|
||||
if (filled.length > 0) {
|
||||
/*
|
||||
* **只点名他填得了的那几列** —— 件上摆着、却空着的主键格。
|
||||
* 压根不在件上的主键(如案号 `round_ref`,由基座按全表一致补)不许出现在这句话里:
|
||||
* 那是基座的活,写进去等于把人指向一扇他打不开的门(拒回指错方向比不指更糟)。
|
||||
*/
|
||||
// 自增列不点名 —— 人填不了它(拒回指向打不开的门比不指更糟)
|
||||
const missing = keys.filter(
|
||||
(k) => k in cells && (cells[k] ?? '').trim() === '' && declared.fields.find((f) => f.name === k)?.auto !== true,
|
||||
);
|
||||
// 人读名优先(件上摆的是「客户编号」,不是 customer_code)—— 取不到就退列名
|
||||
const label = (name: string): string => {
|
||||
const found = declared.fields.find((f) => f.name === name) as { label?: string } | undefined;
|
||||
return found?.label ?? name;
|
||||
};
|
||||
out.unkeyed.push({
|
||||
address: `${domain}.${object}`,
|
||||
keys: missing,
|
||||
hint:
|
||||
missing.length > 0
|
||||
? `这一行没能落下 —— ${missing.map(label).join('、')}是它的钥匙,填上才存得住`
|
||||
: '这一行没能落下 —— 认不出它是哪一行(主键这几格件上没摆出来),请报给开发',
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const was = page.rows.find((r) => keys.every((k) => String((r as Record<string, unknown>)[k] ?? '') === key[k]));
|
||||
if (!rowChanged(was as Record<string, unknown> | undefined, patch)) continue;
|
||||
await container.ledger.draftWrite({ taskId, address: { domain, object }, key, patch });
|
||||
if (was === undefined) out.added += 1;
|
||||
else out.rows += 1;
|
||||
if (was === undefined) {
|
||||
out.added += 1;
|
||||
out.addedKeys.push(rowLabel(key, declared.caseKey));
|
||||
} else out.rows += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -192,7 +316,7 @@ async function writeEditedRows(
|
||||
async function dropEditedRows(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
declared: { domain: string; object: string; fields: ReadonlyArray<{ name: string; primaryKey: boolean }> },
|
||||
declared: { domain: string; object: string; caseKey?: string | undefined; fields: ReadonlyArray<{ name: string; primaryKey: boolean }> },
|
||||
lines: readonly EditedRow[],
|
||||
): Promise<WriteBack> {
|
||||
const { domain, object } = declared;
|
||||
@@ -208,7 +332,7 @@ async function dropEditedRows(
|
||||
const one = hit.length === 1 ? hit[0] : undefined;
|
||||
if (one === undefined) continue;
|
||||
const gone = await container.ledger.draftDrop({ taskId, address: { domain, object }, key: one.key });
|
||||
if (gone) out.dropped.push(Object.values(one.key).map(String).join('/'));
|
||||
if (gone) out.dropped.push(rowLabel(one.key, declared.caseKey));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -483,23 +607,35 @@ export function parseRowKey(raw: string, keys: readonly string[]): Record<string
|
||||
*/
|
||||
async function noteEdits(container: Container, taskId: TaskId, got: WriteBack): Promise<number> {
|
||||
const touched = got.rows + got.added + got.dropped.length;
|
||||
if (touched === 0 && got.bad.length === 0) return touched;
|
||||
// 一行都没落下也要留痕 —— 那正是最该说出来的一次(从前这里把 unkeyed 一并吞了)
|
||||
if (touched === 0 && got.bad.length === 0 && got.unkeyed.length === 0) return touched;
|
||||
const parts: string[] = [];
|
||||
if (got.rows > 0) parts.push(`改了 ${got.rows} 行`);
|
||||
if (got.added > 0) parts.push(`新增 ${got.added} 行`);
|
||||
/*
|
||||
* **加的行要点名**(2026-08-24 柴浩实测):只写「新增 1 行」,模型守着轨迹里
|
||||
* 「识别出 1 家」把人加的那家从计划到收口全程无视 —— 这条痕是它下一拍的行动指南,
|
||||
* 得说清加的是谁、且与既有的行同等作数。
|
||||
*/
|
||||
if (got.added > 0) parts.push(`新增 ${got.added} 行(${got.addedKeys.slice(0, 5).join('、')})`);
|
||||
if (got.dropped.length > 0) parts.push(`撤掉 ${got.dropped.length} 行(${got.dropped.slice(0, 5).join('、')})`);
|
||||
if (got.bad.length > 0) parts.push(`${got.bad.length} 格填的值与列型对不上,已跳过(${got.bad.slice(0, 3).join('、')})`);
|
||||
// 没落下的行要在日志里点名 —— 下一拍模型看的是这条痕,人回看也是它
|
||||
if (got.unkeyed.length > 0) {
|
||||
parts.push(`${got.unkeyed.length} 行没落下(${got.unkeyed.map((u) => u.hint).slice(0, 2).join(';')})`);
|
||||
}
|
||||
await container.memory.append({
|
||||
taskId,
|
||||
kind: 'human-input',
|
||||
title:
|
||||
`就地修订成果:${parts.join(';')}` +
|
||||
(got.dropped.length === 0 ? '' : ' —— 撤掉的是人定的,别再写回去;条目数跟着改对'),
|
||||
(got.dropped.length === 0 ? '' : ' —— 撤掉的是人定的,别再写回去;条目数跟着改对') +
|
||||
(got.added === 0 ? '' : ' —— 新增的行是人定的,与既有的行同等作数:后续每一步按台账现读的全表办,别按你先前识别的行数办'),
|
||||
detail: {
|
||||
rows: got.rows,
|
||||
...(got.added === 0 ? {} : { added: got.added }),
|
||||
...(got.added === 0 ? {} : { added: got.added, addedKeys: got.addedKeys }),
|
||||
...(got.dropped.length === 0 ? {} : { dropped: got.dropped }),
|
||||
...(got.bad.length === 0 ? {} : { rejected: got.bad }),
|
||||
...(got.unkeyed.length === 0 ? {} : { unkeyed: got.unkeyed }),
|
||||
},
|
||||
// 版本戳:这条不由活动产出,与引擎那几条同一口径
|
||||
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
|
||||
@@ -529,9 +665,9 @@ async function applyViewTableEdits(
|
||||
tables: Readonly<Record<string, readonly EditedRow[]>>;
|
||||
drops?: Readonly<Record<string, readonly EditedRow[]>> | undefined;
|
||||
},
|
||||
): Promise<number> {
|
||||
): Promise<{ rows: number; unkeyed: WriteBack['unkeyed'] }> {
|
||||
const declarations = await declarationsOf(container, taskId);
|
||||
if (declarations === undefined) return 0;
|
||||
if (declarations === undefined) return { rows: 0, unkeyed: [] };
|
||||
const got = nothing();
|
||||
/*
|
||||
* **先撤后写** —— 顺序有意义:人可能把一行删掉、又在同一次保存里加回一行同主键的。
|
||||
@@ -543,13 +679,15 @@ async function applyViewTableEdits(
|
||||
for (const { declared, lines } of writesOf(declarations, payload.tables)) {
|
||||
merge(got, await writeEditedRows(container, taskId, declared, lines));
|
||||
}
|
||||
return noteEdits(container, taskId, got);
|
||||
return { rows: await noteEdits(container, taskId, got), unkeyed: got.unkeyed };
|
||||
}
|
||||
|
||||
/** 台账声明的最小形状 —— 这一段只用到这几样 */
|
||||
type LedgerDeclaration = {
|
||||
domain: string;
|
||||
object: string;
|
||||
/** 单标识列 —— 摆给人看的行标识里去掉它(rowLabel) */
|
||||
caseKey?: string | undefined;
|
||||
fields: ReadonlyArray<{
|
||||
name: string;
|
||||
type: string;
|
||||
@@ -990,8 +1128,8 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
}
|
||||
}
|
||||
|
||||
// 对话流 = 时间线 + 对话卡族,按时间全序 —— 与任务记忆同源、不同筛选
|
||||
const merged: ChatMessage[] = [...chat, ...cards].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
|
||||
// 对话流 = 时间线 + 对话卡族,**按任务内序号排**(时钟跨机器会偏,序号不会;见 chat-order.ts)
|
||||
const merged: ChatMessage[] = orderChat([...chat, ...cards], await container.memory.history(taskId));
|
||||
|
||||
// 视图区:活动推的 HTML 优先(它是活动按局面组织的完整一屏);
|
||||
// 尚无推送时给一份进度概览,不留空屏
|
||||
@@ -1191,10 +1329,16 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
const body: ApiError = { code: 'conflict', message: '这一单已收尾,改不动了' };
|
||||
return c.json(body, 409);
|
||||
}
|
||||
const rows = await applyViewTableEdits(container, taskId, parsed.data);
|
||||
const saved = await applyViewTableEdits(container, taskId, parsed.data);
|
||||
// 数据变了,件跟着变 —— 算出来的那些(类徽 / 分布条 / 去向)由这一次重渲重新求值
|
||||
if (rows > 0) await container.views.repush(taskId);
|
||||
return c.json({ ok: true, rows });
|
||||
if (saved.rows > 0) await container.views.repush(taskId);
|
||||
/*
|
||||
* **有行没落下就如实说** —— 保存说成功、东西却不在,是这一屏最伤人的一种谎
|
||||
* (2026-08-24 用户实测:件上加的那个客户推到下一步就不见了)。
|
||||
* 仍回 200:别的格子是真落下了,不该因为一行没钥匙把整次保存判死;
|
||||
* 但把没落下的那几行摆回去,前端据此红字点名,人补上主键再存一次。
|
||||
*/
|
||||
return c.json({ ok: true, rows: saved.rows, ...(saved.unkeyed.length === 0 ? {} : { unkeyed: saved.unkeyed }) });
|
||||
});
|
||||
|
||||
/** 回传:与后端回传校验**同 schema** —— 前端提交校验用的也是它 */
|
||||
@@ -1267,6 +1411,14 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
* 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。
|
||||
*/
|
||||
await applyBoundTableEdits(container, outcome.taskId, outcome.order, parsed.data);
|
||||
/*
|
||||
* **返回之前就把这单标成「在办事」**(2026-08-25 用户:「右边聊天框先有反应,中间的
|
||||
* html 过一会儿才变成 AI 正在推进中,看上去很不流畅」)。
|
||||
* 从前 runtime 要等派发器投递、推进器抢到租约、开跑前那一刻才翻成 running ——
|
||||
* 前端 onSuccess 那一次重取抓到的还是 awaiting-event:对话区(卡状态已落)
|
||||
* 先变,工作区(读 runtime)要等下一次推送。两块该读同一份事实。
|
||||
*/
|
||||
await container.task.wake(outcome.taskId).catch(() => false);
|
||||
// 人答完了就接着跑,别让他对着不动的屏幕等两轮轮询(见 workers/pulse.ts)
|
||||
void pulse();
|
||||
return c.json({ ok: true, producesCompletion: outcome.producesCompletion });
|
||||
@@ -1388,6 +1540,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
* 眼睛正盯着屏幕。等两轮轮询(实测 6.8 秒)那段时间对话区一动不动,
|
||||
* 人以为没点上,会再点一次。见 `workers/pulse.ts`。
|
||||
*/
|
||||
await container.task.wake(cardTaskId).catch(() => false); // 与工单回传同一条理:返回前先标「在办」
|
||||
void pulse();
|
||||
return c.json({ ok: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
listChannelsRoute,
|
||||
listActivityMemoryRoute,
|
||||
listGovTasksRoute,
|
||||
ledgerByActivityRoute,
|
||||
listLedgerObjectsRoute,
|
||||
installActivityRoute,
|
||||
listProcessPackagesRoute,
|
||||
@@ -699,6 +700,17 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
data,
|
||||
...(message === '' ? {} : { message }),
|
||||
});
|
||||
/*
|
||||
* **勾了「落真库」就在这一单上做个记号** —— 放行合并时主数据不再挡下来
|
||||
* (见 `container.ts` 的 `isSimulated` 与 `ledger-service` 那道门)。
|
||||
*
|
||||
* 为什么落在路由里直接改一列,而不是让 `task.start` 收这个参数:
|
||||
* 它是**试跑台这一处的破例**,不是任务本身的属性 ——
|
||||
* 让整个任务模块的契约为一个试跑开关让路,是把临时的东西修进地基里。
|
||||
*/
|
||||
if (parsed.data.landMaster) {
|
||||
await container.db.execute(sql`update sys.task set land_master = true where id = ${task.id}`);
|
||||
}
|
||||
container.broadcaster.publish({ kind: 'task', id: task.id, reason: 'testbed-start' });
|
||||
container.broadcaster.publish({ kind: 'system', id: 'testbed', reason: 'testbed-start' });
|
||||
return c.json({ taskId: task.id, title: task.title });
|
||||
@@ -1416,6 +1428,134 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
return c.json({ objects: await container.ledger.listObjects() });
|
||||
});
|
||||
|
||||
/**
|
||||
* **按活动看数据域** —— 「我这个活动写了哪几张表,现在各有几行,刚跑那一单落下了什么」。
|
||||
*
|
||||
* 数据只从三处来,一处不编:
|
||||
* · 表与主键 —— 活动清单的台账声明(谁声明的,就归谁);
|
||||
* · 行数 —— `describe` + `query` 的 total(正式表的真实行数;没物化就没有这个数,
|
||||
* **不填 0** —— 「还没建表」与「建了但空着」是两件事);
|
||||
* · 草稿行数与时刻 —— `ledger.__draft`(在办中的单写下的,放行才进正式表)。
|
||||
*/
|
||||
app.get(ledgerByActivityRoute.path, async (c) => {
|
||||
const denied = await readData(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
|
||||
const [cards, materialized] = await Promise.all([
|
||||
container.registry.list(),
|
||||
container.ledger.listObjects(),
|
||||
]);
|
||||
const known = new Map(materialized.map((o) => [`${o.domain}.${o.object}`, o]));
|
||||
|
||||
/** 草稿层:一次查完按地址分组 —— 每张表单独查会把这一屏拖成几十条 SQL */
|
||||
const draftRows = rowsOf<{ domain: string; object: string; n: string; at: Date | string }>(
|
||||
await container.db.execute(
|
||||
sql`select domain, object, count(*)::text as n, max(updated_at) as at from ledger.__draft group by domain, object`,
|
||||
),
|
||||
);
|
||||
const drafts = new Map(
|
||||
draftRows.map((r) => [
|
||||
`${r.domain}.${r.object}`,
|
||||
{ n: Number(r.n), at: r.at instanceof Date ? r.at.toISOString() : String(r.at) },
|
||||
]),
|
||||
);
|
||||
|
||||
/** 每个活动最近几单 —— 「刚跑完那一单」在这儿认得出来 */
|
||||
const recentRows = rowsOf<{
|
||||
activity_id: string;
|
||||
id: string;
|
||||
title: string;
|
||||
runtime: string;
|
||||
terminal: string | null;
|
||||
at: Date | string;
|
||||
}>(
|
||||
await container.db.execute(sql`
|
||||
select activity_id, id, title, runtime, terminal, coalesce(finished_at, started_at) as at
|
||||
from (select *, row_number() over (partition by activity_id order by coalesce(finished_at, started_at) desc) as rn
|
||||
from sys.task) t
|
||||
where rn <= 3`),
|
||||
);
|
||||
|
||||
const claimed = new Set<string>();
|
||||
const activities = await Promise.all(
|
||||
cards.map(async (card) => {
|
||||
const manifest = await container.registry.manifestOf(card.activityId).catch(() => undefined);
|
||||
const declared = manifest?.declarations.ledger ?? [];
|
||||
const tables = await Promise.all(
|
||||
declared.map(async (d) => {
|
||||
const address = `${d.domain}.${d.object}`;
|
||||
claimed.add(address);
|
||||
const meta = known.get(address);
|
||||
/*
|
||||
* 行数要**真去数**:`listObjects` 只说有没有这张表。数不出来(表还没物化)
|
||||
* 就不给这个数 —— 摆一个 0 会把「活动声明了但一次都没写过」说成「写过、现在空了」。
|
||||
*/
|
||||
const total =
|
||||
meta === undefined
|
||||
? undefined
|
||||
: await container.ledger
|
||||
.query({ address: { domain: d.domain, object: d.object }, where: {}, orderBy: [], limit: 1, offset: 0 })
|
||||
.then((p) => p.total)
|
||||
.catch(() => undefined);
|
||||
const draft = drafts.get(address);
|
||||
return {
|
||||
domain: d.domain,
|
||||
object: d.object,
|
||||
...(d.label === undefined ? {} : { label: d.label }),
|
||||
...(d.kind === undefined ? {} : { kind: d.kind }),
|
||||
...(d.caseKey === undefined ? {} : { caseKey: d.caseKey }),
|
||||
primaryKey: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
|
||||
...(total === undefined ? {} : { rows: total }),
|
||||
drafts: draft?.n ?? 0,
|
||||
...(draft === undefined ? {} : { draftAt: draft.at }),
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
activityId: card.activityId,
|
||||
name: card.name ?? card.activityId,
|
||||
/*
|
||||
* 版本取**此刻在用的那一版** —— 按装入时刻取最新的 active。
|
||||
* 不能拿「第一个 active」:一个活动装过二十几版,数组里前面躺的是老版本,
|
||||
* 屏上就会写着 1.0.0 而实际跑的是 2.1.0(**界面不撒谎**,版本号尤其不能错)。
|
||||
*/
|
||||
...(((): { version?: string } => {
|
||||
const usable = (card.packageVersions ?? []).filter((v) => v.state !== 'uninstalled');
|
||||
const newest = [...usable].sort((a, b) => (a.installedAt < b.installedAt ? 1 : -1))[0];
|
||||
return newest === undefined ? {} : { version: newest.version };
|
||||
})()),
|
||||
tables,
|
||||
recent: recentRows
|
||||
.filter((r) => r.activity_id === card.activityId)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
runtime: r.runtime,
|
||||
...(r.terminal === null ? {} : { terminal: r.terminal }),
|
||||
at: r.at instanceof Date ? r.at.toISOString() : String(r.at),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
/* 库里有、没人声明的:多半是退役活动留下的空壳 —— 单列一档,不混进上面装作正常 */
|
||||
const unclaimed = await Promise.all(
|
||||
materialized
|
||||
.filter((o) => !claimed.has(`${o.domain}.${o.object}`))
|
||||
.map(async (o) => ({
|
||||
domain: o.domain,
|
||||
object: o.object,
|
||||
...(o.label === undefined ? {} : { label: o.label }),
|
||||
...((await container.ledger
|
||||
.query({ address: { domain: o.domain, object: o.object }, where: {}, orderBy: [], limit: 1, offset: 0 })
|
||||
.then((p) => ({ rows: p.total }))
|
||||
.catch(() => ({}))) as { rows?: number }),
|
||||
})),
|
||||
);
|
||||
|
||||
return c.json({ activities, unclaimed });
|
||||
});
|
||||
|
||||
app.get(browseLedgerRoute.path, async (c) => {
|
||||
const denied = await readData(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
|
||||
@@ -23,16 +23,14 @@ import { MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from './seed-mvp-data.ts';
|
||||
* A2/A3 于 2026-07-31 转正:它们的案例模型已简化为「一张执行单办完」,
|
||||
* 剧本与 A1 同一套机制,不再是治理台里那两个 viewTemplates 为空的占位槽。
|
||||
*/
|
||||
const REAL_PACKAGES = [
|
||||
'quote-intake',
|
||||
'quote-alternative',
|
||||
'quote-sourcing',
|
||||
'quote-pricing',
|
||||
'quote-negotiate',
|
||||
'quote-archive',
|
||||
'crm-relationship-coverage',
|
||||
'brand-content-toolkit',
|
||||
] as const;
|
||||
/*
|
||||
* 2026-08-24 用户定:**只装 E04 / I01**,A1–A7 报价链六个包从系统里卸载(源码留着,
|
||||
* 只是不再播种、不再安装;库里已装的版本已置 uninstalled)。要装回来把名字加回这里
|
||||
* 再跑 activities:install 即可 —— 它们的 dist 照旧随 activities:build 产出。
|
||||
* 退役名单:quote-intake · quote-alternative · quote-sourcing · quote-pricing ·
|
||||
* quote-negotiate · quote-archive
|
||||
*/
|
||||
const REAL_PACKAGES = ['crm-relationship-coverage', 'brand-content-toolkit'] as const;
|
||||
|
||||
/**
|
||||
* 活动包根目录。
|
||||
|
||||
@@ -344,8 +344,20 @@ const TRIGGER_REGISTRATION = triggerConfigSchema.parse({
|
||||
spec: { kind: 'registration' },
|
||||
});
|
||||
|
||||
/**
|
||||
* 2026-08-24 用户定:报价链 A1–A7 已从系统卸载,建在它们上面的「统一报价」流程包、
|
||||
* 两个 RFQ 触发器与两个询价邮箱**不再播种**(装不上的活动激活流程包会被闭合校验拦下)。
|
||||
* `MVP_PACKAGE` / 触发器常量原样留着 —— 它们是流程编译与触发装配那几组测试的夹具,
|
||||
* 也是日后装回报价链时的种子。要恢复播种把这一位拨回 true。
|
||||
*/
|
||||
const SEED_QUOTE_FLOW = false;
|
||||
|
||||
export async function seedMvpFlow(container: Container): Promise<void> {
|
||||
const log = (line: string) => process.stdout.write(line + '\n');
|
||||
if (!SEED_QUOTE_FLOW) {
|
||||
log('④ 流程包「统一报价」与 RFQ 触发器/邮箱:报价链已卸载(2026-08-24),不播种');
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* 保存即生效,且**内容没变就不再存新版**。
|
||||
|
||||
@@ -236,11 +236,27 @@ export function rowReadings(
|
||||
* 哪几列算必填由**台账声明**说了算(`fields[].required`)——业务知识写在声明里,
|
||||
* 基座只管照着数。缺 = 没有这个键、或值是 null / 空串(与 `coerceToColumn` 的空值口径一致)。
|
||||
*/
|
||||
/**
|
||||
* **摆给人看的行标识:主键去掉单标识那一段**(2026-08-25 用户:「只保留斜杠右边部分」)。
|
||||
*
|
||||
* 单据表的主键是 `单号 + 业务键`(如 round_ref + contact_code)。缺项红带、执行卡提交闸、
|
||||
* 拒回主键名单从前把两段都摆(`COV-20260825002227/CU-0142-01`)—— 可人正在**这一单里**
|
||||
* 看这张件,单号是他脚下的地面,不是区分行的信息;真正认得出行的是右边那一段。
|
||||
* `caseKey` 是声明里的单标识,照它去掉;去完一段都不剩(主键只有单号)就退回整段。
|
||||
*/
|
||||
export function rowLabel(id: Readonly<Record<string, unknown>>, caseKey: string | undefined): string {
|
||||
const parts = Object.entries(id)
|
||||
.filter(([k, v]) => k !== caseKey && v !== undefined && v !== null && String(v) !== '')
|
||||
.map(([, v]) => String(v));
|
||||
return parts.length > 0 ? parts.join('/') : Object.values(id).map(String).join('/');
|
||||
}
|
||||
|
||||
export function missingReadings(
|
||||
mine: Readonly<Record<string, readonly unknown[]>>,
|
||||
declared: ReadonlyArray<{
|
||||
domain: string;
|
||||
object: string;
|
||||
caseKey?: string | undefined;
|
||||
fields: ReadonlyArray<{
|
||||
name: string;
|
||||
label?: string | undefined;
|
||||
@@ -252,7 +268,7 @@ export function missingReadings(
|
||||
}>,
|
||||
): Record<string, string | number> {
|
||||
const out: Record<string, string | number> = {};
|
||||
for (const { domain, object, fields } of declared) {
|
||||
for (const { domain, object, caseKey, fields } of declared) {
|
||||
const rows = mine[`mine:${domain}.${object}`] ?? [];
|
||||
const keys = fields.filter((f) => f.primaryKey).map((f) => f.name);
|
||||
const blank = (one: Record<string, unknown>, c: string): boolean =>
|
||||
@@ -282,7 +298,7 @@ export function missingReadings(
|
||||
if (gaps.length === 0) continue;
|
||||
const id: Record<string, string> = {};
|
||||
for (const k of keys) if (!blank(one, k)) id[k] = String(one[k]);
|
||||
bad.push(`${Object.values(id).join('/')} 缺${gaps.map((f) => f.label ?? f.name).join('、')}`);
|
||||
bad.push(`${rowLabel(id, caseKey)} 缺${gaps.map((f) => f.label ?? f.name).join('、')}`);
|
||||
}
|
||||
out[`${domain}_${object}_${suffix}`] = bad.length;
|
||||
if (bad.length > 0) out[`${domain}_${object}_${suffix}_keys`] = bad.slice(0, 12).join(';');
|
||||
@@ -331,6 +347,65 @@ export function tallyReadings(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* **这一列有几个不同的值** —— `{ '域_对象_distinct_<列>': N }`,声明列标 `distinct: true` 才数。
|
||||
*
|
||||
* 为什么要有这一类(2026-08-24 柴浩实测:「新增了一行要覆盖的客户,后面推进直接无视了」):
|
||||
* 覆盖范围确认那张卡上人加的行落进了台账、focus 段里模型也看得见,可 S3 的收步判据
|
||||
* 只数「计划有没有」(`plan_rows > 0`)—— 计划盖住一家还是两家,没有一个基座数说得出来。
|
||||
* 模型锚在自己轨迹的「识别出 1 家」上把新行漏掉,引擎照样自动收步、放行一路绿灯。
|
||||
* 「每家候选客户都进了经营计划」要成为机检,就得有「计划里有几家客户」这个数;
|
||||
* 它必须由基座数:计划行数 ≠ 客户数(一家客户几位关键人就几行),而模型自报的数
|
||||
* 恰恰就是这次数漏的那一个。
|
||||
*
|
||||
* 只数**本单写下的行**(与 `_rows` 同一基准);空值不算一种 —— 「没写」不是一个取值。
|
||||
* 声明了的列**一律先铺 0**:未绑定的名字参与比较求值为假,零行也得是一个数。
|
||||
*/
|
||||
export function distinctReadings(
|
||||
mine: Readonly<Record<string, readonly unknown[]>>,
|
||||
declared: ReadonlyArray<{
|
||||
domain: string;
|
||||
object: string;
|
||||
fields: ReadonlyArray<{ name: string; distinct?: boolean | undefined }>;
|
||||
}>,
|
||||
): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const { domain, object, fields } of declared) {
|
||||
for (const field of fields) {
|
||||
if (field.distinct !== true) continue;
|
||||
const rows = mine[`mine:${domain}.${object}`] ?? [];
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>;
|
||||
const value = String(one[field.name] ?? '').trim();
|
||||
if (value !== '') seen.add(value);
|
||||
}
|
||||
out[`${domain}_${object}_distinct_${field.name}`] = seen.size;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* **本轮碰过的行,以「落库后的样子」摆出来** —— `touched:域.对象`。
|
||||
*
|
||||
* 起因(2026-08-24 柴浩实测):台账更新确认屏的落库预览绑的是 `mine:`(本单草稿),
|
||||
* 而草稿行只有模型这轮写过的那几列 —— Itelma 档案里既有的客户类型/行业/区域、
|
||||
* Ekaterina 的职务/角色全不在里面:只读面一片空白,编辑框也空(像是数据丢了)。
|
||||
* 签字的人要看的是**签下去之后这一行长什么样** = 正式现值 + 本单草稿叠加;
|
||||
* 但整表不行(没碰过的客户不该出现在「将写入」里)—— 所以按草稿键过滤:
|
||||
* 本轮碰过哪几行,就摆那几行的叠加后全貌。
|
||||
*/
|
||||
export function touchedRows(
|
||||
merged: ReadonlyArray<unknown>,
|
||||
draftKeys: ReadonlyArray<Readonly<Record<string, unknown>>>,
|
||||
): unknown[] {
|
||||
const rows = merged.filter((r): r is Record<string, unknown> => typeof r === 'object' && r !== null);
|
||||
return draftKeys
|
||||
.map((key) => rows.find((row) => Object.entries(key).every(([k, v]) => String(row[k] ?? '') === String(v ?? ''))))
|
||||
.filter((r) => r !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* **这一单到底调没调那个工具** —— `{ 'tool_crm_history_price_read_calls': N }`。
|
||||
*
|
||||
@@ -782,12 +857,25 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
* (实测:一单 RFQ-SSE-01 的交代里,说的全是 RFQ-2026-1007 / 1016 的分类结果)。
|
||||
*/
|
||||
const mine: Record<string, unknown[]> = {};
|
||||
const draftKeysOf: Record<string, Array<Record<string, unknown>>> = {};
|
||||
for (const draft of await deps.ledger.taskDrafts(taskId)) {
|
||||
(mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch });
|
||||
(draftKeysOf[`${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key });
|
||||
for (const [field, value] of Object.entries({ ...draft.key, ...draft.patch })) {
|
||||
if (value !== null && value !== undefined) scope[field] = value;
|
||||
}
|
||||
}
|
||||
/*
|
||||
* `touched:域.对象` —— 本轮碰过的行的**叠加后全貌**(正式现值 + 草稿),
|
||||
* 给落库预览这类「签下去之后长什么样」的画面用;`域.对象` 整表里本就有
|
||||
* 叠加行,这里只按草稿键挑出本轮的那几行。不进提示词(restPart 滤掉 ——
|
||||
* 内容与 mine/整表重叠,白占截断窗)。
|
||||
*/
|
||||
const touched: Record<string, unknown[]> = {};
|
||||
for (const [address, draftKeys] of Object.entries(draftKeysOf)) {
|
||||
const merged = scope[address];
|
||||
if (Array.isArray(merged)) touched[`touched:${address}`] = touchedRows(merged, draftKeys);
|
||||
}
|
||||
|
||||
/*
|
||||
* **本单每张表写下了几行** —— 判据要能自己数,不能只信活动报的数。
|
||||
@@ -826,8 +914,10 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
...rowKeyList(mine, declared),
|
||||
...missingReadings(mine, declared),
|
||||
...tallyReadings(mine, declared),
|
||||
...distinctReadings(mine, declared),
|
||||
...toolCallReadings(trail, manifest.declarations.tools),
|
||||
...mine,
|
||||
...touched,
|
||||
};
|
||||
return enriched;
|
||||
},
|
||||
@@ -845,7 +935,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
object: d.object,
|
||||
label: d.label ?? `${d.domain}.${d.object}`,
|
||||
keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
|
||||
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type })),
|
||||
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type, refersTo: f.refersTo })),
|
||||
}));
|
||||
},
|
||||
/**
|
||||
@@ -866,6 +956,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
object: string;
|
||||
key: Record<string, string | number>;
|
||||
patch: Record<string, unknown>;
|
||||
/** 只验不写 —— 多行 write 先整批过门再落笔(见 loop 那一侧的两遍) */
|
||||
dryRun?: boolean;
|
||||
}) {
|
||||
const allowed = await taskData.writable(input.taskId);
|
||||
const target = allowed.find((a) => a.domain === input.domain && a.object === input.object);
|
||||
@@ -882,6 +974,39 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
throw new Error(`台账无此列:${strayed.join('、')}(${target.label} 可写列:${[...known].join('、')})`);
|
||||
}
|
||||
|
||||
/*
|
||||
* **引用列查引用**(2026-08-24,措辞治不了的结构治):声明了 `refersTo` 的列,
|
||||
* 值必须在被引用的表里真实存在(正式 + 本单草稿叠加)。起因:经营计划的
|
||||
* contact_code 本该指向真实联系人,模型三次给缺口节点**自造顺延引用码 +
|
||||
* 占位人名** —— 反例描述、说明书纪律、禁令逐层写全都拦不住;
|
||||
* 现在写入那一刻当场拒,拒回指路(墙要配路)。
|
||||
*/
|
||||
for (const field of target.fields) {
|
||||
if (field.refersTo === undefined) continue;
|
||||
const raw = input.key[field.name] ?? input.patch[field.name];
|
||||
const value = raw === undefined || raw === null ? '' : String(raw).trim();
|
||||
if (value === '') continue;
|
||||
const [rd, ro, rc] = field.refersTo.split('.');
|
||||
if (rd === undefined || ro === undefined || rc === undefined) continue;
|
||||
const referenced = await deps.ledger.taskView(input.taskId, {
|
||||
address: { domain: rd, object: ro },
|
||||
where: {},
|
||||
orderBy: [],
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
});
|
||||
const exists = referenced.rows.some((r) => String((r as Record<string, unknown>)[rc] ?? '') === value);
|
||||
if (!exists) {
|
||||
const refLabel = allowed.find((a) => a.domain === rd && a.object === ro)?.label ?? `${rd}.${ro}`;
|
||||
throw new Error(
|
||||
`${field.label ?? field.name}「${value}」在${refLabel}里不存在 —— ` +
|
||||
`${target.label} 的这一列只许引用台账里真实存在的值,不许自造编号或占位引用。` +
|
||||
'要指的那个人/那一行还不存在的话,先把它真实建出来(要经营的人不存在就发数据录入卡收人),' +
|
||||
'或者把这一笔挂到已存在的那一行名下',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* **按列型归型,当场归不了当场拒** —— 不能留到放行那一刻。
|
||||
*
|
||||
@@ -921,6 +1046,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
before === undefined ||
|
||||
Object.entries(patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v));
|
||||
|
||||
if (input.dryRun === true) return { changed };
|
||||
await deps.ledger.draftWrite({
|
||||
taskId: input.taskId,
|
||||
address: { domain: input.domain, object: input.object },
|
||||
@@ -1933,6 +2059,16 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
}
|
||||
const domain = input.address.slice(0, at);
|
||||
const object = input.address.slice(at + 1);
|
||||
/*
|
||||
* **一笔 write 整批先验后写**(2026-08-24 柴浩实测逼出):从前逐行边验边写,
|
||||
* 第 2 行撞墙(refersTo 查无此码)时第 1 行已经落了草稿,而轨迹只记「行动被拒」——
|
||||
* 模型与人都以为一行没写,回执骨架从此少一行(计划 7 / 回执 6),
|
||||
* 到放行门撞成两堵墙(「每条计划都有回执」vs「每家候选都进了计划」)五拍熔断。
|
||||
* 被拒就得是一行都没落:先把每一行过一遍门,全过了再落。
|
||||
*/
|
||||
for (const row of input.rows) {
|
||||
await taskData.draftWrite({ taskId: input.taskId, domain, object, key: row.key, patch: row.patch, dryRun: true });
|
||||
}
|
||||
for (const row of input.rows) {
|
||||
await taskData.draftWrite({ taskId: input.taskId, domain, object, key: row.key, patch: row.patch });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 跨进程租约 —— **两个后端连同一个库时,一张单只许一个在推**。
|
||||
*
|
||||
* 2026-08-24 柴浩那一单就死在这上头:本机与局域网另一台各跑一个后端,两边的推进器
|
||||
* 同时推同一张单,两拍并发写一份 `loop_state`,后写的整份覆盖先写的 ——
|
||||
* 「给 S2 发过卡」那个事实(worldTouched)被抹掉、S1 被收了两遍;
|
||||
* 随后 finish 那道门说「从没发过卡」、重发那道门说「人已答过」,两堵墙对着撞,五拍熔断。
|
||||
* 进程内那把 `taskMutex` 对这种局面无能为力(它自己的注释里就写着)。
|
||||
*
|
||||
* 这里测的是**发给库的那句话对不对**:判与占必须在同一条 SQL 里
|
||||
* (分两句写就是经典竞态),且带过期时间(进程崩了不锁死)。
|
||||
* SQL 的并发语义另以两条真连接对着真库跑过 20 轮 —— 每轮恰好一个赢。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { claimTask, releaseTask, LEASE_MS } from '../task-claim.ts';
|
||||
|
||||
/** 记下发出去的 SQL —— drizzle 的 sql`` 片段拼回可读字符串 */
|
||||
function fakeDb(rowCount: number) {
|
||||
const sent: string[] = [];
|
||||
return {
|
||||
sent,
|
||||
execute: async (q: unknown) => {
|
||||
const chunks = (q as { queryChunks?: unknown[] }).queryChunks ?? [];
|
||||
sent.push(
|
||||
chunks
|
||||
.map((c) => {
|
||||
const one = c as { value?: unknown };
|
||||
return Array.isArray(one.value) ? one.value.join('') : String(one.value ?? '?');
|
||||
})
|
||||
.join(''),
|
||||
);
|
||||
return { rows: Array.from({ length: rowCount }, () => ({ id: 't1' })) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 传给被测函数那一下再转 —— 测试桩不必长得跟真 Db 一样 */
|
||||
const asDb = (stub: unknown): Parameters<typeof claimTask>[0] => stub as Parameters<typeof claimTask>[0];
|
||||
|
||||
describe('claimTask —— 抢这一张单的推进权', () => {
|
||||
it('抢到了(UPDATE 命中一行)返回 true', async () => {
|
||||
expect(await claimTask(asDb(fakeDb(1)), 't1')).toBe(true);
|
||||
});
|
||||
|
||||
it('**没抢到返回 false** —— 别的进程正推着,这一轮就跳过', async () => {
|
||||
expect(await claimTask(asDb(fakeDb(0)), 't1')).toBe(false);
|
||||
});
|
||||
|
||||
it('判与占在同一条 SQL 里:where 带「没人占 或 已过期」,并 returning', async () => {
|
||||
const db = fakeDb(1);
|
||||
await claimTask(asDb(db), 't1');
|
||||
const sql = db.sent[0] ?? '';
|
||||
expect(sql).toContain('update sys.task');
|
||||
expect(sql).toContain('advancing_until is null or advancing_until < now()');
|
||||
expect(sql).toContain('returning id');
|
||||
});
|
||||
|
||||
it('**租约带过期时间** —— 进程崩了不把这张单锁死', async () => {
|
||||
const db = fakeDb(1);
|
||||
await claimTask(asDb(db), 't1');
|
||||
expect(db.sent[0] ?? '').toContain('milliseconds');
|
||||
// 默认租约比最慢的一轮宽得多,又不至于锁上半天
|
||||
expect(LEASE_MS).toBeGreaterThanOrEqual(60_000);
|
||||
expect(LEASE_MS).toBeLessThanOrEqual(15 * 60_000);
|
||||
});
|
||||
|
||||
it('还锁就是清空那一列', async () => {
|
||||
const db = fakeDb(1);
|
||||
await releaseTask(asDb(db), 't1');
|
||||
expect(db.sent[0] ?? '').toContain('advancing_until = null');
|
||||
});
|
||||
|
||||
it('**还锁失败不抛** —— 这一拍的成果不该被还锁连累掉', async () => {
|
||||
const broken = { execute: async () => Promise.reject(new Error('库连不上')) };
|
||||
await expect(releaseTask(asDb(broken), 't1')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import type { Delivery } from '@modules/event';
|
||||
import { claimPendingDeliveries } from '@modules/event';
|
||||
import type { Container } from '../container.ts';
|
||||
import { taskMutex } from './task-lock.ts';
|
||||
import { claimTask, releaseTask } from './task-claim.ts';
|
||||
|
||||
/** 消费者:按目标类型分派 */
|
||||
export type Consumer = (delivery: Delivery, container: Container) => Promise<void>;
|
||||
@@ -31,6 +32,14 @@ export async function dispatchOnce(deps: DispatcherDeps): Promise<number> {
|
||||
for (const delivery of batch) {
|
||||
// 同任务按序:取到锁才处理,取不到说明同目标的另一条正在处理,留待下一轮
|
||||
await taskMutex.tryRun(`${delivery.target.kind}:${String(delivery.target.id)}`, async () => {
|
||||
/*
|
||||
* **投递也会推一拍**(consumer 里 `task.deliver` 末尾就是 `tick`)——
|
||||
* 所以它同样要抢库里那份租约:上面那把锁只在本进程内算数,而两个后端连同一个库时
|
||||
* (2026-08-24 实测)投递与另一台的推进器会并发写同一份 `loop_state`。
|
||||
* 抢不到就先不处理,这一条留给下一轮 —— 它还在 pending,不会丢。
|
||||
*/
|
||||
const taskId = delivery.target.kind === 'task' ? String(delivery.target.id) : undefined;
|
||||
if (taskId !== undefined && !(await claimTask(container.db, taskId))) return;
|
||||
try {
|
||||
const consumer = deps.consumers.get(delivery.target.kind);
|
||||
if (consumer !== undefined) {
|
||||
@@ -53,6 +62,9 @@ export async function dispatchOnce(deps: DispatcherDeps): Promise<number> {
|
||||
handled += 1;
|
||||
} catch (error) {
|
||||
await container.event.reportFailure(delivery.id, error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
// 推完就还租约 —— 不还也不会锁死(会过期),还了下一条投递立刻接得上
|
||||
if (taskId !== undefined) await releaseTask(container.db, taskId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { and, eq, sql } from 'drizzle-orm';
|
||||
import { taskTable } from '@modules/task';
|
||||
import type { Container } from '../container.ts';
|
||||
import { taskMutex } from './task-lock.ts';
|
||||
import { claimTask, releaseTask } from './task-claim.ts';
|
||||
|
||||
export type AdvancerOptions = {
|
||||
readonly container: Container;
|
||||
@@ -51,6 +52,22 @@ export async function advanceOnce(options: AdvancerOptions): Promise<number> {
|
||||
* 抢不到就跳过 —— 那边正推着,这边再推就是两条线同时改一份运行时状态。
|
||||
*/
|
||||
const ran = await taskMutex.tryRun(`task:${row.id}`, async () => {
|
||||
/*
|
||||
* **再抢一次库里的租约** —— 上面那把锁只在本进程内算数。
|
||||
*
|
||||
* 两个后端连同一个库时(本机 + 局域网另一台,2026-08-24 实测),两边的推进器
|
||||
* 各扫各的、同时推同一张单:两拍并发写一份 `loop_state`,后写的整份覆盖先写的,
|
||||
* 「发过卡」这个事实就此消失,门与门对着撞,五拍熔断。
|
||||
* 抢不到说明别的进程正推着 —— 跳过,它推完自然轮得到。
|
||||
*/
|
||||
if (!(await claimTask(container.db, row.id))) return;
|
||||
try {
|
||||
return await advanceOne();
|
||||
} finally {
|
||||
await releaseTask(container.db, row.id);
|
||||
}
|
||||
|
||||
async function advanceOne(): Promise<void> {
|
||||
/** 这一轮之前它是什么样 —— 变了才广播,不然每两秒给在线浏览器发一次噪声 */
|
||||
const before = await container.task.get(row.id as never).catch(() => undefined);
|
||||
for (let tick = 0; tick < (options.maxTicks ?? 3); tick += 1) {
|
||||
@@ -95,7 +112,7 @@ export async function advanceOnce(options: AdvancerOptions): Promise<number> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
void ran;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 任务推进的**跨进程租约** —— 一张单同一时刻只许一个后端在推。
|
||||
*
|
||||
* `task-lock.ts` 那把是**进程内**的互斥,它开头就写着:「MVP 单进程,进程内互斥就够;
|
||||
* 多进程部署时再叠一层」。2026-08-24 那一天就撞上了:本机与局域网另一台机器上各跑着
|
||||
* 一个后端,连的是同一个库,两边的推进器各自 2 秒扫一次「running 的单」,
|
||||
* **同时推同一张**。两拍并发跑在同一份 `loop_state` 上,后写的整份覆盖先写的 ——
|
||||
* 「给 S2 发过卡」这个事实被抹掉、S1 被收了两遍,随后 finish 与重发两道门对着撞,
|
||||
* 连拒五拍熔断(柴浩那一单)。
|
||||
*
|
||||
* 为什么不用 `pg_try_advisory_lock`:它是**会话级**的,而我们经连接池取连接 ——
|
||||
* 加锁与解锁多半落在不同连接上。`pg_try_advisory_xact_lock` 是事务级的,而推一拍
|
||||
* 要调模型、要写好几张表,不该整个包在一个事务里。
|
||||
*
|
||||
* 所以用**库里的一列**当租约:抢到才推,推完就还;**带过期时间** —— 进程崩了不锁死,
|
||||
* 租约一过别的进程照样接得上。这是任务队列的老办法,跨进程、跨机器都算数。
|
||||
*/
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
|
||||
/**
|
||||
* 租约多久过期。推一拍最慢是一次模型调用(实测 1.5-5 秒,关思考之前有过 56 秒),
|
||||
* 而推进器一轮最多连推 3 拍 —— 给到 5 分钟:比最慢的一轮宽得多,又不至于
|
||||
* 让崩掉的进程把一张单锁上半天。
|
||||
*/
|
||||
export const LEASE_MS = 5 * 60 * 1000;
|
||||
|
||||
function rowsOf(result: unknown): unknown[] {
|
||||
if (Array.isArray(result)) return result;
|
||||
const rows = (result as { rows?: unknown }).rows;
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 抢这一张单的推进权。**抢到返回 true**,没抢到(别的进程正推着)返回 false。
|
||||
*
|
||||
* 一条 SQL 里判 + 占:`where advancing_until is null or advancing_until < now()` ——
|
||||
* 两个进程同时来,PG 的行锁保证只有一个的 UPDATE 命中,另一个 RETURNING 空。
|
||||
* 判和占分两句写是经典的竞态,这里不给它机会。
|
||||
*/
|
||||
export async function claimTask(db: Db, taskId: string, leaseMs: number = LEASE_MS): Promise<boolean> {
|
||||
const result = await db.execute(sql`
|
||||
update sys.task
|
||||
set advancing_until = now() + (${leaseMs} || ' milliseconds')::interval
|
||||
where id = ${taskId}
|
||||
and (advancing_until is null or advancing_until < now())
|
||||
returning id`);
|
||||
return rowsOf(result).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推完就还 —— 不还也不会锁死(租约会过期),但还了下一轮就能接着推,
|
||||
* 不必干等到过期。**失败不抛**:还锁失败不该把这一拍的成果连累掉。
|
||||
*/
|
||||
export async function releaseTask(db: Db, taskId: string): Promise<void> {
|
||||
await db
|
||||
.execute(sql`update sys.task set advancing_until = null where id = ${taskId}`)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
@@ -107,6 +107,15 @@ describe('判据谓词的名字必须绑得上', () => {
|
||||
expect(codes(manifest({ predicate: 'disposition = "照原价报"', fields: ['disposition'] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('`distinct` 声明出的名字也绑得上 —— 覆盖闭合判据的落点(2026-08-24)', () => {
|
||||
const bound = manifest({ predicate: 'd_record_distinct_customer_code = d_record_rows', fields: ['customer_code'] });
|
||||
(bound.declarations.ledger[0]!.fields[0] as { distinct?: boolean }).distinct = true;
|
||||
expect(codes(bound)).toEqual([]);
|
||||
// 没标 distinct,这个名字就不存在 —— 拒装,别让判据指着一个永远为假的读数
|
||||
const unbound = manifest({ predicate: 'd_record_distinct_customer_code = d_record_rows', fields: ['customer_code'] });
|
||||
expect(codes(unbound)).toEqual(['acceptance-predicate-unbound']);
|
||||
});
|
||||
|
||||
it('L3 不机检 —— 没谓词也不报', () => {
|
||||
expect(codes(manifest({ level: 'L3', predicate: undefined }))).toEqual([]);
|
||||
});
|
||||
@@ -295,3 +304,47 @@ describe('工具调用读数绑得上', () => {
|
||||
expect(withTool('tool_erp_write_calls > 0').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── refersTo 的引用目标必须声明过(2026-08-24)──────────────────────── */
|
||||
import { referenceFindings } from '../internal/domain/static-audit.ts';
|
||||
|
||||
const REF_LEDGER = (refersTo: string) => ({
|
||||
declarations: {
|
||||
ledger: [
|
||||
{
|
||||
domain: 'crm_coverage',
|
||||
object: 'plan',
|
||||
kind: 'record' as const,
|
||||
caseKey: 'round_ref',
|
||||
label: '计划',
|
||||
fields: [
|
||||
{ name: 'round_ref', type: 'text' as const, primaryKey: true, masked: false },
|
||||
{ name: 'contact_code', type: 'text' as const, primaryKey: true, masked: false, refersTo },
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: 'customer',
|
||||
object: 'contact',
|
||||
kind: 'master' as const,
|
||||
label: '联系人',
|
||||
fields: [{ name: 'contact_code', type: 'text' as const, primaryKey: true, masked: false }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
describe('refersTo 目标要在本活动的声明里 —— 写错的地址会变成永远查无此值的死墙', () => {
|
||||
it('指向声明过的对象与列 —— 放行', () => {
|
||||
expect(referenceFindings(REF_LEDGER('customer.contact.contact_code'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('表名写错 —— 拒装', () => {
|
||||
const found = referenceFindings(REF_LEDGER('customer.contacts.contact_code'));
|
||||
expect(found.map((f) => f.code)).toEqual(['reference-target-unbound']);
|
||||
});
|
||||
|
||||
it('列名写错 —— 拒装', () => {
|
||||
const found = referenceFindings(REF_LEDGER('customer.contact.code'));
|
||||
expect(found.map((f) => f.code)).toEqual(['reference-target-unbound']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,6 +290,12 @@ function boundNames(manifest: AcceptanceAuditInput): ReadonlySet<string> {
|
||||
const slug = value.toLowerCase().replace(/[^a-z0-9_]/g, '');
|
||||
if (slug !== '') out.add(`${table.domain}_${table.object}_${field.name}_${slug}`);
|
||||
}
|
||||
/*
|
||||
* 不同值计数读数 `域_对象_distinct_<列>`(2026-08-24 起)——
|
||||
* 「每家候选客户都进了计划」这类**覆盖闭合**判据的落点:
|
||||
* 计划行数 ≠ 客户数,闭合要成为机检就得有「有几家不同客户」这个基座数。
|
||||
*/
|
||||
if (field.distinct === true) out.add(`${table.domain}_${table.object}_distinct_${field.name}`);
|
||||
}
|
||||
}
|
||||
/*
|
||||
@@ -413,6 +419,7 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks
|
||||
|
||||
out.push(...danglingListeners(manifest));
|
||||
out.push(...deliverableAddressFindings(manifest));
|
||||
out.push(...referenceFindings(manifest));
|
||||
out.push(...ledgerKindFindings(manifest));
|
||||
out.push(...acceptanceLevelFindings(manifest));
|
||||
out.push(...loopFindings(manifest));
|
||||
@@ -420,6 +427,36 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* `refersTo` 的引用目标必须是**本活动声明过的对象与列**(2026-08-24 立)。
|
||||
*
|
||||
* 引用检查在写入那一刻执行 —— 目标地址写错(表名手滑、列名不存在)的话,
|
||||
* 那道墙就成了「永远查无此值」的死墙,活动装上就跑不动。与判据谓词绑定同一个道理:
|
||||
* 这种错只有跑到写入那一刻才炸,必须拦在安装期。
|
||||
*/
|
||||
export function referenceFindings(manifest: LedgerAuditInput): StaticAuditFinding[] {
|
||||
const out: StaticAuditFinding[] = [];
|
||||
for (const table of manifest.declarations.ledger) {
|
||||
for (const field of table.fields) {
|
||||
if (field.refersTo === undefined) continue;
|
||||
const [rd, ro, rc] = field.refersTo.split('.');
|
||||
const referenced = manifest.declarations.ledger.find((l) => l.domain === rd && l.object === ro);
|
||||
const column = referenced?.fields.find((f) => f.name === rc);
|
||||
if (referenced !== undefined && column !== undefined) continue;
|
||||
out.push({
|
||||
code: 'reference-target-unbound',
|
||||
severity: 'error',
|
||||
message:
|
||||
`${table.domain}.${table.object}.${field.name} 的 refersTo 指向 \`${field.refersTo}\`,` +
|
||||
`而本活动的台账声明里${referenced === undefined ? '没有这张表' : '这张表没有这一列'} —— ` +
|
||||
'引用检查会变成「永远查无此值」的死墙,装上就跑不动。改成声明里真实存在的 域.对象.列。',
|
||||
at: `declarations.ledger ${table.domain}.${table.object}.${field.name}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function auditPassed(findings: readonly StaticAuditFinding[]): boolean {
|
||||
return !findings.some((f) => f.severity === 'error');
|
||||
}
|
||||
|
||||
@@ -138,6 +138,39 @@ describe('同一轮的交代与卡并成一条', () => {
|
||||
expect(out).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('**隔着一张决策卡(另一路合并、不进对话项)也不并** —— 上一步的话不许认领这一步的卡(2026-08-25 实测)', () => {
|
||||
// I01 实测形:说「请审核」→ 发决策卡 → 人答 → 收口 → 发验收卡;验收卡曾被挂到「请审核」上,排到决策卡前面
|
||||
const out = projectChat([
|
||||
say('n', '两份内容初稿已草拟完成,请审核'),
|
||||
entry('card-sent', { id: 'd', title: '内容初稿已草拟,共 2 份,请审核。', detail: { k: 'decision', kind: 'chat-card', id: 'dc-1' } }),
|
||||
entry('card-reply', { id: 'r', title: '内容初稿已草拟,共 2 份,请审核。', detail: { value: 'approve' } }),
|
||||
entry('observation', { id: 'o', title: '事件到达(human):dc-1', detail: { kind: 'human' } }),
|
||||
card('c', '收口验收', 'wo-7'),
|
||||
]);
|
||||
const ai = out.filter((m) => m.k === 'ai');
|
||||
expect(ai).toHaveLength(2);
|
||||
expect(ai[0]?.k === 'ai' ? ai[0].ref : 'x').toBeUndefined(); // 「请审核」那句话不带工单
|
||||
expect(ai[1]?.k === 'ai' ? ai[1].ref : undefined).toBe('wo-7'); // 验收卡自成一条,排在后面
|
||||
});
|
||||
|
||||
it('隔着人的答复 / 内部观察也不并 —— 「刚说过」按记忆序列判,不按对话项判', () => {
|
||||
const out = projectChat([
|
||||
say('n', '我去查一下'),
|
||||
entry('observation', { id: 'o', title: '这一拍在往下办事', detail: { internal: true } }),
|
||||
card('c', '受理需求确认', 'wo-8'),
|
||||
]);
|
||||
expect(out.filter((m) => m.k === 'ai')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('装配戳夹在中间不算隔开 —— 它是信封件,不是一拍', () => {
|
||||
const out = projectChat([
|
||||
say('n', '请核对'),
|
||||
entry('thought', { id: 't', title: '装配技能:x@1.0.0' }),
|
||||
card('c', '受理需求确认', 'wo-9'),
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('收尾那条结论不认领卡 —— 它是这一单的句号,不是派工', () => {
|
||||
// 用**没顺利收尾**的那种:released 起同日不再出声(见上一组)
|
||||
const out = projectChat([entry('terminal', { id: 't', title: '终态:blocked' }), card('c', '卡', 'wo-5')]);
|
||||
|
||||
@@ -70,13 +70,25 @@ export function projectChat(entries: readonly NarrativeEntry[]): ChatMessage[] {
|
||||
* 验收卡又自成一条 —— 第一轮并对了、第二轮没并,同一屏两种样子。
|
||||
*/
|
||||
let lastIsTerminal = false;
|
||||
/**
|
||||
* **「刚说过话」要按记忆序列判,不按对话项判**(2026-08-25 柴浩:「上一步的卡片跑到
|
||||
* 这一步的下面来了」)。从前只看 `out` 的末项是不是还没挂卡的 AI 发言 —— 可决策卡 /
|
||||
* 录入卡的 card-sent、人的答复、内部观察都**不进 out**,在它眼里那句话一直「空着」:
|
||||
* 实测 I01 一单,「两份初稿…请审核」那句话后面发的是决策卡(另一路合并),41 秒后
|
||||
* 人答完、模型收口、**验收卡被挂到了上一步那句话上**,以它的时刻排到了决策卡前面。
|
||||
* 紧邻 = 这张卡与那句话之间没有任何别的记忆条目(装配戳这类信封件除外)。
|
||||
*/
|
||||
let sayable = false;
|
||||
for (const e of entries) {
|
||||
const id = cardIdSchema.parse(e.id);
|
||||
const envelope = e.kind === 'thought' && e.title.startsWith('装配技能');
|
||||
if (e.kind === 'narration') {
|
||||
out.push({ k: 'ai', id, at: e.at, text: e.title, tag: tagOf(e.detail) });
|
||||
lastIsTerminal = false;
|
||||
sayable = true;
|
||||
continue;
|
||||
}
|
||||
if (!envelope && e.kind !== 'card-sent') sayable = false;
|
||||
/*
|
||||
* 触发事件与系统观察在对话区**长一个样**(都是灰头像的系统气泡)——
|
||||
* 分家分在归侧与日志上,不在对话区:人在对话流里要的是「发生了什么」,
|
||||
@@ -126,8 +138,11 @@ export function projectChat(entries: readonly NarrativeEntry[]): ChatMessage[] {
|
||||
}
|
||||
if (e.kind === 'card-sent') {
|
||||
const ref = workOrderRef(e.detail);
|
||||
// 跨人卡进对方的消息要素,不在本任务对话流上露面
|
||||
if (ref === undefined) continue;
|
||||
// 跨人卡 / 决策卡 / 录入卡进别的路,不在这里露面 —— 但它们隔在话与工单之间,那句话就不再「刚说过」
|
||||
if (ref === undefined) {
|
||||
sayable = false;
|
||||
continue;
|
||||
}
|
||||
// 验收件请人裁决 = 判据;执行件派工 = 指导
|
||||
const tag = e.title.includes('验收') ? ('criterion' as const) : ('guidance' as const);
|
||||
/*
|
||||
@@ -141,10 +156,12 @@ export function projectChat(entries: readonly NarrativeEntry[]): ChatMessage[] {
|
||||
* 就把卡挂到那句话上,不另起一条。没说话就自己成一条 —— 卡不能不出现。
|
||||
*/
|
||||
const last = out[out.length - 1];
|
||||
if (last?.k === 'ai' && last.ref === undefined && !lastIsTerminal) {
|
||||
if (sayable && last?.k === 'ai' && last.ref === undefined && !lastIsTerminal) {
|
||||
out[out.length - 1] = { ...last, tag, ref: workOrderIdSchema.parse(ref) };
|
||||
sayable = false; // 一句话只领一张卡
|
||||
continue;
|
||||
}
|
||||
sayable = false;
|
||||
out.push({ k: 'ai', id, at: e.at, text: e.title, tag, ref: workOrderIdSchema.parse(ref) });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ export function renderRows(html: string, scope: Readonly<Record<string, unknown>
|
||||
* `data-file="地址/主键值"`:点它就是点开那一份文件(外层接住,见 ViewContainer)。
|
||||
* 模版这一侧照旧只做取值,「点了去哪」不在这份 HTML 里。
|
||||
*/
|
||||
const address = path.replace(/^mine:/, '');
|
||||
const address = path.replace(/^(?:mine|touched):/, '');
|
||||
const body = rows
|
||||
.map((row, index) => {
|
||||
const one = (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>;
|
||||
|
||||
@@ -59,7 +59,8 @@ export function createInteractionService(deps: InteractionServiceDeps): Interact
|
||||
by,
|
||||
};
|
||||
await deps.store.appendMessage(taskId, message);
|
||||
await deps.memory.append({ taskId, kind: 'human-input', title: text, detail: { by } });
|
||||
// 带上消息 id —— 对话区按记忆序号排序时要靠它认出这条话的落点(chat-order.ts)
|
||||
await deps.memory.append({ taskId, kind: 'human-input', title: text, detail: { by, id: message.id } });
|
||||
await deps.events.publishHumanEvent({ taskId, cardId: message.id, payload: { kind: 'free-input', text } });
|
||||
broadcast({ kind: 'chat', taskId, message });
|
||||
return message;
|
||||
|
||||
@@ -41,4 +41,44 @@ describe('parseJsonLoose:模型出参的宽容提取', () => {
|
||||
it('实在不是 JSON:undefined,不抛', () => {
|
||||
expect(parseJsonLoose('这不是 JSON')).toBeUndefined();
|
||||
});
|
||||
|
||||
/*
|
||||
* 2026-08-24 实测形(E04 收口拍,全流程括号最深的输出):模型在 profile 行上
|
||||
* **少吐了一个 `}`** —— 行对象没关上,后面关 rows 的 `]` 被 drop 档当噪声丢掉,
|
||||
* 整段错位、到尾配不平,两连败退化成兜底卡。close 档把欠的那个补上。
|
||||
*/
|
||||
it('**少吐一个闭括号:自动补上,整拍不作废**(实测形状,E04 收口拍)', () => {
|
||||
// 第一行 patch 后少了关「行对象」的 } —— ]}]},{ 处 drop 档会把 ] 丢掉
|
||||
const slip =
|
||||
'{"say":"收口","actions":[' +
|
||||
'{"kind":"write","address":"customer.profile","rows":[{"key":{"customer_code":"CU-0142"},' +
|
||||
'"patch":{"decision_chain":{"nodes":[{"node":"bt-01","covered":true}]}}]},' +
|
||||
'{"kind":"write","address":"crm_coverage.candidate","rows":[{"key":{"customer_code":"CU-0142"},"patch":{"status":"covered"}}]}]}';
|
||||
const got = parseJsonLoose(slip) as { actions: Array<{ kind: string; address: string }> };
|
||||
expect(got.actions.map((a) => a.kind)).toEqual(['write', 'write']);
|
||||
expect(got.actions[1]?.address).toBe('crm_coverage.candidate');
|
||||
});
|
||||
|
||||
it('输出被截断(结尾整段闭括号没吐):欠的全补上,能救就救', () => {
|
||||
const cut = '{"say":"写一半断了","actions":[{"kind":"write","rows":[{"patch":{"a":1';
|
||||
const got = parseJsonLoose(cut) as { actions: Array<{ kind: string }> };
|
||||
expect(got.actions[0]?.kind).toBe('write');
|
||||
});
|
||||
|
||||
it('**少括号的第二形:行对象没关、后面直接跟下一行** —— 对象里逗号后冒出 { 就是上一个没关(实测形状)', () => {
|
||||
// rows 第一行的 } 少了,紧接着 ,{"key"… —— 括号到尾能配平,strict 与前两档全看不出
|
||||
const slip =
|
||||
'{"say":"收口","actions":[{"kind":"write","address":"customer.profile","rows":[' +
|
||||
'{"key":{"customer_code":"CU-0142"},"patch":{"decision_chain":{"nodes":[{"node":"bt-01"}]}},' +
|
||||
'{"key":{"customer_code":"CU-E2E-ADD"},"patch":{"coverage_score":0}}]}]}';
|
||||
const got = parseJsonLoose(slip) as { actions: Array<{ rows: Array<{ key: { customer_code: string } }> }> };
|
||||
expect(got.actions[0]?.rows.map((r) => r.key.customer_code)).toEqual(['CU-0142', 'CU-E2E-ADD']);
|
||||
});
|
||||
|
||||
it('两档顺序不能反:多括号那形仍走 drop 档,行动串一个不丢', () => {
|
||||
// 与上面「多吐一个闭括号」同形 —— close 档若先跑,会提前关掉 actions 把 finish 截掉
|
||||
const slip = '{"say":"x","actions":[{"kind":"write","rows":[{"patch":{"t":"A"}}]}},{"kind":"finish","itemId":"S1"}]}';
|
||||
const got = parseJsonLoose(slip) as { actions: Array<{ kind: string }> };
|
||||
expect(got.actions.map((a) => a.kind)).toEqual(['write', 'finish']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,7 +95,12 @@ export function parseJsonLoose(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(candidate);
|
||||
} catch {
|
||||
return firstBalanced(candidate);
|
||||
/*
|
||||
* 两档修复**有序**:先按「多余闭括号当噪声丢」(2026-08-02 那形),
|
||||
* 不行再按「缺失闭括号自动补」(2026-08-24 那形)—— 顺序不能反:
|
||||
* 补括号那一档对「多括号」的输出会提前把外层对象关掉,行动串被截半。
|
||||
*/
|
||||
return firstBalanced(candidate, 'drop') ?? firstBalanced(candidate, 'close');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,20 +122,39 @@ export function parseJsonLoose(text: string): unknown {
|
||||
* 后面多出来的照旧当噪声 —— 多写的那一份本就是下一拍的事。
|
||||
* 字符串与转义照认,否则值里带 `}` 的会把栈带偏。
|
||||
*/
|
||||
function firstBalanced(text: string): unknown {
|
||||
/*
|
||||
* ── 2026-08-24:少一个闭括号的形,丢字符治不了,得**补** ────────────────
|
||||
*
|
||||
* E04 收口那一拍是全流程括号最深的输出(decision_chain + interaction_timeline
|
||||
* 两个嵌套 JSON 列),关思考的模型在 profile 行上少吐了一个 `}`:
|
||||
* `…"contactCode":"CU-0142-01"}]}}]}` —— 行对象没关上,后面的 `]`(关 rows)
|
||||
* 在栈眼里就成了「对不上的闭括号」被当噪声丢掉,整段从此错位、到尾都配不平,
|
||||
* 解析两连败,六个动作全对的一拍退化成「需要你判断下一步」的兜底卡。
|
||||
*
|
||||
* `close` 档的两条规则(仍只合成闭括号,不发明任何内容):
|
||||
* · 闭括号对不上栈顶、但**栈更深处有它** → 把中间欠的闭括号补上再收它
|
||||
* (那正是模型漏写的那几个);栈里压根没有 → 还是噪声,丢;
|
||||
* · 文本走完栈没清空(输出被截断的形)→ 把欠的全补上。
|
||||
*/
|
||||
function firstBalanced(text: string, mode: 'drop' | 'close'): unknown {
|
||||
const start = text.search(/[[{]/);
|
||||
if (start < 0) return undefined;
|
||||
const stack: string[] = [];
|
||||
const kept: string[] = [];
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
/** 上一个串外的有意义字符 —— close 档靠它认「对象里逗号后面冒出了 { 」 */
|
||||
let lastSignificant = '';
|
||||
for (let i = start; i < text.length; i += 1) {
|
||||
const ch = text[i] as string;
|
||||
if (inString) {
|
||||
kept.push(ch);
|
||||
if (escaped) escaped = false;
|
||||
else if (ch === '\\') escaped = true;
|
||||
else if (ch === '"') inString = false;
|
||||
else if (ch === '"') {
|
||||
inString = false;
|
||||
lastSignificant = '"';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
@@ -139,14 +163,35 @@ function firstBalanced(text: string): unknown {
|
||||
continue;
|
||||
}
|
||||
if (ch === '{' || ch === '[') {
|
||||
/*
|
||||
* 2026-08-24 第二种少括号形:行对象没关上,后面直接跟 `,{"key":…`(下一行)。
|
||||
* 括号本身到尾都能配平(少的那个 `}` 被后面某个多出来的位置吃掉),strict 与
|
||||
* 前两档全看不出 —— 但**对象里逗号后面只能跟键名**,冒出 `{`/`[` 一定是上一个
|
||||
* 对象没关。close 档:把欠的 `}` 补在那个逗号之前(逗号其实属于外层数组)。
|
||||
*/
|
||||
if (mode === 'close' && lastSignificant === ',' && stack[stack.length - 1] === '}') {
|
||||
const comma = kept.lastIndexOf(',');
|
||||
if (comma >= 0) {
|
||||
kept.splice(comma, 0, '}');
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
stack.push(ch === '{' ? '}' : ']');
|
||||
kept.push(ch);
|
||||
lastSignificant = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '}' || ch === ']') {
|
||||
if (stack[stack.length - 1] !== ch) continue; // 对不上的闭括号 = 噪声,丢
|
||||
if (stack[stack.length - 1] !== ch) {
|
||||
// 对不上的闭括号:drop 档当噪声丢;close 档若栈更深处有它,先把欠的补上
|
||||
if (mode === 'drop' || !stack.includes(ch)) continue;
|
||||
while (stack.length > 0 && stack[stack.length - 1] !== ch) {
|
||||
kept.push(stack.pop() as string);
|
||||
}
|
||||
}
|
||||
stack.pop();
|
||||
kept.push(ch);
|
||||
lastSignificant = ch;
|
||||
if (stack.length === 0) {
|
||||
try {
|
||||
return JSON.parse(kept.join(''));
|
||||
@@ -157,6 +202,16 @@ function firstBalanced(text: string): unknown {
|
||||
continue;
|
||||
}
|
||||
kept.push(ch);
|
||||
if (!/\s/.test(ch)) lastSignificant = ch;
|
||||
}
|
||||
// 文本走完栈没清空:close 档把欠的闭括号全补上(输出被截断的形);drop 档照旧作废
|
||||
if (mode === 'close' && stack.length > 0) {
|
||||
while (stack.length > 0) kept.push(stack.pop() as string);
|
||||
try {
|
||||
return JSON.parse(kept.join(''));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1737,3 +1737,57 @@ describe('同一张卡再发一遍的三档', () => {
|
||||
expect(resendVerdict([sent(''), replied('')], ' ').kind).toBe('send');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **发过卡这件事要落住**(2026-08-24,柴浩那一单熔断逼出来的一组回归)。
|
||||
*
|
||||
* `worldTouched` 是 finish 那道门唯一的证据:人工激活的项完成与否不该由数据形状推出来,
|
||||
* 只认「真给它发过一张卡」。那一单里这个标记不见了 —— 于是 finish 说「从没发过卡」、
|
||||
* 重发说「人已答过」,两堵墙对着撞,五拍熔断。
|
||||
* 查下来标记本身在这三条路上都落得住(下面三条钉死),丢是丢在**两个后端并发写同一份
|
||||
* 局面**上(见 `workers/task-claim.ts`)—— 这三条测试保证的是:这一侧别再出新的洞。
|
||||
*/
|
||||
describe('worldTouched 要落进保存的局面', () => {
|
||||
it('给人工激活的项发一张卡 → 保存下来的那一项标着 worldTouched', async () => {
|
||||
const h = harness({
|
||||
parsed: STAGE_MODEL,
|
||||
runtime: READY_RUNTIME2,
|
||||
action: { kind: 'human', itemId: 'GATED', card: { title: '回执工单', ask: '请回填' }, workOrder: 'execution' },
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(1);
|
||||
const gated = h.envelope.runtime.items.find((i) => i.id === 'GATED');
|
||||
expect(gated?.worldTouched, 'GATED 上没有 worldTouched —— 发过的卡没被记住').toBe(true);
|
||||
});
|
||||
|
||||
it('**再跑一拍之后仍然记得**(柴浩那一单:卡发过了,下一拍 finish 却说「从没发过卡」)', async () => {
|
||||
// 桩按调用时读 over.action —— 留着这个引用,拍与拍之间换动作
|
||||
const over: { parsed: CaseModel; runtime: CaseRuntime; action: Record<string, unknown> } = {
|
||||
parsed: STAGE_MODEL,
|
||||
runtime: READY_RUNTIME2,
|
||||
action: { kind: 'human', itemId: 'GATED', card: { title: '回执工单', ask: '请回填' }, workOrder: 'execution' },
|
||||
};
|
||||
const h = harness(over);
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.envelope.runtime.items.find((i) => i.id === 'GATED')?.worldTouched).toBe(true);
|
||||
|
||||
// 第二拍:干点别的(写一笔)—— 它会把局面重新存一遍
|
||||
over.action = { kind: 'write', itemId: 'FREE', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 2 } }] };
|
||||
await h.runner.runTurn(TASK);
|
||||
const gated = h.envelope.runtime.items.find((i) => i.id === 'GATED');
|
||||
expect(gated?.worldTouched, '第二拍存完之后,发过卡这件事被抹掉了').toBe(true);
|
||||
});
|
||||
|
||||
it('**人把卡答回来之后仍然记得** —— 回音落库那一下也存局面', async () => {
|
||||
const h = harness({
|
||||
parsed: STAGE_MODEL,
|
||||
runtime: READY_RUNTIME2,
|
||||
action: { kind: 'human', itemId: 'GATED', card: { title: '回执工单', ask: '请回填' }, workOrder: 'execution' },
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
const cardId = 'card-1';
|
||||
await h.runner.onEvent(TASK, { kind: 'human', ref: cardId, payload: { values: {} } });
|
||||
const gated = h.envelope.runtime.items.find((i) => i.id === 'GATED');
|
||||
expect(gated?.worldTouched, '人答回来之后,发过卡这件事被抹掉了').toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
port 注入:memory / event / message / interaction / ledger
|
||||
被依赖: process, org(停用前置检查的在途任务), report, app
|
||||
|
||||
近变(2026-08-25):`TaskService.wake(taskId)` —— 人答复落库那一刻就把 runtime 翻成
|
||||
`running` 并 setPhase('active')(顺带广播);只对 awaiting-event / awaiting-human 生效,
|
||||
已终结 / suspended / 已在跑的原样不动并返回 false。回传路由在返回之前调它,
|
||||
让对话区(卡状态)与工作区(runtime)读到同一份事实。
|
||||
|
||||
近变(2026-07-30):`Task` / `StartTaskInput` 加 `origin?`(`testbed` = 设计台试跑)+
|
||||
`sys.task.origin` 列。**认得出试跑单才拔得掉** —— 工作流那侧靠
|
||||
`source->>'ref'='testbed'` 认,任务这侧从前没有对应记号:
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* `wake` —— 人答复落库那一刻就把「在办事」写进投影(2026-08-25 用户:「右边聊天框先有反应,
|
||||
* 中间的 html 过一会儿才变成 AI 正在推进中」)。从前 runtime 要等派发器投递、推进器抢到
|
||||
* 租约、开跑前那一刻才翻;那几秒里对话区与工作区读到的是两份相反的事实。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { LandedFacts, Task } from '../contract.ts';
|
||||
import { createTaskService } from '../internal/usecase/task-service.ts';
|
||||
import type { TaskServiceDeps } from '../internal/usecase/task-service.ts';
|
||||
|
||||
function harness(runtime: Task['runtime'], terminal?: Task['terminal']) {
|
||||
const phases: string[] = [];
|
||||
const task = {
|
||||
id: 't-1',
|
||||
activityId: 'e04',
|
||||
ownerId: 's-1',
|
||||
title: 'x',
|
||||
runtime,
|
||||
terminal,
|
||||
data: {},
|
||||
caps: { steps: 40, tokens: 200000, humanCalls: 2, activeMinutes: 60 },
|
||||
used: { steps: 0, tokens: 0, humanCalls: 0, activeMs: 0 },
|
||||
startedAt: '2026-08-25T00:00:00.000Z',
|
||||
} as unknown as Task;
|
||||
const tasks = new Map<string, Task>([[task.id, task]]);
|
||||
const deps: TaskServiceDeps = {
|
||||
store: {
|
||||
get: async (id) => tasks.get(id),
|
||||
insert: async (t) => void tasks.set(t.id, t),
|
||||
update: async (id, patch) => {
|
||||
const next = { ...tasks.get(id)!, ...patch };
|
||||
tasks.set(id, next);
|
||||
return next;
|
||||
},
|
||||
addConsumption: async (id) => tasks.get(id)!.used,
|
||||
lastProgressAt: async () => new Date(),
|
||||
markProgress: async () => undefined,
|
||||
listByOwner: async () => [],
|
||||
},
|
||||
activities: { load: async () => ({ caps: task.caps, envelope: {}, inputSchema: {}, businessModel: {}, stallAfter: 'PT30M' }) },
|
||||
schemas: { validate: () => ({ ok: true, errors: [] }) },
|
||||
engine: { load: async () => undefined, advance: async () => ({ status: 'running' }), onEvent: async () => ({ status: 'running' }) },
|
||||
memory: { append: async () => undefined, hasHistory: async () => true, seal: async () => undefined, distill: async () => undefined },
|
||||
landing: { land: async () => ({ converged: true, emittedPorts: [] as string[], ledgerRowsMerged: 0, facts: {} as LandedFacts }) },
|
||||
listeners: { hasArmedListener: async () => true },
|
||||
persistence: { hasDrafts: async () => false, hasRuntimeState: async () => true },
|
||||
events: { publishTerminal: async () => undefined, publishControl: async () => undefined },
|
||||
notifier: { notify: async () => undefined },
|
||||
interaction: { setPhase: async (_id, phase) => void phases.push(phase) },
|
||||
intake: { askCompletion: async () => undefined },
|
||||
comms: { record: async () => undefined },
|
||||
clock: { now: () => new Date('2026-08-25T00:00:00.000Z') },
|
||||
ids: { next: () => 't-x' },
|
||||
};
|
||||
return { service: createTaskService(deps), tasks, phases };
|
||||
}
|
||||
|
||||
describe('task.wake —— 人答完那一刻就标「在办」', () => {
|
||||
it('挂起等外部(awaiting-event)→ running,并把 phase 推成 active(顺带广播)', async () => {
|
||||
const h = harness('awaiting-event');
|
||||
expect(await h.service.wake('t-1' as never)).toBe(true);
|
||||
expect(h.tasks.get('t-1')?.runtime).toBe('running');
|
||||
expect(h.phases).toEqual(['active']);
|
||||
});
|
||||
|
||||
it('挂起等人(awaiting-human)同样翻', async () => {
|
||||
const h = harness('awaiting-human');
|
||||
expect(await h.service.wake('t-1' as never)).toBe(true);
|
||||
expect(h.tasks.get('t-1')?.runtime).toBe('running');
|
||||
});
|
||||
|
||||
it('**已在跑 / 暂停 / 已终结的原样不动**,返回 false、不广播', async () => {
|
||||
for (const [runtime, terminal] of [['running', undefined], ['suspended', undefined], ['terminal', 'released']] as const) {
|
||||
const h = harness(runtime, terminal);
|
||||
expect(await h.service.wake('t-1' as never), runtime).toBe(false);
|
||||
expect(h.tasks.get('t-1')?.runtime).toBe(runtime);
|
||||
expect(h.phases).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -103,6 +103,13 @@ export type TaskService = {
|
||||
/** 控制三语义:暂停 / 叫停 / 终止 */
|
||||
control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise<ControlOutcome>;
|
||||
resume(taskId: TaskId, by: StaffId): Promise<Task>;
|
||||
/**
|
||||
* **人答复落库那一刻就把「在办事」写进投影**(2026-08-25):不等派发器投递、推进器
|
||||
* 抢到租约再翻 —— 那几秒里对话区(卡状态已落)与工作区(读 runtime)说两句相反的话。
|
||||
* 只在挂起等人 / 等外部时翻;已终结、暂停(suspended)、已在跑的原样不动。
|
||||
* 返回翻没翻 —— 调用方据此决定要不要广播。
|
||||
*/
|
||||
wake(taskId: TaskId): Promise<boolean>;
|
||||
/** 改派:在途任务的负责人变更,**留痕并通知双方** */
|
||||
reassign(taskId: TaskId, to: StaffId, by: StaffId): Promise<Task>;
|
||||
|
||||
|
||||
@@ -393,6 +393,16 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
return outcome;
|
||||
},
|
||||
|
||||
async wake(taskId) {
|
||||
const task = await require(taskId);
|
||||
if (task.terminal !== undefined) return false;
|
||||
if (task.runtime !== 'awaiting-event' && task.runtime !== 'awaiting-human') return false;
|
||||
await deps.store.update(taskId, { runtime: 'running' });
|
||||
// setPhase 顺带广播(interaction 的 onPush → broadcaster),浏览器立刻重取
|
||||
await deps.interaction.setPhase(taskId, 'active');
|
||||
return true;
|
||||
},
|
||||
|
||||
async resume(taskId, by) {
|
||||
const task = await require(taskId);
|
||||
if (task.terminal !== undefined) throw new TaskAlreadyTerminalError(taskId, task.terminal);
|
||||
|
||||
@@ -28,6 +28,7 @@ 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 { queryKeys } from './query.ts';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
|
||||
type ModuleKey = 'tasks' | 'approvals' | 'messages' | 'minutes' | 'report';
|
||||
|
||||
@@ -165,15 +166,10 @@ export function AppShell() {
|
||||
<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()}
|
||||
>
|
||||
<Btn className="jc-topbar-logout" busy={logout.isPending} onClick={() => logout.mutate()}>
|
||||
<Ico name="log-out" s={14} />
|
||||
退出登录
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import { ArchiveHome } from '@features/gov-archive';
|
||||
import { TestbedHome } from '@features/gov-testbed';
|
||||
import { CockpitHome } from '@features/gov-memory';
|
||||
import { ChanPage, LlmPage, ModelsHome, SystemHome } from '@features/gov-system';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
|
||||
/* ── URL 编解码:`#/<surface>/<zone>/<tab>[/<k>:<id>…]` ────────────────── */
|
||||
|
||||
@@ -203,10 +204,10 @@ export function GovShell({ surface }: { surface: Surface }) {
|
||||
<Ico name="list-checks" s={13} />
|
||||
工作台
|
||||
</a>
|
||||
<button className="g-tbtn" onClick={() => logout.mutate()} disabled={logout.isPending}>
|
||||
<Btn className="g-tbtn" onClick={() => logout.mutate()} busy={logout.isPending}>
|
||||
<Ico name="log-out" s={13} />
|
||||
登出
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -62,6 +62,11 @@ if (govSurface !== undefined) {
|
||||
await import('./styles/proto.vendor.css');
|
||||
await import('./styles/app.css');
|
||||
}
|
||||
/*
|
||||
* 键感与「在办」那一层三面共用,**装在最后** —— 同名规则以它为准。
|
||||
* (2026-08-24 用户:「点击了都不知道点没点上」;见 styles/press.css 头注)
|
||||
*/
|
||||
await import('./styles/press.css');
|
||||
|
||||
/**
|
||||
* 鉴权壳(ADR-010)—— 会话是**服务端**的事:401 即落登录页。
|
||||
|
||||
@@ -2907,6 +2907,16 @@
|
||||
.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); }
|
||||
/* 两组翻页同时在场时(多张卡各自多页)各带一个字,单独在场时不摆 —— 一个数不必解释自己 */
|
||||
/*
|
||||
* 保存之后的红字 —— **有行没落下就摆在件的上方**(2026-08-24 用户:件上新增的
|
||||
* 客户推到下一步就不见了)。它不是错误提示:别的格子确实存下了,只有没钥匙的那几行没落 ——
|
||||
* 所以用「提醒」的分量,点名缺哪几列,人补上再存一次。
|
||||
*/
|
||||
.proto-root .jc-savewarn { display: flex; align-items: flex-start; gap: 7px; margin: 0 0 10px;
|
||||
padding: 9px 12px; border: 1px solid #f3b6b3; border-radius: 9px; background: #fff5f5;
|
||||
color: var(--danger); font-size: 12.5px; line-height: 1.6; }
|
||||
.proto-root .jc-savewarn svg { flex: none; margin-top: 2px; }
|
||||
|
||||
.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; }
|
||||
|
||||
@@ -245,6 +245,60 @@
|
||||
哪几张只是一单一份的凭证。而「该不该管它、谁有权改它」全看性质:
|
||||
主数据要治理(谁能改、依据、去重、时效),单据只需查得到、派生根本不该手写。
|
||||
所以分区要**看得见**:一条带色的区头 + 一句说清这一类是什么。 */
|
||||
/* 试跑台:「连主数据一起落真库」那一格 —— 破例要看得见自己在破例 */
|
||||
.tb-land { display: flex; align-items: flex-start; gap: 9px; padding: 10px 12px; cursor: pointer;
|
||||
border: 1px dashed var(--g-line, #dfe1e5); border-radius: 10px; background: #fbfbfc; transition: all .16s; }
|
||||
.tb-land:hover { border-color: var(--g-brand-soft, #b6d0ff); }
|
||||
.tb-land.on { border-style: solid; border-color: #f3b6b3; background: #fff7f6; }
|
||||
.tb-land input { margin-top: 2px; flex: none; }
|
||||
.tb-land b { display: block; font-size: 12.5px; font-weight: 700; color: var(--g-ink, #1f2329); }
|
||||
.tb-land.on b { color: #c9372c; }
|
||||
.tb-land em { display: block; margin-top: 2px; font-style: normal; font-size: 11.5px; line-height: 1.65; color: var(--g-ink-3, #646a73); }
|
||||
|
||||
/* ══ 数据域:按活动分组(2026-08-24 重写)══════════════════════════════
|
||||
一屏回答的是「我这个活动写了哪几张表、现在各有几行」——
|
||||
所以行数是主角:字号最大、放行末端;性质降成一枚小徽,地址与主键退成灰字。 */
|
||||
.g-actdata { display: flex; flex-direction: column; gap: 10px; padding: 14px 16px 13px;
|
||||
background: #fff; border: 1px solid var(--g-line); border-radius: 12px; box-shadow: var(--g-shadow, 0 1px 2px rgba(31,35,41,.05)); }
|
||||
.g-actdata + .g-actdata { margin-top: 14px; }
|
||||
.g-actdata-h { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
|
||||
.g-actdata-h > svg { color: var(--g-brand, #3370ff); flex: none; }
|
||||
.g-actdata-h b { font-size: 14.5px; font-weight: 800; color: var(--g-ink, #1f2329); }
|
||||
.g-actdata-h .n { margin-left: auto; font-size: 12px; font-weight: 700; color: var(--g-ink-3, #646a73); }
|
||||
.g-actdata-h .n em { font-style: normal; color: var(--g-brand, #3370ff); }
|
||||
.g-actdata-h p { flex-basis: 100%; margin: 2px 0 0; font-size: 12px; line-height: 1.7; color: var(--g-ink-3, #646a73); }
|
||||
|
||||
.g-actdata-tables { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 8px; }
|
||||
.g-tbl { display: grid; grid-template-columns: 1fr auto; grid-template-areas: 't n' 'a n';
|
||||
gap: 2px 12px; width: 100%; padding: 9px 12px; text-align: left; font: inherit; cursor: pointer;
|
||||
background: var(--g-soft, #f7f8fa); border: 1px solid transparent; border-radius: 9px; transition: all .16s; }
|
||||
.g-tbl:hover { background: #fff; border-color: var(--g-brand-soft, #b6d0ff); box-shadow: 0 2px 8px rgba(51,112,255,.10); }
|
||||
.g-tbl-t { grid-area: t; display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.g-tbl-t b { font-size: 13px; font-weight: 700; color: var(--g-ink, #1f2329); }
|
||||
.g-tbl-k { font-style: normal; font-size: 10px; font-weight: 800; padding: 1px 6px; border-radius: 4px;
|
||||
background: #eef1f5; color: #5a6068; }
|
||||
.g-tbl-k.t-m { background: #eaf1ff; color: #2b5fd9; }
|
||||
.g-tbl-k.t-r { background: #f2f3f5; color: #5a6068; }
|
||||
.g-tbl-k.t-d { background: #fdf0e6; color: #b25e15; }
|
||||
.g-tbl-a { grid-area: a; font-size: 11px; color: var(--g-ink-4, #8f959e); overflow-wrap: anywhere; }
|
||||
.g-tbl-a em { font-style: normal; }
|
||||
/* 行数:这一屏的主角 */
|
||||
.g-tbl-n { grid-area: n; align-self: center; text-align: right; font-size: 11.5px; color: var(--g-ink-3, #646a73); white-space: nowrap; }
|
||||
.g-tbl-n b { font-size: 17px; font-weight: 800; color: var(--g-ink, #1f2329); margin-right: 2px; }
|
||||
.g-tbl-n .none { font-style: normal; color: var(--g-ink-4, #8f959e); }
|
||||
.g-tbl-n .draft { display: block; font-style: normal; font-size: 10.5px; font-weight: 700; color: var(--g-brand, #3370ff); }
|
||||
|
||||
.g-actdata-runs { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; padding-top: 2px; }
|
||||
.g-actdata-runs > em { font-style: normal; font-size: 11px; font-weight: 700; color: var(--g-ink-4, #8f959e); }
|
||||
.g-run { display: inline-flex; align-items: center; gap: 5px; padding: 2px 9px; border-radius: 999px;
|
||||
font-size: 11px; font-weight: 700; background: #f2f3f5; color: #5a6068; }
|
||||
.g-run i { font-style: normal; font-weight: 600; opacity: .72; }
|
||||
.g-run.t-ok { background: #e9f7ee; color: #1f7a45; }
|
||||
.g-run.t-live { background: #eaf1ff; color: #2b5fd9; }
|
||||
.g-run.t-wait { background: #fdf0e6; color: #b25e15; }
|
||||
.g-run.t-warn { background: #fff1f0; color: #c9372c; }
|
||||
.g-actdata.t-u { background: #fbfbfc; }
|
||||
|
||||
.g-kind { display: flex; flex-direction: column; gap: 12px; }
|
||||
.g-kind + .g-kind { margin-top: 22px; }
|
||||
.g-kind-h { display: grid; grid-template-columns: auto auto auto 1fr; align-items: center; gap: 8px;
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/* ==========================================================================
|
||||
按下去要有反应,在办事要说出来 —— 三面共用的一层(2026-08-24)
|
||||
|
||||
起因是用户一句话:「点击了都不知道点没点上」。查下来是两件事各缺一半:
|
||||
① **按下那一刻没有键感** —— 治理台整套 `g-*` 控件一条 `:active` 都没有,
|
||||
工作台也只有零星几个。点下去屏上纹丝不动,人只能靠「后来发生了什么」倒推。
|
||||
② **等回音那一段完全静默** —— 请求在路上的几百毫秒里,按钮长得和没点过一模一样。
|
||||
这一段恰恰是人最想要个准信的时候(慢的接口要好几秒)。
|
||||
|
||||
所以这一层只做两件事:**键感**(`:active`)与**在办**(`aria-busy`)。
|
||||
两者都只动 transform / background,不改布局、不换 DOM —— 谁都不必为它改结构。
|
||||
|
||||
装载在两份基座之后(main.tsx),所以同名规则以它为准;
|
||||
台面自己已经写过的 `:active` 特异度更高,原样保留(它们本就是同一个手感)。
|
||||
========================================================================== */
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
一 · 键感:按下就下沉
|
||||
小控件缩 .97 手感最准;整行整卡那种大件同样缩会「晃」,只给极轻的一点。
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
button:not(:disabled):not([aria-busy='true']):active,
|
||||
summary:active,
|
||||
[role='button']:not([aria-disabled='true']):active {
|
||||
transform: scale(.97);
|
||||
}
|
||||
|
||||
/*
|
||||
* 按下要**立刻**到位。不少控件把 transform 一起塞进了 0.16s 的过渡里
|
||||
* (`transition: all var(--t-fast)`)—— 而一次点击常常比这还短:
|
||||
* 手抬起来的时候键帽才刚下去一半,看着就像「没反应」。
|
||||
* 按下这一刻不过渡;松开后 `:active` 不再命中,回弹仍走控件自己的过渡(软的)。
|
||||
* 两档特异度是为了压过工作台那批 `.proto-root .x`(0,2,0)的基础规则。
|
||||
*/
|
||||
.proto-root button:not(:disabled):active,
|
||||
.proto-root [role='button']:not([aria-disabled='true']):active,
|
||||
button:not(:disabled):active,
|
||||
[role='button']:not([aria-disabled='true']):active {
|
||||
transition-duration: 0s;
|
||||
}
|
||||
|
||||
/* 大件(整行清单 / 整张卡 / 整条规则):幅度收到几乎看不见,只留一点「陷下去」 */
|
||||
.g-nrow:active,
|
||||
.g-entry:active,
|
||||
.g-tile:active,
|
||||
.g-wf:active,
|
||||
.g-rule:active,
|
||||
.g-fcard:active,
|
||||
.g-mcard:active,
|
||||
.proto-root .jc-dv-row:active,
|
||||
.proto-root .task-item:active,
|
||||
.proto-root .jc-branch:active {
|
||||
transform: scale(.995);
|
||||
}
|
||||
|
||||
/* 键盘走到哪儿要看得见 —— 鼠标点击不出这个圈(:focus-visible 的本分) */
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
summary:focus-visible,
|
||||
[role='button']:focus-visible {
|
||||
outline: 2px solid var(--brand, #3370ff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
二 · 在办:脚下走一道不定长进度条
|
||||
|
||||
**为什么是 `::after` 而不是 background-image**:第一版把这道条画进按钮自己的
|
||||
background,在真页面上量出来是**画不出来的** —— `.tbtn` 这类控件的皮肤用
|
||||
`background:` 简写上色,特异度(0,2,0 起)压过 `[aria-busy]`(0,1,0),
|
||||
`background-image` 当场被顶回 none(动画倒是在跑,条却不存在)。
|
||||
`::after` 这一层没人跟它抢:全仓的按钮类没有一条定义过 `::before/::after`(审过)。
|
||||
同理**不给 `<select>` 挂 aria-busy** —— 替换元素画不出伪元素,它那一行才是该点亮的东西。
|
||||
|
||||
条只动 transform,`currentColor` 取字色:白字蓝底的主钮出白条,深字白底的次钮出深条。
|
||||
不给 `disabled`:那会把按钮画成灰的,而它此刻正在**做**这件事,不是做不了。
|
||||
真做不了的照旧 `disabled`(见 shared/Btn.tsx 的注)。
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
[aria-busy='true'] {
|
||||
position: relative;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
[aria-busy='true']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
/* 左右各让出一点、离底边抬起 2px —— 贴着边画会与控件自己的边框糊成一条,看着只是「框粗了」 */
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
bottom: 2px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
opacity: .85;
|
||||
pointer-events: none;
|
||||
animation: jc-busy-run 1.1s cubic-bezier(.45, 0, .55, 1) infinite;
|
||||
}
|
||||
|
||||
/* 从左抽出、再从左收走 —— 不定长进度条的经典两拍,全程只有 transform 在动 */
|
||||
@keyframes jc-busy-run {
|
||||
0% { transform: scaleX(0); transform-origin: left; }
|
||||
45% { transform: scaleX(1); transform-origin: left; }
|
||||
55% { transform: scaleX(1); transform-origin: right; }
|
||||
100% { transform: scaleX(0); transform-origin: right; }
|
||||
}
|
||||
|
||||
/* 在办的时候不再摆「可点」的手感:悬停不上色、按下不下沉(点了也不算数) */
|
||||
[aria-busy='true']:hover,
|
||||
[aria-busy='true']:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
三 · 关掉动效的人:条不跑了,但**仍然要看得出在办事** ——
|
||||
停在中间摆一道静态的杠,别让这类用户回到「什么都没有」那个起点。
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
button:active,
|
||||
summary:active,
|
||||
[role='button']:active,
|
||||
.g-nrow:active,
|
||||
.g-entry:active,
|
||||
.g-tile:active,
|
||||
.g-wf:active,
|
||||
.g-rule:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 条不跑了,但**仍然要看得出在办事** —— 摆一道静态的杠,别让这类用户回到「什么都没有」那个起点 */
|
||||
[aria-busy='true']::after {
|
||||
animation: none;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type { Session } from '@contracts';
|
||||
import { loginRoute } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { ApiCallError, call } from '@web-shared/typed-fetch.ts';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
|
||||
export function LoginPage({ onLogin }: { onLogin(session: Session): void }) {
|
||||
const [account, setAccount] = useState('');
|
||||
@@ -75,9 +76,10 @@ export function LoginPage({ onLogin }: { onLogin(session: Session): void }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="jc-login-go" type="submit" disabled={busy || !ready}>
|
||||
{/* 在办的那一段照旧可提交 —— `submit` 自己有 `busy` 闸,按下去什么也不会多发一遍 */}
|
||||
<Btn className="jc-login-go" type="submit" disabled={!ready} busy={busy}>
|
||||
{busy ? '登录中…' : '登录'}
|
||||
</button>
|
||||
</Btn>
|
||||
<p className="jc-login-hint">初始口令由部署时的 JCAI_SEED_PASSWORD 决定</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { AssistantAside, Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
|
||||
import { atMinute } from '@web-shared/at.ts';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
|
||||
const STATE_CHIP = {
|
||||
pending: { tone: 'todo' as const, label: '待审批', pulse: true },
|
||||
@@ -159,24 +160,24 @@ export function ApprovalDesk() {
|
||||
/>
|
||||
{error !== undefined && <div className="jc-callout t-bad">{error}</div>}
|
||||
<div className="jc-mod-actions">
|
||||
<button
|
||||
type="button"
|
||||
<Btn
|
||||
className="card-btn"
|
||||
busy={decide.isPending && decide.variables.verdict === 'reject'}
|
||||
disabled={decide.isPending}
|
||||
onClick={() => decide.mutate({ item: sel, verdict: 'reject' })}
|
||||
>
|
||||
<Ico name="rotate-ccw" s={13} />
|
||||
驳回
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</Btn>
|
||||
<Btn
|
||||
className="card-btn primary"
|
||||
busy={decide.isPending && decide.variables.verdict === 'pass'}
|
||||
disabled={decide.isPending}
|
||||
onClick={() => decide.mutate({ item: sel, verdict: 'pass' })}
|
||||
>
|
||||
<Ico name="check" s={13} />
|
||||
通过
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
</Section>
|
||||
) : (
|
||||
|
||||
@@ -15,6 +15,7 @@ import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { AssistantAside, Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
|
||||
import { atFull, atMinute } from '@web-shared/at.ts';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
|
||||
const KIND_META: Readonly<Record<string, { icon: string; grad: readonly [string, string]; kicker: string }>> = {
|
||||
notice: { icon: 'bell', grad: ['#7f3bf5', '#5b21cf'], kicker: '系统 · 通知' },
|
||||
@@ -261,10 +262,10 @@ function ConfirmFire({ confirm }: { confirm: NonNullable<SystemMessageView['conf
|
||||
return (
|
||||
<>
|
||||
<NoteBand>这封来件命中了触发器,但运营配置要求人工确认 —— 你点了,流程才起;不点,它就停在这儿。</NoteBand>
|
||||
<button className="jc-btn primary" disabled={fire.isPending} onClick={() => fire.mutate()}>
|
||||
<Btn className="jc-btn primary" busy={fire.isPending} onClick={() => fire.mutate()}>
|
||||
<Ico name="play" s={13} />
|
||||
{fire.isPending ? '起单中…' : '确认起单'}
|
||||
</button>
|
||||
</Btn>
|
||||
{fire.isError && <NoteBand>没起成:{(fire.error as { message?: string }).message ?? '未知原因'}</NoteBand>}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Ico } from '@web-shared/Ico.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { AssistantAside, Avatar, AvatarStack, Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
|
||||
export function MinuteDesk() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -165,15 +166,15 @@ export function MinuteDesk() {
|
||||
<button type="button" className="card-btn" onClick={() => setComposing(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
<Btn
|
||||
className="card-btn primary"
|
||||
disabled={draft.title.trim() === '' || draft.occurredOn.trim() === '' || create.isPending}
|
||||
disabled={draft.title.trim() === '' || draft.occurredOn.trim() === ''}
|
||||
busy={create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
<Ico name="check" s={13} />
|
||||
保存纪要
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
</DocPanel>
|
||||
) : sel === undefined ? (
|
||||
@@ -235,14 +236,9 @@ export function MinuteDesk() {
|
||||
onChange={(e) => setPackageId(e.target.value)}
|
||||
placeholder="流程包 id"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="card-btn primary"
|
||||
disabled={register.isPending}
|
||||
onClick={() => register.mutate(r.id)}
|
||||
>
|
||||
<Btn className="card-btn primary" busy={register.isPending} onClick={() => register.mutate(r.id)}>
|
||||
提交起单
|
||||
</button>
|
||||
</Btn>
|
||||
<button type="button" className="card-btn" onClick={() => setRegisterFor(undefined)}>
|
||||
取消
|
||||
</button>
|
||||
@@ -291,10 +287,10 @@ function AppendResolution({ onAppend, busy }: { onAppend: (text: string) => void
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<Btn
|
||||
className="card-btn primary"
|
||||
disabled={text.trim() === '' || busy}
|
||||
disabled={text.trim() === ''}
|
||||
busy={busy}
|
||||
onClick={() => {
|
||||
onAppend(text.trim());
|
||||
setText('');
|
||||
@@ -302,7 +298,7 @@ function AppendResolution({ onAppend, busy }: { onAppend: (text: string) => void
|
||||
>
|
||||
<Ico name="plus" s={13} />
|
||||
追加
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Ico } from '@web-shared/Ico.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { AssistantAside, Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
|
||||
const PERIODS: ReadonlyArray<{ k: ReportPeriodName; ico: string }> = [
|
||||
{ k: 'day', ico: 'calendar-days' },
|
||||
@@ -183,10 +184,10 @@ export function ReportDesk() {
|
||||
已确认提交 · 汇报进入组织视图
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" className="card-btn primary" disabled={submit.isPending} onClick={() => submit.mutate()}>
|
||||
<Btn className="card-btn primary" busy={submit.isPending} onClick={() => submit.mutate()}>
|
||||
<Ico name="check" s={13} />
|
||||
确认提交
|
||||
</button>
|
||||
</Btn>
|
||||
)}
|
||||
</div>
|
||||
</DocPanel>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
|
||||
import { ChatStream, WorkingStrip } from './chat-bubbles.tsx';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
|
||||
export function TaskChat({
|
||||
detail,
|
||||
@@ -115,9 +116,23 @@ export function TaskChat({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="copilot-send" disabled={say.isPending}>
|
||||
<Btn
|
||||
className="copilot-send"
|
||||
busy={say.isPending}
|
||||
onClick={(e) => {
|
||||
/*
|
||||
* 这一钮从前**没有 onClick** —— 点它什么都不会发生,只有在文本框里按 Enter 才发得出去。
|
||||
* 「点了都不知道点没点上」在这儿是字面意思:它根本就没接线。
|
||||
*/
|
||||
const box = e.currentTarget.closest('.composer-row')?.querySelector('textarea');
|
||||
const text = box?.value.trim() ?? '';
|
||||
if (text === '' || box === null || box === undefined) return;
|
||||
say.mutate(text);
|
||||
box.value = '';
|
||||
}}
|
||||
>
|
||||
<Ico name="send" s={14} /> 发送
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,6 +146,8 @@ export function TaskChat({
|
||||
orders={detail.workOrders}
|
||||
thinking={say.isPending}
|
||||
working={working}
|
||||
/* 卡上刚交出去的那一笔还在路上 —— 卡上的钮据此说「在办事」 */
|
||||
replying={replyCard.isPending || replyOrder.isPending}
|
||||
onSay={(text) => say.mutate(text)}
|
||||
onReplyCard={(reply) => replyCard.mutate(reply)}
|
||||
onReplyOrder={(reply) => replyOrder.mutate(reply)}
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { ViewEdits, ViewSave } from '@ui';
|
||||
import { GuideCard, WorkOrderReport } from './work-order-report.tsx';
|
||||
import { FileViewer } from './FileViewer.tsx';
|
||||
import { atFull, atMinute } from '@web-shared/at.ts';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
|
||||
|
||||
type Tab = 'open' | 'comms' | 'done' | 'wf' | 'log';
|
||||
@@ -189,16 +190,30 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
||||
* 实测:人把三条料全改成 A 类,底下的去向还写着「去选型」。
|
||||
* 落完重取任务投影,整份件跟着一起变。**工单不办结,放行门的位置没动。**
|
||||
*/
|
||||
/**
|
||||
* **有行没落下就说出来**(2026-08-24 用户:「件上新增的那个客户,推到下一步就不见了」)。
|
||||
* 主键那几格空着就凑不出钥匙、存不住;从前基座悄悄跳过、接口照回 ok ——
|
||||
* 人看到「保存成功」,东西却不在。现在把没落下的行红字点名,他补上钥匙再存一次。
|
||||
*/
|
||||
const [saveWarn, setSaveWarn] = useState<string | undefined>(undefined);
|
||||
|
||||
const saveTable = useMutation({
|
||||
mutationFn: (save: ViewSave) =>
|
||||
call(saveTableRoute, {
|
||||
call<{ ok: boolean; rows: number; unkeyed?: Array<{ hint: string }> }>(saveTableRoute, {
|
||||
params: { taskId: detail.card.id },
|
||||
body: {
|
||||
tables: save.tables as Record<string, Array<Record<string, string>>>,
|
||||
drops: save.drops as Record<string, Array<Record<string, string>>>,
|
||||
},
|
||||
}),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['desk', 'task', detail.card.id] }),
|
||||
onSuccess: (out) => {
|
||||
setSaveWarn(
|
||||
out.unkeyed === undefined || out.unkeyed.length === 0
|
||||
? undefined
|
||||
: `有 ${out.unkeyed.length} 行没能存下 —— ${out.unkeyed.map((u) => u.hint).join(';')}`,
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ['desk', 'task', detail.card.id] });
|
||||
},
|
||||
});
|
||||
|
||||
/*
|
||||
@@ -221,6 +236,14 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
||||
onError: (error: unknown) => setFileError(error instanceof Error ? error.message : '这份资料打不开'),
|
||||
});
|
||||
|
||||
/*
|
||||
* 正在取的是哪一份 —— 先摘成两个普通值再用。
|
||||
* 直接在 JSX 里写 `openAssetFile.isPending && openAssetFile.variables.key === …` 不行:
|
||||
* 上面那一层三元已经按 `isPending` 分过岔,进到 else 分支里它被收窄成「不在办」,
|
||||
* `variables` 整个不存在(TS 判 never)—— 同一个判断在同一棵树里只能问一次。
|
||||
*/
|
||||
const fetchingFileKey = openAssetFile.isPending ? openAssetFile.variables.key : undefined;
|
||||
|
||||
const markRead = useMutation({
|
||||
mutationFn: (input: { commId: string; read: boolean }) =>
|
||||
call(markCommReadRoute, { params: { commId: input.commId }, body: { read: input.read } }),
|
||||
@@ -490,7 +513,14 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
||||
vpClassName="wf-body"
|
||||
inset={-6}
|
||||
>
|
||||
{openFile !== undefined ? (
|
||||
{openAssetFile.isPending ? (
|
||||
/*
|
||||
* **取件的那一段要说出来** —— 资料名是件上的一个链接,点它没有按钮可以变样;
|
||||
* 那份文件又是服务端**现造**出来的字节(造 + 传,慢的有一两秒)。
|
||||
* 从前这一段屏上一动不动,人只好再点一次 —— 于是同一份资料取两遍。
|
||||
*/
|
||||
<EmptyState ico="file-text" tone="brand" title="正在取这一份资料…" desc="文件是台账那一行现造出来的,造好就摆在这儿。" />
|
||||
) : openFile !== undefined ? (
|
||||
<FileViewer doc={openFile} href={fileHref} />
|
||||
) : fileError !== undefined ? (
|
||||
/* 那一份取不回来:**如实说是哪一句**(服务端原话),别把人留在原地猜 */
|
||||
@@ -528,16 +558,15 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
||||
不做「打开页签即已读」:那是替人宣布他看过了,而他可能只是路过。
|
||||
反向也留着:看了一半想留个记号,标回未读。
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
<Btn
|
||||
className={'jc-comm-mark' + (m.unread ? '' : ' done')}
|
||||
disabled={markRead.isPending}
|
||||
busy={markRead.isPending && markRead.variables.commId === m.id}
|
||||
aria-label={m.unread ? `把「${m.subject}」标为已读` : `把「${m.subject}」标回未读`}
|
||||
onClick={() => markRead.mutate({ commId: m.id, read: m.unread })}
|
||||
>
|
||||
<Ico name={m.unread ? 'check' : 'rotate-ccw'} s={11} />
|
||||
{m.unread ? '标为已读' : '标回未读'}
|
||||
</button>
|
||||
</Btn>
|
||||
</header>
|
||||
<div className="jc-comm-meta">
|
||||
<span className="jc-mono">{m.channel}</span>
|
||||
@@ -641,10 +670,10 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
||||
{detail.assetFiles.every((f) => f.archived) ? null : <i>在办中 · 放行时存档</i>}
|
||||
</div>
|
||||
{detail.assetFiles.map((f) => (
|
||||
<button
|
||||
<Btn
|
||||
key={`${f.address}/${f.key}`}
|
||||
type="button"
|
||||
className={'jc-dv jc-dv-row s-ok m-file f-' + f.format}
|
||||
busy={fetchingFileKey === f.key}
|
||||
onClick={() => {
|
||||
setFileError(undefined);
|
||||
openAssetFile.mutate({ address: f.address, key: f.key });
|
||||
@@ -664,7 +693,7 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
||||
<span className={'jc-dv-fmt f-' + f.format}>{f.format}</span>
|
||||
<Ico name="chevron-right" s={15} />
|
||||
</span>
|
||||
</button>
|
||||
</Btn>
|
||||
))}
|
||||
{doneList.length > 0 && <div className="jc-dvs-sub">定稿报告<em>{doneList.length} 份</em></div>}
|
||||
</>
|
||||
@@ -780,6 +809,17 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
||||
* 判据换成直接问 `detail.viewHtml` 本身,`cur` 只在没有件、要退回卡壳
|
||||
* 呈现,或收边传值(`onField`)时才用得上。
|
||||
*/
|
||||
<>
|
||||
{/*
|
||||
**有行没落下,红字摆在件的上方** —— 而不是让人以为存好了。
|
||||
位置有意)在件之上:他刚按完保存,眼睛就在这一带。
|
||||
*/}
|
||||
{saveWarn !== undefined && (
|
||||
<div className="jc-savewarn" role="alert">
|
||||
<Ico name="info" s={14} />
|
||||
<span>{saveWarn}</span>
|
||||
</div>
|
||||
)}
|
||||
<ViewContainer
|
||||
html={detail.viewHtml}
|
||||
title={`${detail.card.activity} · 汇报报告`}
|
||||
@@ -806,6 +846,7 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
||||
if (cur !== undefined) collectEdits(save.tables, cur, onField);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : cur !== undefined ? (
|
||||
/* 活动没推件的(还没到推件那一拍、或包里就没有模版)才退回工单报告:那时工作区总不能是白的 */
|
||||
<article className="jc-mst-doc">
|
||||
|
||||
@@ -15,13 +15,29 @@
|
||||
*
|
||||
* DOM 与类名逐字照搬原型:皮肤是原型的原样,自造类名等于没样式。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createContext, useContext, useEffect, useRef, useState } from 'react';
|
||||
import type { FormField, AiTag, ChatCardReply, ChatMessage, WorkOrder, WorkOrderReply } from '@contracts';
|
||||
import { EVENT_TYPE_LABEL, isRejection } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
import { ChatAvatar } from '@web-shared/ChatAvatar.tsx';
|
||||
import { atClock } from '@web-shared/at.ts';
|
||||
|
||||
/**
|
||||
* 「这一下交上去了,回音还在路上」—— 卡族共用一个在办标记。
|
||||
*
|
||||
* 卡上的钮全都要走一趟服务端(交工单 / 裁决 / 选一支 / 答一张卡),而这一趟里
|
||||
* 屏上从前**什么都不变**:人按下「提交」之后只能干等,常常再按一次。
|
||||
* 逐层往下传 prop 要穿过 ChatStream → Msg → 各卡三四层,卡族又都在这一份文件里 ——
|
||||
* 用一个上下文最省事,也最不容易漏掉某一张卡(漏掉的那张就是下一次「点了没反应」)。
|
||||
*/
|
||||
const ReplyBusyContext = createContext(false);
|
||||
|
||||
/** 卡上的钮据此说「在办事」;`ChatStream` 从任务面递进来 */
|
||||
function useReplyBusy(): boolean {
|
||||
return useContext(ReplyBusyContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型章:结论 = auto,指导 = exe,判据 = acc(原型 TAG_UI 口径)。
|
||||
*
|
||||
@@ -81,6 +97,8 @@ export type TaskChatProps = {
|
||||
* 状态来自任务投影的 runtime,不是前端猜的。
|
||||
*/
|
||||
readonly working?: boolean;
|
||||
/** 卡上刚交出去的那一笔还在路上 —— 卡上的钮据此说「在办事」 */
|
||||
readonly replying?: boolean;
|
||||
onSay(text: string): void;
|
||||
onReplyCard(reply: ChatCardReply): void;
|
||||
onReplyOrder(reply: WorkOrderReply): void;
|
||||
@@ -98,6 +116,7 @@ export function ChatStream({
|
||||
orders,
|
||||
thinking,
|
||||
working = false,
|
||||
replying = false,
|
||||
onSay,
|
||||
onReplyCard,
|
||||
onReplyOrder,
|
||||
@@ -125,6 +144,7 @@ export function ChatStream({
|
||||
};
|
||||
|
||||
return (
|
||||
<ReplyBusyContext.Provider value={replying}>
|
||||
<div className="jc-tch">
|
||||
<header className="jc-tch-head">
|
||||
<span className="jc-tch-mark">
|
||||
@@ -186,12 +206,13 @@ export function ChatStream({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="copilot-send" disabled={text.trim() === '' || blocked} onClick={send}>
|
||||
<Btn className="copilot-send" disabled={text.trim() === '' || blocked} busy={thinking} onClick={send}>
|
||||
<Ico name="send" s={14} /> 发送
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ReplyBusyContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -274,6 +295,7 @@ function Msg({
|
||||
orderValues?: Readonly<Record<string, string>> | undefined;
|
||||
onField: (key: string, value: string) => void;
|
||||
}) {
|
||||
const busy = useReplyBusy();
|
||||
// 人的消息:右侧品牌气泡(纯文本)
|
||||
if (m.k === 'human') {
|
||||
return (
|
||||
@@ -495,9 +517,10 @@ function Msg({
|
||||
{m.chosen === undefined ? (
|
||||
<div className="jc-branches">
|
||||
{m.options.map((o, i) => (
|
||||
<button
|
||||
<Btn
|
||||
key={o.value}
|
||||
className={'jc-branch' + (o.recommended === true ? ' rec' : '')}
|
||||
busy={busy}
|
||||
onClick={() => onReplyCard({ k: 'decision', cardId: m.id, value: o.value })}
|
||||
>
|
||||
<span className="jc-branch-n jc-mono">{String.fromCharCode(65 + i)}</span>
|
||||
@@ -514,7 +537,7 @@ function Msg({
|
||||
)}
|
||||
</span>
|
||||
<Ico name="chevron-right" s={14} />
|
||||
</button>
|
||||
</Btn>
|
||||
))}
|
||||
{m.allowCustom && (
|
||||
<CustomOption
|
||||
@@ -767,6 +790,7 @@ function ExecForm({
|
||||
orderValues?: Readonly<Record<string, string>> | undefined;
|
||||
onField: (key: string, value: string) => void;
|
||||
}) {
|
||||
const busy = useReplyBusy();
|
||||
const val = (f: FormField) => orderValues?.[f.key] ?? f.ai ?? '';
|
||||
/*
|
||||
* **必填还空着就按不下提交**(2026-08-02 用户定)——两处必填,一个门:
|
||||
@@ -842,9 +866,10 @@ function ExecForm({
|
||||
</div>
|
||||
)}
|
||||
<div className="jc-fc-ft">
|
||||
<button
|
||||
<Btn
|
||||
className="jc-btn jc-fc-go"
|
||||
disabled={!ok}
|
||||
busy={busy}
|
||||
onClick={() =>
|
||||
onReplyOrder({
|
||||
mode: 'execution',
|
||||
@@ -854,7 +879,7 @@ function ExecForm({
|
||||
}
|
||||
>
|
||||
<Ico name="check" s={13} /> {order.submitLabel ?? '提交'}
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -880,6 +905,7 @@ function AcceptForm({
|
||||
*/
|
||||
orderValues?: Readonly<Record<string, string>> | undefined;
|
||||
}) {
|
||||
const busy = useReplyBusy();
|
||||
const [why, setWhy] = useState('');
|
||||
/** 选择组:恰选一口,选口即裁分支 —— 没选之前「通过」不亮 */
|
||||
const [port, setPort] = useState<string | undefined>(undefined);
|
||||
@@ -971,9 +997,10 @@ function AcceptForm({
|
||||
/>
|
||||
</div>
|
||||
<div className="jc-fc-ft">
|
||||
<button
|
||||
<Btn
|
||||
className="jc-btn jc-btn-bad"
|
||||
disabled={why.trim() === ''}
|
||||
busy={busy}
|
||||
onClick={() =>
|
||||
onReplyOrder({
|
||||
mode: 'acceptance',
|
||||
@@ -987,11 +1014,12 @@ function AcceptForm({
|
||||
}
|
||||
>
|
||||
重做
|
||||
</button>
|
||||
</Btn>
|
||||
<span className="jc-fc-sp" />
|
||||
<button
|
||||
<Btn
|
||||
className="jc-btn jc-btn-ok"
|
||||
disabled={needChoice && port === undefined}
|
||||
busy={busy}
|
||||
title={needChoice && port === undefined ? `先在上面选一个「${choice.group}」` : undefined}
|
||||
onClick={() =>
|
||||
onReplyOrder({
|
||||
@@ -1021,7 +1049,7 @@ function AcceptForm({
|
||||
}
|
||||
>
|
||||
<Ico name="circle-check" s={14} /> {order.passLabel ?? '确认通过'}
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1029,6 +1057,7 @@ function AcceptForm({
|
||||
|
||||
/* 决策的收尾项:自定义口径 */
|
||||
function CustomOption({ n, onPick }: { n: string; onPick: (txt: string) => void }) {
|
||||
const busy = useReplyBusy();
|
||||
const [txt, setTxt] = useState('');
|
||||
return (
|
||||
<div className="jc-branch jc-branch-custom">
|
||||
@@ -1044,9 +1073,9 @@ function CustomOption({ n, onPick }: { n: string; onPick: (txt: string) => void
|
||||
if (e.key === 'Enter' && txt.trim() !== '') onPick(txt.trim());
|
||||
}}
|
||||
/>
|
||||
<button className="jc-btn" disabled={txt.trim() === ''} onClick={() => onPick(txt.trim())}>
|
||||
<Btn className="jc-btn" disabled={txt.trim() === ''} busy={busy} onClick={() => onPick(txt.trim())}>
|
||||
就按这个
|
||||
</button>
|
||||
</Btn>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -1068,6 +1097,7 @@ function FillForm({
|
||||
* 人核对一眼、补上它填不出的那个即可。
|
||||
* 用惰性初值而不是 `useEffect` 回填:回填会在人已经开始打字之后把他敲的字冲掉。
|
||||
*/
|
||||
const busy = useReplyBusy();
|
||||
const [vals, setVals] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(fields.filter((f) => (f.value ?? '') !== '').map((f) => [f.key, f.value ?? ''])),
|
||||
);
|
||||
@@ -1093,9 +1123,9 @@ function FillForm({
|
||||
<button type="button" className="jc-cant-link" onClick={() => setCant(false)}>
|
||||
← 还是填吧
|
||||
</button>
|
||||
<button className="jc-btn" disabled={reason.trim() === ''} onClick={() => onCant(reason.trim())}>
|
||||
<Btn className="jc-btn" disabled={reason.trim() === ''} busy={busy} onClick={() => onCant(reason.trim())}>
|
||||
<Ico name="send" s={13} /> 让 AI 调整需求
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1120,9 +1150,9 @@ function FillForm({
|
||||
<button type="button" className="jc-cant-link" onClick={() => setCant(true)}>
|
||||
没法填?告诉 AI 原因
|
||||
</button>
|
||||
<button className="jc-btn" disabled={!ok} onClick={() => onDone(vals)}>
|
||||
<Btn className="jc-btn" disabled={!ok} busy={busy} onClick={() => onDone(vals)}>
|
||||
<Ico name="check" s={13} /> 补给 AI
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1130,6 +1160,7 @@ function FillForm({
|
||||
|
||||
/* 评价卡的打分器:星级 + 改进建议(建议进活动记忆) */
|
||||
function RateForm({ onDone }: { onDone: (score: number, note: string) => void }) {
|
||||
const busy = useReplyBusy();
|
||||
const [score, setScore] = useState(0);
|
||||
const [hover, setHover] = useState(0);
|
||||
const [note, setNote] = useState('');
|
||||
@@ -1164,9 +1195,9 @@ function RateForm({ onDone }: { onDone: (score: number, note: string) => void })
|
||||
/>
|
||||
</label>
|
||||
<div className="jc-cfill-acts">
|
||||
<button className="jc-btn" disabled={score === 0} onClick={() => onDone(score, note.trim())}>
|
||||
<Btn className="jc-btn" disabled={score === 0} busy={busy} onClick={() => onDone(score, note.trim())}>
|
||||
<Ico name="check" s={13} /> 提交评价
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1192,6 +1223,7 @@ function EntryCard({
|
||||
* 联系人由 AI 自己写 —— 而联系人档的 `contact_code` 是它编出来的,人一眼没看过。
|
||||
* 值按段隔开(`段号.列名`):两张表都有 `name` 这一列,平铺就会互相盖掉。
|
||||
*/
|
||||
const busy = useReplyBusy();
|
||||
const groups = [
|
||||
{
|
||||
title: '',
|
||||
@@ -1290,9 +1322,9 @@ function EntryCard({
|
||||
{!done && (
|
||||
<div className="jc-cfill-acts">
|
||||
{blank.length > 0 && <span className="jc-entry-warn">还差:{blank.map((f) => f.label).join('、')}</span>}
|
||||
<button className="jc-btn" disabled={blank.length > 0} onClick={() => onDone(vals)}>
|
||||
<Btn className="jc-btn" disabled={blank.length > 0} busy={busy} onClick={() => onDone(vals)}>
|
||||
<Ico name="database" s={13} /> 确认写入
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -41,6 +41,7 @@ import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import { useGovActivities, useGovApprovalTemplates } from '@web-shared/gov/gov-api.ts';
|
||||
import type { GovApprovalTemplates } from '@web-shared/gov/gov-api.ts';
|
||||
import { atFull } from '@web-shared/at.ts';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
|
||||
export type HomeProps = { tab: string; nav: Nav };
|
||||
|
||||
@@ -177,14 +178,9 @@ function ApprovalTemplates({ data }: { data: GovApprovalTemplates['templates'] }
|
||||
<button className="mbtn" aria-label="编辑" onClick={() => setEditing({ ...t })}>
|
||||
<Ico name="pen-line" s={13} />
|
||||
</button>
|
||||
<button
|
||||
className="mbtn bad"
|
||||
aria-label="删除"
|
||||
disabled={drop.isPending}
|
||||
onClick={() => drop.mutate(t.id)}
|
||||
>
|
||||
<Btn className="mbtn bad" aria-label="删除" busy={drop.isPending} onClick={() => drop.mutate(t.id)}>
|
||||
<Ico name="trash-2" s={13} />
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
<div className="plate">
|
||||
<span>
|
||||
@@ -288,7 +284,8 @@ function TemplateForm({
|
||||
label: pending ? '保存中…' : '保存',
|
||||
ico: 'send',
|
||||
primary: true,
|
||||
disabled: pending || !idOk || t.name.trim() === '' || t.basis.some((b) => b.field === '' || b.label === ''),
|
||||
disabled: !idOk || t.name.trim() === '' || t.basis.some((b) => b.field === '' || b.label === ''),
|
||||
busy: pending,
|
||||
onClick: () => onSave({ ...t, name: t.name.trim() }),
|
||||
},
|
||||
]}
|
||||
@@ -516,7 +513,8 @@ function InstallCard() {
|
||||
label: install.isPending ? '两审中…' : '装入并送审',
|
||||
ico: 'circle-check',
|
||||
primary: true,
|
||||
disabled: install.isPending || manifestText.trim() === '' || artifact.trim() === '',
|
||||
disabled: manifestText.trim() === '' || artifact.trim() === '',
|
||||
busy: install.isPending,
|
||||
ask: {
|
||||
title: '经安装通道装入活动包',
|
||||
note: <Note>无代码通道:交的是数据。两审过了才上架;不过审会把是哪一条拦的说清楚。</Note>,
|
||||
|
||||
@@ -11,14 +11,31 @@
|
||||
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
|
||||
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
|
||||
*/
|
||||
import { Card, Chip, Clear, Entry, NList, NRow, Note, PageHead, QueryGate, Sec } from '@web-shared/gov/primitives.tsx';
|
||||
import { Card, Chip, Clear, NList, NRow, Note, PageHead, QueryGate, Sec } from '@web-shared/gov/primitives.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { useGovLedger, useGovObjects } from '@web-shared/gov/gov-api.ts';
|
||||
import { domainShort, sortDomains } from '@web-shared/gov/domains.ts';
|
||||
import { useGovDataByActivity, useGovLedger, useGovObjects } from '@web-shared/gov/gov-api.ts';
|
||||
import { domainShort } from '@web-shared/gov/domains.ts';
|
||||
|
||||
export type HomeProps = { tab: string; nav: Nav };
|
||||
|
||||
/**
|
||||
* 记录浏览的格子文本 —— JSON 列(决策链地图 / 互动时间线)从前被 String() 成
|
||||
* `[object Object]`(2026-08-24 用户报):对象与数组原样 stringify 摆出来,
|
||||
* 超长截断进 title(截了才加省略号,悬停看全文)。这是取值,不是判断。
|
||||
*/
|
||||
const cellText = (v: unknown): string => {
|
||||
if (v === null || v === undefined) return '';
|
||||
if (typeof v === 'object') {
|
||||
try {
|
||||
return JSON.stringify(v);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
return String(v);
|
||||
};
|
||||
|
||||
/* ==================== 设计侧:数据域(定义面) ==================== */
|
||||
|
||||
/**
|
||||
@@ -26,103 +43,151 @@ export type HomeProps = { tab: string; nav: Nav };
|
||||
* 行数与行浏览入口不在这里 —— 那在运营中心的台账。
|
||||
*/
|
||||
/**
|
||||
* 三类的呈现次序与说辞 —— **按「要不要管它」排**,不按字母。
|
||||
* **按活动看**(2026-08-24 用户定:「按活动数据来分类,要让每个活动执行完成后
|
||||
* 台账在这里看到变化」)。
|
||||
*
|
||||
* 主数据摆最前:一屏里真正需要治理的就是它(谁能改、依据是什么、多久算过期)。
|
||||
* 单据量最大但不需要「管」—— 查得到、回放得出就够了,所以摆中间。
|
||||
* 派生摆最后,并且明写「不该手写」:手写的派生值会和它的来源打架,而没人会发现。
|
||||
* 从前这一屏按**性质**分组(主数据/单据/派生)—— 那是「治理这张表」的人的分法,
|
||||
* 答的是「这个对象定义全不全」。可人跑完一单来这儿,问的是另一件事:
|
||||
* **我这个活动写了哪几张表,现在各有几行,刚才那一单落下了什么**。
|
||||
* 按活动分组、把行数摆在最显眼处,那个问题才答得了;性质降成一枚徽,
|
||||
* 它仍要紧(单据与主数据的治理方式不同),但不该再当分组的轴。
|
||||
*
|
||||
* `unknown` 是**库里有表、却没有哪个在装的活动声明它**——多半是退役活动留下的空壳。
|
||||
* 它不是一种性质,是一种状况,所以单列一档、说清楚,不混进上面三类假装正常。
|
||||
* 三个数各说各的,不许混成一个:
|
||||
* · **正式** —— 过了放行门的事实,这张表现在真有几行;
|
||||
* · **草稿** —— 还在某一单草稿层的行(在办中,放行才进正式表);
|
||||
* · **未物化** —— 活动声明了、一次都没写过,所以库里还没这张表。
|
||||
* 它与「有表但空着」是两件事,不拿 0 混过去。
|
||||
*/
|
||||
const KIND_ORDER = ['master', 'record', 'derived', 'unknown'] as const;
|
||||
type KindKey = (typeof KIND_ORDER)[number];
|
||||
const KIND_BADGE: Readonly<Record<string, { label: string; tone: string }>> = {
|
||||
master: { label: '主数据', tone: 'm' },
|
||||
record: { label: '单据', tone: 'r' },
|
||||
derived: { label: '派生', tone: 'd' },
|
||||
};
|
||||
|
||||
const KIND_VIEW: Readonly<Record<KindKey, { title: string; ico: string; desc: string; tone: string }>> = {
|
||||
master: {
|
||||
title: '主数据',
|
||||
ico: 'library',
|
||||
tone: 'm',
|
||||
desc: '跨单积累的经营资产 —— 会被反复更新,下一单读它做判断。治理都在这一类上:谁能改、依据是什么、怎么去重、多久算过期。',
|
||||
},
|
||||
record: {
|
||||
title: '单据',
|
||||
ico: 'file-text',
|
||||
tone: 'r',
|
||||
desc: '一单一份的过程与结论 —— 放行即定稿、只增不改。它不需要「管理」,需要的是查得到、回放得出;主键第一列就是那一单的标识。',
|
||||
},
|
||||
derived: {
|
||||
title: '派生',
|
||||
ico: 'trending-up',
|
||||
tone: 'd',
|
||||
desc: '能由单据与主数据算出来的指标 —— 本不该手写:手写的派生值会和它的来源打架,而没人会发现。标出来,至少让它可被重算校验。',
|
||||
},
|
||||
unknown: {
|
||||
title: '无人声明',
|
||||
ico: 'help-circle',
|
||||
tone: 'u',
|
||||
desc: '库里有这张表,却没有哪个在装的活动声明它 —— 多半是退役活动留下的空壳。台账「只增不破坏」,清空壳表是一次性的人工决定。',
|
||||
},
|
||||
/** 一单此刻是什么样 —— 终态优先(它才是结论),没终态就说运行态 */
|
||||
function runWord(run: { runtime: string; terminal?: string | undefined }): { text: string; tone: string } {
|
||||
if (run.terminal === 'released') return { text: '已放行', tone: 'ok' };
|
||||
if (run.terminal !== undefined) return { text: run.terminal, tone: 'warn' };
|
||||
if (run.runtime === 'running') return { text: 'AI 推进中', tone: 'live' };
|
||||
return { text: '等人办', tone: 'wait' };
|
||||
}
|
||||
|
||||
const atMinute = (iso: string): string => {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? '' : `${d.getMonth() + 1}-${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export function DataHome({ nav }: HomeProps) {
|
||||
const q = useGovObjects();
|
||||
const objects = q.data?.objects ?? [];
|
||||
const q = useGovDataByActivity();
|
||||
const activities = q.data?.activities ?? [];
|
||||
const unclaimed = q.data?.unclaimed ?? [];
|
||||
// 一张表都没声明的活动不占地方 —— 那多半是还没写到台账的槽位
|
||||
const withTables = activities.filter((a) => a.tables.length > 0);
|
||||
|
||||
return (
|
||||
<QueryGate query={q}>
|
||||
<>
|
||||
{KIND_ORDER.map((kind) => {
|
||||
const inKind = objects.filter((o) => (o.kind ?? 'unknown') === kind);
|
||||
if (inKind.length === 0) return null;
|
||||
const view = KIND_VIEW[kind];
|
||||
const domains = sortDomains([...new Set(inKind.map((o) => o.domain))]);
|
||||
{withTables.map((a) => {
|
||||
const formal = a.tables.reduce((n, t) => n + (t.rows ?? 0), 0);
|
||||
const drafting = a.tables.reduce((n, t) => n + t.drafts, 0);
|
||||
return (
|
||||
<section key={kind} className={'g-kind t-' + view.tone}>
|
||||
<div className="g-kind-h">
|
||||
<Ico name={view.ico} s={15} />
|
||||
<b>{view.title}</b>
|
||||
<span className="n">{inKind.length} 张表</span>
|
||||
<p>{view.desc}</p>
|
||||
<section key={a.activityId} className="g-actdata">
|
||||
<div className="g-actdata-h">
|
||||
<Ico name="list-checks" s={15} />
|
||||
<b>{a.name}</b>
|
||||
<span className="g-mono">
|
||||
{a.activityId}
|
||||
{a.version === undefined ? '' : `@${a.version}`}
|
||||
</span>
|
||||
<span className="n">
|
||||
{a.tables.length} 张表 · 正式 {formal} 行
|
||||
{drafting > 0 && <em> · 在办 {drafting} 行</em>}
|
||||
</span>
|
||||
</div>
|
||||
{domains.map((d) => {
|
||||
const inDomain = inKind.filter((o) => o.domain === d);
|
||||
return (
|
||||
<Card
|
||||
key={d}
|
||||
ico="database"
|
||||
title={domainShort(d)}
|
||||
extra={
|
||||
<span className="g-mono">
|
||||
{d} · {inDomain.length} 对象
|
||||
|
||||
<div className="g-actdata-tables">
|
||||
{a.tables.map((t) => {
|
||||
const badge = KIND_BADGE[t.kind ?? ''];
|
||||
return (
|
||||
<button
|
||||
key={`${t.domain}.${t.object}`}
|
||||
type="button"
|
||||
className="g-tbl"
|
||||
onClick={() => nav.push({ k: 'obj', id: `${t.domain}.${t.object}` })}
|
||||
>
|
||||
<span className="g-tbl-t">
|
||||
<b>{t.label ?? t.object}</b>
|
||||
{badge !== undefined && <i className={'g-tbl-k t-' + badge.tone}>{badge.label}</i>}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="g-entries">
|
||||
{inDomain.map((o) => (
|
||||
<Entry
|
||||
key={o.object}
|
||||
ico="table"
|
||||
// 人读名优先;没登记 label 才退回对象键 —— 治理台不该拿 `intake_record` 给人看
|
||||
title={o.label ?? o.object}
|
||||
/* 单据把**按什么分单**摆出来 —— 那是它和主数据最要紧的差别 */
|
||||
desc={
|
||||
o.caseKey === undefined
|
||||
? `${o.domain}.${o.object}`
|
||||
: `${o.domain}.${o.object} · 按 ${o.caseKey} 分单`
|
||||
}
|
||||
onClick={() => nav.push({ k: 'obj', id: `${o.domain}.${o.object}` })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<span className="g-tbl-a g-mono">
|
||||
{t.domain}.{t.object}
|
||||
{t.primaryKey.length > 0 && <em> · 主键 {t.primaryKey.join(' + ')}</em>}
|
||||
</span>
|
||||
<span className="g-tbl-n">
|
||||
{/* 「没物化」不写 0 —— 声明了没写过,与写过又空了不是一回事 */}
|
||||
{t.rows === undefined ? (
|
||||
<em className="none">还没建表</em>
|
||||
) : (
|
||||
<>
|
||||
<b>{t.rows}</b> 行
|
||||
</>
|
||||
)}
|
||||
{t.drafts > 0 && <i className="draft">在办 {t.drafts} 行</i>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{a.recent.length > 0 && (
|
||||
<div className="g-actdata-runs">
|
||||
<em>最近的单</em>
|
||||
{a.recent.map((run) => {
|
||||
const word = runWord(run);
|
||||
return (
|
||||
<span key={run.id} className={'g-run t-' + word.tone}>
|
||||
{word.text}
|
||||
<i>{atMinute(run.at)}</i>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{objects.length === 0 && (
|
||||
<Clear>还没有物化的对象 —— 活动第一次写入时,系统按它的声明把表建出来,然后出现在这里。</Clear>
|
||||
|
||||
{unclaimed.length > 0 && (
|
||||
<section className="g-actdata t-u">
|
||||
<div className="g-actdata-h">
|
||||
<Ico name="help-circle" s={15} />
|
||||
<b>无人声明</b>
|
||||
<span className="n">{unclaimed.length} 张表</span>
|
||||
<p>库里有这张表,却没有哪个在装的活动声明它 —— 多半是退役活动留下的空壳。台账「只增不破坏」,清空壳表是一次性的人工决定。</p>
|
||||
</div>
|
||||
<div className="g-actdata-tables">
|
||||
{unclaimed.map((o) => (
|
||||
<button
|
||||
key={`${o.domain}.${o.object}`}
|
||||
type="button"
|
||||
className="g-tbl"
|
||||
onClick={() => nav.push({ k: 'obj', id: `${o.domain}.${o.object}` })}
|
||||
>
|
||||
<span className="g-tbl-t">
|
||||
<b>{o.label ?? o.object}</b>
|
||||
</span>
|
||||
<span className="g-tbl-a g-mono">
|
||||
{o.domain}.{o.object}
|
||||
</span>
|
||||
<span className="g-tbl-n">{o.rows === undefined ? <em className="none">读不到</em> : <><b>{o.rows}</b> 行</>}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{withTables.length === 0 && unclaimed.length === 0 && (
|
||||
<Clear>还没有活动声明台账 —— 装一个活动包,它声明的表就出现在这里;第一次写入时表才真建出来。</Clear>
|
||||
)}
|
||||
</>
|
||||
</QueryGate>
|
||||
@@ -238,11 +303,15 @@ export function ObjPage({ id }: { id: string }) {
|
||||
<tbody>
|
||||
{(page.data?.rows ?? []).map((row, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map((c) => (
|
||||
<td key={c.name} className="g-mono">
|
||||
{String(row[c.name] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
{columns.map((c) => {
|
||||
const full = cellText(row[c.name]);
|
||||
const long = full.length > 120;
|
||||
return (
|
||||
<td key={c.name} className="g-mono" title={long ? full : undefined}>
|
||||
{long ? `${full.slice(0, 120)}…` : full}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -50,6 +50,7 @@ import { FlowFigureBlock } from '@web-shared/gov/flow/FlowFigure.tsx';
|
||||
import { packageToGraph } from '@web-shared/gov/flow/pkg-to-graph.ts';
|
||||
import { mergeGraphIntoPackage } from '@web-shared/gov/flow/graph-to-pkg.ts';
|
||||
import type { FlowEdge, FlowGraph, FlowNode } from '@web-shared/gov/flow/flow-layout.ts';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
|
||||
export type HomeProps = { tab: string; nav: Nav };
|
||||
|
||||
@@ -239,7 +240,8 @@ function FlowShelf({ nav }: { nav: Nav }) {
|
||||
label: create.isPending ? '建流程中…' : '建流程并进入',
|
||||
ico: 'plus',
|
||||
primary: true,
|
||||
disabled: create.isPending || !idOk || idTaken || nfName.trim() === '',
|
||||
disabled: !idOk || idTaken || nfName.trim() === '',
|
||||
busy: create.isPending,
|
||||
onClick: () => create.mutate(),
|
||||
},
|
||||
]}
|
||||
@@ -333,9 +335,9 @@ function FlowShelf({ nav }: { nav: Nav }) {
|
||||
<>
|
||||
<span className="q">删掉整条流?{f.versions.length} 个版本一起没</span>
|
||||
<button onClick={() => setMenu({ id: f.id, drop: false })}>算了</button>
|
||||
<button className="bad" disabled={dropFlow.isPending} onClick={() => dropFlow.mutate(f.id)}>
|
||||
<Btn className="bad" busy={dropFlow.isPending} onClick={() => dropFlow.mutate(f.id)}>
|
||||
删除
|
||||
</button>
|
||||
</Btn>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -793,19 +795,18 @@ function FlowLab({
|
||||
*/}
|
||||
{editingVersions ? (
|
||||
<>
|
||||
<button
|
||||
<Btn
|
||||
className="g-ver-act"
|
||||
disabled={saveVersions.isPending}
|
||||
onClick={() => {
|
||||
setEditingVersions(false);
|
||||
setEdits({});
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
</Btn>
|
||||
<Btn
|
||||
className="g-ver-act"
|
||||
disabled={saveVersions.isPending}
|
||||
busy={saveVersions.isPending}
|
||||
onClick={() => {
|
||||
const drafts = versions.flatMap((v) => {
|
||||
const d = edits[v.version];
|
||||
@@ -825,13 +826,13 @@ function FlowLab({
|
||||
}}
|
||||
>
|
||||
<Ico name="send" s={11} /> {saveVersions.isPending ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</Btn>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="g-ver-act" onClick={askClone} disabled={cloneVersion.isPending}>
|
||||
<Btn className="g-ver-act" onClick={askClone} busy={cloneVersion.isPending}>
|
||||
<Ico name="plus" s={11} /> 新建
|
||||
</button>
|
||||
</Btn>
|
||||
{/* 这枚「编辑」只作用于**版本记录**:号、名、删 —— 画布的编辑在流程图卡上 */}
|
||||
<button
|
||||
className="g-ver-act"
|
||||
@@ -995,15 +996,16 @@ function FlowLab({
|
||||
节点 · 属性
|
||||
{dirty && <span className="n dirty">有改动待保存</span>}
|
||||
{/* 与「新建 / 编辑」同款素钮 —— 可不可存由禁用态说,不靠换色抢戏 */}
|
||||
<button
|
||||
<Btn
|
||||
className="g-ver-act"
|
||||
disabled={!dirty || save.isPending}
|
||||
disabled={!dirty}
|
||||
busy={save.isPending}
|
||||
onClick={() => {
|
||||
if (graph !== null) save.mutate({ graph, idMap: {} });
|
||||
}}
|
||||
>
|
||||
<Ico name="send" s={11} /> {save.isPending ? '保存中…' : `保存 v${pkg.version}`}
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
<div className="g-nodecard-b">
|
||||
{saveErr !== undefined && (
|
||||
|
||||
@@ -112,6 +112,16 @@ function PackageConfig({
|
||||
['node-owners'],
|
||||
);
|
||||
|
||||
/*
|
||||
* 勾选与下拉也是「点一下就去请求」—— 它们不是按钮,给不了键感,
|
||||
* 那就在办事的这一段把**那一行**点亮(条画在行脚下)。
|
||||
* 认 `variables` 只亮真正在路上的那一行 —— 三个开关共用一个 mutation,
|
||||
* 不认就变成整表一起闪,等于没说是哪一条在办。
|
||||
* 条不画在 `<select>` 自己身上:它的下拉箭头就是 background-image,画上去会顶掉箭头。
|
||||
*/
|
||||
const busyTrigger = toggle.isPending ? toggle.variables.id : confirm.isPending ? confirm.variables.id : undefined;
|
||||
const busyNode = setOwner.isPending ? setOwner.variables.nodeId : undefined;
|
||||
|
||||
return (
|
||||
<Card ico="workflow" title={name} extra={<span className="g-mono">{packageId}</span>}>
|
||||
{/* ── 触发器的运营面:开不开 · 要不要人点头 · 谁点头 —— 规则本身在设计中心改 ── */}
|
||||
@@ -126,7 +136,7 @@ function PackageConfig({
|
||||
triggers.map((t) => {
|
||||
const confirmed = t.confirm.required;
|
||||
return (
|
||||
<div key={t.id} className="g-runconf-r">
|
||||
<div key={t.id} className="g-runconf-r" aria-busy={busyTrigger === t.id ? true : undefined}>
|
||||
<St tone={t.enabled ? 'ok' : 'mute'} ico="zap">
|
||||
{t.name}
|
||||
</St>
|
||||
@@ -186,7 +196,7 @@ function PackageConfig({
|
||||
</div>
|
||||
<div className="g-runconf-own">
|
||||
{nodes.map((n) => (
|
||||
<label key={n.id} className="g-runconf-node">
|
||||
<label key={n.id} className="g-runconf-node" aria-busy={busyNode === n.id ? true : undefined}>
|
||||
<span className="nid">{n.id}</span>
|
||||
<span className="lb">{n.label}</span>
|
||||
{n.kind === 'approval' && <Chip tone="warn">审批</Chip>}
|
||||
|
||||
@@ -339,7 +339,8 @@ function WorkGroupForm({
|
||||
label: save.isPending ? '保存中…' : `保存(已绑 ${bound} 个岗位)`,
|
||||
ico: 'circle-check',
|
||||
primary: true,
|
||||
disabled: save.isPending || draftName.trim() === '',
|
||||
disabled: draftName.trim() === '',
|
||||
busy: save.isPending,
|
||||
ask: {
|
||||
title: `保存工作组:${draftName}`,
|
||||
note: <Note>绑定先于策略 —— 起单人有组,整条流的岗位就按组指人;没绑定的岗位仍走四策略。</Note>,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { Card, Chip, NList, NRow, Note, PageHead, QueryGate, Rows, St } from '@web-shared/gov/primitives.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import { useGovChannels, useGovLlm, useGovLlmProviders } from '@web-shared/gov/gov-api.ts';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
|
||||
export type HomeProps = { tab: string; nav: Nav };
|
||||
|
||||
@@ -256,10 +257,15 @@ function LlmDrawer({
|
||||
</small>
|
||||
</label>
|
||||
<div className="g-testrow">
|
||||
<button className="g-btn xs" disabled={test.isPending || d.model.trim() === '' || urlNeeded} onClick={() => test.mutate()}>
|
||||
<Btn
|
||||
className="g-btn xs"
|
||||
disabled={d.model.trim() === '' || urlNeeded}
|
||||
busy={test.isPending}
|
||||
onClick={() => test.mutate()}
|
||||
>
|
||||
<Ico name={test.isPending ? 'hourglass' : 'zap'} s={11} />
|
||||
{test.isPending ? '测试中…' : '测试连接'}
|
||||
</button>
|
||||
</Btn>
|
||||
{probed !== undefined && (
|
||||
<span className={'r' + (probed.ok ? ' ok' : ' bad')}>
|
||||
{probed.ok ? `连通 · ${probed.latencyMs ?? '—'} ms` : (probed.error ?? '不通')}
|
||||
@@ -286,12 +292,12 @@ function LlmDrawer({
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<button className="g-btn" onClick={onCancel}>
|
||||
<Btn className="g-btn" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
<button className="g-btn primary" disabled={save.isPending || invalid} onClick={() => save.mutate()}>
|
||||
</Btn>
|
||||
<Btn className="g-btn primary" disabled={invalid} busy={save.isPending} onClick={() => save.mutate()}>
|
||||
{save.isPending ? '保存中…' : isNew ? '接入' : '保存'}
|
||||
</button>
|
||||
</Btn>
|
||||
</footer>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -362,10 +368,10 @@ function LlmCard({ i, nav, onEdit, onDropped }: { i: LlmInstanceView; nav: Nav;
|
||||
<span className="host" title={i.baseUrl}>
|
||||
{hostOf(i.baseUrl)}
|
||||
</span>
|
||||
<button className="tbtn" disabled={probe.isPending} onClick={() => probe.mutate()}>
|
||||
<Btn className="tbtn" busy={probe.isPending} onClick={() => probe.mutate()}>
|
||||
<Ico name={probe.isPending ? 'hourglass' : 'zap'} s={11} />
|
||||
{probe.isPending ? '测试中' : '测试'}
|
||||
</button>
|
||||
</Btn>
|
||||
</>
|
||||
) : (
|
||||
/* 不可用就别摆统计:一句判词 + 一条去路,比四个假数字有用 */
|
||||
@@ -391,9 +397,9 @@ function LlmCard({ i, nav, onEdit, onDropped }: { i: LlmInstanceView; nav: Nav;
|
||||
<>
|
||||
<span className="q">删掉这条实例?活动里声明了它的名就删不掉</span>
|
||||
<button onClick={() => setMenu('open')}>算了</button>
|
||||
<button className="bad" disabled={drop.isPending} onClick={() => drop.mutate()}>
|
||||
<Btn className="bad" busy={drop.isPending} onClick={() => drop.mutate()}>
|
||||
删除
|
||||
</button>
|
||||
</Btn>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { APR_ST, RUNTIME_ST, TERMINAL_ST, WF_ST } from '@web-shared/gov/tone.ts'
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import { FlowFigure } from '@web-shared/gov/flow/FlowFigure.tsx';
|
||||
import { atFull, atMinute } from '@web-shared/at.ts';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
import {
|
||||
useGovApprovals,
|
||||
useGovStaff,
|
||||
@@ -379,6 +380,7 @@ export function TaskPage({ id, nav }: { id: string; nav: Nav }) {
|
||||
ico: semantic === 'terminate' ? 'power' : semantic === 'halt' ? 'hand' : 'pause',
|
||||
danger,
|
||||
disabled: !live,
|
||||
busy: control.isPending,
|
||||
ask: {
|
||||
title: `控制事件 · ${CONTROL_SEMANTIC_LABEL[semantic]}:${task.title}`,
|
||||
note: <Note>控制事件走统一事件信封(四类事件之控制事件),先持久后投递。</Note>,
|
||||
@@ -422,6 +424,7 @@ export function TaskPage({ id, nav }: { id: string; nav: Nav }) {
|
||||
ico: 'play',
|
||||
primary: true,
|
||||
disabled: !suspended,
|
||||
busy: resume.isPending,
|
||||
ask: {
|
||||
title: `恢复任务:${task.title}`,
|
||||
note: <Note tone="ok">恢复 = 从挂起点继续;预算与监听解冻,任务记忆只追加、不回滚。</Note>,
|
||||
@@ -457,10 +460,15 @@ export function TaskPage({ id, nav }: { id: string; nav: Nav }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="g-acts-r">
|
||||
<button className="g-btn primary" disabled={assignee === '' || !live} onClick={() => reassign.mutate(assignee)}>
|
||||
<Btn
|
||||
className="g-btn primary"
|
||||
disabled={assignee === '' || !live}
|
||||
busy={reassign.isPending}
|
||||
onClick={() => reassign.mutate(assignee)}
|
||||
>
|
||||
<Ico name="user-plus" s={13} />
|
||||
确认改派
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
</Sec>
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ 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';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
|
||||
type Parsed<S> = S extends { parse: (data: unknown) => infer O } ? O : never;
|
||||
export type ActivityState = Parsed<typeof testActivityStateRoute.output>;
|
||||
@@ -54,6 +55,21 @@ export function ActivityRun({
|
||||
const picked = activities.find((a) => a.activityId === pickedId) ?? activities[0];
|
||||
const [data, setData] = useState<Readonly<Record<string, string>>>({});
|
||||
const [ownerId, setOwner] = useState('');
|
||||
/**
|
||||
* **连主数据一起落真库**(2026-08-24 用户:「数据需要真实入库」「那你把这个开关打开了吗?」)。
|
||||
*
|
||||
* **试跑台这一处默认勾上** —— 人打开这一屏是来看活动真跑一遍的:
|
||||
* 台账更新明细写着「亲密度 3 → 10」而 `customer.contact` 还是 3,
|
||||
* 单据说改了、主数据没改,两份事实对不上,这一屏就白看了。
|
||||
*
|
||||
* 代价照旧存在,所以那一格勾上时是**红的**、并写清在破例:主数据按业务实体分行,
|
||||
* 试跑往 `customer.profile` 建一位客户,那位客户就永久留在库里 ——
|
||||
* 「新客户来函」那类**只能测一次**的样例,跑之前把这一格取消掉。
|
||||
*
|
||||
* 接口那一侧的默认仍是 `false`(`testRunActivityRoute`):e2e 与别的程序化调用照旧受保护,
|
||||
* 那道保护本来就是为它们设的。默认的差别不是自相矛盾 —— **人手点的和脚本跑的,要的东西不一样**。
|
||||
*/
|
||||
const [landMaster, setLandMaster] = useState(true);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -68,7 +84,7 @@ export function ActivityRun({
|
||||
}, [picked?.activityId]);
|
||||
|
||||
const start = useMutation({
|
||||
mutationFn: (input: { activityId: string; data: Record<string, unknown>; ownerId: string }) =>
|
||||
mutationFn: (input: { activityId: string; data: Record<string, unknown>; ownerId: string; landMaster: boolean }) =>
|
||||
call<{ taskId: string; title: string }>(testRunActivityRoute, { body: input }),
|
||||
onSuccess: (r) => {
|
||||
setError(undefined);
|
||||
@@ -172,7 +188,7 @@ export function ActivityRun({
|
||||
}
|
||||
}
|
||||
setError(undefined);
|
||||
start.mutate({ activityId: card.activityId, data: shaped, ownerId });
|
||||
start.mutate({ activityId: card.activityId, data: shaped, ownerId, landMaster });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -321,6 +337,30 @@ export function ActivityRun({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tb-asn">
|
||||
<label className={'tb-land' + (landMaster ? ' on' : '')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={landMaster}
|
||||
onChange={(e) => setLandMaster(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<b>连主数据一起落真库</b>
|
||||
{/* 星号是 Markdown,JSX 里不渲染 —— 屏上就真的印着两个星号(界面不撒谎,也别摆记号) */}
|
||||
<em>
|
||||
{landMaster ? (
|
||||
<>
|
||||
这一单放行时,客户档案 / 联系人 / 品牌内容库这类主数据会<b>真的写进正式表</b> ——
|
||||
演示与联调要看的就是它
|
||||
</>
|
||||
) : (
|
||||
<>只落单据:主数据留在活动缓存,免得试跑把样例客户写成老客户(「新客户来函」那条路就再也试不出来)</>
|
||||
)}
|
||||
</em>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="g-rig-h">
|
||||
<span className="n">03</span>
|
||||
这个活动手上有什么
|
||||
@@ -338,14 +378,15 @@ export function ActivityRun({
|
||||
'起单会先销毁上一单;这是**真任务** —— 照旧过预算、案例模型与放行门,只是可以随时拔掉'
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
<Btn
|
||||
className={'tb-go' + (fireable ? ' armed' : '')}
|
||||
disabled={!fireable || start.isPending}
|
||||
disabled={!fireable}
|
||||
busy={start.isPending}
|
||||
onClick={() => picked && fire(picked)}
|
||||
>
|
||||
<Ico name="play" s={13} />
|
||||
{start.isPending ? '起单中…' : '起单试跑'}
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -642,10 +683,10 @@ function ActivityObserve({
|
||||
* 跑完之后已经没有什么可停的,还写「停止仿真」会让人以为不点它这一单就还占着;
|
||||
* 而它真正的作用是把这一单连根拔掉、腾出仪器再来一单。
|
||||
*/
|
||||
<button className="tb-stop" disabled={stopping} onClick={onStop}>
|
||||
<Btn className="tb-stop" busy={stopping} onClick={onStop}>
|
||||
<Ico name={live ? 'square' : 'rotate-ccw'} s={11} />
|
||||
{stopping ? '销毁中…' : live ? '停止仿真' : '清场 · 再来一单'}
|
||||
</button>
|
||||
</Btn>
|
||||
),
|
||||
})}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import { ActivityRun } from './activity-run.tsx';
|
||||
import { isLive, useRunTrace, useRunWorkflow } from './run-api.ts';
|
||||
import { TraceSheet } from './trace-sheet.tsx';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
|
||||
type RunOutput = { workflowId: string; state: string };
|
||||
|
||||
@@ -389,10 +390,10 @@ function PackageRun({ nav, hint }: { nav: Nav; hint?: ReactNode }): ReactNode {
|
||||
'起单会先销毁上一单;任务真实落到被指派人的工作台,抽不出的键由受理补全单收齐'
|
||||
)}
|
||||
</span>
|
||||
<button className={'tb-go' + (fireable ? ' armed' : '')} disabled={!fireable || run.isPending} onClick={fire}>
|
||||
<Btn className={'tb-go' + (fireable ? ' armed' : '')} disabled={!fireable} busy={run.isPending} onClick={fire}>
|
||||
<Ico name="play" s={13} />
|
||||
{run.isPending ? '起单中…' : '起单试跑'}
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -519,10 +520,10 @@ function Observe({
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<button className="tb-stop" disabled={stopping} onClick={onBack}>
|
||||
<Btn className="tb-stop" busy={stopping} onClick={onBack}>
|
||||
<Ico name="square" s={11} />
|
||||
{stopping ? '销毁中…' : '停止仿真'}
|
||||
</button>
|
||||
</Btn>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { QueryGate } from '@web-shared/gov/primitives.tsx';
|
||||
import { DraftGroups } from './drafts.tsx';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
|
||||
/** 详情比列表多三样正文 —— 形状从契约的 parse 出参接出来,不手搓 */
|
||||
type Parsed<S> = S extends { parse: (data: unknown) => infer O } ? O : never;
|
||||
@@ -119,15 +120,16 @@ export function TestLogs(): ReactNode {
|
||||
<span className="word">试跑存档</span>
|
||||
<span className="det">{runs.length === 0 ? '还没有存档' : `${runs.length} 次试跑`}</span>
|
||||
<span className="x">
|
||||
<button
|
||||
<Btn
|
||||
className="tb-stop"
|
||||
disabled={runs.length === 0 || clearAll.isPending}
|
||||
disabled={runs.length === 0}
|
||||
busy={clearAll.isPending}
|
||||
onClick={() => clearAll.mutate()}
|
||||
title="清空全部存档"
|
||||
>
|
||||
<Ico name="trash-2" s={11} />
|
||||
{clearAll.isPending ? '清空中…' : '清空'}
|
||||
</button>
|
||||
</Btn>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -141,7 +143,12 @@ export function TestLogs(): ReactNode {
|
||||
) : (
|
||||
<div className="tb-logs">
|
||||
{runs.map((r) => (
|
||||
<button key={r.id} className="tb-log-r" onClick={() => setOpen(r.id)}>
|
||||
<button
|
||||
key={r.id}
|
||||
className="tb-log-r"
|
||||
aria-busy={dropOne.isPending && dropOne.variables === r.id ? true : undefined}
|
||||
onClick={() => setOpen(r.id)}
|
||||
>
|
||||
<span className={'kd ' + r.kind}>{r.kind === 'package' ? '流程' : '活动'}</span>
|
||||
<RunId id={r.id} />
|
||||
<span className="sub">
|
||||
@@ -218,10 +225,10 @@ function LogDetail({
|
||||
<Ico name="corner-up-left" s={10} />
|
||||
回目录
|
||||
</button>
|
||||
<button className="tb-stop" disabled={dropping} onClick={onDrop}>
|
||||
<Btn className="tb-stop" busy={dropping} onClick={onDrop}>
|
||||
<Ico name="trash-2" s={11} />
|
||||
{dropping ? '删除中…' : '删掉这条'}
|
||||
</button>
|
||||
</Btn>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -381,7 +381,8 @@ function ImportToolCard() {
|
||||
label: importTool.isPending ? '两审中…' : '导入并送审',
|
||||
ico: 'circle-check',
|
||||
primary: true,
|
||||
disabled: importTool.isPending || name.trim() === '' || endpoint.trim() === '',
|
||||
disabled: name.trim() === '' || endpoint.trim() === '',
|
||||
busy: importTool.isPending,
|
||||
ask: {
|
||||
title: `导入工具:${name.trim()}`,
|
||||
note: <Note>与第一方同一条门:静态审 + 试运行审。过了才入目录;入目录还不等于能用,要再给活动授白名单。</Note>,
|
||||
|
||||
@@ -370,7 +370,8 @@ function TriggerForm({
|
||||
label: save.isPending ? '保存中…' : '保存规则',
|
||||
ico: 'circle-check',
|
||||
primary: true,
|
||||
disabled: save.isPending || draft.name.trim() === '',
|
||||
disabled: draft.name.trim() === '',
|
||||
busy: save.isPending,
|
||||
ask: {
|
||||
title: `保存调起规则:${draft.name}`,
|
||||
note: <Note>触发器与流程包平级 —— 改规则不动任何流程包版本,已起的单照常跑完。</Note>,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 会去请求服务端的那种按钮 —— 三面共用(2026-08-24 用户:「点击了都不知道点没点上」)。
|
||||
*
|
||||
* 一枚按钮按下去之后,人要在**三个时刻**都看得见东西:
|
||||
* · **按下那一刻** —— 键帽下沉(`:active`,纯 CSS,见 `styles/press.css`),证明这一下点着了;
|
||||
* · **在办事的这一段** —— `aria-busy` 亮着,按钮脚下走一道不定长进度条,鼠标也进不来
|
||||
* (`pointer-events: none`)。这一段从前是**完全静默**的:屏上什么都没变,人只好再点一次;
|
||||
* · **办完那一刻** —— 条收掉,按钮恢复可按。
|
||||
*
|
||||
* 为什么用 `aria-disabled` + `pointer-events: none`,不用原生 `disabled`:
|
||||
* `disabled` 会把按钮画成灰的 —— 那是「这件事现在做不了」,而它此刻正在**做**。
|
||||
* 界面不撒谎:在办事就画成在办事,别画成不可用。真正做不了的照旧给 `disabled`。
|
||||
*
|
||||
* 在办的判断有两条路,谁先来算谁:
|
||||
* · `busy`(常用)—— 把 react-query 的 `isPending` 递进来;
|
||||
* · `onClick` 返回了 Promise —— 自己盯着它落地。
|
||||
* 两条路之外还有唯一通道上的去重闸(`typed-fetch`),那是**最后一道**:
|
||||
* 就算这里漏了,同一笔请求也不会真的发两遍。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState, type ButtonHTMLAttributes, type MouseEvent, type ReactNode } from 'react';
|
||||
|
||||
type BtnProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> & {
|
||||
/** 在办事(通常是 `mutation.isPending`) */
|
||||
busy?: boolean | undefined;
|
||||
/** 返回 Promise 的话,不必再给 `busy` —— 落地之前一直算在办 */
|
||||
onClick?: ((e: MouseEvent<HTMLButtonElement>) => unknown) | undefined;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
function isThenable(v: unknown): v is Promise<unknown> {
|
||||
return typeof v === 'object' && v !== null && typeof (v as { then?: unknown }).then === 'function';
|
||||
}
|
||||
|
||||
export function Btn({ busy, onClick, disabled, type = 'button', ...rest }: BtnProps) {
|
||||
const [awaiting, setAwaiting] = useState(false);
|
||||
const alive = useRef(true);
|
||||
// 上来先把旗子插回去 —— StrictMode 开发下 effect 走「挂载 → 拆卸 → 再挂载」,
|
||||
// 只在拆卸时落旗的话第二次挂载后它永远是 false,靠 Promise 收尾的按钮会一直亮着「在办事」。
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
return () => {
|
||||
alive.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const pending = busy === true || awaiting;
|
||||
|
||||
const fire = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
// 在办事的时候再点一下:什么也不做(不是拒绝,是这一下本来就是余震)
|
||||
if (pending || disabled === true || onClick === undefined) return;
|
||||
const r = onClick(e);
|
||||
if (!isThenable(r)) return;
|
||||
setAwaiting(true);
|
||||
const settle = (): void => {
|
||||
if (alive.current) setAwaiting(false);
|
||||
};
|
||||
r.then(settle, settle);
|
||||
},
|
||||
[pending, disabled, onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
type={type}
|
||||
disabled={disabled === true}
|
||||
{...(pending ? { 'aria-busy': true, 'aria-disabled': true } : {})}
|
||||
onClick={fire}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 唯一通道上的那道闸 —— 「同一笔请求不发两遍」(2026-08-24)。
|
||||
*
|
||||
* 这道闸不是性能优化,是**双击不该变成两笔**:点「新建纪要」手抖点两下,
|
||||
* 从前真的建两份;点「装入」两下,真的装两遍。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { markCommReadRoute, sessionRoute } from '@contracts';
|
||||
import { ApiCallError, DOUBLE_CLICK_MS, call, callKey, resetCallGuards } from '../typed-fetch.ts';
|
||||
|
||||
const write = markCommReadRoute;
|
||||
const read = sessionRoute;
|
||||
|
||||
/** 手动控制何时落地的假网关 —— 在途那一段要摆得住才测得了 */
|
||||
function gateway() {
|
||||
const calls: string[] = [];
|
||||
const pending: Array<(v: unknown) => void> = [];
|
||||
const fetchMock = vi.fn((url: string, init?: { method?: string }) => {
|
||||
calls.push(`${init?.method ?? 'GET'} ${url}`);
|
||||
return new Promise((resolve) => {
|
||||
pending.push(() => resolve({ ok: true, status: 200, json: () => Promise.resolve({ n: calls.length }) }));
|
||||
});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return { calls, settleAll: () => pending.splice(0).forEach((f) => f(undefined)) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetCallGuards();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('callKey —— 差一个字就是另一笔', () => {
|
||||
it('方法 + 落地地址 + 报文,三样合起来才是身份', () => {
|
||||
expect(callKey(write, { params: { commId: 'a' } })).toBe('POST /api/desk/comms/a/read ');
|
||||
expect(callKey(write, { params: { commId: 'a' }, body: { read: true } })).toBe(
|
||||
'POST /api/desk/comms/a/read {"read":true}',
|
||||
);
|
||||
});
|
||||
|
||||
it('**改了一格再存不算重复**:报文不同,身份就不同', () => {
|
||||
expect(callKey(write, { body: { read: true } })).not.toBe(callKey(write, { body: { read: false } }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('在途去重', () => {
|
||||
it('**同一笔还在路上,第二次点击不再发第二个请求**', async () => {
|
||||
const g = gateway();
|
||||
const a = call(write, { params: { commId: 'a' } });
|
||||
const b = call(write, { params: { commId: 'a' } });
|
||||
expect(g.calls).toHaveLength(1);
|
||||
g.settleAll();
|
||||
expect(await a).toEqual(await b);
|
||||
});
|
||||
|
||||
it('不同的那一笔照发不误', async () => {
|
||||
const g = gateway();
|
||||
void call(write, { params: { commId: 'a' } });
|
||||
void call(write, { params: { commId: 'b' } });
|
||||
expect(g.calls).toEqual(['POST /api/desk/comms/a/read', 'POST /api/desk/comms/b/read']);
|
||||
g.settleAll();
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
it('读请求同样只发一个(点开同一份资料连点两下)', async () => {
|
||||
const g = gateway();
|
||||
void call(read);
|
||||
void call(read);
|
||||
expect(g.calls).toHaveLength(1);
|
||||
g.settleAll();
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
});
|
||||
|
||||
describe('落地后的双击窗 —— 只管写请求', () => {
|
||||
it('**刚成功那一笔在窗内原样重来,交回上一次的结果、不再发**', async () => {
|
||||
const g = gateway();
|
||||
const first = call(write, { params: { commId: 'a' } });
|
||||
g.settleAll();
|
||||
await first;
|
||||
|
||||
const again = await call(write, { params: { commId: 'a' } });
|
||||
expect(g.calls).toHaveLength(1);
|
||||
expect(again).toEqual(await first);
|
||||
});
|
||||
|
||||
it('过了窗口就是新的一次点击,照发', async () => {
|
||||
const g = gateway();
|
||||
const first = call(write, { params: { commId: 'a' } });
|
||||
g.settleAll();
|
||||
await first;
|
||||
|
||||
vi.advanceTimersByTime(DOUBLE_CLICK_MS + 1);
|
||||
void call(write, { params: { commId: 'a' } });
|
||||
expect(g.calls).toHaveLength(2);
|
||||
g.settleAll();
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
it('**读请求不设这个窗** —— 写完立刻重取的那一下不能被当成余震压掉', async () => {
|
||||
const g = gateway();
|
||||
const first = call(read);
|
||||
g.settleAll();
|
||||
await first;
|
||||
|
||||
void call(read);
|
||||
expect(g.calls).toHaveLength(2);
|
||||
g.settleAll();
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
it('**失败的不进窗**:那时候再点是重试,重试必须真的重试', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.resolve({ ok: false, status: 500, statusText: 'x', json: () => Promise.resolve({ code: 'internal', message: '炸了' }) })),
|
||||
);
|
||||
await expect(call(write, { params: { commId: 'a' } })).rejects.toBeInstanceOf(ApiCallError);
|
||||
await expect(call(write, { params: { commId: 'a' } })).rejects.toBeInstanceOf(ApiCallError);
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FlowEdge, FlowGraph, FlowNode, FlowNodeKind } from "./flow-layout.ts";
|
||||
import { createFlowEditor, syncElementLabel, type FlowEditorHandles } from "./flow-editor-core.ts";
|
||||
import "diagram-js/assets/diagram-js.css";
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
|
||||
/**
|
||||
* 手工绘图编辑器(React 壳):内核是 flow-editor-core 的 diagram-js modeling 全栈
|
||||
@@ -208,10 +209,10 @@ export function FlowEditor({ initial, drilledIds, onSave, onCancel }: FlowEditor
|
||||
>
|
||||
<Ico name="x" s={12} /> 取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
<Btn
|
||||
className="be-btn solid"
|
||||
disabled={!canSave}
|
||||
busy={busy}
|
||||
onClick={() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
@@ -225,7 +226,7 @@ export function FlowEditor({ initial, drilledIds, onSave, onCancel }: FlowEditor
|
||||
}}
|
||||
>
|
||||
<Ico name="check" s={12} /> {busy ? "保存中…" : "保存绘图"}
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
{error && <p className="fe-error">{error}</p>}
|
||||
|
||||
|
||||
@@ -24,10 +24,12 @@ import type {
|
||||
GovWorkGroupsOutput,
|
||||
GovWorkflowOutput,
|
||||
ProcessPackage,
|
||||
GovLedgerByActivityOutput,
|
||||
} from '@contracts';
|
||||
import type { ApprovalTemplate } from '@contracts';
|
||||
import {
|
||||
browseLedgerRoute,
|
||||
ledgerByActivityRoute,
|
||||
govMetricsRoute,
|
||||
govTaskLogRoute,
|
||||
govWorkflowRoute,
|
||||
@@ -57,6 +59,7 @@ export type GovActivities = GovActivitiesOutput;
|
||||
export type GovTools = GovToolsOutput;
|
||||
export type GovObjects = GovObjectsOutput;
|
||||
export type GovLedgerPage = GovLedgerPageOutput;
|
||||
export type GovLedgerByActivity = GovLedgerByActivityOutput;
|
||||
export type GovMemory = GovMemoryOutput;
|
||||
export type GovMetrics = GovMetricsOutput;
|
||||
export type GovChannels = GovChannelsOutput;
|
||||
@@ -139,6 +142,17 @@ export const useGovObjects = (opts?: { enabled?: boolean }): UseQueryResult<GovO
|
||||
enabled: opts?.enabled ?? true,
|
||||
});
|
||||
|
||||
/**
|
||||
* **按活动看数据域**(2026-08-24)—— 「我这个活动写了哪几张表,现在各有几行」。
|
||||
* 键挂在 `['gov','data','by-activity']` 下:`kind:'system'` 的推送按 `gov` 前缀失效,
|
||||
* 一单跑完这一屏跟着变(数据域不以任务为轴,靠的就是那条系统级广播)。
|
||||
*/
|
||||
export const useGovDataByActivity = (): UseQueryResult<GovLedgerByActivity> =>
|
||||
useQuery({
|
||||
queryKey: ['gov', 'data', 'by-activity'],
|
||||
queryFn: () => call<GovLedgerByActivity>(ledgerByActivityRoute),
|
||||
});
|
||||
|
||||
export const useGovLedger = (domain: string | undefined, object: string | undefined): UseQueryResult<GovLedgerPage> =>
|
||||
useQuery({
|
||||
queryKey: ['gov', 'ledger', domain, object],
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
import { useState, type CSSProperties, type Dispatch, type ReactNode, type SetStateAction } from 'react';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
|
||||
export function Chip({ tone = 'mute', ico, children }: { tone?: string; ico?: string; children: ReactNode }) {
|
||||
return (
|
||||
@@ -359,16 +360,16 @@ export function ListEdit({
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(items.map((x, j) => (j === i ? e.target.value : x)))}
|
||||
/>
|
||||
<button className="g-le-x" title="删除该项" onClick={() => onChange(items.filter((_, j) => j !== i))}>
|
||||
<Btn className="g-le-x" title="删除该项" onClick={() => onChange(items.filter((_, j) => j !== i))}>
|
||||
<Ico name="trash-2" s={13} />
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <span className="g-le-e">未声明</span>}
|
||||
<button className="g-le-add" onClick={() => onChange([...items, ''])}>
|
||||
<Btn className="g-le-add" onClick={() => onChange([...items, ''])}>
|
||||
<Ico name="plus" s={12} />
|
||||
添加
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -390,6 +391,8 @@ export type ActSpec = {
|
||||
primary?: boolean;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
/** 这一钮的请求还在路上 —— 递 `mutation.isPending`,按钮自己会说「在办事」 */
|
||||
busy?: boolean;
|
||||
ask?: AskSpec | undefined;
|
||||
onClick?: () => void;
|
||||
};
|
||||
@@ -417,10 +420,10 @@ export function AskBand({ spec, danger, onCancel }: { spec: AskSpec; danger?: bo
|
||||
</div>
|
||||
</div>
|
||||
<div className="g-ask-a">
|
||||
<button className="g-btn" onClick={onCancel}>
|
||||
<Btn className="g-btn" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
</Btn>
|
||||
<Btn
|
||||
className={'g-btn ' + (danger ? 'danger' : 'primary')}
|
||||
onClick={() => {
|
||||
spec.onOk();
|
||||
@@ -428,7 +431,7 @@ export function AskBand({ spec, danger, onCancel }: { spec: AskSpec; danger?: bo
|
||||
}}
|
||||
>
|
||||
{spec.okLabel}
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -441,9 +444,10 @@ export function Acts({ items, align = 'start' }: { items: readonly ActSpec[]; al
|
||||
<div className="g-acts">
|
||||
<div className="g-acts-r" style={{ justifyContent: align === 'end' ? 'flex-end' : 'flex-start' }}>
|
||||
{items.map((it, i) => (
|
||||
<button
|
||||
<Btn
|
||||
key={i}
|
||||
disabled={it.disabled === true}
|
||||
busy={it.busy === true}
|
||||
className={
|
||||
'g-btn' + (it.primary ? ' primary' : '') + (it.danger ? ' danger' : '') + (armed === i ? ' armed' : '')
|
||||
}
|
||||
@@ -451,7 +455,7 @@ export function Acts({ items, align = 'start' }: { items: readonly ActSpec[]; al
|
||||
>
|
||||
{it.ico && <Ico name={it.ico} s={13} />}
|
||||
{it.label}
|
||||
</button>
|
||||
</Btn>
|
||||
))}
|
||||
</div>
|
||||
{a?.ask && <AskBand spec={a.ask} {...(a.danger === undefined ? {} : { danger: a.danger })} onCancel={() => setArmed(null)} />}
|
||||
@@ -495,8 +499,10 @@ export function PageHead({
|
||||
{acts && (
|
||||
<div className="g-dh-a">
|
||||
{acts.map((it, i) => (
|
||||
<button
|
||||
<Btn
|
||||
key={i}
|
||||
disabled={it.disabled === true}
|
||||
busy={it.busy === true}
|
||||
className={
|
||||
'g-btn' + (it.primary ? ' primary' : '') + (it.danger ? ' danger' : '') + (armed === i ? ' armed' : '')
|
||||
}
|
||||
@@ -504,7 +510,7 @@ export function PageHead({
|
||||
>
|
||||
{it.ico && <Ico name={it.ico} s={13} />}
|
||||
{it.label}
|
||||
</button>
|
||||
</Btn>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
/**
|
||||
* 服务端交互的唯一通道(编码规范 §9.2):组件内**禁止裸 fetch**。
|
||||
* 路由契约来自 `@contracts/api`;**禁止 Hono RPC 客户端** —— 那要求前端 import 后端类型(§5.1)。
|
||||
*
|
||||
* **同一笔请求不发两遍**(2026-08-24):唯一通道正是安放这道闸的地方 ——
|
||||
* 按在这里,所有点击路径(按钮 / 下拉 / 勾选 / 组件里临时写的 mutate)一次盖全,
|
||||
* 不必指望每个调用点自己记得防抖。两段:
|
||||
* · **在途去重** —— 同一笔还在路上,第二次点击拿到的是同一个 Promise,不再发第二个请求;
|
||||
* · **落地后的双击窗**(仅写请求)—— 刚成功那一笔在 `DOUBLE_CLICK_MS` 内原样重来,
|
||||
* 当作同一次点击的余震,交回上一次的结果。失败的不进这个窗:那时候再点是**重试**,
|
||||
* 重试必须真的重试。
|
||||
* 读请求(GET)只做在途去重,不设落地窗 —— 写完立刻重取的那一下不能被当成余震压掉,
|
||||
* 否则屏上摆的是改之前的数(界面不撒谎)。
|
||||
*/
|
||||
import type { RouteContract } from '@contracts';
|
||||
|
||||
@@ -36,7 +46,36 @@ function queryString(query: Readonly<Record<string, string | undefined>> = {}):
|
||||
return s === '' ? '' : `?${s}`;
|
||||
}
|
||||
|
||||
export async function call<T>(route: RouteContract, options: CallOptions = {}): Promise<T> {
|
||||
/** 双击阈值 —— 一次成功落地之后这么久之内的同一笔,算同一次点击的余震 */
|
||||
export const DOUBLE_CLICK_MS = 450;
|
||||
|
||||
/**
|
||||
* 一笔请求的身份 = 方法 + 落地后的完整地址 + 报文。
|
||||
* 差一个字就是另一笔(改了一格再存不会被当成重复),一模一样才谈得上「点了两下」。
|
||||
*/
|
||||
export function callKey(route: RouteContract, options: CallOptions = {}): string {
|
||||
const url = resolvePath(route.path, options.params) + queryString(options.query);
|
||||
return `${route.method} ${url} ${options.body === undefined ? '' : JSON.stringify(options.body)}`;
|
||||
}
|
||||
|
||||
/** 在路上的:键 → 那一个 Promise */
|
||||
const inFlight = new Map<string, Promise<unknown>>();
|
||||
/** 刚落地的写请求:键 → { 时刻, 结果 } */
|
||||
const justDone = new Map<string, { at: number; value: unknown }>();
|
||||
|
||||
/** 过了窗口的旧条目扫掉 —— 这张表只服务「刚刚那一下」,不该随会话一直长 */
|
||||
function sweep(): void {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of justDone) if (now - v.at >= DOUBLE_CLICK_MS) justDone.delete(k);
|
||||
}
|
||||
|
||||
/** 测试用:把两张表清空(浏览器里没有调用方) */
|
||||
export function resetCallGuards(): void {
|
||||
inFlight.clear();
|
||||
justDone.clear();
|
||||
}
|
||||
|
||||
async function send<T>(route: RouteContract, options: CallOptions): Promise<T> {
|
||||
const response = await fetch(resolvePath(route.path, options.params) + queryString(options.query), {
|
||||
method: route.method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -56,3 +95,35 @@ export async function call<T>(route: RouteContract, options: CallOptions = {}):
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export function call<T>(route: RouteContract, options: CallOptions = {}): Promise<T> {
|
||||
const key = callKey(route, options);
|
||||
const write = route.method !== 'GET';
|
||||
|
||||
const flying = inFlight.get(key);
|
||||
if (flying !== undefined) return flying as Promise<T>;
|
||||
|
||||
if (write) {
|
||||
const done = justDone.get(key);
|
||||
if (done !== undefined && Date.now() - done.at < DOUBLE_CLICK_MS) return Promise.resolve(done.value as T);
|
||||
if (done !== undefined) justDone.delete(key);
|
||||
}
|
||||
|
||||
const promise = send<T>(route, options).then(
|
||||
(value) => {
|
||||
inFlight.delete(key);
|
||||
// 只记成功的:失败之后再点是重试,重试得真的发出去
|
||||
if (write) {
|
||||
sweep();
|
||||
justDone.set(key, { at: Date.now(), value });
|
||||
}
|
||||
return value;
|
||||
},
|
||||
(error: unknown) => {
|
||||
inFlight.delete(key);
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
inFlight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user