数据目录 F2b / B6:剖析报告里点「填写含义」,填完(保存或取消)回到报告,不用重新剖析:刚填的一列改写成「含义已填写」、按钮换成「修改含义」,焦点落在下一列还有待填写的「填写含义」上,一列接一列地填;正在编辑这张表时说一声再回报告;补检查脚本的断言

This commit is contained in:
ThearchyHelios
2026-10-04 02:06:47 +08:00
parent 09fed6a414
commit 31305a352d
6 changed files with 146 additions and 29 deletions
+5
View File
@@ -2512,6 +2512,11 @@ export const CATALOG_UI_TEXT = {
usedOnly: '只看运行中查询过的表',
usedOnlyHint: '使用次数大于 0、还在表结构里的表,和顶部摘要的口径一致',
usedOnlyClear: '取消「只看运行中查询过的表」',
// ---- 剖析报告里的「填写含义」:填完回到报告,接着填下一列
codesFilled: '含义已填写',
editMeanings: '修改含义',
editMeaningsLabel: (col: string) => `修改含义:列 ${col} 的码值`,
fillWhileEditing: '正在编辑这张表,保存或取消后再填写码值的含义',
}
// ===========================================================================
+24 -4
View File
@@ -13,7 +13,7 @@ import { DraftDialog } from './DraftDialog'
import { ProfileDialog } from './ProfileDialog'
import type { ProfileJob } from './ProfileDialog'
import { ProfileSettingsDialog } from './ProfileSettings'
import { profileBlockOf, profileSettingsOf, secondsText } from './profile'
import { fillKey, profileBlockOf, profileSettingsOf, secondsText } from './profile'
import { TableDetail } from './TableDetail'
import { TableIndex } from './TableIndex'
import { filterCounts, listParamsOf, progressOf, rowFromDetail, usageSummary, visibleRows, writeListParams } from './model'
@@ -108,8 +108,13 @@ export function CatalogPage() {
const [job, setJob] = useState<(ProfileJob & { sourceId: string }) | null>(null)
/** 剖析设置刚存过:数据源列表重取回来之前按存下的这份算(预算、开没开) */
const [savedOptions, setSavedOptions] = useState<{ sourceId: string; options: Record<string, unknown> } | null>(null)
/** 从剖析报告点「填写含义」:打开那张表并弹出那一列的码值 */
/**
* 从剖析报告点「填写含义」:报告先收起,打开那张表并弹出那一列的码值;码值弹窗关上(保存或取消)后回到报告,
* 焦点落在下一列的「填写含义」上,一列接一列地填
*/
const [fillCodes, setFillCodes] = useState<{ table: string; column: string; seq: number } | null>(null)
/** 回到报告时从哪一列接着往下 */
const [returnTo, setReturnTo] = useState<{ table: string; column: string } | null>(null)
const profilingRef = useRef(profiling)
profilingRef.current = profiling
const alive = useRef(true)
@@ -214,6 +219,20 @@ export function CatalogPage() {
openTable(t)
}, [openTable])
const jobRef = useRef(job)
jobRef.current = job
/** 从报告打开的码值弹窗关上了:记下这一列还剩几个含义待填写(报告里跟着改),回到报告 */
const onFillDone = useCallback((seq: number, pending: number | null) => {
const fill = fillCodes?.seq === seq ? fillCodes : null
const cur = jobRef.current
if (!fill || !alive.current || cur?.phase !== 'done' || cur.sourceId !== sourceRef.current) return
if (pending != null) {
setJob((j) => (j && j.phase === 'done' ? { ...j, filled: { ...j.filled, [fillKey(fill.table, fill.column)]: pending } } : j))
}
setReturnTo({ table: fill.table, column: fill.column })
setProfiling(true)
}, [fillCodes])
// 上一张 / 下一张按清单当前的筛选和排序走;正在看的表不在筛选结果里(刚确认完、从关联关系跳过来)时按全部表走
const [prev, next] = useMemo(() => {
if (!table) return [null, null]
@@ -323,7 +342,7 @@ export function CatalogPage() {
{table
? (
<TableDetail sourceId={sourceId} table={table} rows={rows} drafted={drafted} fillCodes={fillCodes} prev={prev} next={next}
onOpen={openTable} onBack={backToList} onDetail={onDetail} />
onFillDone={onFillDone} onOpen={openTable} onBack={backToList} onDetail={onDetail} />
)
: <Overview rows={rows} empty={empty} systemNotes={data.system_notes} draft={draftButton(true)} onOpen={openTable} />}
</section>
@@ -374,7 +393,8 @@ export function CatalogPage() {
filtered={filtered}
job={myJob}
onStart={(tables) => void startProfile(tables)}
onClose={() => setProfiling(false)}
onClose={() => { setProfiling(false); setReturnTo(null) }}
returnTo={returnTo}
onSettings={() => setProfileSettingsOpen(true)}
onGoSource={() => navigate(back)}
onOpenTable={openFromReport}
+51 -15
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { ArrowUpRight, CircleCheck, OctagonX, ScanSearch, ShieldAlert, TriangleAlert } from 'lucide-react'
import clsx from 'clsx'
@@ -8,10 +8,10 @@ import type {
} from '../../types'
import { ErrorState, Modal, Notice, Spinner, useTicker } from '../../components/ui'
import {
CATALOG_CARDINALITY_LABEL, CATALOG_TEXT as CT, PROFILE_SKIP_KIND_LABEL, PROFILE_SKIP_REASON_LABEL, PROFILE_STOP_LABEL, PROFILE_STOP_NEXT, PROFILE_TEXT as PT,
CATALOG_CARDINALITY_LABEL, CATALOG_TEXT as CT, CATALOG_UI_TEXT as UT, PROFILE_SKIP_KIND_LABEL, PROFILE_SKIP_REASON_LABEL, PROFILE_STOP_LABEL, PROFILE_STOP_NEXT, PROFILE_TEXT as PT,
} from '../../lib/terms'
import { STATUS_TONE, StatusChip } from './parts'
import { PROFILE_DEFAULT_TABLES, PROFILE_MAX_TABLES, coverageText, secondsText } from './profile'
import { PROFILE_DEFAULT_TABLES, PROFILE_MAX_TABLES, coverageText, fillKey, secondsText } from './profile'
import type { ProfileBlock } from './profile'
// ===========================================================================
@@ -26,7 +26,8 @@ import type { ProfileBlock } from './profile'
/** 一次剖析的状态。放在页面上而不是弹窗里:关掉弹窗剖析照常进行,完成后还能再打开报告 */
export type ProfileJob =
| { phase: 'running'; startedAt: number; tables: string[] | null; settings: CatalogProfileSettings }
| { phase: 'done'; report: CatalogProfileOut; ms: number }
/** filled:从报告去填了含义的码值列(fillKey)→ 保存后还剩几个含义待填写,报告里按它改写那一列 */
| { phase: 'done'; report: CatalogProfileOut; ms: number; filled?: Record<string, number> }
| { phase: 'blocked'; kind: ProfileBlock; message: string; tables: string[] | null }
| { phase: 'failed'; error: unknown; tables: string[] | null }
@@ -37,7 +38,8 @@ const SKIP_SHOWN = 6
const CODES_SHOWN = 12
export function ProfileDialog({
sourceName, settings, rows, visible, selected, filtered, job, onStart, onClose, onSettings, onGoSource, onOpenTable, onFillCodes, onReset,
sourceName, settings, rows, visible, selected, filtered, job, returnTo, onStart, onClose, onSettings, onGoSource, onOpenTable, onFillCodes,
onReset,
}: {
sourceName: string
/** 这个数据源此刻的剖析设置(数据源列表里读的) */
@@ -48,6 +50,8 @@ export function ProfileDialog({
filtered: boolean
/** null:还没开始(选范围) */
job: ProfileJob | null
/** 填完一列码值的含义、回到报告:焦点落在下一列还有待填写的「填写含义」上 */
returnTo?: { table: string; column: string } | null
/** tables 为 null 时由服务端挑表 */
onStart: (tables: string[] | null) => void
onClose: () => void
@@ -152,7 +156,8 @@ export function ProfileDialog({
)
footer = <button type="button" className="btn" onClick={onClose} data-autofocus data-profile-background="">{PT.background}</button>
} else if (job.phase === 'done') {
body = <Report report={job.report} ms={job.ms} onSettings={onSettings} onOpenTable={onOpenTable} onFillCodes={onFillCodes} />
body = <Report report={job.report} ms={job.ms} filled={job.filled ?? {}} returnTo={returnTo ?? null} onSettings={onSettings}
onOpenTable={onOpenTable} onFillCodes={onFillCodes} />
footer = (
<>
<button type="button" className="btn btn-ghost mr-auto" onClick={onReset} data-profile-again="">{PT.again}</button>
@@ -230,9 +235,11 @@ function Budget({ settings: s, sourceName, onSettings }: { settings: CatalogProf
// 报告
// ---------------------------------------------------------------------------
function Report({ report: r, ms, onSettings, onOpenTable, onFillCodes }: {
function Report({ report: r, ms, filled, returnTo, onSettings, onOpenTable, onFillCodes }: {
report: CatalogProfileOut
ms: number
filled: Record<string, number>
returnTo: { table: string; column: string } | null
onSettings: () => void
onOpenTable: (table: string) => void
onFillCodes: (table: string, column: string) => void
@@ -242,8 +249,19 @@ function Report({ report: r, ms, onSettings, onOpenTable, onFillCodes }: {
// 一项发现、一项跳过都没有的表合成一行,几十张表时报告不被空卡片撑长
const quiet = r.tables.filter((t) => !t.error && !t.findings.length && !t.skipped.length && !t.date_ranges.length)
const shown = r.tables.filter((t) => !quiet.includes(t))
// 填完一列回到报告:焦点交给它后面第一个还有含义待填写的「填写含义」,没有了就落回刚填的那一列。
// 弹窗自己的初始焦点在下一帧,这里先聚焦,它看到焦点已在弹窗里就不再动
const root = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!returnTo) return
const buttons = [...(root.current?.querySelectorAll<HTMLButtonElement>('[data-profile-fill-key]') ?? [])]
const at = buttons.findIndex((b) => b.dataset.profileFillKey === fillKey(returnTo.table, returnTo.column))
const target = buttons.slice(at + 1).find((b) => Number(b.dataset.pending) > 0) ?? buttons[at]
target?.focus()
target?.scrollIntoView({ block: 'nearest' })
}, [returnTo])
return (
<div className="space-y-3" data-profile-report={stop ?? 'done'}>
<div ref={root} className="space-y-3" data-profile-report={stop ?? 'done'}>
<div className="flex items-start gap-2">
{stop
? <TriangleAlert size={15} className="mt-px shrink-0" style={{ color: 'var(--st-waiting)' }} aria-hidden />
@@ -268,7 +286,7 @@ function Report({ report: r, ms, onSettings, onOpenTable, onFillCodes }: {
</Notice>
)}
{r.note && <Notice tone="info" attr={{ 'data-profile-note': '' }}>{r.note}</Notice>}
{shown.map((t) => <TableReport key={t.table_name} t={t} onOpenTable={onOpenTable} onFillCodes={onFillCodes} />)}
{shown.map((t) => <TableReport key={t.table_name} t={t} filled={filled} onOpenTable={onOpenTable} onFillCodes={onFillCodes} />)}
{quiet.length > 0 && (
<p className="text-2xs leading-relaxed text-faint" data-profile-quiet={quiet.length}>
{PT.noFindings}:<span className="mono">{quiet.map((t) => t.table_name).join('、')}</span>
@@ -285,8 +303,9 @@ function sizeText(size: CatalogProfileSize | null): { text: string; hint?: strin
return null
}
function TableReport({ t, onOpenTable, onFillCodes }: {
function TableReport({ t, filled, onOpenTable, onFillCodes }: {
t: CatalogProfileTable
filled: Record<string, number>
onOpenTable: (table: string) => void
onFillCodes: (table: string, column: string) => void
}) {
@@ -325,7 +344,10 @@ function TableReport({ t, onOpenTable, onFillCodes }: {
)}
{codes.length > 0 && (
<Group title={PT.findingCodes}>
{codes.map((f) => <CodesLine key={f.path} f={f} onFill={() => onFillCodes(t.table_name, f.column)} />)}
{codes.map((f) => (
<CodesLine key={f.path} f={f} fillKey={fillKey(t.table_name, f.column)} filled={filled[fillKey(t.table_name, f.column)]}
onFill={() => onFillCodes(t.table_name, f.column)} />
))}
</Group>
)}
{date && (
@@ -388,9 +410,17 @@ function RelationLine({ f }: { f: CatalogProfileRelationFinding }) {
)
}
function CodesLine({ f, onFill }: { f: CatalogProfileCodesFinding; onFill: () => void }) {
function CodesLine({ f, fillKey: key, filled, onFill }: {
f: CatalogProfileCodesFinding
fillKey: string
/** 从报告去填过含义:保存后还剩几个待填写。没去填过为 undefined,按剖析时的说 */
filled: number | undefined
onFill: () => void
}) {
// 服务端在 summary 里写了几个含义待填写(别的来源写过的含义会沿用);认不出时按都待填写
const pending = Number(f.summary.match(/(\d+)\s*个含义待填写/)?.[1] ?? (f.summary.includes('待填写') ? f.values.length : 0))
const reported = Number(f.summary.match(/(\d+)\s*个含义待填写/)?.[1] ?? (f.summary.includes('待填写') ? f.values.length : 0))
const pending = filled ?? reported
const done = filled != null && filled === 0
return (
<li className="text-xs" data-profile-finding="codes" data-column={f.column}>
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
@@ -409,8 +439,14 @@ function CodesLine({ f, onFill }: { f: CatalogProfileCodesFinding; onFill: () =>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
{pending > 0 && <span className="text-2xs" style={{ color: 'var(--st-waiting)' }} data-codes-pending={pending}>{PT.codesPendingN(pending)}</span>}
<button type="button" className="btn btn-xs" onClick={onFill} aria-label={PT.fillMeaningsLabel(f.column)} data-profile-fill={f.column}>
{PT.fillMeanings}
{done && (
<span className="inline-flex items-center gap-1 text-2xs text-dim" data-codes-filled="">
<CircleCheck size={11} className="shrink-0" style={{ color: 'var(--st-done)' }} aria-hidden /> {UT.codesFilled}
</span>
)}
<button type="button" className="btn btn-xs" onClick={onFill} aria-label={done ? UT.editMeaningsLabel(f.column) : PT.fillMeaningsLabel(f.column)}
data-profile-fill={f.column} data-profile-fill-key={key} data-pending={pending}>
{done ? UT.editMeanings : PT.fillMeanings}
</button>
</div>
</li>
+32 -8
View File
@@ -12,7 +12,7 @@ import { useLeaveGuard } from '../../lib/leave'
import { formatDateTime, formatNumber, formatTime } from '../../lib/format'
import {
CATALOG_CARDINALITY_LABEL, CATALOG_IMPACT_TEXT, CATALOG_KIND_HINT, CATALOG_KIND_LABEL, CATALOG_TABLE_FIELD_HINT,
CATALOG_TABLE_FIELD_LABEL, CATALOG_TEXT as CT, CODES_TEXT as KT,
CATALOG_TABLE_FIELD_LABEL, CATALOG_TEXT as CT, CATALOG_UI_TEXT as UT, CODES_TEXT as KT,
} from '../../lib/terms'
import { CatalogImpactList } from '../../components/CatalogImpact'
import { CountBadges, ItemMark, StatusChip, StatusLegend, SystemNotesNotice } from './parts'
@@ -51,7 +51,7 @@ const PROBLEM_TEXT = {
type Editing = { initial: EditForm; form: EditForm }
type Conflict = 'edit' | 'review'
export function TableDetail({ sourceId, table, rows, drafted, fillCodes, prev, next, onOpen, onBack, onDetail }: {
export function TableDetail({ sourceId, table, rows, drafted, fillCodes, onFillDone, prev, next, onOpen, onBack, onDetail }: {
sourceId: string
table: string
/** 全部表:关联关系的目标表能点就跳过去,编辑时给目标表做候选 */
@@ -60,6 +60,11 @@ export function TableDetail({ sourceId, table, rows, drafted, fillCodes, prev, n
drafted: { seq: number; tables: Set<string> }
/** 从剖析报告点「填写含义」过来:载入后打开这一列的码值弹窗(seq 让同一列连点两次也会再开) */
fillCodes?: { table: string; column: string; seq: number } | null
/**
* 从剖析报告打开的码值弹窗关上了(保存了、取消了),或者打不开(正在编辑这张表):页面据此回到报告,接着处理下一列。
* pending 是保存后这一列还有几个含义待填写;取消、打不开为 null
*/
onFillDone?: (seq: number, pending: number | null) => void
prev: string | null
next: string | null
onOpen: (table: string) => void
@@ -79,6 +84,17 @@ export function TableDetail({ sourceId, table, rows, drafted, fillCodes, prev, n
const [onlyPending, setOnlyPending] = useState(false)
/** 正在填写哪一列的码值含义 */
const [codesFor, setCodesFor] = useState<string | null>(null)
/** 码值弹窗是从剖析报告的「填写含义」打开的:那一次的 seq。弹窗关上时据此回到报告 */
const fillOpen = useRef<number | null>(null)
const onFillDoneRef = useRef(onFillDone)
onFillDoneRef.current = onFillDone
/** 收起码值弹窗;是从剖析报告打开的就告诉页面(回到报告)。back=false:不回报告(别人刚改过,先处理冲突横幅) */
const closeCodes = useCallback((pending: number | null, back = true) => {
const seq = fillOpen.current
fillOpen.current = null
setCodesFor(null)
if (seq != null && back) onFillDoneRef.current?.(seq, pending)
}, [])
const loadSeq = useRef(0)
const onDetailRef = useRef(onDetail)
onDetailRef.current = onDetail
@@ -108,6 +124,7 @@ export function TableDetail({ sourceId, table, rows, drafted, fillCodes, prev, n
setOnlyPending(false)
setLive('')
setCodesFor(null)
fillOpen.current = null
void load()
}, [load])
@@ -126,9 +143,16 @@ export function TableDetail({ sourceId, table, rows, drafted, fillCodes, prev, n
const fillSeen = useRef(0)
useEffect(() => {
if (!fillCodes || fillCodes.table !== table || fillCodes.seq === fillSeen.current) return
if (!detail || detail.table_name !== table || editingRef.current) return
if (!detail || detail.table_name !== table) return
fillSeen.current = fillCodes.seq
if (detail.notes.columns?.[fillCodes.column]?.codes) setCodesFor(fillCodes.column)
if (!editingRef.current && detail.notes.columns?.[fillCodes.column]?.codes) {
fillOpen.current = fillCodes.seq
setCodesFor(fillCodes.column)
return
}
// 打不开(正在编辑这张表,或者这一列的码值已经没了):说一声,回到报告
if (editingRef.current) toast.warn(UT.fillWhileEditing)
onFillDoneRef.current?.(fillCodes.seq, null)
}, [fillCodes, table, detail])
const changes = edit ? changeCount(edit.initial, edit.form) : 0
@@ -199,13 +223,13 @@ export function TableDetail({ sourceId, table, rows, drafted, fillCodes, prev, n
setDetail(next)
setConflict(null)
onDetailRef.current(next)
setCodesFor(null)
closeCodes(pendingCodes(value))
const filled = Math.max(0, pendingCodes(item.value) - pendingCodes(value))
toast.ok(KT.saved(filled))
setLive(KT.saved(filled))
} catch (e) {
if (e instanceof ApiError && e.status === 409) {
setCodesFor(null)
closeCodes(null, false)
setConflict('review')
} else {
toast.error(e)
@@ -213,7 +237,7 @@ export function TableDetail({ sourceId, table, rows, drafted, fillCodes, prev, n
} finally {
setBusy('')
}
}, [sourceId, table])
}, [sourceId, table, closeCodes])
const setColumn = useCallback((col: string, f: ColumnField, v: string) => {
setEdit((e) => e && ({ ...e, form: { ...e.form, columns: { ...e.form.columns, [col]: { ...e.form.columns[col], [f]: v } } } }))
@@ -453,7 +477,7 @@ export function TableDetail({ sourceId, table, rows, drafted, fillCodes, prev, n
{codesFor && notes.columns?.[codesFor]?.codes && (
<CodesDialog column={codesFor} item={notes.columns[codesFor]!.codes!} saving={busy === `codes:${codesFor}`}
onSave={(v, complete) => void saveCodes(codesFor, v, complete)} onClose={() => setCodesFor(null)} />
onSave={(v, complete) => void saveCodes(codesFor, v, complete)} onClose={() => closeCodes(null)} />
)}
{edit && (
+3
View File
@@ -173,3 +173,6 @@ export function withProfileOption(options: Record<string, unknown> | null | unde
const { [PROFILE_OPTION]: _old, ...rest } = options ?? {}
return value ? { ...rest, [PROFILE_OPTION]: value } : rest
}
/** 剖析报告里一列码值的键(表名 + 列名):从报告去填含义、填完回到报告时按它对上是哪一列 */
export const fillKey = (table: string, column: string): string => `${table}\u0000${column}`
+31 -2
View File
@@ -1242,7 +1242,7 @@ const BUDGET_DETAIL = '本次剖析的查询次数已用完(上限 60 条)
* 剖析的默认答复:入园记录的闸机关系核实成已验证(覆盖率 98.7%),会员关系覆盖率不够保持推断,状态列的码值候选多出一个
* 没有含义的取值 2;订单只剖析了一半就用完了查询次数,订单明细一条没查。ms 是服务端「执行」多久
*/
function profileReply(state, { ms = 0 } = {}) {
function profileReply(state, { ms = 0, extraCodes = false } = {}) {
return async (route) => {
if (ms) await sleep(ms)
const visits = state.tables[S1].find((t) => t.name === 'visits')
@@ -1258,6 +1258,8 @@ function profileReply(state, { ms = 0 } = {}) {
notes.columns.status.codes.note = `数据剖析(${PROFILE_DAY}):统计全表,182000 行非空值共 3 个取值:1(94%)、9(5%)、2(1%)。`
// 统计的是全表:服务端给码值候选标「已列全」
notes.columns.status.codes.complete = true
// extraCodes:票种列也取到码值候选(两个取值,含义都空着)——报告里有两列要填,填完一列回到报告接着填下一列
if (extraCodes) notes.columns.ticket_type_id = { codes: item({ 11: '', 12: '' }, 'profile', 'proposed') }
writeEntry(visits, notes)
const tables = [
{
@@ -1270,6 +1272,8 @@ function profileReply(state, { ms = 0 } = {}) {
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 个含义待填写' },
...(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 个含义待填写' }] : []),
],
skipped: [
{ kind: 'codes', target: 'member_level', path: 'columns.member_level.codes', reason: 'masked', detail: 'visits.member_level 在数据源设置中被遮罩,不取值' },
@@ -1550,10 +1554,35 @@ await section('数据目录 · 数据剖析', async () => {
&& (await statusCodes.innerText()).includes('已退票') && (await markStatus(page, 'columns.status.codes')) === 'confirmed')
// 报告里的「填写含义」直达
await page.locator('[data-catalog-profile]').click()
replies.profile.push(profileReply(state))
replies.profile.push(profileReply(state, { extraCodes: true }))
await page.locator('[data-profile-start]').click()
await until(async () => (await profilePhase(page)) === 'done')
check('窗口开着时完成:直接看报告,不另弹提示', (await toastBox.count()) <= 1)
// 点「填写含义」:报告收起、弹出那一列的码值;保存或取消之后回到报告,这一列改写成「含义已填写」,焦点落在下一列上
const fillLine = (col) => profileBox(page).locator(`[data-profile-finding="codes"][data-column="${col}"]`)
await fillLine('status').locator('[data-profile-fill]').click()
const fillBox = page.locator('[data-codes-dialog="status"]')
check('报告里点「填写含义」:报告收起,弹出这一列的码值', await until(async () => (await fillBox.count()) === 1)
&& (await profileBox(page).count()) === 0)
await fillBox.locator('input[data-code="2"]').fill('已退票')
await page.keyboard.press('Enter')
check('填完保存:回到报告(不用重新剖析)', await until(async () => (await profilePhase(page)) === 'done') && (await fillBox.count()) === 0
&& count(sent, /catalog\/profile$/) === 2, String(await profilePhase(page)))
const statusLine = fillLine('status')
check('……刚填的这一列写「含义已填写」,按钮改成「修改含义」', (await statusLine.locator('[data-codes-filled]').innerText().catch(() => '')).includes('含义已填写')
&& await statusLine.locator('[data-codes-pending]').count() === 0 && (await statusLine.locator('[data-profile-fill]').innerText()) === '修改含义',
(await statusLine.innerText().catch(() => '')).replace(/\s+/g, ' '))
check('……还没填的下一列照旧写待填写,焦点落在它的「填写含义」上', (await fillLine('ticket_type_id').locator('[data-codes-pending]').innerText()) === '2 个含义待填写'
&& await until(async () => page.evaluate(() => document.activeElement?.getAttribute('data-profile-fill') === 'ticket_type_id'), 2000),
await page.evaluate(() => document.activeElement?.outerHTML?.slice(0, 120) ?? ''))
await page.keyboard.press('Enter')
const nextBox = page.locator('[data-codes-dialog="ticket_type_id"]')
check('……回车接着填下一列', await until(async () => (await nextBox.count()) === 1))
await page.keyboard.press('Escape')
check('码值弹窗取消:同样回到报告,那一列仍写待填写', await until(async () => (await profilePhase(page)) === 'done') && (await nextBox.count()) === 0
&& (await fillLine('ticket_type_id').locator('[data-codes-pending]').innerText()) === '2 个含义待填写')
await page.locator('[data-profile-close]').click()
await until(async () => (await profileBox(page).count()) === 0)
await openDetail(page, S1, 'orders')
await page.locator('[data-catalog-profile]').click()
await profileBox(page).waitFor()