人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 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:
@@ -48,6 +48,27 @@ export function displayValue(value: unknown): string {
|
||||
}
|
||||
|
||||
const SLOT = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-slot\s*=\s*"([^"]+)"([^>]*)>([\s\S]*?)<\/\1>/g;
|
||||
/** `data-blank`:这一格空着的时候**真的留空**,不填「—」 */
|
||||
const BLANK = /\s*\bdata-blank(?:\s*=\s*"[^"]*")?/g;
|
||||
/** `data-class-of="路径"`:把该路径的值消毒成一个 class 后缀 —— **按值上色**用 */
|
||||
const CLASS_OF = /\s*\bdata-class-of\s*=\s*"([^"]+)"/;
|
||||
|
||||
/**
|
||||
* 值 → class 后缀。**只留 ASCII 字母数字与横杠,全部小写,封顶 24 字**;
|
||||
* 一个字符都不合规就不给 class(宁可没有颜色,不给一个错的钩子)。
|
||||
*
|
||||
* 为什么要有它:模版不许算,于是「A 绿 / B 橙 / C 紫」这类**按值分档的表现**
|
||||
* 从前一概做不了 —— 只能三个值一个色,那等于假装读懂了值。
|
||||
* 而把值投影成 class 仍然只是取值:没有表达式、没有分支,判断留在样式表里。
|
||||
* 消毒是硬的:属性位不开放插值,这里是唯一一处值进属性,所以只放行白名单字符。
|
||||
*/
|
||||
export function classToken(value: unknown): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
const text = String(typeof value === 'boolean' ? (value ? 'true' : 'false') : value)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '');
|
||||
return text.slice(0, 24);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把作用域填进模版的槽位。
|
||||
@@ -55,13 +76,72 @@ const SLOT = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-slot\s*=\s*"([^"]+)"([^>]*)>([\s
|
||||
* 填充**消费槽位**:填完就把 `data-slot` 摘掉 —— 一来第二遍不会再拿外层作用域
|
||||
* 把行内已填好的值冲掉,二来交出去的是干净 HTML,不是带模板属性的半成品。
|
||||
* 未命中的槽位填「—」而不是留原样:模版里的占位文字漏到成品上比空着更糟。
|
||||
*
|
||||
* ── 例外:`data-blank` ────────────────────────────────────────────────
|
||||
* 表格里「这一格客户没给」和「这一格填了个破折号」是两件事。带 `data-blank`
|
||||
* 的槽位空值就**留空**,交给模版自己的 CSS 去标(`td:empty::after` 出「必填 / 选填」
|
||||
* 胶囊、`tr:has(td.req:empty)` 整行标底)—— 判断仍在样式里,不在这里:
|
||||
* 填数还是只做取值与转义两件事,没有表达式、没有分支。
|
||||
*/
|
||||
export function renderTemplate(html: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
return html.replace(SLOT, (_all, tag: string, before: string, path: string, after: string) => {
|
||||
const attrs = `${before}${after}`.replace(/\s+/g, ' ').trimEnd();
|
||||
const text = escapeHtml(displayValue(resolveSlot(scope, path)));
|
||||
const filled = html.replace(SLOT, (_all, tag: string, before: string, path: string, after: string) => {
|
||||
const raw = `${before}${after}`;
|
||||
const blank = /\bdata-blank\b/.test(raw);
|
||||
const attrs = paintClass(raw.replace(BLANK, ''), scope).replace(/\s+/g, ' ').trimEnd();
|
||||
const value = resolveSlot(scope, path);
|
||||
const empty = value === undefined || value === null || value === '';
|
||||
const text = blank && empty ? '' : escapeHtml(displayValue(value));
|
||||
return `<${tag}${attrs === '' ? '' : ` ${attrs.trim()}`}>${text}</${tag}>`;
|
||||
});
|
||||
/*
|
||||
* **值进 value 属性的那一种** —— `<input … data-slot="pn" data-value/>`。
|
||||
*
|
||||
* 上一遍认的是「开合标签之间填字」,而 input 是空元素,没有「之间」。
|
||||
* 这一份是可改格子的输入孪生件(见 `twinInputs`):值得进 `value=`,
|
||||
* 空值就真空着 —— 空的 input 会显示自己的 placeholder,那正是「这一格还没有」。
|
||||
* 属性位插值只此一处,所以转义照文本位那四个字符来,引号也在其中。
|
||||
*/
|
||||
const withValues = filled.replace(
|
||||
/<input\b([^>]*?)\bdata-slot\s*=\s*"([^"]+)"([^>]*?)\bdata-value\b([^>]*)>/g,
|
||||
(_all, before: string, path: string, mid: string, after: string) => {
|
||||
const value = resolveSlot(scope, path);
|
||||
const empty = value === undefined || value === null || value === '';
|
||||
// 末尾那个自闭合斜杠会被 `after` 一并捞进来 —— 不摘掉就写出 `/ value="…"`
|
||||
const attrs = `${before}${mid}${after}`.replace(/\/\s*$/, '').replace(/\s+/g, ' ').trim();
|
||||
return `<input ${attrs} value="${empty ? '' : escapeHtml(displayValue(value))}"/>`;
|
||||
},
|
||||
);
|
||||
/*
|
||||
* **只上色、不填字的那些格子** —— 剩下的 `data-class-of` 单独走一遍。
|
||||
*
|
||||
* 上一遍只认「带 data-slot 的元素」,于是分布条那种**纯色块**
|
||||
* (`<i data-class-of="type_tag"></i>`,一格一条料、格里不写字)整条静默失色:
|
||||
* 属性还在,一个 class 都没长出来,屏上是一排灰格子 —— 而灰格子看着像
|
||||
* 「还没算出来」,不像「没上色」。两遍都栽过同一个坑,所以把两件事分开:
|
||||
* 取值填字是一件事,按值上色是另一件事,后者不该以前者为条件。
|
||||
*/
|
||||
return withValues.replace(
|
||||
/<([a-zA-Z][\w-]*)\b([^>]*?\bdata-class-of\s*=\s*"[^"]+"[^>]*)>/g,
|
||||
(_all, tag: string, attrs: string) => {
|
||||
const painted = paintClass(attrs, scope).replace(/\s+/g, ' ').trimEnd();
|
||||
return `<${tag}${painted === '' ? '' : ` ${painted.trim()}`}>`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `data-class-of="路径"` → 把该路径的值消毒后追加成 `v-<值>`,并**消费掉属性本身**。
|
||||
* 消费是要紧的:交出去的是干净 HTML,行模版复制第二遍时也不会再追加一次。
|
||||
*/
|
||||
function paintClass(attrs: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
const classOf = CLASS_OF.exec(attrs);
|
||||
if (classOf === null) return attrs;
|
||||
const rest = attrs.replace(CLASS_OF, '');
|
||||
const token = classToken(resolveSlot(scope, classOf[1] ?? ''));
|
||||
if (token === '') return rest;
|
||||
return /\bclass\s*=\s*"[^"]*"/.test(rest)
|
||||
? rest.replace(/\bclass\s*=\s*"([^"]*)"/, (_m, cls: string) => `class="${cls} v-${token}"`)
|
||||
: `${rest} class="v-${token}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,19 +152,57 @@ const ROWS = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-rows\s*=\s*"([^"]+)"([^>]*)>([\s
|
||||
|
||||
export function renderRows(html: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
return html.replace(ROWS, (_all, tag: string, before: string, path: string, after: string, inner: string) => {
|
||||
const attrs = `${before}${after}`.replace(/\s+/g, ' ').trim();
|
||||
const raw = `${before}${after}`;
|
||||
const attrs = raw.replace(/\s+/g, ' ').trim();
|
||||
const open = `<${tag}${attrs === '' ? '' : ` ${attrs}`}>`;
|
||||
const value = resolveSlot(scope, path);
|
||||
const rows = Array.isArray(value) ? value : [];
|
||||
/*
|
||||
* 这一组行**可就地改**吗 —— 容器上的 `data-edit="域.对象"` 说了算。
|
||||
* 是的话每一行多渲一份输入孪生件,并把行号钉在行上(写回时按它对行)。
|
||||
*/
|
||||
const editable = /\bdata-edit\s*=\s*"([^"]+)"/.exec(raw) !== null;
|
||||
const body = rows
|
||||
.map((row) =>
|
||||
renderTemplate(inner, (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>),
|
||||
)
|
||||
.map((row, index) => {
|
||||
const one = (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>;
|
||||
return renderTemplate(editable ? twinInputs(inner, index) : inner, one);
|
||||
})
|
||||
.join('');
|
||||
return `${open}${body}</${tag}>`;
|
||||
});
|
||||
}
|
||||
|
||||
/** 带 `data-col` 的取值元素:`<span … data-slot="pn" data-col="PN">` */
|
||||
const EDITABLE_CELL = /<(span|b)\b([^>]*?\bdata-col\s*=\s*"([^"]+)"[^>]*)><\/\1>/g;
|
||||
|
||||
/**
|
||||
* **给可改的格子配一个输入孪生件** —— 只读那一份原样留着,旁边多一个 `<input>`,
|
||||
* 两者由样式表二选一显示(根节点带不带 `editing`)。
|
||||
*
|
||||
* 为什么是「都渲出来」而不是「渲成输入」:同一份件既要在待处理页给人改,
|
||||
* 又要在工作成果里定稿归档 —— 两处是同一份 HTML。渲两套等于存两份、也就会分叉;
|
||||
* 而容器是 `sandbox="allow-same-origin"` 的无脚本 iframe,外层**读得到也改得动**
|
||||
* 里面的 DOM:开编辑就是给根节点加个 class,收值就是把 input 读出来。
|
||||
* 模版这一侧仍然只做取值 —— 判断(显示哪一份)留在样式表。
|
||||
*
|
||||
* `data-col` 写的是**台账列名**,不是表头文字:写回时基座按列名对格,
|
||||
* 表头改个说法不该让写回错位。
|
||||
*/
|
||||
function twinInputs(inner: string, rowIndex: number): string {
|
||||
return inner.replace(EDITABLE_CELL, (all, _tag: string, attrs: string, col: string) => {
|
||||
const slot = /\bdata-slot\s*=\s*"([^"]+)"/.exec(attrs)?.[1];
|
||||
if (slot === undefined) return all;
|
||||
// 只读那一份的排版类(mono / num)照搬给输入件 —— 同一列两态字体要一致
|
||||
const style = (/\bclass\s*=\s*"([^"]*)"/.exec(attrs)?.[1] ?? '')
|
||||
.split(/\s+/)
|
||||
.filter((c) => c === 'mono' || c === 'num')
|
||||
.join(' ');
|
||||
const input = `<input class="jc-cell${style === '' ? '' : ` ${style}`}" data-col="${col}" data-row="${rowIndex}" data-slot="${slot}" data-value/>`;
|
||||
// 只读那一份把 `data-col` 交出去 —— 它的活已经干完(告诉这里是哪一列),留着只是噪音
|
||||
return `${all.replace(/\s*\bdata-col\s*=\s*"[^"]*"/, '')}${input}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** 先展开行,再填标量槽 —— 顺序不能反,否则行内槽位会被外层作用域填掉 */
|
||||
export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
return renderTemplate(renderRows(html, scope), scope);
|
||||
|
||||
Reference in New Issue
Block a user