From ab9d45c428d4eef0a6c99bc06a1bf252465083f1 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sun, 4 Oct 2026 03:06:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=20F2b?= =?UTF-8?q?=EF=BC=9A=E5=89=96=E6=9E=90=E6=8A=A5=E5=91=8A=E5=92=8C=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E9=A1=B5=E8=AF=BB=E7=BB=93=E6=9E=84=E5=8C=96=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=EF=BC=8C=E4=B8=8D=E5=86=8D=E4=BB=8E=E5=8E=9F=E8=AF=9D?= =?UTF-8?q?=E9=87=8C=E8=AE=A4=E2=80=94=E2=80=94=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E6=9C=80=E5=B0=8F=E6=94=B9=E5=8A=A8=EF=BC=9A=E7=A0=81=E5=80=BC?= =?UTF-8?q?=E5=80=99=E9=80=89=E7=9A=84=E7=BB=93=E8=AE=BA=E5=B8=A6=20pendin?= =?UTF-8?q?g=EF=BC=88=E8=BF=98=E6=9C=89=E5=87=A0=E4=B8=AA=E5=90=AB?= =?UTF-8?q?=E4=B9=89=E5=BE=85=E5=A1=AB=E5=86=99=EF=BC=89=EF=BC=8C=E5=85=B3?= =?UTF-8?q?=E7=B3=BB=E7=9A=84=E7=BB=93=E8=AE=BA=E5=B8=A6=20cardinality=5Fc?= =?UTF-8?q?hecked=EF=BC=9B=E6=8A=A5=E5=91=8A=E9=87=8C=E3=80=8CN=20?= =?UTF-8?q?=E4=B8=AA=E5=90=AB=E4=B9=89=E5=BE=85=E5=A1=AB=E5=86=99=E3=80=8D?= =?UTF-8?q?=E8=AF=BB=20pending=EF=BC=8C=E5=9F=BA=E6=95=B0=E5=86=99?= =?UTF-8?q?=E3=80=8C=E5=B7=B2=E7=94=A8=E6=95=B0=E6=8D=AE=E6=A0=B8=E5=AE=9E?= =?UTF-8?q?=E3=80=8D=E6=88=96=E3=80=8C=E5=AD=90=E8=A1=A8=E4=B8=80=E4=BE=A7?= =?UTF-8?q?=E6=9C=AA=E6=A0=B8=E5=AE=9E=E3=80=8D=E8=AF=BB=20cardinality=5Fc?= =?UTF-8?q?hecked=EF=BC=8C=E6=8C=91=E8=A1=A8=E6=97=B6=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=E7=9A=84=E7=A9=BA=E8=A1=A8=E8=AF=BB=20empty=5Ftables=20?= =?UTF-8?q?=E5=88=97=E5=87=BA=E6=9D=A5=EF=BC=9B=E8=A1=A8=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E7=9A=84=E5=85=B3=E8=81=94=E5=85=B3=E7=B3=BB=E6=A0=87=E5=87=BA?= =?UTF-8?q?=E5=9F=BA=E6=95=B0=E5=B7=B2=E6=A0=B8=E5=AE=9E=EF=BC=9B=E5=89=96?= =?UTF-8?q?=E6=9E=90=E8=AF=B4=E6=98=8E=E4=B8=8D=E5=B8=A6=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E5=90=8E=EF=BC=8C=E7=A0=81=E5=80=BC=E5=BC=B9=E7=AA=97=E7=9A=84?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E5=90=8E=E9=9D=A2=E6=8C=89=E8=BF=99=E4=B8=80?= =?UTF-8?q?=E9=A1=B9=E7=9A=84=20updated=5Fat=20=E5=86=99=E6=97=B6=E9=97=B4?= =?UTF-8?q?=EF=BC=9B=E8=A1=A5=E5=90=8E=E7=AB=AF=E6=B5=8B=E8=AF=95=E5=92=8C?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E8=84=9A=E6=9C=AC=E7=9A=84=E6=96=AD=E8=A8=80?= =?UTF-8?q?=EF=BC=88=E5=81=87=E7=AD=94=E5=A4=8D=E7=9A=84=20summary=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=86=99=E5=BE=85=E5=A1=AB=E5=86=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/data/catalog_profile.py | 11 +++--- backend/tests/test_catalog_profile.py | 5 +++ frontend/src/lib/terms.ts | 6 ++++ frontend/src/pages/catalog/CodesDialog.tsx | 7 +++- frontend/src/pages/catalog/ProfileDialog.tsx | 19 ++++++++--- frontend/src/pages/catalog/TableDetail.tsx | 8 ++++- frontend/src/types.ts | 4 +++ scripts/check-catalog.mjs | 36 ++++++++++++++------ 8 files changed, 75 insertions(+), 21 deletions(-) diff --git a/backend/app/data/catalog_profile.py b/backend/app/data/catalog_profile.py index cc788d28..820bc6d4 100644 --- a/backend/app/data/catalog_profile.py +++ b/backend/app/data/catalog_profile.py @@ -930,7 +930,8 @@ async def _check_dates(cx: _Context, table: str, columns: list[str], total: int, def _relation_finding(o: _RelationOutcome, rel: dict[str, Any], *, status: str, confirmed: bool) -> dict[str, Any]: """一条关系的核实结论。previous_status 是剖析之前目录里这条关系的状态:报告按它说「升为已验证」还是「仍为已验证」 - (重新剖析、目录没变时不能说升了)。""" + (重新剖析、目录没变时不能说升了);cardinality_checked:基数是不是用数据核实的(子表一侧数过是否唯一), + 报告据此写「已用数据核实」,不从说明的原话里认。""" previous = rel.get("status") if confirmed: summary = "已人工确认,只补充覆盖率和基数" @@ -943,8 +944,8 @@ def _relation_finding(o: _RelationOutcome, rel: dict[str, Any], *, status: str, return {"kind": "relation", "path": f"relations.{o.rid}", "target": o.target, "columns": list(rel.get("columns") or []), "to_table": rel.get("to_table"), "to_columns": list(rel.get("to_columns") or []), "status": status, "confirmed": confirmed, - "previous_status": previous, "coverage": o.coverage, "cardinality": o.cardinality, "sample": o.sample, - "matched": o.matched, "summary": summary} + "previous_status": previous, "coverage": o.coverage, "cardinality": o.cardinality, + "cardinality_checked": o.cardinality_checked, "sample": o.sample, "matched": o.matched, "summary": summary} def _codes_value(c: _CodesOutcome, item: dict[str, Any] | None) -> dict[str, str]: @@ -959,11 +960,13 @@ def _codes_value(c: _CodesOutcome, item: dict[str, Any] | None) -> dict[str, str def _codes_finding(c: _CodesOutcome, value: dict[str, str]) -> dict[str, Any]: + """码值候选的结论。pending:写进目录的码值里还有几个含义空着(别的来源写过的含义会沿用),报告按它说 + 「N 个含义待填写」,不从 summary 的原话里认。""" pending = sum(1 for v in value.values() if not v.strip()) summary = f"{len(c.values)} 个取值" + (f",{pending} 个含义待填写" if pending else "") return {"kind": "codes", "path": f"columns.{c.column}.codes", "column": c.column, "values": [{"value": k, "rows": n} for k, n in c.values], "rows": c.rows, "status": "proposed", - "summary": summary} + "pending": pending, "summary": summary} def _patch_confirmed(rel: dict[str, Any], o: _RelationOutcome, at: str) -> bool: diff --git a/backend/tests/test_catalog_profile.py b/backend/tests/test_catalog_profile.py index 26a877f9..f9e0c599 100644 --- a/backend/tests/test_catalog_profile.py +++ b/backend/tests/test_catalog_profile.py @@ -231,6 +231,11 @@ async def test_profile_verifies_name_relations_and_proposes_codes_and_business_d gate = next(f for f in visits["findings"] if f["kind"] == "relation" and f["columns"] == ["gate_id"]) assert gate["status"] == "verified" and gate["path"] == f"relations.{_relation(notes, 'gate_id')['id']}" assert gate["sample"] == 8 and gate["matched"] == 8 and gate["summary"] + # 结构化的字段:基数用数据核实过没有、码值还有几个含义待填写(界面读这些,不从原话里认) + assert isinstance(gate["cardinality_checked"], bool) + codes = next(f for f in visits["findings"] if f["kind"] == "codes") + blank = sum(1 for v in notes["columns"][codes["column"]]["codes"]["value"].values() if not v.strip()) + assert codes["pending"] == blank and (f"{blank} 个含义待填写" in codes["summary"]) == (blank > 0) assert visits["row_estimate"] == {"rows": 1500, "method": "count", "at_least": None} assert visits["added"] == 2 and visits["updated"] == 2 and visits["error"] is None diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 7b55b372..37388a16 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2541,6 +2541,12 @@ export const CATALOG_UI_TEXT = { publishSqlErrorBlocked: 'SQL 检查发现的错误级问题:受管级别会拦下发布,请先修改 SQL', publishSqlWarning: 'SQL 检查的提醒:两个发布级别都只提醒、不拦发布', publishSqlErrors: (n: number) => `,其中 ${formatNumber(n)} 处是 SQL 检查发现的错误级问题(受管级别会拦下)`, + // ---- 剖析的结构化读数:基数用数据核实过没有、挑表时跳过的空表 + cardinalityChecked: '已用数据核实', + cardinalityByStructure: '子表一侧未核实,按表结构推断', + cardinalityCheckedShort: '已核实', + cardinalityCheckedHint: '基数由数据剖析用数据核实(子表一侧确有重复或确实唯一),一对多关联后重复计算的检查据此报错误', + emptyTables: (n: number) => `挑表时跳过 ${formatNumber(n)} 张空表`, } // =========================================================================== diff --git a/frontend/src/pages/catalog/CodesDialog.tsx b/frontend/src/pages/catalog/CodesDialog.tsx index 726ca239..e3d8a312 100644 --- a/frontend/src/pages/catalog/CodesDialog.tsx +++ b/frontend/src/pages/catalog/CodesDialog.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import type { CatalogItem } from '../../types' import { isComposing, Modal, Spinner } from '../../components/ui' +import { formatDateTime, formatTime } from '../../lib/format' import { CODES_TEXT as KT } from '../../lib/terms' import { StatusChip } from './parts' import { codesComplete } from './model' @@ -45,8 +46,12 @@ export function CodesDialog({ column, item, saving, onSave, onClose }: { {item.note && ( -
+
{KT.note}:{item.note} + {/* 说明里不带日期,时间看这一项的 updated_at(按本地时间显示) */} + {item.updated_at && ( + ({formatTime(item.updated_at)}) + )}
)}
diff --git a/frontend/src/pages/catalog/ProfileDialog.tsx b/frontend/src/pages/catalog/ProfileDialog.tsx index 1c64d1e3..028f2d65 100644 --- a/frontend/src/pages/catalog/ProfileDialog.tsx +++ b/frontend/src/pages/catalog/ProfileDialog.tsx @@ -287,6 +287,12 @@ function Report({ report: r, ms, filled, returnTo, onSettings, onOpenTable, onFi )} {r.note && {r.note}} + {/* 挑表途中确知是空表、跳过的表(服务端 empty_tables):一张都没剖析时 note 已经说了,这里只在剖析了别的表时列出 */} + {r.tables.length > 0 && (r.empty_tables?.length ?? 0) > 0 && ( +

+ {UT.emptyTables(r.empty_tables!.length)}:{r.empty_tables!.join('、')} +

+ )} {shown.map((t) => )} {quiet.length > 0 && (

@@ -418,7 +424,13 @@ function RelationLine({ f }: { f: CatalogProfileRelationFinding }) {

{PT.coverage(coverageText(f.coverage))} {' · '} - {f.cardinality ? CATALOG_CARDINALITY_LABEL[f.cardinality] : PT.cardinalityUnknown} + + {f.cardinality ? CATALOG_CARDINALITY_LABEL[f.cardinality] : PT.cardinalityUnknown} + {/* 基数用数据核实过没有:读服务端的 cardinality_checked,不从说明的原话里认 */} + {f.cardinality && f.cardinality_checked !== undefined && ( + ({f.cardinality_checked ? UT.cardinalityChecked : UT.cardinalityByStructure}) + )} + {' · '} {PT.sampled(f.sample, f.matched)}
@@ -440,9 +452,8 @@ function CodesLine({ f, fillKey: key, filled, onFill }: { filled: number | undefined onFill: () => void }) { - // 服务端在 summary 里写了几个含义待填写(别的来源写过的含义会沿用);认不出时按都待填写 - const reported = Number(f.summary.match(/(\d+)\s*个含义待填写/)?.[1] ?? (f.summary.includes('待填写') ? f.values.length : 0)) - const pending = filled ?? reported + // 几个含义待填写读服务端的 pending(别的来源写过的含义会沿用),不从 summary 的原话里认;老服务端不给时不说 + const pending = filled ?? f.pending ?? 0 const done = filled != null && filled === 0 return (
  • diff --git a/frontend/src/pages/catalog/TableDetail.tsx b/frontend/src/pages/catalog/TableDetail.tsx index 3b67a5be..7ab52d7c 100644 --- a/frontend/src/pages/catalog/TableDetail.tsx +++ b/frontend/src/pages/catalog/TableDetail.tsx @@ -757,7 +757,13 @@ function RelationList({ relations, busy, targetOf, onOpen, onReview }: { {label(CT.relationHead.cardinality)} - {r.cardinality ? CATALOG_CARDINALITY_LABEL[r.cardinality] : {CT.cardinalityNone}} + + {r.cardinality ? CATALOG_CARDINALITY_LABEL[r.cardinality] : {CT.cardinalityNone}} + {/* 数据剖析用数据核实过的基数(cardinality_checked):一对多关联的检查据此给「错误」,其余的最多「提醒」 */} + {r.cardinality && r.cardinality_checked && ( + {UT.cardinalityCheckedShort} + )} + {label(CT.relationHead.coverage)} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9e655426..dc8a1c4c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2901,6 +2901,8 @@ export interface CatalogProfileRelationFinding { previous_status?: CatalogStatus | null coverage: number cardinality: CatalogCardinality | null + /** 基数是不是用数据核实的(子表一侧数过是否唯一)。老服务端不给 */ + cardinality_checked?: boolean /** 抽了几个不同的键值、在被指向表里对上几个 */ sample: number matched: number @@ -2915,6 +2917,8 @@ export interface CatalogProfileCodesFinding { values: { value: string; rows: number }[] rows: number status: CatalogStatus + /** 写进目录的码值里还有几个含义空着(别的来源写过的含义会沿用)。老服务端不给 */ + pending?: number summary: string } diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs index dc647529..c09d4b4d 100644 --- a/scripts/check-catalog.mjs +++ b/scripts/check-catalog.mjs @@ -1346,13 +1346,13 @@ function profileReply(state, { ms = 0, extraCodes = false } = {}) { const before = (id) => visits.notes.relations.find((r) => r.id === id)?.status ?? null const previous = { gate: before(GATE_REL), member: before(MEMBER_REL) } notes.relations = notes.relations.map((r) => (r.id === GATE_REL ? { - ...r, source: 'profile', status: 'verified', coverage: 0.987, cardinality: 'many_to_one', updated_at: at, - note: `数据剖析(${PROFILE_DAY}):子表抽样 300 个不同键值,父表对上 296 个,覆盖率 98.7%;被指向列是主键;子表一侧有重复值,多对一。`, + ...r, source: 'profile', status: 'verified', coverage: 0.987, cardinality: 'many_to_one', cardinality_checked: true, updated_at: at, + note: '数据剖析:子表抽样 300 个不同键值,父表对上 296 个,覆盖率 98.7%;被指向列是主键;子表一侧有重复值,多对一。', } : r)) notes.relations.push(rel(['member_id'], 'members', ['id'], 'profile', { coverage: 0.62, updated_at: at, - note: `数据剖析(${PROFILE_DAY}):抽样覆盖率 62%,可能不是这条关系;子表抽样 200 个不同键值,父表对上 124 个;被指向列是主键。` })) - notes.columns.status.codes = item({ 1: '已入园', 9: '已作废', 2: '' }, 'profile', 'proposed') - notes.columns.status.codes.note = `数据剖析(${PROFILE_DAY}):统计全表,182000 行非空值共 3 个取值:1(94%)、9(5%)、2(1%)。` + note: '数据剖析:抽样覆盖率 62%,可能不是这条关系;子表抽样 200 个不同键值,父表对上 124 个;被指向列是主键。' })) + notes.columns.status.codes = { ...item({ 1: '已入园', 9: '已作废', 2: '' }, 'profile', 'proposed'), updated_at: at } + notes.columns.status.codes.note = '数据剖析:统计全表,182000 行非空值共 3 个取值:1(94%)、9(5%)、2(1%)。' // 统计的是全表:服务端给码值候选标「已列全」 notes.columns.status.codes.complete = true // extraCodes:票种列也取到码值候选(两个取值,含义都空着)——报告里有两列要填,填完一列回到报告接着填下一列 @@ -1363,16 +1363,17 @@ function profileReply(state, { ms = 0, extraCodes = false } = {}) { table_name: 'visits', queries: 38, row_estimate: { rows: 182000, method: 'stats', at_least: null }, findings: [ { kind: 'relation', path: `relations.${GATE_REL}`, target: 'gate_id → gates.id', columns: ['gate_id'], to_table: 'gates', to_columns: ['id'], - status: 'verified', confirmed: false, previous_status: previous.gate, coverage: 0.987, cardinality: 'many_to_one', sample: 300, matched: 296, + status: 'verified', confirmed: false, previous_status: previous.gate, coverage: 0.987, cardinality: 'many_to_one', cardinality_checked: true, + sample: 300, matched: 296, summary: previous.gate === 'verified' ? '覆盖率 98.7%,仍为有确证' : '覆盖率 98.7%,升为有确证' }, { kind: 'relation', path: `relations.${MEMBER_REL}`, target: 'member_id → members.id', columns: ['member_id'], to_table: 'members', to_columns: ['id'], status: 'proposed', confirmed: false, previous_status: previous.member, coverage: 0.62, cardinality: 'many_to_one', - sample: 200, matched: 124, + cardinality_checked: false, sample: 200, matched: 124, summary: '抽样覆盖率 62%,保持推断' }, { kind: 'codes', path: 'columns.status.codes', column: 'status', values: [{ value: '1', rows: 171080 }, { value: '9', rows: 9100 }, - { value: '2', rows: 1820 }], rows: 182000, status: 'proposed', summary: '3 个取值,1 个含义待填写' }, + { value: '2', rows: 1820 }], rows: 182000, status: 'proposed', pending: 1, summary: '3 个取值' }, ...(extraCodes ? [{ kind: 'codes', path: 'columns.ticket_type_id.codes', column: 'ticket_type_id', values: [{ value: '11', rows: 120000 }, - { value: '12', rows: 62000 }], rows: 182000, status: 'proposed', summary: '2 个取值,2 个含义待填写' }] : []), + { value: '12', rows: 62000 }], rows: 182000, status: 'proposed', pending: 2, summary: '2 个取值' }] : []), ], skipped: [ { kind: 'codes', target: 'member_level', path: 'columns.member_level.codes', reason: 'masked', detail: 'visits.member_level 在数据源设置中被遮罩,不取值' }, @@ -1395,6 +1396,7 @@ function profileReply(state, { ms = 0, extraCodes = false } = {}) { return json({ profiled_at: at, actor: ACTOR, settings: { ...PROFILE_ON, sample_size: 2000, max_scan_rows: 100000, max_total_s: 120 }, queries_used: 60, stopped: 'budget', tables, total: { added: 1, updated: 2, removed: 0 }, note: null, + empty_tables: ['ext_log_001', 'ext_log_002'], })(route) } } @@ -1588,7 +1590,7 @@ await section('数据目录 · 数据剖析', async () => { const visitsCard = report.locator('[data-profile-table="visits"]') const gate = visitsCard.locator(`[data-profile-finding="relation"][data-path="relations.${GATE_REL}"]`) check('关系核实:覆盖率、基数、抽样规模,升为已验证', (await gate.locator('[data-profile-coverage]').innerText()) === '覆盖率 98.7%' - && (await gate.locator('[data-profile-cardinality]').innerText()) === '多对一' && (await gate.innerText()).includes('抽样 300 个键值,对上 296 个') + && (await gate.locator('[data-profile-cardinality]').innerText()) === '多对一(已用数据核实)' && (await gate.innerText()).includes('抽样 300 个键值,对上 296 个') && (await gate.locator('[data-profile-outcome]').innerText()) === '升为已验证' && (await gate.getAttribute('data-status')) === 'verified', (await gate.innerText()).replace(/\s+/g, ' ')) // 结论是状态说明,不是链接:带框的小标签,文字用次要文字色(不是强调蓝),不能点 @@ -1607,6 +1609,11 @@ await section('数据目录 · 数据剖析', async () => { check('……结论用状态标签的样子:带框、次要文字色,不是蓝色正文、不像链接', outcomeLook.kind === 'raised' && outcomeLook.color === outcomeLook.dim && outcomeLook.color !== outcomeLook.accent && outcomeLook.border === 'solid' && outcomeLook.cursor !== 'pointer' && !outcomeLook.clickable, JSON.stringify(outcomeLook)) + // 结构化的读数:基数核实读 cardinality_checked,几个含义待填写读 pending(假答复的 summary 里故意不写),空表读 empty_tables + check('……基数用数据核实过没有,读 cardinality_checked', (await gate.locator('[data-profile-cardinality]').getAttribute('data-checked')) === 'true' + && (await visitsCard.locator(`[data-path="relations.${MEMBER_REL}"] [data-profile-cardinality]`).innerText()) === '多对一(子表一侧未核实,按表结构推断)') + check('……挑表时跳过的空表读 empty_tables', (await report.locator('[data-profile-empty-tables="2"]').innerText().catch(() => '')) + === '挑表时跳过 2 张空表:ext_log_001、ext_log_002') const member = visitsCard.locator(`[data-profile-finding="relation"][data-path="relations.${MEMBER_REL}"]`) check('……覆盖率不够的保持推断,写明原因', (await member.locator('[data-profile-outcome]').innerText()) === '保持推断:覆盖率不足,可能不是这条关系' && (await member.locator('[data-profile-coverage]').innerText()) === '覆盖率 62%') @@ -1635,6 +1642,11 @@ await section('数据目录 · 数据剖析', async () => { (await gateRow.innerText().catch(() => '')).replace(/\s+/g, ' ')) await openMark(page, `relations.${GATE_REL}`) check('……点开来源:写明抽样规模和覆盖率的剖析说明', (await panel(page).innerText()).includes('子表抽样 300 个不同键值,父表对上 296 个')) + // 说明里不带日期:时间看这一项的 updated_at(按本地时间,2026-10-03 03:00 UTC 是 11:00) + check('……说明不带日期,时间按这一项的 updated_at 写', !/\d{4}-\d{2}-\d{2}/.test(await panel(page).locator('.whitespace-pre-wrap').innerText()) + && /更新于 (2026\/)?10\/3 11:00/.test(await panel(page).innerText()), (await panel(page).innerText()).replace(/\s+/g, ' ')) + check('……关系表上写基数已用数据核实', await page.locator(`[data-relation="${GATE_REL}"] [data-relation-cardinality][data-checked="true"]`).count() === 1 + && (await page.locator(`[data-relation="${GATE_REL}"] [data-relation-cardinality]`).innerText()).includes('已核实')) await page.keyboard.press('Escape') const statusCodes = page.locator('[data-column="status"] [data-cell="codes"]') check('……码值的含义空着写「含义待填写」,给「填写含义」', (await statusCodes.locator('[data-code="2"] [data-code-pending]').innerText()) === '含义待填写' @@ -1650,7 +1662,9 @@ await section('数据目录 · 数据剖析', async () => { check('码值弹窗:每个码值一个输入框,空着的写「含义待填写」,光标在它上面', await codesBox.locator('input[data-code]').count() === 3 && (await codesBox.locator('input[data-code="2"]').getAttribute('placeholder')) === '含义待填写' && await until(async () => page.evaluate(() => document.activeElement?.getAttribute('data-code') === '2'))) - check('……写明剖析说明(各取值的占比)', (await codesBox.innerText()).includes('1(94%)、9(5%)、2(1%)')) + check('……写明剖析说明(各取值的占比),时间按这一项的 updated_at', (await codesBox.innerText()).includes('1(94%)、9(5%)、2(1%)') + && /^((2026\/)?10\/3 11:00)$/.test(await codesBox.locator('[data-codes-note-at]').innerText().catch(() => '')), + await codesBox.locator('[data-codes-note]').innerText().catch(() => '')) check('……没填时保存禁用', await page.locator('[data-codes-save]').isDisabled()) check('……写明当前已列出全部取值(剖析统计了全表),可以取消', await codesBox.locator('[data-codes-complete]').isChecked() && (await codesBox.innerText()).includes('已列出全部取值') && (await statusCodes.locator('[data-codes-complete-mark]').count()) === 1)