资料文件真产出来了;E04/I01 补到验收线,顺手填了几个「墙拦住却不给路」的坑
**资料文件全链**(用户:审核资料这一步要真产出 word/excel/md/html,前端能渲、点资料名能开)
接手时这一步一个文件都产不出来 —— S3 剧本白纸黑字写着「file_ref 本 MVP 阶段可留空」。
文件是台账那一行的投影,不另存一份:
· 声明 `file:{titleColumn,bodyColumn,formatColumn}`,它同时是授权面(没声明就点不开)
· 现造真字节 —— docx/xlsx 是自己打的 OOXML 包(zip-write:store+CRC32,零依赖)
· 读回来渲 —— 解的就是造出来那份字节,渲得出来才证明文件是真的
· 件上 `data-file-of` → 工作区二级界面(Word 纸面/Excel 网格/渲好的 md/沙箱 html)+ 下载原件
放行落地前把每一份冻进 `sys.task_asset_file`(迁移 0029):归档件点开的必须是签字那一刻
那一份,而资料落在主数据上、试跑单不落正式表 —— 不存这一份,收工后一个都点不开。
工作成果里「产出的资料」自成一摞。Markdown 富文本渲染顺带销账(仍零依赖)。
**Harness**
· 画面按「这一拍办完之后」在办的那一步挑 —— 从前拿拍首的局面决定拍尾的事:
收掉 S4、发出 S5 的卡,而屏上还摆着 S4 的只读件,人无处可填(墙要配路第四次现形)
· 验收「重做」真能退回重做,且打回后台账没动就不许再提交(软硬两段,不锁死单子)
· 立监听的授权面是清单声明;发卡的授权面是调人预算,不是计划项
· 读工具同参重调有兜底;空转写身后还有行动就不废串;finish 的 doneWhen 按写完后的草稿算
**界面**
· 翻件与翻页各归各位(从前二选一,多卡多页时页翻不动);没得翻写「—」不写 0/0
· 工作成果详情能翻份;表宽超出能横滑(E04/I01 抄版式时把 overflow-x 抄丢了)
· 查看面灰底滚过一屏变白 —— 两条独立的路都治了(沙箱内不同色 / 弹性子项不长个)
· 测试台「取不到」不再当成「还活着」
**活动**
E04 关系覆盖、I01 品牌内容各自补到验收线:判据全读基座数、判据指向的东西摆上卡面、
样例满载且名字 ≤14 字;提交前要裁的判据搬进说明书(模型会压拍,压过之后只有说明书还在)。
验收:e2e i01 4/4、e04 3/3 全部 released,资料在办中渲得出、放行后存了档点得开;
门禁 typecheck/lint/depcruise/1628 测全绿。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* **手写控件嵌套多一层,`collect()` 按行归组不能错行**(2026-08-21 修死)。
|
||||
*
|
||||
* 自动孪生的格子是 `<td><input data-col></td>`,往上两层正好是 `<tr>`;
|
||||
* 手写的三选一(A2 替代等级、A4 决策卡这类)套了一层分组容器
|
||||
* (`<td><div class="jc-radiogrp"><label class="jc-rop"><input type=radio></label></div></td>`,
|
||||
* input 到 `<tr>` 隔了 4 层)——`cell.parentElement?.parentElement` 这个定深两层的老算法
|
||||
* 只能爬到那层 `<div class="jc-radiogrp">`,爬不到真正的行。E04 的执行回执表就是这样
|
||||
* 栽的:一次改 3 行的完成状态,保存却只留下 1 行(其余两行的单选组各自拆成了
|
||||
* 不带主键的伪行,服务端按"随便找到的第一行"兜底,写来写去只剩一行生效)。
|
||||
*
|
||||
* 修法是 `rowOf`:从格子往上走到 host(`data-edit` 容器)的**直接子元素**为止,
|
||||
* 不管中间嵌了几层都能对上真正的那一行。这里直接用真实 DOM(jsdom,不是手搭的假对象)
|
||||
* 复刻这个嵌套形状,断言 `collect()` 认出的是 3 行而不是被拆散的碎片。
|
||||
*
|
||||
* 完成状态这一列本身已经从三选一 pill 改成了原生 `<select>`(2026-08-21,见
|
||||
* `report.ts`),不再嵌那层 `.jc-radiogrp` div——但 `rowOf` 的修法是通用的,
|
||||
* 不管嵌几层都对;这里的 `.jc-radiogrp` 用例代表仍在用手写三选一的其余活动
|
||||
* (A2/A4 那几张卡),证明这条路对它们同样成立。
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { collect, fileRefOf, unanswered } from '../src/ViewContainer.tsx';
|
||||
|
||||
/** 三行,每行一个手写三选一(照 A2/A4 现存的 `.jc-radiogrp` 结构,input 距 <tr> 四层) */
|
||||
function radioTable(): Document {
|
||||
const doc = document.implementation.createHTMLDocument('');
|
||||
// name 按行缀 __rN(照 view-render.ts 的 uniqueNames())—— 不缀的话 jsdom 与真浏览器
|
||||
// 一样,把同名 radio 当一组处理,勾选第 2 行会顺手把第 1 行刚勾的那个悄悄取消掉
|
||||
const row = (code: string, i: number): string => `
|
||||
<tr>
|
||||
<td><span class="jc-vcell mono" data-slot="contact_code"></span><input class="jc-cell mono" data-col="contact_code" value="${code}"/></td>
|
||||
<td class="req">
|
||||
<div class="jc-radiogrp">
|
||||
<label class="jc-rop"><input type="radio" name="cs__r${i}" class="jc-cell" data-col="completion_status" value="done"><span>完成</span></label>
|
||||
<label class="jc-rop"><input type="radio" name="cs__r${i}" class="jc-cell" data-col="completion_status" value="partial"><span>部分完成</span></label>
|
||||
<label class="jc-rop"><input type="radio" name="cs__r${i}" class="jc-cell" data-col="completion_status" value="skipped"><span>未执行</span></label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
doc.body.innerHTML = `<table><tbody data-edit="crm_coverage.receipt">
|
||||
${row('CU-0142-01', 0)}${row('CU-0087-02', 1)}${row('CU-0087-01', 2)}
|
||||
</tbody></table>`;
|
||||
return doc;
|
||||
}
|
||||
|
||||
describe('collect():三选一嵌了一层分组容器,三行各自的完成状态不许串行/丢主键', () => {
|
||||
it('三行都点了不同的选项,一次 collect 认出三行,主键各自带对(老算法在这里会把三行拆成更多、且各自缺主键)', () => {
|
||||
const doc = radioTable();
|
||||
const rows = Array.from(doc.querySelectorAll('tr'));
|
||||
(rows[0]!.querySelector('input[value="done"]') as HTMLInputElement).checked = true;
|
||||
(rows[1]!.querySelector('input[value="partial"]') as HTMLInputElement).checked = true;
|
||||
(rows[2]!.querySelector('input[value="skipped"]') as HTMLInputElement).checked = true;
|
||||
|
||||
const got = collect(doc, 'crm_coverage.receipt');
|
||||
|
||||
expect(got).toHaveLength(3);
|
||||
const byCode = new Map(got.map((r) => [r['contact_code'], r]));
|
||||
expect(byCode.get('CU-0142-01')?.['completion_status']).toBe('done');
|
||||
expect(byCode.get('CU-0087-02')?.['completion_status']).toBe('partial');
|
||||
expect(byCode.get('CU-0087-01')?.['completion_status']).toBe('skipped');
|
||||
});
|
||||
|
||||
it('只点了 1 行的单选,另外两行没点(没答)—— 保存只交出那一行的新值,其余两行原值不动', () => {
|
||||
const doc = radioTable();
|
||||
const rows = Array.from(doc.querySelectorAll('tr'));
|
||||
(rows[1]!.querySelector('input[value="done"]') as HTMLInputElement).checked = true;
|
||||
|
||||
const got = collect(doc, 'crm_coverage.receipt');
|
||||
const byCode = new Map(got.map((r) => [r['contact_code'], r]));
|
||||
expect(byCode.get('CU-0087-02')?.['completion_status']).toBe('done');
|
||||
expect(byCode.get('CU-0142-01')).not.toHaveProperty('completion_status');
|
||||
expect(byCode.get('CU-0087-01')).not.toHaveProperty('completion_status');
|
||||
});
|
||||
});
|
||||
|
||||
/** 照 report.ts 现在的 RECEIPT_TABLE:完成状态是原生 select,直接是 <td> 的子节点 */
|
||||
function selectTable(): Document {
|
||||
const doc = document.implementation.createHTMLDocument('');
|
||||
const row = (code: string): string => `
|
||||
<tr>
|
||||
<td><span class="jc-vcell mono" data-slot="contact_code"></span><input class="jc-cell mono" data-col="contact_code" value="${code}"/></td>
|
||||
<td class="req">
|
||||
<select class="jc-cell" data-col="completion_status" required>
|
||||
<option value=""></option>
|
||||
<option value="done">完成</option>
|
||||
<option value="partial">部分完成</option>
|
||||
<option value="skipped">未执行</option>
|
||||
</select>
|
||||
<span class="jc-tag"></span>
|
||||
</td>
|
||||
</tr>`;
|
||||
doc.body.innerHTML = `<table><tbody data-edit="crm_coverage.receipt">
|
||||
${row('CU-0142-01')}${row('CU-0087-02')}${row('CU-0087-01')}
|
||||
</tbody></table>`;
|
||||
return doc;
|
||||
}
|
||||
|
||||
describe('collect() 与 unanswered():下拉换掉三选一之后,同样的多行独立编辑要成立', () => {
|
||||
it('三行各选一个完成状态,三行都收得到、互不串行', () => {
|
||||
const doc = selectTable();
|
||||
const selects = Array.from(doc.querySelectorAll<HTMLSelectElement>('select[data-col="completion_status"]'));
|
||||
selects[0]!.value = 'done';
|
||||
selects[1]!.value = 'partial';
|
||||
selects[2]!.value = 'skipped';
|
||||
|
||||
const got = collect(doc, 'crm_coverage.receipt');
|
||||
const byCode = new Map(got.map((r) => [r['contact_code'], r]));
|
||||
expect(byCode.get('CU-0142-01')?.['completion_status']).toBe('done');
|
||||
expect(byCode.get('CU-0087-02')?.['completion_status']).toBe('partial');
|
||||
expect(byCode.get('CU-0087-01')?.['completion_status']).toBe('skipped');
|
||||
});
|
||||
|
||||
it('下拉停在空占位 → unanswered() 为真,collect() 跳过这一列(没答=不改,不是清空)', () => {
|
||||
const doc = selectTable();
|
||||
const select = doc.querySelector<HTMLSelectElement>('select[data-col="completion_status"]')!;
|
||||
expect(unanswered(select)).toBe(true);
|
||||
|
||||
const got = collect(doc, 'crm_coverage.receipt');
|
||||
expect(got.find((r) => r['contact_code'] === 'CU-0142-01')).not.toHaveProperty('completion_status');
|
||||
});
|
||||
|
||||
it('下拉选了具体值 → unanswered() 为假', () => {
|
||||
const doc = selectTable();
|
||||
const select = doc.querySelector<HTMLSelectElement>('select[data-col="completion_status"]')!;
|
||||
select.value = 'done';
|
||||
expect(unanswered(select)).toBe(false);
|
||||
});
|
||||
|
||||
it('普通文本输入永远算已答(没有"没答"这一态)', () => {
|
||||
const doc = selectTable();
|
||||
const input = doc.querySelector<HTMLInputElement>('input[data-col="contact_code"]')!;
|
||||
expect(unanswered(input)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/** 照 E04 目标件的执行回执:结果摘要 / 客户反馈是 textarea(一句话装不下) */
|
||||
function textareaTable(): Document {
|
||||
const doc = document.implementation.createHTMLDocument('');
|
||||
const row = (code: string): string => `
|
||||
<tr>
|
||||
<td><span class="jc-vcell mono" data-slot="contact_code"></span><input class="jc-cell mono" data-col="contact_code" value="${code}"/></td>
|
||||
<td class="req"><textarea class="jc-cell" data-col="result_summary" placeholder="谈了什么"></textarea></td>
|
||||
</tr>`;
|
||||
doc.body.innerHTML = `<table><tbody data-edit="crm_coverage.receipt">
|
||||
${row('CU-0142-01')}${row('CU-0087-02')}
|
||||
</tbody></table>`;
|
||||
return doc;
|
||||
}
|
||||
|
||||
describe('collect():textarea 与文本输入同源同待 —— E04 回执的结果摘要照目标件是多行文本', () => {
|
||||
it('人在 textarea 里写的话收得到,主键对得上行', () => {
|
||||
const doc = textareaTable();
|
||||
const areas = Array.from(doc.querySelectorAll<HTMLTextAreaElement>('textarea[data-col="result_summary"]'));
|
||||
areas[0]!.value = '见到了产品 VP,达成样品测试意向';
|
||||
areas[1]!.value = '技术交流完成,对方提了认证问题';
|
||||
|
||||
const got = collect(doc, 'crm_coverage.receipt');
|
||||
const byCode = new Map(got.map((r) => [r['contact_code'], r]));
|
||||
expect(byCode.get('CU-0142-01')?.['result_summary']).toBe('见到了产品 VP,达成样品测试意向');
|
||||
expect(byCode.get('CU-0087-02')?.['result_summary']).toBe('技术交流完成,对方提了认证问题');
|
||||
});
|
||||
|
||||
it('textarea 与文本输入同款:永远算已答(空着收空串,语义同 input)', () => {
|
||||
const doc = textareaTable();
|
||||
const area = doc.querySelector<HTMLTextAreaElement>('textarea[data-col="result_summary"]')!;
|
||||
expect(unanswered(area)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **件上的资料名点开哪一份** —— `data-file` 的解析(2026-08-23)。
|
||||
* 认不出就不认:点不动比发一个取不回来的请求好(界面不撒谎)。
|
||||
*/
|
||||
describe('fileRefOf —— data-file 解析', () => {
|
||||
it('域.对象/主键值 → 地址与主键各归各位', () => {
|
||||
expect(fileRefOf('content.asset/CT-2026-0001')).toEqual({ address: 'content.asset', key: 'CT-2026-0001' });
|
||||
});
|
||||
|
||||
it('主键值里带斜杠照样归主键 —— 只认第一个斜杠', () => {
|
||||
expect(fileRefOf('content.asset/round=R/1')).toEqual({ address: 'content.asset', key: 'round=R/1' });
|
||||
});
|
||||
|
||||
it('多列主键写 列=值,值 —— 原样交给服务端认', () => {
|
||||
expect(fileRefOf('c.a/round_ref=BR-1,req_seq=2')).toEqual({ address: 'c.a', key: 'round_ref=BR-1,req_seq=2' });
|
||||
});
|
||||
|
||||
it('缺一半 / 形状不对 / 空的:一律不认', () => {
|
||||
expect(fileRefOf(null)).toBeUndefined();
|
||||
expect(fileRefOf('')).toBeUndefined();
|
||||
expect(fileRefOf('content.asset')).toBeUndefined();
|
||||
expect(fileRefOf('content.asset/')).toBeUndefined();
|
||||
expect(fileRefOf('/CT-1')).toBeUndefined();
|
||||
expect(fileRefOf('不是地址/CT-1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ const GLYPHS: Record<string, React.ReactNode> = {
|
||||
"sliders-horizontal": (<><line x1="21" x2="14" y1="4" y2="4" /><line x1="10" x2="3" y1="4" y2="4" /><line x1="21" x2="12" y1="12" y2="12" /><line x1="8" x2="3" y1="12" y2="12" /><line x1="21" x2="16" y1="20" y2="20" /><line x1="12" x2="3" y1="20" y2="20" /><line x1="14" x2="14" y1="2" y2="6" /><line x1="8" x2="8" y1="10" y2="14" /><line x1="16" x2="16" y1="18" y2="22" /></>),
|
||||
filter: (<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />),
|
||||
"arrow-left": (<><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></>),
|
||||
download: (<><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" x2="12" y1="15" y2="3" /></>),
|
||||
"file-spreadsheet": (<><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M8 13h2" /><path d="M14 13h2" /><path d="M8 17h2" /><path d="M14 17h2" /></>),
|
||||
"file-type": (<><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M9 13v-1h6v1" /><path d="M11 18h2" /><path d="M12 12v6" /></>),
|
||||
wrench: (<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />),
|
||||
|
||||
@@ -38,6 +38,13 @@ export type ViewContainerProps = {
|
||||
readonly editable?: boolean;
|
||||
/** 人按下「保存」时把那张表收上来;不传就是纯呈现 */
|
||||
readonly onEdits?: (save: ViewSave) => void;
|
||||
/**
|
||||
* **件上的资料名被点了** —— 收到 `data-file="域.对象/主键值"` 那一下。
|
||||
*
|
||||
* 容器只报「点的是哪一份」,取文件、渲文件都不在这儿:这份组件的本分是
|
||||
* 把沙箱里发生的事如实交出去,至于跳到哪一屏是工作区的事。
|
||||
*/
|
||||
readonly onOpenFile?: (ref: { address: string; key: string }) => void;
|
||||
/**
|
||||
* **多页件**(2026-08-02 用户:「一个执行单可以派多个 HTML 待执行报告,
|
||||
* 合理利用右上角的翻页」):件里带 `data-page` 的可见段各算一页,
|
||||
@@ -103,7 +110,12 @@ function morphNode(oldEl: Element, newEl: Element): void {
|
||||
const bothElements = o.nodeType === Node.ELEMENT_NODE && n.nodeType === Node.ELEMENT_NODE;
|
||||
if (!bothElements) {
|
||||
if (o.nodeType !== n.nodeType) (o as ChildNode).replaceWith(n);
|
||||
else if (o.textContent !== n.textContent) o.textContent = n.textContent;
|
||||
else if (o.textContent !== n.textContent) {
|
||||
o.textContent = n.textContent;
|
||||
// textarea 的拟值在正文:拟值真变了,活值跟上(与 input 的 value 同一条规矩)
|
||||
const owner = o.parentElement;
|
||||
if (owner?.tagName === 'TEXTAREA') (owner as HTMLTextAreaElement).value = n.textContent ?? '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
morphNode(o as Element, n as Element);
|
||||
@@ -154,13 +166,42 @@ function bodyOf(doc: Document, address: string): HTMLElement | null {
|
||||
return doc.querySelector<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`);
|
||||
}
|
||||
|
||||
/** 一张表的输入格子;`gone` 的行(人删掉的)不算在内 */
|
||||
function cellsOf(doc: Document, address: string): HTMLInputElement[] {
|
||||
/**
|
||||
* 一个格子所在的那一行 —— **host 的直接子元素**,不管格子本身嵌了几层。
|
||||
*
|
||||
* 自动孪生的格子(`twinInputs` 写出来的)是 `<td><input></td>`,往上两层正好是行;
|
||||
* 手写的三选一(`.jc-radiogrp` 套一层 `<label>`)是 `<td><div><label><input></label></div></td>`,
|
||||
* 往上两层只到得了那个 radio 组的 `<div>` —— **每个 radio 组各算一行**,同一张表格里
|
||||
* 三组三行、真正的行反倒被拆成了 N 个碎片,主键那一列(在别的 td 里)一个也进不去这些碎片。
|
||||
* 一路往上走到 host 的直接子元素为止,不管中间嵌了几层都能对上真正的那一行。
|
||||
*/
|
||||
function rowOf(host: Element, cell: Element): Element | null {
|
||||
let node: Element | null = cell;
|
||||
while (node !== null && node.parentElement !== host) node = node.parentElement;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** 可改格子的三种控件 —— 文本输入 / 下拉 / 多行文本,同源同待 */
|
||||
type EditCell = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
||||
|
||||
/** 一张表的输入格子;`gone` 的行(人删掉的)不算在内。下拉与多行文本(textarea)与文本输入同源同待 */
|
||||
function cellsOf(doc: Document, address: string): EditCell[] {
|
||||
const host = bodyOf(doc, address);
|
||||
if (host === null) return [];
|
||||
return Array.from(host.querySelectorAll<HTMLInputElement>('input[data-col]')).filter(
|
||||
(i) => i.closest('.jc-row-gone') === null,
|
||||
);
|
||||
return Array.from(
|
||||
host.querySelectorAll<EditCell>('input[data-col], select[data-col], textarea[data-col]'),
|
||||
).filter((i) => i.closest('.jc-row-gone') === null);
|
||||
}
|
||||
|
||||
/**
|
||||
* **「没答」= 「不改」**——radio 一组都没点、下拉停在空占位,是同一句话的两种写法。
|
||||
* 三选一没有第四态可以表达「维持原值」,只能靠「没有一个是选中的」;下拉同理,靠
|
||||
* `<option value="">` 这个空占位表达「还没选」。跳过这一格,原值在台账里原样留着。
|
||||
*/
|
||||
export function unanswered(cell: EditCell): boolean {
|
||||
if (cell.type === 'radio') return !(cell as HTMLInputElement).checked;
|
||||
if (cell.type === 'select-one') return cell.value === '';
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,19 +211,21 @@ function cellsOf(doc: Document, address: string): HTMLInputElement[] {
|
||||
* (删掉第 2 行,余下的仍是 0/2/3 —— 拿它当下标会在中间留一个洞)。
|
||||
* 认行元素则怎么增删都对得上:同一个 `<tr>` 里的格子就是同一行。
|
||||
*/
|
||||
function collect(doc: Document, address: string): Array<Record<string, string>> {
|
||||
export function collect(doc: Document, address: string): Array<Record<string, string>> {
|
||||
const host = bodyOf(doc, address);
|
||||
if (host === null) return [];
|
||||
const byRow = new Map<Element, Record<string, string>>();
|
||||
for (const cell of cellsOf(doc, address)) {
|
||||
const col = cell.dataset['col'];
|
||||
const row = cell.parentElement?.parentElement ?? cell.parentElement;
|
||||
const row = rowOf(host, cell);
|
||||
if (col === undefined || row == null) continue;
|
||||
/*
|
||||
* **radio 选项:没选中的不算话,一组都没选 = 这一列不动**(2026-08-02,
|
||||
* A2 替代等级三选)。radio 的 `value` 是选项值,恒有 —— 照 text 那样读,
|
||||
* 三个选项互相覆盖、没人点过也会交出一个值,把台账里的原值抹掉;
|
||||
* 跳过整列则原值保留:「没点」的意思是「不改」,不是「清空」。
|
||||
* **radio 选项 / 下拉:没答的不算话,没答 = 这一列不动**(2026-08-02,
|
||||
* A2 替代等级三选;2026-08-20 扩到下拉)。radio 的 `value` 是选项值,恒有;
|
||||
* 下拉一旦渲染也恒有一个 `.value`(哪怕是空占位)—— 都照 text 那样读的话,
|
||||
* 没人碰过也会交出一个值,把台账里的原值抹掉;跳过则原值保留。
|
||||
*/
|
||||
if (cell.type === 'radio' && !cell.checked) continue;
|
||||
if (unanswered(cell)) continue;
|
||||
const one = byRow.get(row) ?? {};
|
||||
one[col] = cell.value;
|
||||
byRow.set(row, one);
|
||||
@@ -212,10 +255,10 @@ function collect(doc: Document, address: string): Array<Record<string, string>>
|
||||
*/
|
||||
function mirrorTwins(doc: Document, address: string): void {
|
||||
for (const cell of cellsOf(doc, address)) {
|
||||
// 与 collect 同一条规矩:没选中的 radio 不算话(「没点」= 不改)
|
||||
if (cell.type === 'radio' && !cell.checked) continue;
|
||||
// 与 collect 同一条规矩:没答的不算话(「没点/没选」= 不改)
|
||||
if (unanswered(cell)) continue;
|
||||
const twin = cell.previousElementSibling;
|
||||
if (twin === null || twin.tagName === 'INPUT') continue;
|
||||
if (twin === null || twin.tagName === 'INPUT' || twin.tagName === 'SELECT' || twin.tagName === 'TEXTAREA') continue;
|
||||
twin.textContent = cell.value;
|
||||
}
|
||||
}
|
||||
@@ -248,7 +291,9 @@ function addRow(doc: Document, address: string): boolean {
|
||||
const fresh = last.cloneNode(true) as HTMLElement;
|
||||
fresh.classList.remove('jc-row-gone');
|
||||
fresh.classList.add('jc-row-new');
|
||||
for (const cell of Array.from(fresh.querySelectorAll<HTMLInputElement>('input[data-col]'))) {
|
||||
for (const cell of Array.from(
|
||||
fresh.querySelectorAll<EditCell>('input[data-col], select[data-col], textarea[data-col]'),
|
||||
)) {
|
||||
cell.value = '';
|
||||
// 序号不再有意义(见 `collect` 的注释),清掉免得两行同号
|
||||
delete cell.dataset['row'];
|
||||
@@ -261,7 +306,23 @@ function addRow(doc: Document, address: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ViewContainer({ html, title = '活动视图', editable = false, onEdits, page, onPages }: ViewContainerProps) {
|
||||
/**
|
||||
* `data-file="域.对象/主键值"` → 取文件要的那两样。
|
||||
*
|
||||
* 认第一个斜杠:主键值里再有斜杠也照样归主键(文件名常带 `/`,地址永远不带)。
|
||||
* 缺一半的一律不认 —— 宁可点不动,也不发一个取不回来的请求。
|
||||
*/
|
||||
export function fileRefOf(raw: string | null): { address: string; key: string } | undefined {
|
||||
const ref = raw ?? '';
|
||||
const at = ref.indexOf('/');
|
||||
if (at <= 0 || at === ref.length - 1) return undefined;
|
||||
const address = ref.slice(0, at);
|
||||
// 地址必须是「域.对象」两段 —— 形状不对就不是一份资料的地址
|
||||
if (!/^[a-z][\w]*\.[a-z][\w]*$/i.test(address)) return undefined;
|
||||
return { address, key: ref.slice(at + 1) };
|
||||
}
|
||||
|
||||
export function ViewContainer({ html, title = '活动视图', editable = false, onEdits, onOpenFile, page, onPages }: ViewContainerProps) {
|
||||
const ref = useRef<HTMLIFrameElement>(null);
|
||||
const [height, setHeight] = useState(FALLBACK_HEIGHT);
|
||||
/*
|
||||
@@ -279,7 +340,13 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
|
||||
const marked = Array.from(doc.body.querySelectorAll<HTMLElement>('[data-page]'));
|
||||
for (const el of marked) el.style.removeProperty('display');
|
||||
const visible = marked.filter((el) => doc.defaultView?.getComputedStyle(el).display !== 'none');
|
||||
onPagesRef.current?.(visible.length);
|
||||
/*
|
||||
* **一份不分页的件是「一页」,不是「零页」**(2026-08-23 用户:「这个 0/0 显示的
|
||||
* 不正常」)。绝大多数件压根没写 `data-page`(整篇铺开就是全部内容)——
|
||||
* 从前这里如实回报 0,外层拿它去写页码,屏上就出「0/0」。
|
||||
* 页数报的是**这份件有几页**,件在手上,再少也是一页。
|
||||
*/
|
||||
onPagesRef.current?.(Math.max(1, visible.length));
|
||||
// 不传 page(如工作成果的静态归档)= 不分页,全部照常纵排
|
||||
if (pageRef.current === undefined || visible.length <= 1) return;
|
||||
const current = Math.min(pageRef.current, visible.length - 1);
|
||||
@@ -323,6 +390,8 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
|
||||
*/
|
||||
const sink = useRef(onEdits);
|
||||
sink.current = onEdits;
|
||||
const fileSink = useRef(onOpenFile);
|
||||
fileSink.current = onOpenFile;
|
||||
/** 进编辑态那一刻的样子 —— 「取消」要还原成它(值 + 那时有几行) */
|
||||
const snapshot = useRef(new Map<string, { values: string[]; rows: number }>());
|
||||
|
||||
@@ -350,10 +419,23 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
|
||||
* 认能力不认血统:有 `closest` 就当它是元素。
|
||||
*/
|
||||
const target = event.target as { closest?: (s: string) => HTMLElement | null } | null;
|
||||
const hit =
|
||||
typeof target?.closest === 'function'
|
||||
? target.closest('[data-edit-open],[data-edit-cancel],[data-edit-save],[data-edit-add],[data-edit-del]')
|
||||
: null;
|
||||
if (typeof target?.closest !== 'function') return;
|
||||
|
||||
/*
|
||||
* **资料名:点开那一份文件** —— 先看这一条,再看编辑闸。
|
||||
* 两者不会同时命中(一个在表格格子里、一个在闸上),先后只是省一次匹配。
|
||||
*/
|
||||
const fileHit = target.closest('[data-file]');
|
||||
if (fileHit !== null && fileHit !== undefined) {
|
||||
const ref = fileRefOf(fileHit.getAttribute('data-file'));
|
||||
if (ref !== undefined) {
|
||||
event.preventDefault();
|
||||
fileSink.current?.(ref);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const hit = target.closest('[data-edit-open],[data-edit-cancel],[data-edit-save],[data-edit-add],[data-edit-del]');
|
||||
if (hit === null || hit === undefined) return;
|
||||
event.preventDefault();
|
||||
|
||||
@@ -380,7 +462,7 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
|
||||
// 快照按下「编辑」那一刻的样子:值 + 那时的行数 —— 「取消」两样都要还原
|
||||
// (radio 的 value 是恒定的选项值,快照与还原认的是 checked)
|
||||
snapshot.current.set(open, {
|
||||
values: cellsOf(doc, open).map((c) => (c.type === 'radio' ? (c.checked ? '1' : '') : c.value)),
|
||||
values: cellsOf(doc, open).map((c) => (c.type === 'radio' ? ((c as HTMLInputElement).checked ? '1' : '') : c.value)),
|
||||
rows: Array.from(bodyOf(doc, open)?.children ?? []).length,
|
||||
});
|
||||
setOpen(open, true);
|
||||
@@ -398,7 +480,7 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
|
||||
cellsOf(doc, cancel).forEach((c, i) => {
|
||||
const saved = was.values[i];
|
||||
if (saved === undefined) return;
|
||||
if (c.type === 'radio') c.checked = saved === '1';
|
||||
if (c.type === 'radio') (c as HTMLInputElement).checked = saved === '1';
|
||||
else c.value = saved;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user