数据目录起草(前端):模型拿不准、留空的列(中文名和含义都没给)单独写出来,不算没写全、表照样是已完成。起草对话框逐表写「12 列模型拿不准、留空了」,列名悬停看得到、也可以展开(最多 5 个,没列全写一共几列);结束后写合计「共 13 列模型拿不准、留空了(2 张表)」和办法——在起草要求里补充缩写和术语的含义,再重新起草这几张表,已有的内容不会丢;「重新起草这 N 张表」回到设置,范围就是这几张、勾着用模型、焦点在起草要求,补完再开始;结束后默认列出的表连同有留空列的(「只看未写全、有留空列的表」);关着对话框时结束的提示也接着写留空的列;设置阶段的「上一次起草」都写完了、只是有留空列的也提,写明几列。清单上上次起草有留空列的表一行淡色轻提示(不是失败标记,和失败、部分完成的标记分开;还在起草的那一次不算「上次」);表详情顶部同样提示(只有留空列时是提示、不是失败;部分完成又有留空列的并在一条里),给「重新起草这张表」和「补充起草要求」(打开起草设置,范围只有这张表)。类型加 CatalogDraftTask.blank_columns、blank_sample,CatalogDraftJob.blank_columns,last_draft.blank_columns(老服务端没有,按 0)。check-catalog 新加一节覆盖逐表与汇总、展开列名、默认列表、清单轻提示(颜色同次要信息、不是警告色)、进行中不算「上次」、回到设置重新起草(先存起草要求再建任务)、结束提示、表详情的两个按钮,窄屏展开列名一并量;check-all 里数据目录的耗时按实测改成 130 秒

This commit is contained in:
ThearchyHelios
2026-10-13 20:30:18 +08:00
parent a0b06f93c4
commit b91a652efe
9 changed files with 410 additions and 61 deletions
+25 -1
View File
@@ -2395,6 +2395,8 @@ export const CATALOG_TEXT = {
// - 「只重试失败的表」:重新起草失败、部分完成和已停止的表;已经写进目录的内容不回滚、不受影响。
// - 「没写出来」:一次调用没有写出内容(额度用完、只给空壳、超时、出错)。接连没写出来时服务端给一句「提醒」,
// 好转后收起;提醒是提醒的样式,不是错误。超时的调用不计用量。
// - 「留空」:模型列出了一列(或这一段请它写了),中文名和含义却都没给,写「N 列模型拿不准、留空了」。表的状态不因此
// 变,不算没写全;办法是在起草要求里补充缩写和术语的含义,再重新起草,已有的内容不会丢。
// ===========================================================================
/** 起草要求、本次补充说明的字数上限(服务端同一个数:超长 422) */
@@ -2424,9 +2426,14 @@ export const CATALOG_DRAFT_TEXT = {
tooMany: (max: number) => `一次最多起草 ${formatNumber(max)} 张表,请缩小范围`,
saveGuidanceFailed: (reason: string) => `起草要求未能保存,未开始起草:${reason}`,
startFailed: (reason: string) => `未能开始起草:${reason}`,
// ---- 上一次起草(设置阶段,没写全的才提)
// ---- 上一次起草(设置阶段,没写全、有留空列的才提)
last: (state: string, at: string) => `上一次起草(${at}):${state}`,
/** 上一次的结果:状态,有留空的列时接着写几列 */
lastState: (title: string, blank: number) => (blank ? `${title},${formatNumber(blank)} 列模型拿不准、留空了` : title),
lastOpen: '查看',
// ---- 从结果、表详情回到设置:范围就是那几张表
scopeBlank: (n: number) => `有留空列的 ${formatNumber(n)} 张表`,
scopeTable: (name: string) => `这张表:${name}`,
// ---- 进行中
queued: '排队中,等待开始…',
running: '正在起草…',
@@ -2457,6 +2464,15 @@ export const CATALOG_DRAFT_TEXT = {
timeoutNote: '超时的调用不计用量,实际用量可能更多',
/** 额度用完、超时多了,服务端把之后的段缩小了 */
partColumns: (n: number) => `模型多次用完额度或超时,之后的段每段最多 ${formatNumber(n)} 列`,
// ---- 留空的列:模型列出了、中文名和含义却都没给。表的状态不因此变
blank: (n: number) => `${formatNumber(n)} 列模型拿不准、留空了`,
/** 留空的列名(服务端最多给 5 个);没列全时写一共几列 */
blankSample: (sample: string[], n: number) =>
`留空的列:${sample.join('、')}${n > sample.length ? ` 等 ${formatNumber(n)} 列` : ''}`,
blankTotal: (columns: number, tables: number) => `共 ${formatNumber(columns)} 列模型拿不准、留空了(${formatNumber(tables)} 张表)`,
blankAdvice: '可以在起草要求里补充缩写和术语的含义,再重新起草这几张表,已有的内容不会丢',
blankRedraft: (n: number) => `重新起草这 ${formatNumber(n)} 张表`,
blankRedraftHint: '回到起草设置,范围是这几张表:先在起草要求里补充缩写和术语的含义,再开始起草',
// ---- 结束
title: {
queued: '排队中,等待开始…',
@@ -2486,6 +2502,7 @@ export const CATALOG_DRAFT_TEXT = {
retryHint: '重新起草失败、部分完成和已停止的表;已写进目录的内容不受影响',
showAll: (n: number) => `显示全部 ${formatNumber(n)} 张表`,
showProblems: '只看未写全的表',
showProblemsBlank: '只看未写全、有留空列的表',
tasks: '逐表进度',
taskStatus: {
pending: '等待', running: '起草中', ok: '已完成', partial: '部分完成', failed: '失败', skipped: '已跳过', cancelled: '已停止',
@@ -2501,6 +2518,8 @@ export const CATALOG_DRAFT_TEXT = {
toast: {
succeeded: (summary: string) => `数据目录起草完成:${summary}`,
partial: (n: number) => `数据目录起草完成,${formatNumber(n)} 张表未写全`,
/** 完成、部分完成的提示后面接着写留空的列(没有就不写) */
withBlank: (text: string, blank: number) => (blank ? `${text};${formatNumber(blank)} 列模型拿不准、留空了` : text),
failed: (reason: string) => (reason ? `数据目录起草失败:${reason}` : '数据目录起草失败'),
cancelled: '数据目录起草已停止',
interrupted: '服务重启,数据目录起草中断',
@@ -2510,6 +2529,11 @@ export const CATALOG_DRAFT_TEXT = {
lastFailed: '上次起草失败',
lastPartial: '上次起草部分完成',
lastWith: (label: string, reason: string) => `${label}:${reason}`,
/** 上次起草有留空的列:清单上是轻提示(不是失败标记),表详情顶部同一句 */
lastBlank: (n: number) => `上次起草有 ${formatNumber(n)} 列模型拿不准、留空了`,
lastBlankAdvice: '可以在起草要求里补充缩写和术语的含义,再重新起草这张表,已有的内容不会丢',
draftGuidance: '补充起草要求',
draftGuidanceHint: '打开起草设置,范围是这张表:先在起草要求里补充缩写和术语的含义,再开始起草',
redraft: '重新起草这张表',
redraftHint: '用模型重新起草这张表(不带补充说明,带上数据源的起草要求);人工确认、驳回过的项不会被改动',
redraftBusy: '已有起草任务在进行,结束后再重新起草这张表',
+14 -7
View File
@@ -12,8 +12,8 @@ import {
} from '../../lib/terms'
import { StatusLegend, SystemNotesNotice } from './parts'
import { DraftDialog } from './DraftDialog'
import { isJobActive, retryableCount, useDraftJob } from './draftJob'
import type { DraftStartBody } from './draftJob'
import { isJobActive, jobBlank, retryableCount, useDraftJob } from './draftJob'
import type { DraftPreset, DraftStartBody } from './draftJob'
import { ProfileDialog } from './ProfileDialog'
import type { ProfileJob } from './ProfileDialog'
import { ProfileSettingsDialog } from './ProfileSettings'
@@ -105,9 +105,9 @@ export function CatalogPage() {
const [selected, setSelected] = useState<Set<string>>(() => new Set())
/**
* 起草对话框开着没有、打开时显示什么:setup 选范围开始;job 显示页面跟着的任务(进行中的进度、刚结束的结果)。
* busy:是撞上已有任务(409)转过去显示的
* busy:是撞上已有任务(409)转过去显示的;preset:设置阶段预先选好的范围(表详情里「补充起草要求」)
*/
const [draftOpen, setDraftOpen] = useState<{ view: 'setup' | 'job'; busy?: boolean } | null>(null)
const [draftOpen, setDraftOpen] = useState<{ view: 'setup' | 'job'; busy?: boolean; preset?: DraftPreset } | null>(null)
/** 起草写进了这些表:详情据此重新载入(编辑中的不动,保存时服务端会用版本号拦下) */
const [drafted, setDrafted] = useState<{ seq: number; tables: Set<string> }>({ seq: 0, tables: new Set() })
const [profiling, setProfiling] = useState(false)
@@ -169,8 +169,10 @@ export function CatalogPage() {
key: `catalog-draft:${src}`, duration: 10000,
action: { label: DT.viewResult, onClick: () => { if (sourceRef.current === src) setDraftOpen({ view: 'job' }) } },
}
if (job.status === 'succeeded') toast.ok(DT.toast.succeeded(CT.draftSummary(job.added, job.updated, job.removed)), opts)
else if (job.status === 'partial') toast.warn(DT.toast.partial(retryableCount(job)), opts)
// 留空的列不算没写全,提示里接着写一句,点「查看结果」看是哪几张表、怎么办
const blank = jobBlank(job)
if (job.status === 'succeeded') toast.ok(DT.toast.withBlank(DT.toast.succeeded(CT.draftSummary(job.added, job.updated, job.removed)), blank), opts)
else if (job.status === 'partial') toast.warn(DT.toast.withBlank(DT.toast.partial(retryableCount(job)), blank), opts)
else if (job.status === 'failed') toast.error(DT.toast.failed(job.error ?? ''), opts)
else if (job.status === 'interrupted') toast.warn(DT.toast.interrupted, opts)
else toast.warn(DT.toast.cancelled, opts)
@@ -195,6 +197,10 @@ export function CatalogPage() {
toast.error(e)
}
}, [draft.start])
/** 详情里「补充起草要求」(上次起草有留空的列):打开起草设置,范围只有这张表,先补起草要求再开始 */
const draftWithGuidance = useCallback((t: string) => {
setDraftOpen({ view: 'setup', preset: { tables: [t], label: DT.scopeTable(t) } })
}, [])
const onGuidanceSaved = useCallback((g: string | null) => {
setData((cur) => cur && ({ ...cur, guidance: g }))
}, [])
@@ -402,7 +408,7 @@ export function CatalogPage() {
? (
<TableDetail sourceId={sourceId} table={table} rows={rows} drafted={drafted} fillCodes={fillCodes} prev={prev} next={next}
onFillDone={onFillDone} onOpen={openTable} onBack={backToList} onDetail={onDetail}
draftBusy={!!stripJob} onRedraft={redraftTable} />
draftBusy={!!stripJob} onRedraft={redraftTable} onDraftGuidance={draftWithGuidance} />
)
: <Overview rows={rows} empty={empty} systemNotes={data.system_notes} draft={draftButton(true)} onOpen={openTable} />}
</section>
@@ -446,6 +452,7 @@ export function CatalogPage() {
job={draftJob}
initial={draftOpen.view}
initialBusy={!!draftOpen.busy}
preset={draftOpen.preset ?? null}
stopping={draft.stopping}
onStart={startDraft}
onCancel={draft.cancel}
+116 -25
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { CircleCheck, CircleDashed, CircleMinus, CircleSlash, CircleX, RotateCcw, Sparkles, TriangleAlert } from 'lucide-react'
import { CircleCheck, CircleDashed, CircleMinus, CircleSlash, CircleX, Info, RotateCcw, Sparkles, TriangleAlert } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import clsx from 'clsx'
import { ApiError, api } from '../../api/client'
@@ -9,26 +9,33 @@ import { errorMessage } from '../../lib/errors'
import { formatTime } from '../../lib/format'
import { CATALOG_DRAFT_TEXT as DT, CATALOG_TEXT as CT, DRAFT_GUIDANCE_MAX, DRAFT_NOTE_MAX } from '../../lib/terms'
import {
DRAFT_MAX_TABLES, DRAFT_MODEL_UNAVAILABLE, DRAFT_PART_COLUMNS, DRAFT_TOP_N, RETRYABLE, isJobActive, nothingWritten, retryableCount,
taskReason,
DRAFT_MAX_TABLES, DRAFT_MODEL_UNAVAILABLE, DRAFT_PART_COLUMNS, DRAFT_TOP_N, RETRYABLE, blankOf, isJobActive, jobBlank, needsLook,
nothingWritten, retryableCount, taskReason,
} from './draftJob'
import type { DraftStartBody } from './draftJob'
import type { DraftPreset, DraftStartBody } from './draftJob'
// ===========================================================================
// 起草数据目录。起草是服务端的后台任务:这里选范围、写起草要求和本次补充说明,建任务;之后显示页面跟着的那个任务
// (进行中的进度、结束后的结果),任务本身的状态在页面上(draftJob.ts),关掉对话框照常进行。
//
// 三段:设置(范围、用不用模型;用模型时写起草要求、补充说明,写明用哪个起草模型)→ 进行中(总进度、合计、调用接连
// 没写出来时的提醒、逐表的状态和宽表的段进度、没写全的原因;可以停止、在后台继续)→ 结束(汇总、没写全的表和原因、
// 只重试失败的表)。
// 没写出来时的提醒、逐表的状态和宽表的段进度、没写全的原因、留空的列;可以停止、在后台继续)→ 结束(汇总、没写全的表
// 和原因、留空的列合计、只重试失败的表)。
// 已经写进目录的内容不回滚——起草只增改推断项,人工确认、驳回过的项服务端不会动。
//
// 留空的列(模型列出了、中文名和含义都没给)不算没写全,表照样是已完成;结果里写合计和办法——在起草要求里补充缩写和
// 术语的含义,再重新起草这几张表。「重新起草这几张表」回到设置:范围就是那几张、勾着用模型、焦点在起草要求,
// 补完再开始。表详情里的「补充起草要求」也是这样打开(范围是那一张,见 preset)。
// ===========================================================================
type Scope = 'selected' | 'filtered' | 'top' | 'all'
type Scope = 'preset' | 'selected' | 'filtered' | 'top' | 'all'
/** 字数按服务端的算法:去掉首尾空白后的字符数(按码位,不按 UTF-16 的长度) */
const charCount = (s: string) => [...s.trim()].length
/** 起草要求输入框的 id:预先选好范围回到设置时焦点放在这里 */
const GUIDANCE_ID = 'catalog-draft-guidance'
/**
* 建任务(开始、只重试失败的表)没建成怎么说。起草模型用不了(409,建任务和重试都会回):服务端那句话已经写了原因和
* 去处,照原样,长句也不拆成「标题:原因」;别的错误前面写明没有开始
@@ -37,8 +44,8 @@ const startErrorText = (e: unknown) =>
(e instanceof ApiError && e.code === DRAFT_MODEL_UNAVAILABLE ? e.message : DT.startFailed(errorMessage(e)))
export function DraftDialog({
sourceId, sourceDescription, guidance, rows, visible, selected, filtered, job, initial, initialBusy = false, stopping,
onStart, onCancel, onRetry, onTrack, onGuidanceSaved, onClose,
sourceId, sourceDescription, guidance, rows, visible, selected, filtered, job, initial, initialBusy = false, preset: initialPreset = null,
stopping, onStart, onCancel, onRetry, onTrack, onGuidanceSaved, onClose,
}: {
sourceId: string
/** 数据源说明:用模型起草时连同起草要求一起交给模型 */
@@ -58,6 +65,11 @@ export function DraftDialog({
initial: 'setup' | 'job'
/** 打开时显示的任务是撞上已有任务(409)转过来的:写明 */
initialBusy?: boolean
/**
* 打开设置时预先选好的范围(表详情里「补充起草要求」:只有那张表)。给了就勾上用模型、焦点放在起草要求上:
* 打开它就是为了先补起草要求再起草
*/
preset?: DraftPreset | null
/** 点了停止、服务端还没停下来 */
stopping: boolean
/** 建任务;409 已有任务时页面改取进行中的那一个(busy 为真) */
@@ -73,8 +85,12 @@ export function DraftDialog({
// 表结构里已经没有的表起草不了(服务端会记「已跳过」),不算进范围
const live = useMemo(() => rows.filter((r) => r.in_schema).map((r) => r.table_name), [rows])
const liveSet = useMemo(() => new Set(live), [live])
/** 预先选好的范围:打开时给的,或者结果里点了「重新起草这几张表」 */
const [preset, setPreset] = useState<DraftPreset | null>(initialPreset)
const scopes = useMemo(() => {
const out: { key: Scope; label: string; tables: string[] }[] = []
const pre = preset?.tables.filter((t) => liveSet.has(t)) ?? []
if (preset && pre.length) out.push({ key: 'preset', label: preset.label, tables: pre })
const sel = selected.filter((t) => liveSet.has(t))
if (sel.length) out.push({ key: 'selected', label: CT.scopeSelected(sel.length), tables: sel })
const vis = visible.filter((r) => r.in_schema).map((r) => r.table_name)
@@ -82,11 +98,13 @@ export function DraftDialog({
if (live.length > DRAFT_TOP_N) out.push({ key: 'top', label: CT.scopeTop(DRAFT_TOP_N), tables: live.slice(0, DRAFT_TOP_N) })
out.push({ key: 'all', label: CT.scopeAll(live.length), tables: live })
return out
}, [selected, visible, filtered, live, liveSet])
}, [preset, selected, visible, filtered, live, liveSet])
const [scope, setScope] = useState<Scope>(() => scopes[0].key)
const tables = scopes.find((s) => s.key === scope)?.tables ?? scopes[0].tables
const tooMany = tables.length > DRAFT_MAX_TABLES
const [useModel, setUseModel] = useState(false)
const [useModel, setUseModel] = useState(!!initialPreset)
/** 回到设置以后把焦点放进起草要求(预先选好范围的两种入口都是为了先补起草要求) */
const [focusGuidance, setFocusGuidance] = useState(!!initialPreset)
const [guidanceText, setGuidanceText] = useState(guidance)
const [note, setNote] = useState('')
const guidanceLen = charCount(guidanceText)
@@ -102,7 +120,7 @@ export function DraftDialog({
const [busy, setBusy] = useState(initialBusy)
const [acting, setActing] = useState(false)
const [actionError, setActionError] = useState<string | null>(null)
/** 上一次起草(没写全的才提),设置阶段写一行、可以点开 */
/** 上一次起草(没写全、有留空列的才提),设置阶段写一行、可以点开 */
const [last, setLast] = useState<CatalogDraftJob | null>(null)
/** 起草模型的说法:设置里的起草模型,没配就是跟随助手的模型 */
const [modelLine, setModelLine] = useState<{ text: string; warn: string | null } | null>(null)
@@ -120,13 +138,22 @@ export function DraftDialog({
onTrackRef.current(latest)
setBusy(true)
setMode('job')
} else if (latest.status !== 'succeeded') {
} else if (latest.status !== 'succeeded' || jobBlank(latest) > 0) {
setLast(latest)
}
}, () => {})
return () => { alive = false }
}, [inSetup, sourceId])
// 预先选好范围回到设置(打开时给的、结果里点的):焦点放进起草要求
useEffect(() => {
if (!focusGuidance || mode !== 'setup' || !useModel) return
const el = document.getElementById(GUIDANCE_ID)
if (!el) return
el.focus()
setFocusGuidance(false)
}, [focusGuidance, mode, useModel])
// 起草模型:设置里的 catalog_draft 填了就是它;没填跟随助手的模型(写出当前是哪个,用不了时写原因)
useEffect(() => {
let alive = true
@@ -194,6 +221,26 @@ export function DraftDialog({
setBusy(false)
setMode('job')
}
/**
* 结果里「重新起草这几张表」(有留空列的表):回到设置,范围就是这几张,勾上用模型,焦点放进起草要求——先补上
* 缩写和术语的含义再开始。任务本身不动(页面照旧跟着它,开始新的起草才换)
*/
const redraftBlank = (names: string[]) => {
setPreset({ tables: names, label: DT.scopeBlank(names.length) })
setScope('preset')
setUseModel(true)
setFocusGuidance(true)
setStartError(null)
setActionError(null)
setBusy(false)
setLast(null)
setMode('setup')
}
/** 结果里有留空列、还在表结构里的表(「重新起草这几张表」的范围) */
const blankTables = useMemo(
() => (job ? job.tasks.filter((t) => blankOf(t) > 0 && liveSet.has(t.table_name)).map((t) => t.table_name) : []),
[job, liveSet],
)
const phase: 'setup' | 'running' | 'done' = inSetup ? 'setup' : isJobActive(job) ? 'running' : 'done'
const canStart = !!tables.length && !tooMany && !(useModel && (guidanceOver || noteOver)) && !starting
@@ -263,7 +310,7 @@ export function DraftDialog({
{modelLine?.warn && <span className="block text-[var(--warn)]" data-draft-model-warn="">{modelLine.warn}</span>}
</div>
<div data-draft-guidance="">
<Field label={DT.guidance} htmlFor="catalog-draft-guidance" hint={DT.guidanceHint}
<Field label={DT.guidance} htmlFor={GUIDANCE_ID} hint={DT.guidanceHint}
error={guidanceOver ? DT.tooLong(DRAFT_GUIDANCE_MAX) : undefined}>
{(p) => (
<textarea {...p} className="field min-h-[4.5rem] resize-y text-xs leading-relaxed" rows={3} value={guidanceText}
@@ -292,9 +339,14 @@ export function DraftDialog({
)}
</div>
{last && (
// 没写全的用警告色;都写完了、只是有留空列的是提示
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md border px-3 py-2 text-2xs text-dim" data-draft-last={last.status}>
<TriangleAlert size={11} className="shrink-0" style={{ color: 'var(--warn)' }} aria-hidden />
<span className="min-w-0 flex-1">{DT.last(DT.title[last.status], formatTime(last.finished_at ?? last.created_at))}</span>
{last.status === 'succeeded'
? <Info size={11} className="shrink-0" style={{ color: 'var(--accent)' }} aria-hidden />
: <TriangleAlert size={11} className="shrink-0" style={{ color: 'var(--warn)' }} aria-hidden />}
<span className="min-w-0 flex-1">
{DT.last(DT.lastState(DT.title[last.status], jobBlank(last)), formatTime(last.finished_at ?? last.created_at))}
</span>
<button type="button" className="btn btn-sm" onClick={() => openLast(last)} data-draft-last-open="">{DT.lastOpen}</button>
</div>
)}
@@ -303,7 +355,8 @@ export function DraftDialog({
)}
{phase !== 'setup' && job && (
<JobView key={job.id} job={job} running={phase === 'running'} stopping={stopping} busy={busy} />
<JobView key={job.id} job={job} running={phase === 'running'} stopping={stopping} busy={busy}
blankTables={blankTables} onRedraftBlank={redraftBlank} />
)}
{phase !== 'setup' && actionError && <Notice tone="err" attr={{ 'data-draft-action-error': '' }}>{actionError}</Notice>}
</div>
@@ -319,13 +372,26 @@ function Counter({ n, max }: { n: number; max: number }) {
)
}
/** 任务的进度或结果:标题、总进度、提醒(进行中)、合计、模型和用量(超时、没写出来的次数)、没写全的表、逐表列表 */
function JobView({ job, running, stopping, busy }: { job: CatalogDraftJob; running: boolean; stopping: boolean; busy: boolean }) {
/**
* 任务的进度或结果:标题、总进度、提醒(进行中)、合计、模型和用量(超时、没写出来的次数)、没写全的表、留空的列合计
* (结束后)、逐表列表
*/
function JobView({ job, running, stopping, busy, blankTables, onRedraftBlank }: {
job: CatalogDraftJob
running: boolean
stopping: boolean
busy: boolean
/** 有留空列、还在表结构里的表:结束后给「重新起草这几张表」 */
blankTables: string[]
onRedraftBlank: (tables: string[]) => void
}) {
const problems = job.tasks.filter((t) => RETRYABLE.has(t.status))
// 结束后默认只列要看的表:没写全的、有留空列的(几百张表里找它们太费劲),可以展开全部
const attention = job.tasks.filter(needsLook)
const blankCount = job.tasks.filter((t) => blankOf(t) > 0).length
const [showAll, setShowAll] = useState(false)
// 结束后有没写全的表时默认只列这些(几百张表里找失败的太费劲),可以展开全部
const onlyProblems = !running && problems.length > 0 && !showAll
const tasks = onlyProblems ? problems : job.tasks
const onlyProblems = !running && attention.length > 0 && !showAll
const tasks = onlyProblems ? attention : job.tasks
const title = running ? (stopping ? DT.stopping : DT.title[job.status]) : DT.title[job.status]
const empty = nothingWritten(job)
const summary = empty === 'model' ? CT.draftNothingWritten
@@ -351,6 +417,7 @@ function JobView({ job, running, stopping, busy }: { job: CatalogDraftJob; runni
const notice = running ? job.notice?.trim() ?? '' : ''
// 额度用完、超时多了,之后的段缩小了:进行中写一句,段数为什么变多看得明白
const partColumns = running && job.use_model && job.part_columns != null && job.part_columns < DRAFT_PART_COLUMNS ? job.part_columns : null
const blankTotal = jobBlank(job)
return (
<div className="min-w-0 space-y-3">
{busy && <Notice tone="info" attr={{ 'data-draft-busy': '' }}>{DT.busy}</Notice>}
@@ -388,14 +455,27 @@ function JobView({ job, running, stopping, busy }: { job: CatalogDraftJob; runni
{problemText && (
<p className="text-xs leading-relaxed" data-draft-problems="">{problemText}</p>
)}
{!running && blankTotal > 0 && (
// 留空的列不算没写全(表照样是已完成):写合计和办法,给「重新起草这几张表」回到设置先补起草要求
<Notice tone="info" attr={{ 'data-draft-blank-summary': '' }}>
<p className="font-medium">{DT.blankTotal(blankTotal, blankCount)}</p>
<p className="mt-0.5 text-dim">{DT.blankAdvice}</p>
{blankTables.length > 0 && (
<button type="button" className="btn btn-sm mt-2" title={DT.blankRedraftHint} onClick={() => onRedraftBlank(blankTables)}
data-draft-blank-redraft="">
<Sparkles size={11} aria-hidden /> {DT.blankRedraft(blankTables.length)}
</button>
)}
</Notice>
)}
<div>
<div className="mb-1 flex items-center gap-2">
<span className="text-2xs font-medium text-dim" id="catalog-draft-tasks-label">{DT.tasks}</span>
<span className="flex-1" />
{!running && problems.length > 0 && problems.length < job.tasks.length && (
{!running && attention.length > 0 && attention.length < job.tasks.length && (
<button type="button" className="rounded px-1 text-2xs text-[var(--accent)] outline-none hover:underline focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
aria-pressed={showAll} onClick={() => setShowAll((v) => !v)} data-draft-show-all="">
{showAll ? DT.showProblems : DT.showAll(job.tasks.length)}
{showAll ? (blankCount ? DT.showProblemsBlank : DT.showProblems) : DT.showAll(job.tasks.length)}
</button>
)}
</div>
@@ -420,13 +500,16 @@ const TASK_ICON: Record<CatalogDraftTaskStatus, { Icon: LucideIcon | null; color
/**
* 逐表的一行。jobError:任务整体的原因(整体出错、服务重启中断时服务端把没结束的表也记上同一句)。和它一样的不再逐表
* 重复——几百张表各写一遍同一句,真正各有原因的那几张反而找不到;任务的原因已经写在上面(标题、提示)
* 重复——几百张表各写一遍同一句,真正各有原因的那几张反而找不到;任务的原因已经写在上面(标题、提示)。
* 有留空的列时再写一行「N 列模型拿不准、留空了」:列名悬停看得到,也可以展开(服务端最多给 5 个)
*/
function TaskRow({ task: t, jobError }: { task: CatalogDraftTask; jobError: string | null }) {
const { Icon, color } = TASK_ICON[t.status]
const reason = jobError && t.error === jobError ? (t.model_error ?? '') : taskReason(t)
const parts = t.parts_total != null && t.parts_total > 1 && t.status !== 'pending' && t.status !== 'skipped'
const changes = DT.taskAdded(t.added, t.updated)
const blank = blankOf(t)
const sample = t.blank_sample?.filter(Boolean) ?? []
return (
<li className="min-w-0 px-3 py-1.5" data-draft-task={t.table_name} data-status={t.status}>
<div className="flex min-w-0 items-center gap-2 text-xs">
@@ -448,6 +531,14 @@ function TaskRow({ task: t, jobError }: { task: CatalogDraftTask; jobError: stri
{reason && (
<p className="mt-0.5 break-words pl-5 text-2xs leading-relaxed text-dim" data-draft-task-reason="">{reason}</p>
)}
{blank > 0 && (sample.length
? (
<details className="mt-0.5 pl-5 text-2xs leading-relaxed text-dim" data-draft-task-blank={blank}>
<summary className="cursor-pointer select-none hover:text-fg" title={DT.blankSample(sample, blank)}>{DT.blank(blank)}</summary>
<p className="break-words text-faint" data-draft-task-blank-sample="">{DT.blankSample(sample, blank)}</p>
</details>
)
: <p className="mt-0.5 pl-5 text-2xs leading-relaxed text-dim" data-draft-task-blank={blank}>{DT.blank(blank)}</p>)}
</li>
)
}
+39 -16
View File
@@ -21,7 +21,7 @@ import { ColumnTable, NARROW, Value, pendingCodes } from './ColumnTable'
import type { ColumnModel } from './ColumnTable'
import { CodesDialog } from './CodesDialog'
import { coverageText } from './profile'
import { taskReason } from './draftJob'
import { lastBlankOf, taskReason } from './draftJob'
import {
COLUMN_FIELDS, DATE_KEYS, TABLE_FIELDS, buildNotes, changeCount, columnPath, confirmProposed, countsOf, formFromNotes,
relationDraft, relationPath, relationRemoval, tablePath, unknownKeys, validateForm,
@@ -53,7 +53,7 @@ type Editing = { initial: EditForm; form: EditForm }
type Conflict = 'edit' | 'review'
export function TableDetail({
sourceId, table, rows, drafted, fillCodes, onFillDone, prev, next, onOpen, onBack, onDetail, draftBusy = false, onRedraft,
sourceId, table, rows, drafted, fillCodes, onFillDone, prev, next, onOpen, onBack, onDetail, draftBusy = false, onRedraft, onDraftGuidance,
}: {
sourceId: string
table: string
@@ -77,8 +77,10 @@ export function TableDetail({
onDetail: (d: CatalogDetail) => void
/** 这个数据源有排队、进行中的起草任务:「重新起草这张表」等它结束 */
draftBusy?: boolean
/** 上次起草失败、部分完成时的「重新起草这张表」:建只含这张表的起草任务(用模型、不带补充说明) */
/** 上次起草失败、部分完成或有留空列时的「重新起草这张表」:建只含这张表的起草任务(用模型、不带补充说明) */
onRedraft?: (table: string) => Promise<void>
/** 上次起草有留空列时的「补充起草要求」:打开起草设置,范围只有这张表,先补起草要求再开始 */
onDraftGuidance?: (table: string) => void
}) {
const [detail, setDetail] = useState<CatalogDetail | null>(null)
const [loadError, setLoadError] = useState<unknown>(null)
@@ -318,6 +320,9 @@ export function TableDetail({
const counts = countsOf(notes)
/** 这张表最近一次起草的结果(清单上那一行带的) */
const lastDraft = rows.find((r) => r.table_name === table)?.last_draft ?? null
/** 没写全(失败、部分完成);有留空的列(模型拿不准,表照样写完了) */
const lastBad = lastDraft && (lastDraft.status === 'failed' || lastDraft.status === 'partial') ? lastDraft : null
const lastBlank = lastBlankOf(lastDraft)
const label = notes.label && notes.label.status !== 'rejected' ? notes.label.value : null
const kind = notes.kind && notes.kind.status !== 'rejected' ? notes.kind.value : null
const st = detail.structure
@@ -456,21 +461,39 @@ export function TableDetail({
</Notice>
)}
{writeError && <Notice tone="err" attr={{ 'data-catalog-write-error': '' }}>{writeError}</Notice>}
{lastDraft && (lastDraft.status === 'failed' || lastDraft.status === 'partial') && (
// 上次起草没写全:写明原因,给「重新起草这张表」(只含这张表、用模型)。有起草任务在进行、正在编辑这张表时
// 先不给点:起草会写进这张表,编辑中的保存就会撞上版本冲突
<Notice tone={lastDraft.status === 'failed' ? 'err' : 'warn'} attr={{ 'data-catalog-last-draft': lastDraft.status }}>
<p className="font-medium">{lastDraft.status === 'failed' ? DT.lastFailed : DT.lastPartial}</p>
{taskReason(lastDraft) && <p className="mt-0.5 break-words text-dim" data-catalog-last-draft-reason="">{taskReason(lastDraft)}</p>}
{lastDraft && (lastBad || lastBlank > 0) && (
// 上次起草没写全:写明原因;有留空的列:写几列、怎么办(在起草要求里补充缩写和术语的含义)。只有留空的列时是
// 提示(表照样写完了),不是失败。给「重新起草这张表」(只含这张表、用模型);有留空的列再给「补充起草要求」
// (打开起草设置,范围是这张表)。有起草任务在进行、正在编辑这张表时先不给点:起草会写进这张表,编辑中的保存
// 就会撞上版本冲突
<Notice tone={lastBad ? (lastBad.status === 'failed' ? 'err' : 'warn') : 'info'} attr={{ 'data-catalog-last-draft': lastDraft.status }}>
{lastBad && <p className="font-medium">{lastBad.status === 'failed' ? DT.lastFailed : DT.lastPartial}</p>}
{lastBad && taskReason(lastBad) && (
<p className="mt-0.5 break-words text-dim" data-catalog-last-draft-reason="">{taskReason(lastBad)}</p>
)}
{lastBlank > 0 && (
<>
<p className={lastBad ? 'mt-0.5 text-dim' : 'font-medium'} data-catalog-last-draft-blank={lastBlank}>{DT.lastBlank(lastBlank)}</p>
<p className="mt-0.5 text-dim">{DT.lastBlankAdvice}</p>
</>
)}
{onRedraft && (
<>
<button type="button" className="btn btn-sm mt-2" disabled={draftBusy || redrafting || !!edit} title={DT.redraftHint}
onClick={async () => {
setRedrafting(true)
try { await onRedraft(table) } finally { setRedrafting(false) }
}} data-catalog-redraft="">
{redrafting ? <Spinner size={11} /> : <Sparkles size={11} aria-hidden />} {DT.redraft}
</button>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<button type="button" className="btn btn-sm" disabled={draftBusy || redrafting || !!edit} title={DT.redraftHint}
onClick={async () => {
setRedrafting(true)
try { await onRedraft(table) } finally { setRedrafting(false) }
}} data-catalog-redraft="">
{redrafting ? <Spinner size={11} /> : <Sparkles size={11} aria-hidden />} {DT.redraft}
</button>
{lastBlank > 0 && onDraftGuidance && (
<button type="button" className="btn btn-sm btn-ghost" disabled={draftBusy || redrafting || !!edit} title={DT.draftGuidanceHint}
onClick={() => onDraftGuidance(table)} data-catalog-draft-guidance="">
<Pencil size={11} aria-hidden /> {DT.draftGuidance}
</button>
)}
</div>
<p className="mt-1 text-2xs text-faint">{edit ? DT.redraftEditing : draftBusy ? DT.redraftBusy : DT.redraftHint}</p>
</>
)}
+13 -4
View File
@@ -1,6 +1,6 @@
import { memo, useCallback, useEffect, useRef } from 'react'
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react'
import { AlertTriangle, History, ScanSearch, Search, Sparkles, X } from 'lucide-react'
import { AlertTriangle, History, Info, ScanSearch, Search, Sparkles, X } from 'lucide-react'
import clsx from 'clsx'
import type { CatalogTableRow } from '../../types'
import { useRadioGroup } from '../../components/ui'
@@ -10,15 +10,15 @@ import {
} from '../../lib/terms'
import { CountBadges } from './parts'
import { LIST_FILTERS, LIST_SORTS, progressOf } from './model'
import { taskReason } from './draftJob'
import { lastBlankOf, taskReason } from './draftJob'
import type { ListFilter, ListSort } from './model'
// ===========================================================================
// 表清单(数据目录页的左栏):搜索、按审阅进度筛选、排序、多选后批量起草。
// 一行两层:上面是中文名和各状态的项数,下面是表名、类型、关联数和使用次数;还没填中文名的表上面直接写表名,
// 下面淡淡地写「未填写中文名」——一屏几十行「未填写中文名」认不出是哪张表。上次起草失败、部分完成的表再多一行,
// 写明原因(全文在悬停里,点开表还有「重新起草这张表」)。键盘:↑↓ 在行之间移动,Home / End 到两头,Enter 打开;
// 复选框按住 Shift 点选可以连选一段。
// 写明原因(全文在悬停里,点开表还有「重新起草这张表」);上次起草有留空列(模型拿不准)的表另有一行淡色的轻提示,
// 不是失败标记。键盘:↑↓ 在行之间移动,Home / End 到两头,Enter 打开;复选框按住 Shift 点选可以连选一段。
// ===========================================================================
export function TableIndex({
@@ -213,6 +213,8 @@ const Row = memo(function Row({ row: r, index, active, checked, onToggle, onOpen
const last = r.last_draft && (r.last_draft.status === 'failed' || r.last_draft.status === 'partial') ? r.last_draft : null
const lastLabel = last ? (last.status === 'failed' ? DT.lastFailed : DT.lastPartial) : ''
const lastText = last ? (taskReason(last) ? DT.lastWith(lastLabel, taskReason(last)) : lastLabel) : ''
// 上次起草有留空的列:表照样写完了,只是模型对这几列拿不准。淡色的轻提示,和失败标记分开
const blank = lastBlankOf(r.last_draft)
return (
<li
className={clsx('relative flex items-stretch border-b border-hairline', active ? 'bg-accent-soft' : 'hover:bg-hover')}
@@ -262,6 +264,13 @@ const Row = memo(function Row({ row: r, index, active, checked, onToggle, onOpen
<span className="min-w-0 truncate">{lastText}</span>
</span>
)}
{blank > 0 && (
<span className="mt-0.5 flex min-w-0 items-center gap-1 text-2xs text-faint" title={`${DT.lastBlank(blank)}。${DT.lastBlankAdvice}`}
data-row-blank={blank}>
<Info size={10} className="shrink-0" aria-hidden />
<span className="min-w-0 truncate">{DT.lastBlank(blank)}</span>
</span>
)}
</button>
</li>
)
+23
View File
@@ -39,6 +39,29 @@ export const taskReason = (t: { error: string | null; model_error: string | null
/** 每段最多几列(服务端起草的起始上限)。任务里的当前上限比它小:额度用完、超时多了,之后的段缩小了 */
export const DRAFT_PART_COLUMNS = 40
/** 模型拿不准、留空的列数(任务里的一张表、清单上的上次起草);老服务端没有这个键时为 0 */
export const blankOf = (t: { blank_columns?: number } | null | undefined): number => Math.max(0, t?.blank_columns ?? 0)
/**
* 清单、表详情上「上次起草有 N 列模型拿不准、留空了」的 N:这张表的那次起草结束了才算(还在起草的那一次写到一半,
* 不说「上次」)
*/
export const lastBlankOf = (d: { status: CatalogDraftTaskStatus; blank_columns?: number } | null | undefined): number =>
(d && TASK_ENDED.has(d.status) ? blankOf(d) : 0)
/** 一次起草里留空的列合计:服务端给了合计就用它,老服务端没有就按逐表加 */
export const jobBlank = (j: CatalogDraftJob): number => (j.blank_columns != null ? blankOf(j) : j.tasks.reduce((n, t) => n + blankOf(t), 0))
/** 结束后默认列出的表:没写全的(失败、部分完成、已停止)和有留空列的 */
export const needsLook = (t: CatalogDraftTask): boolean => RETRYABLE.has(t.status) || blankOf(t) > 0
/** 回到设置时预先选好的范围(结果里「重新起草这几张表」、表详情里「补充起草要求」) */
export interface DraftPreset {
tables: string[]
/** 范围那一项的写法 */
label: string
}
/**
* 一项都没写进去的任务该怎么说(只对写完了的任务:完成、部分完成)。null:照常写合计。
* 用模型、却没有一张表拿到模型的可用内容:目录没有更新、模型没给内容;有表没写全:目录没有更新、原因见下方;
+11
View File
@@ -2893,6 +2893,13 @@ export interface CatalogDraftTask {
* 大于 0 而新增、更新、删除都是 0,才是「模型给了内容、只是和现有目录一致」
*/
model_items: number | null
/**
* 模型拿不准、留空的列数:模型列出了(或这一段请它写了),中文名和含义却都没给(给了却都没通过校验的也算)。
* 表的状态不因此变(照旧可以是已完成)。老服务端没有这个键
*/
blank_columns?: number
/** 留空的列里的前几个(最多 5 个,列名照表结构原样)。老服务端没有这个键 */
blank_sample?: string[]
/** 整张表没起草的原因:表结构里没有、写入一直冲突、服务重启中断 */
error: string | null
/** 模型那部分没写出来的原因和办法(第几段、哪些列、为什么) */
@@ -2941,6 +2948,8 @@ export interface CatalogDraftJob {
added: number
updated: number
removed: number
/** 各表模型拿不准、留空的列数合计。老服务端没有这个键 */
blank_columns?: number
/** 模型调用的用量;拿不到用量时照实是 0 */
usage: CatalogDraftUsage
/**
@@ -2973,6 +2982,8 @@ export interface CatalogLastDraft {
finished_at: string | null
error: string | null
model_error: string | null
/** 那次起草模型拿不准、留空的列数(同 CatalogDraftTask.blank_columns)。老服务端没有这个键 */
blank_columns?: number
}
/**
+1 -1
View File
@@ -97,7 +97,7 @@ const ORDER = [
const ESTIMATE = {
challenge: 30,
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
evidence: 91, versions: 60, catalog: 110, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
evidence: 91, versions: 60, catalog: 130, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
// 占位,换成真检查时按实测改
schedules: 60, compare: 45, verify: 71, inquiry: 60, bundle: 16, whatif: 33, 'report-export': 30,
}
+168 -7
View File
@@ -429,7 +429,7 @@ const PART_COLUMNS = 40
* 建一个排队的任务:表结构里没有的表照样建进任务、记 skipped。重试建的记着原任务(retry_of,界面不用)。
* 超过 40 列的宽表均匀分段:列分段数 = ceil(列数 / 40),段进度是「表级一段 + 列分段」。
* 用模型的任务记着当前每段最多几列(part_columns,额度用完、超时多了服务端会减半)、调用接连没写出来时的提醒(notice),
* 用量里另记超时和没写出来的调用次数
* 用量里另记超时和没写出来的调用次数;逐表记模型拿不准、留空的列数和前几个列名(blank_columns、blank_sample)
*/
function newJob(state, src, { tables, use_model, note = null, retry_of = null }) {
const id = `check-job-${++state.jobSeq}`
@@ -439,7 +439,7 @@ function newJob(state, src, { tables, use_model, note = null, retry_of = null })
return {
table_name: name, status: t ? 'pending' : 'skipped', columns: cols,
parts_total: t && use_model ? (cols > PART_COLUMNS ? 1 + Math.ceil(cols / PART_COLUMNS) : 1) : null, parts_done: 0,
added: 0, updated: 0, removed: 0, version: t?.version ?? 0, model_items: null,
added: 0, updated: 0, removed: 0, version: t?.version ?? 0, model_items: null, blank_columns: 0, blank_sample: [],
error: t ? null : `表结构里没有 ${name},请先重新探查结构`, model_error: null,
started_at: null, finished_at: t ? null : isoNow(),
}
@@ -454,14 +454,14 @@ function newJob(state, src, { tables, use_model, note = null, retry_of = null })
return job
}
/** 接口返回的形状:计数、合计由逐表算出来 */
/** 接口返回的形状:计数、合计(含留空的列数)由逐表算出来 */
function jobOut(job) {
const counts = { pending: 0, running: 0, ok: 0, partial: 0, failed: 0, skipped: 0, cancelled: 0 }
for (const t of job.tasks) counts[t.status]++
const sum = (k) => job.tasks.reduce((n, t) => n + t[k], 0)
const { stopAsked, ...rest } = job
return clone({ ...rest, total: job.tasks.length, done: job.tasks.filter((t) => TASK_END.has(t.status)).length, counts,
added: sum('added'), updated: sum('updated'), removed: sum('removed') })
added: sum('added'), updated: sum('updated'), removed: sum('removed'), blank_columns: sum('blank_columns') })
}
/**
@@ -527,7 +527,10 @@ function lastDraftOf(state, src, name) {
for (const job of [...state.jobs].reverse()) {
if (job.source_id !== src) continue
const t = job.tasks.find((x) => x.table_name === name)
if (t) return { status: t.status, job_id: job.id, finished_at: t.finished_at, error: t.error, model_error: t.model_error }
if (t) {
return { status: t.status, job_id: job.id, finished_at: t.finished_at, error: t.error, model_error: t.model_error,
blank_columns: t.blank_columns }
}
}
return state.tables[src]?.find((x) => x.name === name)?.lastDraft ?? null
}
@@ -688,6 +691,10 @@ const WIDE_ERR = '这张表 50 列,分 3 段起草,第 3 段(promo_attr_19
const CALL_ERR = '调用模型出错:服务暂时不可用(已重试 2 次)'
/** 调用接连没写出来时服务端给的提醒(虚构的一句,照约定写明次数、原因和办法) */
const DRAFT_NOTICE = '最近 6 次调用有 4 次没写出来(3 次超时、1 次额度用完)。建议在「设置 → 偏好设置」的「起草模型」换一个模型、缩小起草范围,或者停止这次起草'
/** 宽表里模型拿不准、留空的列(服务端最多给 5 个列名) */
const BLANK_SAMPLE = ['promo_attr_31', 'promo_attr_32', 'promo_attr_33', 'promo_attr_34', 'promo_attr_35']
/** 补上缩写含义的起草要求(虚构) */
const GUIDANCE_ABBR = `${S1_GUIDANCE}。promo_attr 开头的列是促销活动的附加属性,按列名后面的编号区分`
const detail = (page) => page.locator('[data-catalog-detail]')
const version = async (page) => Number(await detail(page).getAttribute('data-version'))
const panel = (page) => page.locator('[data-item-panel]')
@@ -1379,6 +1386,158 @@ await section('数据目录 · 起草:接连没写出来的提醒、超时与
await close()
})
await section('数据目录 · 起草:模型拿不准、留空的列', async () => {
const state = freshState()
// 不在这次起草里的一张表:上一次起草部分完成,又有留空的列
state.tables[S1].find((t) => t.name === 'channels').lastDraft = {
status: 'partial', job_id: 'check-job-old', finished_at: T0, error: null, model_error: CALL_ERR, blank_columns: 4,
}
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
await page.locator('[data-catalog-row]').first().waitFor()
check('开始起草(闸机到会员 3 张,用模型)', await startDraft(page, { select: ['gates', 'members'], model: true }))
const job = state.jobs.at(-1)
// 留空的列:模型列出了、中文名和含义都没给。表的状态不因此变。闸机表写完了;宽表还在起草,已经有几列留空
settle(state, job, 'gates', { added: 2, model_items: 3, blank_columns: 1, blank_sample: ['gate_code'] })
settle(state, job, 'store_sales', { status: 'running', parts_done: 2, blank_columns: 5, blank_sample: BLANK_SAMPLE })
const blankOf = (n) => draftBox(page).locator(`[data-draft-task="${n}"] [data-draft-task-blank]`)
const rowBlank = (n) => page.locator(`[data-catalog-row="${n}"] [data-row-blank]`)
check('起草中:逐表跟着写留空的列;清单上写完的表才提示「上次起草」,还在起草的不提示',
await until(async () => (await blankOf('store_sales').innerText({ timeout: 500 }).catch(() => '')).includes('5 列模型拿不准、留空了')
&& (await rowBlank('gates').count()) === 1)
&& (await rowBlank('store_sales').count()) === 0 && (await page.locator('[data-catalog-row="store_sales"] [data-row-last-draft]').count()) === 0,
`${await draftText(page, '[data-draft-task="store_sales"]')} · ${await rowBlank('gates').count()} · ${await rowBlank('store_sales').count()}`)
settle(state, job, 'store_sales', { added: 30, model_items: 38, blank_columns: 12, blank_sample: BLANK_SAMPLE })
check('逐表:写完的宽表写「12 列模型拿不准、留空了」,状态照旧是「已完成」',
await until(async () => (await blankOf('store_sales').innerText({ timeout: 500 }).catch(() => '')).includes('12 列模型拿不准、留空了'))
&& (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-status]')) === '已完成',
await draftText(page, '[data-draft-task="store_sales"]'))
const sample = blankOf('store_sales').locator('[data-draft-task-blank-sample]')
check('……悬停看得到是哪几列', ((await blankOf('store_sales').locator('summary').getAttribute('title')) ?? '').includes(BLANK_SAMPLE.join('、')),
await blankOf('store_sales').locator('summary').getAttribute('title').catch(() => ''))
check('……列名默认收起', !(await sample.isVisible()))
await blankOf('store_sales').locator('summary').click()
check('……展开写出列名,列不全时写一共几列', await until(async () => sample.isVisible())
&& (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-blank-sample]')) === `留空的列:${BLANK_SAMPLE.join('、')} 等 12 列`,
await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-blank-sample]'))
settle(state, job, 'members', { added: 3, model_items: 3 })
finishJob(job)
check('结束:标题照旧「起草完成」(留空的列不算没写全),不给「只重试失败的表」',
await until(async () => (await draftPhase(page)) === 'done') && (await draftText(page, '[data-draft-title]')) === '起草完成'
&& await page.locator('[data-draft-retry]').count() === 0, await draftText(page, '[data-draft-title]'))
const blankSum = await draftText(page, '[data-draft-blank-summary]')
check('……汇总写合计:共 13 列模型拿不准、留空了(2 张表)', blankSum.includes('共 13 列模型拿不准、留空了(2 张表)'), blankSum)
check('……并给出办法:在起草要求里补充缩写和术语的含义,再重新起草这几张表,已有的内容不会丢',
blankSum.includes('在起草要求里补充缩写和术语的含义') && blankSum.includes('重新起草这几张表') && blankSum.includes('已有的内容不会丢'), blankSum)
const shown = await draftBox(page).locator('[data-draft-task]').evaluateAll((els) => els.map((e) => e.getAttribute('data-draft-task')))
check('……逐表默认只列有留空列的 2 张,没有留空列的那张不写,可以显示全部', shown.join(',') === 'gates,store_sales'
&& await draftBox(page).locator('[data-draft-show-all]').count() === 1, shown.join(','))
await draftBox(page).locator('[data-draft-show-all]').click()
check('……显示全部:没有留空列的表不写留空', (await draftBox(page).locator('[data-draft-task]').count()) === 3 && await blankOf('members').count() === 0)
await shot(page, 'catalog-draft-blank')
// 清单:有留空列的表显示轻提示,不是失败标记
check('清单:有留空列的表显示轻提示「上次起草有 12 列模型拿不准、留空了」',
await until(async () => (await rowBlank('store_sales').innerText({ timeout: 500 }).catch(() => '')).includes('上次起草有 12 列模型拿不准、留空了')),
await rowBlank('store_sales').innerText({ timeout: 500 }).catch(() => ''))
const colors = await page.evaluate(() => {
const color = (q) => { const el = document.querySelector(q); return el ? getComputedStyle(el).color : null }
return { blank: color('[data-catalog-row="store_sales"] [data-row-blank]'), faint: color('[data-catalog-row="store_sales"] [data-row-usage]'),
partial: color('[data-catalog-row="channels"] [data-row-last-draft]') }
})
check('……不是失败标记:不写「上次起草失败 / 部分完成」,用次要信息的淡色,不用警告色', await page.locator('[data-catalog-row="store_sales"] [data-row-last-draft]').count() === 0
&& !!colors.blank && colors.blank === colors.faint && colors.blank !== colors.partial, JSON.stringify(colors))
check('……没有留空列的表不提示', await rowBlank('members').count() === 0)
check('……部分完成又有留空列的表:失败标记和留空提示都在', (await page.locator('[data-catalog-row="channels"] [data-row-last-draft="partial"]').count()) === 1
&& (await rowBlank('channels').innerText().catch(() => '')).includes('上次起草有 4 列模型拿不准、留空了'))
// 「重新起草这 2 张表」:回到设置,范围就是这 2 张、勾着用模型,焦点在起草要求;补上缩写的含义再开始
await draftBox(page).locator('[data-draft-blank-redraft]').click()
check('「重新起草这 2 张表」:回到设置,范围是有留空列的这 2 张,勾着用模型,焦点在起草要求',
await until(async () => (await draftPhase(page)) === 'setup')
&& await draftBox(page).locator('[data-draft-scope="preset"] input').isChecked()
&& (await draftText(page, '[data-draft-scope="preset"]')).includes('有留空列的 2 张表')
&& await draftBox(page).locator('[data-draft-model] input').isChecked()
&& await until(async () => page.evaluate(() => document.activeElement?.id === 'catalog-draft-guidance')),
`${await draftText(page, '[data-draft-scope="preset"]')} · ${await page.evaluate(() => document.activeElement?.id ?? '')}`)
check('……起草按钮写「起草 2 张表」', (await page.locator('[data-draft-start]').innerText()).includes('起草 2 张表'))
await draftBox(page).locator('[data-draft-guidance] textarea').fill(GUIDANCE_ABBR)
await page.locator('[data-draft-start]').click()
check('……先存起草要求,再建只含这 2 张表、用模型的任务', await until(async () => (await draftPhase(page)) === 'running')
&& JSON.stringify(lastBody(sent, /catalog\/guidance$/)) === JSON.stringify({ guidance: GUIDANCE_ABBR })
&& JSON.stringify(lastBody(sent, /draft-jobs$/)) === JSON.stringify({ tables: ['gates', 'store_sales'], use_model: true }),
JSON.stringify(lastBody(sent, /draft-jobs$/)))
// 这一回宽表仍有几列拿不准(已有的内容不丢由服务端保证)。关着对话框时结束:提示里也写留空的列
const again = state.jobs.at(-1)
await page.locator('[data-draft-hide]').click()
await until(async () => (await draftBox(page).count()) === 0)
settle(state, again, 'gates', { model_items: 3 })
settle(state, again, 'store_sales', { updated: 5, model_items: 40, blank_columns: 7, blank_sample: BLANK_SAMPLE })
finishJob(again)
const toastBox = page.locator('[data-toast]').filter({ hasText: '起草完成' })
check('关着对话框时结束:提示里写「7 列模型拿不准、留空了」', await until(async () => (await toastBox.count()) > 0)
&& (await toastBox.first().innerText()).includes('7 列模型拿不准、留空了'),
await page.locator('[data-toast]').allInnerTexts().then((t) => t.join(' | ')).catch(() => ''))
await toastBox.getByRole('button', { name: '查看结果' }).click()
check('……点「查看结果」:汇总写合计', await until(async () => (await draftText(page, '[data-draft-blank-summary]')).includes('共 7 列模型拿不准、留空了(1 张表)')),
await draftText(page, '[data-draft-blank-summary]'))
await page.locator('[data-draft-close]').click()
// 再打开起草:设置阶段写明上一次有留空的列,可以点开
await page.locator('[data-catalog-draft]').first().click()
await draftBox(page).waitFor()
check('再打开起草:设置阶段写明上一次起草有 7 列模型拿不准、留空了,可以点开', await until(async () => (await draftText(page, '[data-draft-last]')).includes('7 列模型拿不准、留空了'))
&& await draftBox(page).locator('[data-draft-last-open]').count() === 1, await draftText(page, '[data-draft-last]'))
await page.keyboard.press('Escape')
await until(async () => (await draftBox(page).count()) === 0)
// 表详情:顶部同样提示(不是失败),给「重新起草这张表」和「补充起草要求」
await page.locator('[data-catalog-row="store_sales"] [data-catalog-open-row]').click()
const tip = page.locator('[data-catalog-last-draft]')
check('表详情:顶部写「上次起草有 7 列模型拿不准、留空了」,是提示(不是失败)',
await until(async () => (await tip.innerText({ timeout: 500 }).catch(() => '')).includes('上次起草有 7 列模型拿不准、留空了'))
&& (await tip.getAttribute('data-notice')) === 'info', await tip.innerText({ timeout: 500 }).catch(() => ''))
check('……写明办法:补充起草要求再重新起草,已有的内容不会丢;给「重新起草这张表」', (await tip.innerText()).includes('已有的内容不会丢')
&& await tip.locator('[data-catalog-redraft]').count() === 1, await tip.innerText())
await shot(page, 'catalog-blank-detail')
const jobsBefore = count(sent, /^POST .*draft-jobs$/)
await tip.locator('[data-catalog-draft-guidance]').click()
check('「补充起草要求」:打开起草设置,范围只有这张表,勾着用模型,焦点在起草要求',
await until(async () => (await draftPhase(page)) === 'setup')
&& await draftBox(page).locator('[data-draft-scope="preset"] input').isChecked()
&& (await draftText(page, '[data-draft-scope="preset"]')).includes('store_sales')
&& await draftBox(page).locator('[data-draft-model] input').isChecked()
&& (await page.locator('[data-draft-start]').innerText()).includes('起草 1 张表')
&& await until(async () => page.evaluate(() => document.activeElement?.id === 'catalog-draft-guidance')),
`${await draftText(page, '[data-draft-scope="preset"]')} · ${await page.evaluate(() => document.activeElement?.id ?? '')}`)
await page.keyboard.press('Escape')
check('……没改就关掉:不建任务', await until(async () => (await draftBox(page).count()) === 0) && count(sent, /^POST .*draft-jobs$/) === jobsBefore)
await tip.locator('[data-catalog-redraft]').click()
check('「重新起草这张表」:建只含这张表、用模型的任务', await until(async () => count(sent, /^POST .*draft-jobs$/) === jobsBefore + 1)
&& JSON.stringify(lastBody(sent, /draft-jobs$/)) === JSON.stringify({ tables: ['store_sales'], use_model: true }),
JSON.stringify(lastBody(sent, /draft-jobs$/)))
const third = state.jobs.at(-1)
await until(async () => (await draftPhase(page)) === 'running')
settle(state, third, 'store_sales', { model_items: 40 })
finishJob(third)
await until(async () => (await draftPhase(page)) === 'done')
await page.locator('[data-draft-close]').click()
check('……这回没有留空的列:清单的轻提示和详情的提示都收起', await until(async () => (await rowBlank('store_sales').count()) === 0 && (await tip.count()) === 0))
// 部分完成又有留空列的表:一条说明里写原因和留空的列,只有一个「重新起草这张表」
await page.locator('[data-catalog-row="channels"] [data-catalog-open-row]').click()
check('部分完成又有留空列的表:详情里一条说明写原因和留空的列,只有一个「重新起草这张表」',
await until(async () => (await tip.innerText({ timeout: 500 }).catch(() => '')).includes('上次起草部分完成'))
&& (await tip.innerText()).includes(CALL_ERR) && (await tip.innerText()).includes('4 列模型拿不准、留空了')
&& (await tip.getAttribute('data-notice')) === 'warn' && await tip.locator('[data-catalog-redraft]').count() === 1,
await tip.innerText({ timeout: 500 }).catch(() => ''))
await cleanCopy(page, '留空列的提示')
check('只发过起草任务相关的写请求', writes(sent, /./).every((s) => /catalog\/(draft-jobs|guidance)/.test(s.key)),
JSON.stringify(writes(sent, /./).map((s) => s.key)))
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
})
await section('数据目录 · 表详情与单项审阅', async () => {
const state = freshState()
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
@@ -2469,14 +2628,16 @@ await section('数据目录 · 窄屏', async () => {
await until(async () => (await draftPhase(page)) === 'running')
const narrowJob = state.jobs.at(-1)
settle(state, narrowJob, 'visits', { added: 2 })
settle(state, narrowJob, 'store_sales', { status: 'partial', parts_done: 2, added: 4, model_error: WIDE_ERR })
settle(state, narrowJob, 'store_sales', { status: 'partial', parts_done: 2, added: 4, model_error: WIDE_ERR, blank_columns: 12, blank_sample: BLANK_SAMPLE })
// 接连没写出来的提醒、超时和没写出来的次数、段缩小的说明也都在
Object.assign(narrowJob.usage, { timeouts: 3, failed_calls: 4 })
Object.assign(narrowJob, { part_columns: 20, notice: DRAFT_NOTICE })
await until(async () => (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-reason]')).includes('第 3 段')
&& (await draftText(page, '[data-draft-notice]')) === DRAFT_NOTICE)
await draftBox(page).locator('[data-draft-task="store_sales"] [data-draft-task-blank] summary').click().catch(() => {})
const runFit = await draftFits()
check('……起草进行中:段进度、长原因、提醒都折行,弹窗不横向滚动', runFit.ok, JSON.stringify(runFit))
check('……起草进行中:段进度、长原因、提醒、展开的留空列名都折行,弹窗不横向滚动', runFit.ok
&& await draftBox(page).locator('[data-draft-task-blank-sample]').isVisible().catch(() => false), JSON.stringify(runFit))
await shot(page, 'catalog-narrow-draft')
await page.locator('[data-draft-hide]').click()
await until(async () => (await page.locator('[data-catalog-draft-progress]').count()) === 1)