人手改的那一手真正落得下去;界面不撒谎那几处补齐

手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的:

件上就地改
· 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名,
  于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 ——
  而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。
· **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。
  删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)——
  「没回传就是删了」会把一次不完整的回传变成一次删表。
· **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。
· 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。

判据与门
· 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来,
  人填好的目标价它照样判「客户未提供」的毛病由此消掉。
· 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。
· 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开,
  于是一单没打标就进了放行门。三个数全由基座现数。
· 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。
  **指向两扇打不开的门,比不指还糟。**

界面不撒谎
· 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。
· 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、
  往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。
· 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。
· 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。
· 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。

A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。
五道门禁全绿(971 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
This commit is contained in:
2026-08-02 13:41:44 +08:00
co-authored by Claude Opus 5
parent 94f1c7a4ef
commit 259192f736
228 changed files with 27850 additions and 2847 deletions
+49
View File
@@ -0,0 +1,49 @@
/**
* 时刻一律本地 —— **差八小时的时刻比没有时刻更坏**,它看起来像一个可信的事实。
*
* 实测栽过:后端存 UTC,前端满屏 `at.slice(5, 16)` 直接摆上去,
* 于是一封 08-01 04:18 到的信,工单上写着「派发 07-31 20:18」、气泡写着「20:18」;
* 人拿它去核 SLA、去跟客户对「我们几点收到的」,一核一个错。
*/
import { describe, expect, it } from 'vitest';
import { atClock, atFull, atMinute } from '../at.ts';
const two = (n: number): string => String(n).padStart(2, '0');
describe('ISO → 本地时刻', () => {
const iso = '2026-07-31T20:18:33.000Z';
const at = new Date(iso);
it('atMinute 给「月-日 时:分」,按本地时区', () => {
expect(atMinute(iso)).toBe(
`${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`,
);
});
it('atClock 给「时:分」', () => {
expect(atClock(iso)).toBe(`${two(at.getHours())}:${two(at.getMinutes())}`);
});
it('atFull 带上年', () => {
expect(atFull(iso)).toBe(
`${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`,
);
});
it('**不是照搬 UTC 那几个字符** —— 时区不为零时两者必不相同', () => {
const offset = new Date(iso).getTimezoneOffset();
if (offset !== 0) expect(atMinute(iso)).not.toBe(iso.slice(5, 16).replace('T', ' '));
});
it('不是时刻就留空,不摆一个假的', () => {
for (const bad of ['', '这不是时刻', 'undefined']) {
expect(atMinute(bad)).toBe('');
expect(atClock(bad)).toBe('');
expect(atFull(bad)).toBe('');
}
});
it('秒不进屏 —— 同一分钟内的两个时刻摆出来一模一样', () => {
expect(atMinute('2026-08-01T04:18:01.000Z')).toBe(atMinute('2026-08-01T04:18:59.999Z'));
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* 时刻的摆法 —— **屏上一律本地时刻**。
*
* 后端存的是 UTC ISO 串。从前满屏都是 `at.slice(5, 16).replace('T', ' ')` ——
* 把 UTC 那八个字符直接摆上去,于是**每一个时刻都差着时区**:
* 一封 08-01 04:18 到的信,工单上写着「派发 07-31 20:18」,对话气泡写着「20:18」。
* 差八小时的时刻比没有时刻更坏 —— 它看起来像一个可信的事实,人拿它去核 SLA、
* 去跟客户对「我们几点收到的」,一核一个错(实测在来件那一行栽过一次)。
*
* 只做一件事:ISO → 本地。**不做「几分钟前」这类相对时间** ——
* 相对时间读着舒服,可它随打开的时刻变,截图发给同事就对不上了。
*/
const two = (n: number): string => String(n).padStart(2, '0');
/** `月-日 时:分` —— 工单、气泡、日志里那一小行 */
export function atMinute(iso: string): string {
const at = new Date(iso);
if (Number.isNaN(at.getTime())) return '';
return `${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
}
/** `时:分` —— 同一天之内的密集条目(对话流) */
export function atClock(iso: string): string {
const at = new Date(iso);
if (Number.isNaN(at.getTime())) return '';
return `${two(at.getHours())}:${two(at.getMinutes())}`;
}
/** `年-月-日 时:分` —— 详情页那种要说清哪一天的地方 */
export function atFull(iso: string): string {
const at = new Date(iso);
if (Number.isNaN(at.getTime())) return '';
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
}
@@ -2,7 +2,22 @@
* 数据域短名 —— 字典命中给人读名,字典外回落键尾段:轨上永远有个能认的名字。
*/
import { describe, expect, it } from 'vitest';
import { domainShort, sortDomains } from '../domains.ts';
import { DOMAIN_SHORT, domainShort, sortDomains } from '../domains.ts';
/** 库里现有的全部数据域(2026-07-31 实测 `/api/gov/data/objects`) */
const LIVE_DOMAINS = [
'customer',
'intel',
'project',
'quote_alternative',
'quote_archive',
'quote_intake',
'quote_negotiate',
'quote_pricing',
'quote_sourcing',
'shared',
'supplier',
];
describe('domainShort', () => {
it('已知域给 2-3 字人读名', () => {
@@ -12,20 +27,51 @@ describe('domainShort', () => {
expect(domainShort('supplier')).toBe('供应商');
});
it('未知域回落到键的最后一段,超长截 4 字符', () => {
it('六个活动域跟着活动叫', () => {
expect(domainShort('quote_intake')).toBe('受理');
expect(domainShort('quote_alternative')).toBe('选型');
expect(domainShort('quote_sourcing')).toBe('寻源');
expect(domainShort('quote_pricing')).toBe('定价');
expect(domainShort('quote_negotiate')).toBe('议价');
expect(domainShort('quote_archive')).toBe('归档');
expect(domainShort('shared')).toBe('共享');
});
/*
* 这条是用户提的硬要求:数据域**必须都显示中文**。
* 从前 `quote_*` 一个都没登记,治理台上蹦出 alte / sour / pric 这样的英文残词。
* 拿库里真实存在的域逐个过一遍 —— 漏登记一个,这条就红。
*/
it('库里现有的每一个域都有中文短名 —— 一个都不许漏', () => {
const missing = LIVE_DOMAINS.filter((d) => DOMAIN_SHORT[d] === undefined);
expect(missing).toEqual([]);
for (const d of LIVE_DOMAINS) {
expect(domainShort(d)).toMatch(/^[一-龥]+$/);
}
});
/*
* 字典外回落到**完整尾段**,不再截 4 个字符 ——
* `alternative` 截成 `alte` 不是「能认的名字」,而且把「还没登记」伪装成了「它就叫这个」。
*/
it('未知域回落到键的最后一段,整段留着不截', () => {
expect(domainShort('warehouse_ops')).toBe('ops');
expect(domainShort('some_verylongtail')).toBe('very');
expect(domainShort('some_verylongtail')).toBe('verylongtail');
expect(domainShort('solo')).toBe('solo');
});
});
describe('sortDomains —— 按经营视角排,不按字母', () => {
it('已知域按业务序(项目最前),未知域殿后', () => {
expect(sortDomains(['supplier', 'intel', 'zzz_custom', 'project'])).toEqual([
'project',
'intel',
'supplier',
it('先按一单走下来的次序摆业务域,沉淀域在后,未知域殿后', () => {
expect(sortDomains(['shared', 'quote_pricing', 'zzz_custom', 'quote_intake'])).toEqual([
'quote_intake',
'quote_pricing',
'shared',
'zzz_custom',
]);
});
it('退役占位槽留下的 project 压在已知域最后', () => {
expect(sortDomains(['project', 'quote_intake', 'customer'])).toEqual(['quote_intake', 'customer', 'project']);
});
});
+49 -5
View File
@@ -6,14 +6,50 @@
* 字典之外的域回落到键的最后一段(截 4 字符)—— 新域没配短名时,先有个能认的名字。
*/
export const DOMAIN_SHORT: Readonly<Record<string, string>> = {
project: '项目',
/* 沉淀资产:跨单积累的事实 */
customer: '客户',
intel: '情报',
supplier: '供应商',
shared: '共享',
market: '市场',
/* 每一单的流转单据 —— `project` 是占位槽时代的统一域,活动转正后各归各的 */
project: '项目',
/*
* 真活动包各自的域(2026-07-31 补齐)。
*
* 从前这几个一个都没登记,全落到「截键尾 4 字符」的兜底上:
* `quote_alternative` 显示成 **alte**、`quote_sourcing` 成 **sour**、
* `quote_pricing` 成 **pric** —— 一屏中文里蹦出几个没意义的英文残词。
* 短名跟着活动叫,与设计中心活动库里那六个名字对得上(受理 / 选型 / 寻源 / 定价 / 议价 / 归档)。
*/
quote_intake: '受理',
quote_alternative: '选型',
quote_sourcing: '寻源',
quote_pricing: '定价',
quote_negotiate: '议价',
quote_archive: '归档',
};
/** 业务序:项目(每天看的)→ 客户 → 情报 → 供应商 —— 按经营视角排,不按字母 */
const DOMAIN_ORDER = ['project', 'customer', 'intel', 'supplier'];
/**
* 业务序 —— 按经营视角排,不按字母。
* 先按**一单走下来的次序**摆六个业务域(受理→选型→寻源→定价→议价→归档),
* 再摆跨单沉淀的资产域;`project` 是退役占位槽留下的,压在最后。
*/
const DOMAIN_ORDER = [
'quote_intake',
'quote_alternative',
'quote_sourcing',
'quote_pricing',
'quote_negotiate',
'quote_archive',
'shared',
/* 主数据四域:谁在买 → 为什么买 → 从哪买 → 买什么 */
'customer',
'project',
'supplier',
'market',
'intel',
];
export function sortDomains(domains: readonly string[]): string[] {
return [...domains].sort((a, b) => {
@@ -30,6 +66,14 @@ export function sortDomains(domains: readonly string[]): string[] {
export function domainShort(domain: string): string {
const hit = DOMAIN_SHORT[domain];
if (hit !== undefined) return hit;
const tail = domain.split('_').at(-1) ?? domain;
return tail.length <= 4 ? tail : tail.slice(0, 4);
/*
* 字典外的域**回落到键的完整尾段,不再截字符**(2026-07-31 改)。
*
* 从前截 4 个字符,`quote_alternative` 出来是 `alte`、`quote_pricing` 是 `pric` ——
* 那不是「能认的名字」,是一串看不出所以然的残词,而且它把「这个域还没登记短名」
* 这件事伪装成了「它就叫这个」。整段留着,至少一眼看得出是哪个域、也看得出没配中文。
*
* 新加数据域时请一并往 `DOMAIN_SHORT` 里登记中文短名 —— 这条兜底是**提醒**,不是归宿。
*/
return domain.split('_').at(-1) ?? domain;
}
+9 -1
View File
@@ -172,7 +172,15 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
*/
export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label: string; ico: string }>>> = {
blueprint: [{ key: 'map', label: '企业蓝图', ico: 'route' }],
testbed: [{ key: 'run', label: '仿真试跑', ico: 'flask-conical' }],
/*
* 测试台三格 —— 从前是一格加一个「两档旋钮」,旋钮长在内容里,别处跳不进来、
* 刷新也记不住试的是哪一台。改成页签:试什么、看什么由地址说了算。
*/
testbed: [
{ key: 'run', label: '流程', ico: 'workflow' },
{ key: 'act', label: '活动', ico: 'package' },
{ key: 'log', label: '日志', ico: 'scroll-text' },
],
triggers: [{ key: 'rules', label: '调起规则', ico: 'zap' }],
archive: [{ key: 'docs', label: '资料', ico: 'library' }],
models: [{ key: 'llm', label: '模型实例', ico: 'cpu' }],
+3 -2
View File
@@ -40,7 +40,8 @@ export const APR_ST: Readonly<Record<'pending' | 'passed' | 'rejected', ToneIco
export const SIDE_ST: Readonly<Record<string, ToneIco>> = {
机器产出: { tone: 'auto', ico: 'bot' },
人产出: { tone: 'brand', ico: 'user-round' },
员工动作: { tone: 'brand', ico: 'user-round' },
人裁决: { tone: 'decision', ico: 'shield-check' },
世界等待: { tone: 'wait', ico: 'hourglass' },
系统等待: { tone: 'wait', ico: 'hourglass' },
触发事件: { tone: 'brand', ico: 'zap' },
};