数据目录闭环(前端):表详情加「表助手」——顶部和上次起草的提示里都有「和助手一起完善」入口,对话流式显示、能停止,助手跑过的只读查询写 SQL 和前几行(默认收起),修改提案逐项写改前、改后、依据并能逐项去掉、加回,「待保存的修改」跨多轮累积(同一项以最后一次提案为准),一键保存(最多 200 项,409 时按最新内容重算后停下来再看一眼),没开数据剖析的源写明只能根据表结构和目录推断、不能查数据,有留空列时开场引导先问这几列,有待保存的修改时换表、关闭先问;表详情加「用过这张表的运行」、有效记录条件和去重规则的推断项在值下面写说明(剖析核对的排除比例或没核对的原因)、从审阅队列打开的那一项标出来;起草任务逐表显示块进度(段被拆小后看得出来)和已写进目录的段,停止后写明已写进几段、只重试时接着起草,清单和表详情在最近一次停止、中断又没有新写入时接着用再之前那次起草记下的留空列数提示;check-catalog 加一节按约定的新字段推进

This commit is contained in:
ThearchyHelios
2026-10-14 08:44:37 +08:00
parent a9ffbcfa86
commit 1cc9fe1a3d
9 changed files with 983 additions and 39 deletions
+23 -5
View File
@@ -9,7 +9,7 @@ import { IconButton } from '../../components/ui'
import {
CATALOG_COLUMN_FIELD_LABEL, CATALOG_MEASURE_HINT, CATALOG_MEASURE_LABEL, CATALOG_TEXT as CT, CODES_TEXT as KT,
} from '../../lib/terms'
import { ItemMark } from './parts'
import { FOCUS_RING, FOCUS_STYLE, ItemMark } from './parts'
import type { ReviewTarget } from './parts'
import { COLUMN_FIELDS, codesComplete, columnPath, completeOf } from './model'
import type { ColumnDraft, ColumnField } from './model'
@@ -50,7 +50,16 @@ export interface ColumnModel {
type ColumnTexts = ColumnDraft
export function ColumnTable({ columns, form, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange, onCodesComplete }: {
/** 审阅路径 columns.<列名>.<字段> → [列名, 字段](列名里可以有句点,按最后一个句点切);不是列级项为 null */
function columnOfPath(path: string | null | undefined): [string, string] | null {
if (!path?.startsWith('columns.')) return null
const rest = path.slice('columns.'.length)
const at = rest.lastIndexOf('.')
return at > 0 ? [rest.slice(0, at), rest.slice(at + 1)] : null
}
export function ColumnTable({ columns, form, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange, onCodesComplete,
focusPath = null }: {
columns: ColumnModel[]
/** 编辑中:列名 → 字段 → 文字(form 是当前的,initial 是进入编辑时的);不在编辑时为 null */
form: Record<string, ColumnTexts> | null
@@ -67,7 +76,10 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote
onChange: (col: string, field: ColumnField, value: string) => void
/** 编辑中勾选或取消码值的「已列出全部取值」 */
onCodesComplete: (col: string, value: boolean) => void
/** 从审阅队列打开的那一项(列级项的审阅路径):那一格标出来 */
focusPath?: string | null
}) {
const focus = columnOfPath(focusPath)
return (
<div className="@container relative overflow-x-auto rounded-lg border" data-catalog-columns="">
<table className={clsx('w-full border-collapse text-xs', NARROW.table)}>
@@ -89,7 +101,7 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote
{columns.map((c) => (
<ColumnRow key={c.name} col={c} editing={!!form} draft={form?.[c.name]} initial={initial?.[c.name]} problems={problems} busy={busy}
systemNotes={systemNotes} onReview={onReview} onConfirmColumn={onConfirmColumn} onFillCodes={onFillCodes} onChange={onChange}
onCodesComplete={onCodesComplete} />
onCodesComplete={onCodesComplete} focusField={focus && focus[0] === c.name ? focus[1] : null} />
))}
</tbody>
</table>
@@ -98,7 +110,7 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote
}
const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange,
onCodesComplete }: {
onCodesComplete, focusField }: {
col: ColumnModel
editing: boolean
/** 这一列的表单。只有改了这一列时引用才会变,别的行不重画 */
@@ -112,6 +124,8 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble
onFillCodes: (col: string) => void
onChange: (col: string, field: ColumnField, value: string) => void
onCodesComplete: (col: string, value: boolean) => void
/** 从审阅队列打开的是这一列的哪一项 */
focusField: string | null
}) {
const { name, structure, items } = col
const pending = COLUMN_FIELDS.filter((f) => items?.[f]?.status === 'proposed').length
@@ -121,9 +135,12 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble
const where = CT.whereColumn(name, CATALOG_COLUMN_FIELD_LABEL[f])
const problem = problems[`c:${name}:${f}`]
const changed = editing && ((draft?.[f] ?? '') !== (initial?.[f] ?? '') || (f === 'codes' && completeOf(draft) !== completeOf(initial)))
const focused = focusField === f
return (
// 窄框下没填的项不占行(编辑时照常列出来好填)
<td className={clsx('px-2 py-1.5 align-top', NARROW.cell, !it && !editing && '@max-[40rem]:hidden')} data-cell={f} data-status={it?.status}>
<td className={clsx('px-2 py-1.5 align-top', NARROW.cell, !it && !editing && '@max-[40rem]:hidden', focused && FOCUS_RING)}
style={focused ? FOCUS_STYLE : undefined} data-cell={f} data-status={it?.status}
data-review-focus={focused ? columnPath(name, f) : undefined}>
<span className={NARROW.label}>{CT.columnHead[f]}</span>
<div className="flex min-w-0 flex-1 items-start gap-1.5">
<div className="min-w-0 flex-1">{editing ? input : view}</div>
@@ -223,6 +240,7 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble
</tr>
)
}, (a, b) => a.col === b.col && a.editing === b.editing && a.draft === b.draft && a.initial === b.initial && a.busy === b.busy
&& a.focusField === b.focusField
&& a.systemNotes === b.systemNotes && a.onReview === b.onReview && a.onConfirmColumn === b.onConfirmColumn && a.onFillCodes === b.onFillCodes
&& a.onChange === b.onChange && a.onCodesComplete === b.onCodesComplete
// 格式问题每次都是新对象:只比这一列的几格
+30 -4
View File
@@ -9,8 +9,8 @@ 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, blankOf, isJobActive, jobBlank, needsLook,
nothingWritten, retryableCount, taskReason,
DRAFT_MAX_TABLES, DRAFT_MODEL_UNAVAILABLE, DRAFT_PART_COLUMNS, DRAFT_TOP_N, RETRYABLE, blankOf, chunksOf, isJobActive, jobBlank, needsLook,
nothingWritten, retryableCount, taskReason, writtenOf,
} from './draftJob'
import type { DraftPreset, DraftStartBody } from './draftJob'
@@ -435,6 +435,9 @@ function JobView({ job, running, stopping, busy, blankTables, onRedraftBlank }:
// 额度用完、超时多了,之后的段缩小了:进行中写一句,段数为什么变多看得明白
const partColumns = running && job.use_model && job.part_columns != null && job.part_columns < DRAFT_PART_COLUMNS ? job.part_columns : null
const blankTotal = jobBlank(job)
// 停下来时已经写进了一部分段的表(宽表每写完一段就写进目录):几张、合计几段
const stopped = job.tasks.filter((t) => t.status === 'cancelled' && writtenOf(t) > 0)
const stoppedParts = { tables: stopped.length, parts: stopped.reduce((n, t) => n + writtenOf(t), 0) }
return (
<div className="min-w-0 space-y-3">
{busy && <Notice tone="info" attr={{ 'data-draft-busy': '' }}>{DT.busy}</Notice>}
@@ -468,7 +471,12 @@ function JobView({ job, running, stopping, busy, blankTables, onRedraftBlank }:
{running && <p className="text-2xs leading-relaxed text-faint">{DT.hideHint}</p>}
{job.status === 'failed' && job.error && <Notice tone="err" attr={{ 'data-draft-job-error': '' }}>{job.error}</Notice>}
{job.status === 'interrupted' && <Notice tone="warn" attr={{ 'data-draft-interrupted': '' }}>{DT.interrupted}</Notice>}
{job.status === 'cancelled' && <p className="text-2xs text-dim" data-draft-cancelled="">{DT.cancelled}</p>}
{job.status === 'cancelled' && (
<p className="text-2xs leading-relaxed text-dim" data-draft-cancelled="">
{DT.cancelled}
{stoppedParts.tables > 0 && <span className="block">{DT.cancelledParts(stoppedParts.tables, stoppedParts.parts)}</span>}
</p>
)}
{problemText && (
<p className="text-xs leading-relaxed" data-draft-problems="">{problemText}</p>
)}
@@ -523,7 +531,15 @@ const TASK_ICON: Record<CatalogDraftTaskStatus, { Icon: LucideIcon | null; color
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 started = t.status !== 'pending' && t.status !== 'skipped'
const parts = t.parts_total != null && t.parts_total > 1 && started
// 段被缩小、拆半后实际的块(比段多时才写);已经写进目录的段:起草中写着(停止也不丢),停下来时服务端的原因里
// 没说的才补一句(没写全的段为什么没写出来,服务端的原因里有),写完了的不写
const chunks = started ? chunksOf(t) : null
const written = writtenOf(t)
const writtenText = written > 0 && t.status === 'running' ? DT.partsWritten(written)
: written > 0 && t.status === 'cancelled' && !reason.includes(DT.partsWritten(written))
? DT.partsWrittenStopped(written, Math.max(0, (t.parts_total ?? written) - written)) : ''
const changes = DT.taskAdded(t.added, t.updated)
const blank = blankOf(t)
const sample = t.blank_sample?.filter(Boolean) ?? []
@@ -537,6 +553,11 @@ function TaskRow({ task: t, jobError }: { task: CatalogDraftTask; jobError: stri
{DT.parts(t.parts_done, t.parts_total!)}
</span>
)}
{chunks && (
<span className="tnum shrink-0 text-2xs text-faint" title={DT.chunksHint(chunks.total)} data-draft-task-chunks="">
{DT.chunks(chunks.done, chunks.total)}
</span>
)}
{changes && (
<span className="tnum shrink-0 text-2xs text-faint" title={DT.taskAddedHint(t.added, t.updated, t.removed)}>{changes}</span>
)}
@@ -545,6 +566,11 @@ function TaskRow({ task: t, jobError }: { task: CatalogDraftTask; jobError: stri
{DT.taskStatus[t.status]}
</span>
</div>
{writtenText && (
<p className="mt-0.5 break-words pl-5 text-2xs leading-relaxed text-faint" title={DT.partsWrittenHint} data-draft-task-written="">
{writtenText}
</p>
)}
{reason && (
<p className="mt-0.5 break-words pl-5 text-2xs leading-relaxed text-dim" data-draft-task-reason="">{reason}</p>
)}
@@ -0,0 +1,591 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
import { ArrowUp, BookMarked, Database, Info, MessagesSquare, Minus, Plus, Square, Trash2, X } from 'lucide-react'
import clsx from 'clsx'
import { ApiError, api, streamCatalogAssistant } from '../../api/client'
import type { CatalogAssistantEnd } from '../../api/client'
import type { CatalogDetail } from '../../types'
import { Notice, Spinner, confirmDialog, isComposing, toast } from '../../components/ui'
import { useLeaveGuard } from '../../lib/leave'
import { CATALOG_ASSISTANT_TEXT as T, CATALOG_STATUS_LABEL } from '../../lib/terms'
import { Markdown } from '../../run/Markdown'
import { patchValueText } from '../../run/catalogPatch'
import {
ASSISTANT_MAX_CHARS, ASSISTANT_MAX_SAVE, QUERY_ROWS_SHOWN, applyEvent, applyPreview, assistantChangesOf, blankColumnsOf, changeStateOf,
changeWhere, dropPending, historyOf, mergeProposal, pendingSubmit, pendingVersion, savedKey,
} from './assistant'
import type { AssistantTurn, ChangeState, PendingChange, Turn, TurnPart } from './assistant'
// ===========================================================================
// 表助手:在表详情里和助手对话完善这一张表的数据目录(POST /catalog/{table}/assistant,事件流)。
//
// 回答流式显示,能停止;助手跑过的只读查询(只有开了数据剖析的源才有)写 SQL 和前几行,默认收起;修改提案逐项写改前、
// 改后、依据,可以逐项去掉、加回。提案并进「待保存的修改」:跨多轮累积,同一项以最后一次提案为准,一键保存(最多 200 项)。
// 保存带待保存的修改里最早那条提案对照的版本:别人在那之后改过就 409,按最新内容重算(预览接口,只读)后停下来让人再看
// 一眼。对话不存服务端:每一轮把前面的对话和待保存的修改一起交上去;关闭、换表、离开页面就没有了,有待保存的修改时先问。
// ===========================================================================
type SaveState =
| { phase: 'idle'; error?: string | null; saved?: { n: number; version: number } | null }
| { phase: 'saving' }
| { phase: 'reloading' }
| { phase: 'conflict'; problems: string[] }
const charCount = (s: string) => [...s.trim()].length
export function TableAssistant({ sourceId, table, tableLabel, detail, profileEnabled, lastBlank, editing, onSaved, onClose }: {
sourceId: string
table: string
/** 表的中文名(没有就是表名):标题、提示里用 */
tableLabel: string
/** 页面上这张表最新的目录:留空的列、保存后更新 */
detail: CatalogDetail
/** 数据源开了数据剖析:助手才能发只读查询 */
profileEnabled: boolean
/** 上次起草模型拿不准、留空的列数:开场引导先问这几列 */
lastBlank: number
/** 正在编辑这张表:先不让保存(同一张表两处同时改,保存会撞上版本冲突) */
editing: boolean
/** 保存成功:页面换上返回的目录 */
onSaved: (d: CatalogDetail) => void
/** 关上面板(有待保存的修改时这里先问过了) */
onClose: () => void
}) {
const [turns, setTurns] = useState<Turn[]>([])
const [pending, setPending] = useState<PendingChange[]>([])
const [saved, setSaved] = useState<ReadonlySet<string>>(() => new Set())
const [text, setText] = useState('')
const [save, setSave] = useState<SaveState>({ phase: 'idle' })
const [trayOpen, setTrayOpen] = useState(true)
const turnsRef = useRef(turns)
turnsRef.current = turns
const pendingRef = useRef(pending)
pendingRef.current = pending
const stopRef = useRef<(() => void) | null>(null)
const seq = useRef(0)
const inputRef = useRef<HTMLTextAreaElement>(null)
const logRef = useRef<HTMLDivElement>(null)
const titleId = useId()
const streaming = turns.some((t) => t.role === 'assistant' && t.state === 'streaming')
const dirty = pending.length > 0 || streaming
// 打开时焦点给输入框;卸下时停掉进行中的回答
useEffect(() => { inputRef.current?.focus() }, [])
useEffect(() => () => { stopRef.current?.() }, [])
useLeaveGuard(pending.length > 0, () => confirmDialog({
title: T.leaveTitle, consequences: T.leaveConsequences, confirmLabel: T.leaveConfirm, danger: true,
}))
// 新的内容到了:滚到底(人往上翻着看的时候不打扰)
const lastLen = useRef(0)
useEffect(() => {
const el = logRef.current
if (!el) return
const size = turns.reduce((n, t) => n + (t.role === 'user' ? 1 : t.parts.length + 1), 0)
const near = el.scrollHeight - el.scrollTop - el.clientHeight < 160
if (size !== lastLen.current || near) el.scrollTop = el.scrollHeight
lastLen.current = size
}, [turns])
const blankCols = useMemo(() => (lastBlank > 0 ? blankColumnsOf(detail) : []), [lastBlank, detail])
const hasProposed = useMemo(() => {
const n = detail.notes
const items = [n.label, n.description, n.grain, n.keys, n.kind, n.business_date, n.valid_filter, n.dedup,
...Object.values(n.columns ?? {}).flatMap((c) => Object.values(c ?? {})), ...(n.relations ?? [])]
return items.some((it) => !!it && (it as { status?: string }).status === 'proposed')
}, [detail])
const updateTurn = useCallback((id: string, fn: (t: AssistantTurn) => AssistantTurn) => {
setTurns((ts) => ts.map((t) => (t.id === id && t.role === 'assistant' ? fn(t) : t)))
}, [])
const send = useCallback((raw: string) => {
const question = raw.trim()
if (!question || charCount(question) > ASSISTANT_MAX_CHARS) return
if (turnsRef.current.some((t) => t.role === 'assistant' && t.state === 'streaming')) return
const n = ++seq.current
const userTurn: Turn = { id: `u${n}`, role: 'user', text: question }
const reply: AssistantTurn = { id: `a${n}`, role: 'assistant', parts: [], state: 'streaming' }
const messages = historyOf(turnsRef.current, question)
setTurns((ts) => [...ts, userTurn, reply])
setText('')
let proposals = 0
// 这一轮已经收尾(done、error):之后再来的事件不认
let ended = false
stopRef.current = streamCatalogAssistant(sourceId, table, { messages, pending: pendingSubmit(pendingRef.current) }, (ev) => {
if (ended) return
if (ev.type === 'done' || ev.type === 'error') ended = true
if (ev.type === 'proposal') {
const pid = `${reply.id}.p${++proposals}`
const changes = assistantChangesOf(ev.changes)
updateTurn(reply.id, (t) => applyEvent(t, ev, pid))
if (changes.length) {
const version = typeof ev.version === 'number' && Number.isFinite(ev.version) ? ev.version : detail.version
setPending((cur) => mergeProposal(cur, changes, pid, version))
// 待保存的修改变了:上一次保存的结果、冲突的说明都不再对
setSave({ phase: 'idle' })
}
return
}
updateTurn(reply.id, (t) => applyEvent(t, ev, ''))
}, (end: CatalogAssistantEnd) => {
stopRef.current = null
updateTurn(reply.id, (t) => {
if (t.state !== 'streaming') return t
if (end.aborted) return { ...t, state: 'stopped' }
if (end.error) return { ...t, state: 'error', error: end.error, code: end.code ?? null }
// 流结束了却没有 done:这一轮没答完
return { ...t, state: 'error', error: T.interrupted }
})
})
}, [sourceId, table, detail.version, updateTurn])
const stop = () => stopRef.current?.()
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== 'Enter' || e.shiftKey || isComposing(e)) return
e.preventDefault()
if (!streaming) send(text)
}
const drop = (path: string) => setPending((cur) => dropPending(cur, path))
const restore = (part: Extract<TurnPart, { kind: 'proposal' }>, path: string) => {
const change = part.changes.find((c) => c.path === path)
if (change) setPending((cur) => mergeProposal(cur, [change], part.id, part.version))
}
const clearAll = async () => {
if (!pendingRef.current.length) return
if (!(await confirmDialog({ title: T.clearTitle(pendingRef.current.length), confirmLabel: T.clearConfirm, danger: true }))) return
setPending([])
setSave({ phase: 'idle' })
}
const doSave = async () => {
const list = pendingRef.current
if (!list.length || list.length > ASSISTANT_MAX_SAVE || editing) return
setSave({ phase: 'saving' })
try {
const d = await api.dataCatalog.patch(sourceId, table, pendingSubmit(list), pendingVersion(list))
setSaved((cur) => new Set([...cur, ...list.map((p) => savedKey(p.origin, p.path))]))
setPending((cur) => cur.filter((p) => !list.includes(p)))
setSave({ phase: 'idle', saved: { n: list.length, version: d.version } })
onSaved(d)
toast.ok(T.savedToast(list.length, tableLabel))
} catch (e) {
if (e instanceof ApiError && e.status === 409) {
// 别人刚改过:按最新内容重算(只读),停下来让人再看一眼
setSave({ phase: 'reloading' })
try {
const p = await api.dataCatalog.patchPreview(sourceId, table, pendingSubmit(list))
setPending((cur) => applyPreview(cur, p))
setSave({ phase: 'conflict', problems: p.problems })
} catch {
setSave({ phase: 'idle', error: T.conflictFailed })
}
} else if (e instanceof ApiError && e.kind === 'http' && (e.status === 422 || e.status === 404)) {
setSave({ phase: 'idle', error: e.message })
} else {
setSave({ phase: 'idle' })
toast.error(e)
}
}
}
const close = async () => {
if (dirty && !(await confirmDialog({ title: T.closeTitle, consequences: T.closeConsequences, confirmLabel: T.closeConfirm, danger: true }))) return
stopRef.current?.()
onClose()
}
const onPanelKey = (e: ReactKeyboardEvent<HTMLElement>) => {
if (e.key !== 'Escape' || isComposing(e) || e.defaultPrevented) return
e.preventDefault()
void close()
}
const fresh = turns.length === 0
const len = charCount(text)
const over = len > ASSISTANT_MAX_CHARS
const lastAssistant = [...turns].reverse().find((t): t is AssistantTurn => t.role === 'assistant')
const status = streaming ? T.answering : lastAssistant?.state === 'stopped' ? T.stopped : lastAssistant ? T.answered : ''
const sampleCols = blankCols.slice(0, Math.min(Math.max(lastBlank, 1), 5))
return (
<aside
// 贴着详情的右边:窄一些的屏幕盖在详情上面(手机上铺满);宽屏(xl 起)详情让出这一栏的宽度,不带阴影
className={clsx('absolute inset-y-0 right-0 z-30 flex w-full min-h-0 flex-col border-l bg-panel shadow-elev-2 sm:w-[400px]',
'xl:z-10 xl:w-[380px] xl:shadow-none 2xl:w-[420px]')}
aria-labelledby={titleId}
onKeyDown={onPanelKey}
data-table-assistant={table}
data-streaming={streaming ? 'true' : 'false'}
>
<header className="flex items-start gap-2 border-b px-3 py-2.5">
<MessagesSquare size={14} className="mt-0.5 shrink-0" style={{ color: 'var(--accent)' }} aria-hidden />
<div className="min-w-0 flex-1">
<h3 id={titleId} className="text-xs font-semibold">{T.title}</h3>
<p className="truncate text-2xs text-faint" title={T.subtitle(tableLabel)}>{T.subtitle(tableLabel)}</p>
</div>
<button type="button" className="shrink-0 rounded p-1 text-faint outline-none hover:bg-hover hover:text-fg focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
onClick={() => void close()} aria-label={T.close} title={T.close} data-assistant-close="">
<X size={13} aria-hidden />
</button>
</header>
<div className="border-b px-3 py-2 text-2xs leading-relaxed">
{profileEnabled
? (
<p className="flex items-start gap-1.5 text-faint" data-assistant-profile-on="">
<Database size={11} className="mt-0.5 shrink-0" aria-hidden /> <span className="min-w-0">{T.profileOn}</span>
</p>
)
: (
<p className="flex items-start gap-1.5 text-dim" data-assistant-no-profile="">
<Info size={11} className="mt-0.5 shrink-0" style={{ color: 'var(--st-waiting)' }} aria-hidden /> <span className="min-w-0">{T.noProfile}</span>
</p>
)}
</div>
<div ref={logRef} className="min-h-0 flex-1 space-y-3 overflow-y-auto px-3 py-3" role="log" aria-live="polite" aria-relevant="additions"
aria-busy={streaming} aria-label={T.logLabel} data-assistant-log="">
<div className="space-y-2 rounded-lg border bg-bg px-3 py-2.5 text-xs leading-relaxed" data-assistant-msg="intro">
<p className="text-dim" data-assistant-greeting="">{T.greeting}</p>
{lastBlank > 0 && <p className="text-dim" data-assistant-blank="">{T.blankLead(lastBlank, sampleCols)}</p>}
<p className="text-2xs text-faint">{T.notStored}</p>
{fresh && (
<div className="space-y-1 pt-0.5">
<p className="text-2xs text-faint">{T.suggestLabel}</p>
<div className="flex flex-wrap gap-1.5">
{lastBlank > 0 && blankCols.length > 0 && (
<SuggestButton kind="blank" label={T.suggestBlank(lastBlank)}
onClick={() => send(T.suggestBlankText(blankCols.slice(0, 20)))} />
)}
<SuggestButton kind="facts" label={T.suggestFacts} onClick={() => send(T.suggestFactsText)} />
{hasProposed && <SuggestButton kind="pending" label={T.suggestPending} onClick={() => send(T.suggestPendingText)} />}
</div>
</div>
)}
</div>
{turns.map((t) => (t.role === 'user'
? (
<div key={t.id} className="flex justify-end" data-assistant-msg="user">
<span className="sr-only">{T.you}:</span>
<p className="max-w-[88%] whitespace-pre-wrap break-words rounded-lg bg-accent-soft px-3 py-2 text-xs leading-relaxed"
data-assistant-user-text="">
{t.text}
</p>
</div>
)
: <AssistantTurnView key={t.id} turn={t} pending={pending} saved={saved} onDrop={drop} onRestore={restore} />))}
</div>
<p className="sr-only" role="status" data-assistant-status="">{status}</p>
<SaveNotices save={save} pendingCount={pending.length} />
{pending.length > 0 && (
<PendingTray pending={pending} open={trayOpen} onToggle={() => setTrayOpen((v) => !v)} save={save} editing={editing}
onDrop={drop} onClear={() => void clearAll()} onSave={() => void doSave()} />
)}
<div className="border-t px-3 py-2.5">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
className="field min-h-[2.75rem] flex-1 resize-none text-xs leading-relaxed"
rows={2}
value={text}
placeholder={T.inputPlaceholder}
aria-label={T.inputLabel}
aria-invalid={over ? true : undefined}
onChange={(e) => setText(e.target.value)}
onKeyDown={onKeyDown}
data-assistant-input=""
/>
{streaming
? (
<button type="button" className="btn btn-sm shrink-0" onClick={stop} aria-label={T.stop} title={T.stop} data-assistant-stop="">
<Square size={11} aria-hidden /> <span className="hidden sm:inline">{T.stop}</span>
</button>
)
: (
<button type="button" className="btn btn-sm btn-primary shrink-0" onClick={() => send(text)} disabled={!text.trim() || over}
aria-label={T.send} title={T.inputHint} data-assistant-send="">
<ArrowUp size={12} aria-hidden /> <span className="hidden sm:inline">{T.send}</span>
</button>
)}
</div>
<div className="mt-1 flex items-center gap-2 text-2xs text-faint">
<span className="min-w-0 flex-1 truncate">{over ? <span className="text-[var(--err)]" role="alert">{T.tooLong(ASSISTANT_MAX_CHARS)}</span> : T.inputHint}</span>
<span className={clsx('tnum shrink-0', over && 'text-[var(--err)]')} aria-hidden>{T.count(len, ASSISTANT_MAX_CHARS)}</span>
</div>
</div>
</aside>
)
}
function SuggestButton({ kind, label, onClick }: { kind: string; label: string; onClick: () => void }) {
return (
<button type="button" className="btn btn-sm !text-2xs" onClick={onClick} data-assistant-suggest={kind}>{label}</button>
)
}
// ---------------------------------------------------------------------------
// 一轮回答:正文、只读查询、修改提案按到来的先后排;末尾写这一轮的结局(出错、停止、用量)
// ---------------------------------------------------------------------------
function AssistantTurnView({ turn, pending, saved, onDrop, onRestore }: {
turn: AssistantTurn
pending: PendingChange[]
saved: ReadonlySet<string>
onDrop: (path: string) => void
onRestore: (part: Extract<TurnPart, { kind: 'proposal' }>, path: string) => void
}) {
const usage = turn.usage
return (
<div className="space-y-2" data-assistant-msg="assistant" data-turn-state={turn.state}>
<span className="sr-only">{T.assistant}:</span>
{turn.parts.map((p, i) => {
if (p.kind === 'text') {
return (
<div key={i} className="min-w-0 break-words text-xs" data-assistant-part="text" data-assistant-text="">
<Markdown text={p.text} dense />
</div>
)
}
if (p.kind === 'query') return <QueryBlock key={i} part={p} />
return <ProposalCard key={p.id} part={p} pending={pending} saved={saved} onDrop={onDrop} onRestore={onRestore} />
})}
{turn.state === 'streaming' && (
<p className="flex items-center gap-1.5 text-2xs text-faint" aria-hidden><Spinner size={11} /> {T.answering}</p>
)}
{turn.state === 'error' && (
<Notice tone="err" attr={{ 'data-assistant-error': '' }}>
{turn.error ?? T.interrupted}
</Notice>
)}
{turn.state === 'stopped' && (
<p className="text-2xs text-faint" data-assistant-turn-status="stopped">{T.stopped}</p>
)}
{turn.state === 'done' && (usage || turn.queries) && (
<p className="tnum text-2xs text-faint" data-assistant-turn-status="done">
{[usage ? T.usage(usage.input_tokens, usage.output_tokens) : '', turn.queries ? T.queries(turn.queries) : ''].filter(Boolean).join(' · ')}
</p>
)}
</div>
)
}
/** 助手跑过的一条只读查询:摘要写行数(失败写原因),展开看 SQL 和前几行 */
function QueryBlock({ part }: { part: Extract<TurnPart, { kind: 'query' }> }) {
const failed = !!part.error
return (
<details className="group rounded-lg border bg-bg text-2xs" data-assistant-part="query" data-assistant-query={failed ? 'error' : 'ok'}>
<summary className="flex cursor-pointer select-none items-start gap-1.5 px-2.5 py-1.5 text-dim outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-[var(--accent)]">
<Database size={11} className="mt-0.5 shrink-0" style={{ color: failed ? 'var(--err)' : 'var(--accent)' }} aria-hidden />
<span className="min-w-0 break-words">{failed ? `${T.queryFailed}:${part.error}` : T.query(part.rowCount)}</span>
</summary>
<div className="space-y-1.5 border-t px-2.5 py-2">
<pre className="mono max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-panel px-2 py-1.5 text-2xs" data-assistant-query-sql="">{part.sql}</pre>
{!failed && (part.rows.length > 0 && part.columns.length > 0
? (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-2xs" data-assistant-query-rows="">
<caption className="pb-1 text-left text-faint">{T.queryRows(Math.min(part.rows.length, QUERY_ROWS_SHOWN), part.rowCount)}</caption>
<thead>
<tr className="border-b text-left text-faint">
{part.columns.map((c) => <th key={c} scope="col" className="mono px-1.5 py-1 font-medium">{c}</th>)}
</tr>
</thead>
<tbody>
{part.rows.slice(0, QUERY_ROWS_SHOWN).map((r, i) => (
<tr key={i} className="border-b border-hairline last:border-b-0">
{part.columns.map((c, j) => <td key={c} className="mono px-1.5 py-1 align-top break-all">{cellText(r[j])}</td>)}
</tr>
))}
</tbody>
</table>
</div>
)
: <p className="text-faint">{part.rowCount > 0 ? T.queryRows(0, part.rowCount) : T.queryEmpty}</p>)}
</div>
</details>
)
}
const cellText = (v: unknown): string => (v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v))
/** 一项的值写成一行(码值写成「1=已入园、9=已作废」);没有写「未填写」 */
const valueText = (path: string, v: unknown) => patchValueText(path, v)
/** 一条修改提案:逐项写改前、改后、依据;可以逐项去掉、加回。已保存的项不能再加回 */
function ProposalCard({ part, pending, saved, onDrop, onRestore }: {
part: Extract<TurnPart, { kind: 'proposal' }>
pending: PendingChange[]
saved: ReadonlySet<string>
onDrop: (path: string) => void
onRestore: (part: Extract<TurnPart, { kind: 'proposal' }>, path: string) => void
}) {
return (
<section className="rounded-lg border bg-bg" aria-label={T.proposal(part.changes.length)} data-assistant-part="proposal" data-assistant-proposal={part.id}>
<header className="flex items-center gap-1.5 border-b px-2.5 py-1.5 text-2xs font-semibold">
<BookMarked size={11} className="shrink-0" style={{ color: 'var(--accent)' }} aria-hidden />
{T.proposal(part.changes.length)}
</header>
<ol className="divide-y divide-[var(--hairline)]">
{part.changes.map((c) => {
const state = changeStateOf(pending, saved, part.id, c.path)
return (
<ChangeRow key={c.path} change={c} state={state}
onToggle={state === 'saved' ? undefined : () => (state === 'pending' ? onDrop(c.path) : onRestore(part, c.path))} />
)
})}
</ol>
</section>
)
}
const STATE_TONE: Record<ChangeState, string> = {
pending: 'var(--accent)', replaced: 'var(--text-faint)', dropped: 'var(--text-faint)', saved: 'var(--st-done)',
}
function ChangeRow({ change: c, state, onToggle }: {
change: Parameters<typeof changeWhere>[0]
state: ChangeState
onToggle?: () => void
}) {
const where = changeWhere(c)
const off = state === 'dropped' || state === 'replaced'
return (
<li className="px-2.5 py-2 text-2xs" data-assistant-change={c.path} data-change-state={state}>
<div className="flex items-start gap-2">
<span className={clsx('min-w-0 flex-1 break-words text-xs font-medium', off && 'text-faint line-through')}>{where}</span>
<span className="shrink-0" style={{ color: STATE_TONE[state] }} data-change-state-label="">{T.state[state]}</span>
{onToggle && (
<button type="button" className="shrink-0 rounded px-1 text-dim outline-none hover:bg-hover hover:text-fg focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
onClick={onToggle} aria-label={state === 'pending' ? T.dropLabel(where) : T.restoreLabel(where)} data-assistant-change-toggle="">
{state === 'pending' ? <Minus size={10} className="mr-0.5 inline" aria-hidden /> : <Plus size={10} className="mr-0.5 inline" aria-hidden />}
{state === 'pending' ? T.drop : T.restore}
</button>
)}
</div>
<dl className={clsx('mt-1 grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5 leading-relaxed', off && 'opacity-70')}>
<dt className="text-faint">{T.before}</dt>
<dd className={clsx('break-words', c.before == null ? 'text-faint' : 'text-dim')} data-change-before="">
{valueText(c.path, c.before)}
{c.before != null && c.before_status && <span className="text-faint">({CATALOG_STATUS_LABEL[c.before_status]})</span>}
</dd>
<dt className="text-faint">{T.after}</dt>
<dd className="break-words text-fg" data-change-after="">
{valueText(c.path, c.after)}
{c.state === 'confirm' && <span className="ml-1 text-faint">{T.stateConfirm}</span>}
</dd>
{c.reason && (
<>
<dt className="text-faint">{T.reason}</dt>
<dd className="break-words text-dim" data-change-reason="">{c.reason}</dd>
</>
)}
</dl>
{c.note && <p className="mt-1 break-words text-faint" data-change-note="">{c.note}</p>}
</li>
)
}
// ---------------------------------------------------------------------------
// 待保存的修改:跨多轮累积,可以逐项去掉;一键保存(最多 200 项)
// ---------------------------------------------------------------------------
/**
* 保存的结果:别人刚改过(409)后按最新内容重算了的说明(已经不合法、去掉了的项列出来)、保存被拒的原话、存上了几项。
* 不放在「待保存的修改」里:重算之后可能一项都不剩,那一栏就收起了
*/
function SaveNotices({ save, pendingCount }: { save: SaveState; pendingCount: number }) {
if (save.phase === 'conflict') {
return (
<div className="border-t px-3 py-2">
<Notice tone="warn" attr={{ 'data-assistant-conflict': '' }}>
<p className="font-medium">{T.conflictTitle}</p>
<p className="text-dim">{T.conflictBody}</p>
{save.problems.length > 0 && (
<div data-assistant-problems={save.problems.length}>
<p className="mt-1">{T.problems}</p>
<ul className="list-disc pl-4 text-dim">{save.problems.map((x) => <li key={x}>{x}</li>)}</ul>
</div>
)}
</Notice>
</div>
)
}
if (save.phase !== 'idle') return null
if (save.error) return <div className="border-t px-3 py-2"><Notice tone="err" attr={{ 'data-assistant-save-error': '' }}>{save.error}</Notice></div>
if (save.saved && !pendingCount) {
return <div className="border-t px-3 py-2"><Notice tone="info" attr={{ 'data-assistant-saved': '' }}>{T.saved(save.saved.n, save.saved.version)}</Notice></div>
}
return null
}
function PendingTray({ pending, open, onToggle, save, editing, onDrop, onClear, onSave }: {
pending: PendingChange[]
open: boolean
onToggle: () => void
save: SaveState
editing: boolean
onDrop: (path: string) => void
onClear: () => void
onSave: () => void
}) {
const n = pending.length
const tooMany = n > ASSISTANT_MAX_SAVE
const busy = save.phase === 'saving' || save.phase === 'reloading'
const listId = useId()
return (
<section className="border-t bg-elev" aria-label={T.pending(n)} data-assistant-pending={n}>
<div className="flex items-center gap-2 px-3 pt-2">
<button type="button" className="min-w-0 flex-1 truncate rounded text-left text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
onClick={onToggle} aria-expanded={open} aria-controls={listId} title={open ? T.pendingHide : T.pendingShow}
data-assistant-pending-toggle="">
{T.pending(n)}
</button>
<button type="button" className="shrink-0 rounded px-1 text-2xs text-faint outline-none hover:bg-hover hover:text-fg focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
onClick={onClear} disabled={busy} data-assistant-clear="">
<Trash2 size={10} className="mr-0.5 inline" aria-hidden />{T.clear}
</button>
</div>
{open && (
<ul id={listId} className="mx-3 mt-1.5 max-h-48 divide-y divide-[var(--hairline)] overflow-y-auto rounded-md border bg-panel">
{pending.map((p) => {
const where = changeWhere(p)
return (
<li key={p.path} className="flex items-start gap-2 px-2 py-1.5 text-2xs" data-assistant-pending-item={p.path}>
<div className="min-w-0 flex-1 leading-relaxed">
<p className="break-words font-medium text-fg">{where}</p>
<p className="break-words text-dim">
<span className="text-faint">{T.before} </span>{valueText(p.path, p.before)}
<span className="text-faint"> → {T.after} </span><span className="text-fg">{valueText(p.path, p.after)}</span>
</p>
{p.reason && <p className="break-words text-faint"><span>{T.reason}:</span>{p.reason}</p>}
</div>
<button type="button" className="shrink-0 rounded px-1 text-dim outline-none hover:bg-hover hover:text-fg focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
onClick={() => onDrop(p.path)} disabled={busy} aria-label={T.dropLabel(where)} title={T.dropLabel(where)}
data-assistant-pending-remove="">
{T.drop}
</button>
</li>
)
})}
</ul>
)}
<div className="space-y-1.5 px-3 py-2">
{tooMany && <p className="text-2xs text-[var(--err)]" role="alert" data-assistant-too-many="">{T.tooMany(n, ASSISTANT_MAX_SAVE)}</p>}
{editing && <p className="text-2xs text-[var(--warn)]" data-assistant-editing="">{T.editing}</p>}
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 text-2xs leading-relaxed text-faint">{T.pendingHint}</span>
<button type="button" className="btn btn-sm btn-primary shrink-0" onClick={onSave} disabled={busy || tooMany || editing}
data-assistant-save="">
{busy ? <><Spinner size={11} /> {T.saving}</> : T.save(n)}
</button>
</div>
</div>
</section>
)
}
+102 -15
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { AlertTriangle, ArrowLeft, ChevronLeft, ChevronRight, GitBranch, ListChecks, Lock, Pencil, Plus, RotateCw, Search, Sparkles, X } from 'lucide-react'
import {
AlertTriangle, ArrowLeft, ChevronLeft, ChevronRight, GitBranch, Info, ListChecks, Lock, MessagesSquare, Pencil, Plus, RotateCw, Search, Sparkles, X,
} from 'lucide-react'
import clsx from 'clsx'
import { ApiError, api } from '../../api/client'
import type {
@@ -11,17 +13,20 @@ import { DeleteButton, EmptyState, ErrorState, Notice, Skeleton, Spinner, confir
import { useLeaveGuard } from '../../lib/leave'
import { formatDateTime, formatNumber, formatTime } from '../../lib/format'
import {
CATALOG_CARDINALITY_LABEL, CATALOG_DRAFT_TEXT as DT, CATALOG_IMPACT_TEXT, CATALOG_KIND_HINT, CATALOG_KIND_LABEL, CATALOG_TABLE_FIELD_HINT,
CATALOG_TABLE_FIELD_LABEL, CATALOG_TEXT as CT, CATALOG_UI_TEXT as UT, CODES_TEXT as KT,
CATALOG_ASSISTANT_TEXT as AT, CATALOG_CARDINALITY_LABEL, CATALOG_DRAFT_TEXT as DT, CATALOG_IMPACT_TEXT, CATALOG_KIND_HINT, CATALOG_KIND_LABEL,
CATALOG_REVIEW_TEXT as RT, CATALOG_TABLE_FIELD_HINT, CATALOG_TABLE_FIELD_LABEL, CATALOG_TEXT as CT, CATALOG_UI_TEXT as UT,
CATALOG_USAGE_TEXT, CODES_TEXT as KT,
} from '../../lib/terms'
import { CatalogImpactList } from '../../components/CatalogImpact'
import { CountBadges, ItemMark, StatusChip, StatusLegend, SystemNotesNotice } from './parts'
import { CountBadges, FOCUS_RING, FOCUS_STYLE, ItemMark, StatusChip, StatusLegend, SystemNotesNotice } from './parts'
import type { ReviewTarget } from './parts'
import { ColumnTable, NARROW, Value, pendingCodes } from './ColumnTable'
import type { ColumnModel } from './ColumnTable'
import { CodesDialog } from './CodesDialog'
import { coverageText } from './profile'
import { lastBlankOf, taskReason } from './draftJob'
import { lastBlankInfo, taskReason } from './draftJob'
import { TableAssistant } from './TableAssistant'
import { TableUsageList } from './TableUsage'
import {
COLUMN_FIELDS, DATE_KEYS, TABLE_FIELDS, buildNotes, changeCount, columnPath, confirmProposed, countsOf, formFromNotes,
relationDraft, relationPath, relationRemoval, tablePath, unknownKeys, validateForm,
@@ -54,6 +59,7 @@ type Conflict = 'edit' | 'review'
export function TableDetail({
sourceId, table, rows, drafted, fillCodes, onFillDone, prev, next, onOpen, onBack, onDetail, draftBusy = false, onRedraft, onDraftGuidance,
profileEnabled = false, focusPath = null, usageOpen = false,
}: {
sourceId: string
table: string
@@ -84,6 +90,12 @@ export function TableDetail({
onRedraft?: (table: string) => Promise<void>
/** 上次起草有留空列时的「补充起草要求」:打开起草设置,范围只有这张表,先补起草要求再开始。表结构里已经没有的表不给 */
onDraftGuidance?: (table: string) => void
/** 数据源开了数据剖析:表助手才能发只读查询 */
profileEnabled?: boolean
/** 从审阅队列打开的那一项(审阅路径):载入后滚到那一项、标出来 */
focusPath?: string | null
/** 「用过这张表的运行」直接取(从审阅队列打开时) */
usageOpen?: boolean
}) {
const [detail, setDetail] = useState<CatalogDetail | null>(null)
const [loadError, setLoadError] = useState<unknown>(null)
@@ -114,6 +126,10 @@ export function TableDetail({
const [savedAt, setSavedAt] = useState<number | null>(null)
const [impact, setImpact] = useState<{ version: number; n: number } | null>(null)
const [redrafting, setRedrafting] = useState(false)
/** 表助手开着没有:换表时照旧开着(对话按表重新开始) */
const [assistantOpen, setAssistantOpen] = useState(false)
const assistantEntry = useRef<HTMLButtonElement>(null)
const focusAssistant = () => document.querySelector<HTMLElement>('[data-table-assistant] [data-assistant-input]')?.focus()
const loadSeq = useRef(0)
const onDetailRef = useRef(onDetail)
onDetailRef.current = onDetail
@@ -176,6 +192,18 @@ export function TableDetail({
onFillDoneRef.current?.(fillCodes.seq, null)
}, [fillCodes, table, detail])
// 从审阅队列打开的那一项:载入后滚过去(标出来的样式在各行上)
const focusSeen = useRef('')
useEffect(() => {
if (!focusPath || !detail || detail.table_name !== table) return
const key = `${table}\u0000${focusPath}`
if (focusSeen.current === key) return
focusSeen.current = key
requestAnimationFrame(() => {
document.querySelector(`[data-review-focus="${CSS.escape(focusPath)}"]`)?.scrollIntoView({ block: 'center' })
})
}, [focusPath, detail, table])
const changes = edit ? changeCount(edit.initial, edit.form) : 0
const problems = useMemo(() => (edit ? validateForm(edit.form, PROBLEM_TEXT) : {}), [edit])
const problemCount = Object.keys(problems).length
@@ -325,7 +353,8 @@ export function TableDetail({
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 blankInfo = lastBlankInfo(lastDraft)
const lastBlank = blankInfo.n
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
@@ -384,8 +413,9 @@ export function TableDetail({
}
return (
<div className="flex min-h-0 flex-1 flex-col" data-catalog-detail={table} data-version={detail.version}
data-editing={edit ? 'true' : 'false'}>
// 表助手开着时:宽屏(xl 起)停在右边,详情让出那一栏的宽度;窄一些的屏幕盖在详情上面
<div className={clsx('relative flex min-h-0 flex-1 flex-col', assistantOpen && detail.in_schema && 'xl:pr-[380px] 2xl:pr-[420px]')}
data-catalog-detail={table} data-version={detail.version} data-editing={edit ? 'true' : 'false'}>
<header className="space-y-2 border-b bg-panel px-4 py-3">
<div className="flex items-start gap-2">
<button type="button" className="btn btn-sm btn-ghost -ml-1 shrink-0 lg:hidden" onClick={onBack} aria-label={CT.backToList} data-catalog-detail-back="">
@@ -446,6 +476,14 @@ export function TableDetail({
<button type="button" className="btn btn-sm" disabled={!!busy} onClick={startEdit} data-catalog-edit="">
<Pencil size={11} aria-hidden /> {CT.edit}
</button>
{detail.in_schema && (
// 开着时再点:焦点回到表助手的输入框(关上走面板自己的「关闭」,有待保存的修改时先问)
<button ref={assistantEntry} type="button" className={clsx('btn btn-sm', assistantOpen && 'border-[var(--accent)] text-fg')}
onClick={() => (assistantOpen ? focusAssistant() : setAssistantOpen(true))} aria-expanded={assistantOpen} title={AT.openHint}
data-catalog-assistant-open="header">
<MessagesSquare size={11} aria-hidden /> {AT.open}
</button>
)}
<span className="text-2xs text-faint">{CT.editHint}</span>
</div>
)}
@@ -478,6 +516,7 @@ export function TableDetail({
{lastBlank > 0 && (
<>
<p className={lastBad ? 'mt-0.5 text-dim' : 'font-medium'} data-catalog-last-draft-blank={lastBlank}>{DT.lastBlank(lastBlank)}</p>
{blankInfo.earlier && <p className="mt-0.5 text-2xs text-faint" data-catalog-last-draft-earlier="">{DT.lastBlankEarlier}</p>}
{detail.in_schema && <p className="mt-0.5 text-dim">{DT.lastBlankAdvice}</p>}
</>
)}
@@ -498,6 +537,11 @@ export function TableDetail({
<Pencil size={11} aria-hidden /> {DT.draftGuidance}
</button>
)}
{/* 和助手一起完善:对话补上留空、拿不准的列,不用重新起草 */}
<button type="button" className="btn btn-sm btn-ghost" onClick={() => (assistantOpen ? focusAssistant() : setAssistantOpen(true))}
title={AT.openHint} data-catalog-assistant-open="notice">
<MessagesSquare size={11} aria-hidden /> {AT.open}
</button>
</div>
<p className="mt-1 text-2xs text-faint">{edit ? DT.redraftEditing : draftBusy ? DT.redraftBusy : DT.redraftHint}</p>
</>
@@ -529,7 +573,8 @@ export function TableDetail({
<dl className="divide-y divide-[var(--hairline)]">
{TABLE_FIELDS.map((f) => (
<TableFieldRow key={f} field={f} item={notes[f] as CatalogItem | undefined} edit={edit} busy={!!busy}
problems={problems} columns={st?.columns ?? []} onChange={setTable} onReview={review} />
problems={problems} columns={st?.columns ?? []} onChange={setTable} onReview={review}
focused={focusPath === tablePath(f)} />
))}
</dl>
</div>
@@ -553,7 +598,7 @@ export function TableDetail({
{shownColumns.length
? (
<ColumnTable columns={shownColumns} form={edit?.form.columns ?? null} initial={edit?.initial.columns ?? null}
problems={problems} busy={!!busy} systemNotes={detail.system_notes}
problems={problems} busy={!!busy} systemNotes={detail.system_notes} focusPath={focusPath}
onReview={review} onConfirmColumn={confirmColumn} onFillCodes={fillCodesOf} onChange={setColumn}
onCodesComplete={setCodesComplete} />
)
@@ -568,7 +613,8 @@ export function TableDetail({
{edit
? <RelationEditor drafts={edit.form.relations} initial={edit.initial.relations} problems={problems} rows={rows}
onChange={setRelations} />
: <RelationList relations={notes.relations ?? []} busy={!!busy} targetOf={targetOf} onOpen={onOpen} onReview={review} />}
: <RelationList relations={notes.relations ?? []} busy={!!busy} targetOf={targetOf} onOpen={onOpen} onReview={review}
focusPath={focusPath} />}
</section>
<section aria-labelledby="catalog-sec-impact" data-catalog-section="impact">
@@ -579,6 +625,14 @@ export function TableDetail({
<CatalogImpactList sourceId={sourceId} table={detail.table_name} refreshKey={detail.version}
onLoad={(d) => setImpact({ version: detail.version, n: d.templates.length })} />
</section>
<section aria-labelledby="catalog-sec-usage" data-catalog-section="usage">
<div className="mb-2 flex flex-wrap items-baseline gap-2">
<h3 id="catalog-sec-usage" className="text-xs font-semibold">{CATALOG_USAGE_TEXT.title}</h3>
<span className="text-2xs text-faint">{CATALOG_USAGE_TEXT.hint}</span>
</div>
<TableUsageList sourceId={sourceId} table={detail.table_name} usage={detail.usage} autoLoad={usageOpen} />
</section>
</div>
</div>
@@ -605,6 +659,20 @@ export function TableDetail({
</button>
</footer>
)}
{assistantOpen && detail.in_schema && (
<TableAssistant key={`${sourceId}\u0000${table}`} sourceId={sourceId} table={table} tableLabel={label ?? detail.table_name} detail={detail}
profileEnabled={profileEnabled} lastBlank={lastBlank} editing={!!edit}
onSaved={(d) => {
setDetail(d)
setConflict(null)
onDetailRef.current(d)
setSavedAt(d.version)
}}
onClose={() => {
setAssistantOpen(false)
requestAnimationFrame(() => assistantEntry.current?.focus())
}} />
)}
</div>
)
}
@@ -626,7 +694,10 @@ function blankCodes(notes: CatalogDetail['notes'], pick: (path: string) => boole
// 表级各项
// ---------------------------------------------------------------------------
function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, onReview }: {
/** 值下面写说明的项:有效记录条件、去重规则(模型起草时附的依据、数据剖析核对的排除比例或没有核对的原因) */
const NOTED_FIELDS = new Set<TableField>(['valid_filter', 'dedup'])
function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, onReview, focused = false }: {
field: TableField
item: CatalogItem | undefined
edit: Editing | null
@@ -636,6 +707,8 @@ function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, o
columns: { name: string }[]
onChange: (k: TableKey, v: string) => void
onReview: (t: ReviewTarget, a: CatalogReviewAction) => void
/** 从审阅队列打开的就是这一项:标出来 */
focused?: boolean
}) {
const where = CT.whereTable(CATALOG_TABLE_FIELD_LABEL[field])
const keys: TableKey[] = field === 'business_date' ? [...DATE_KEYS] : [field as TableKey]
@@ -692,9 +765,12 @@ function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, o
}
}
const unknown = edit && field === 'keys' ? unknownKeys(edit.form.table.keys ?? '', columns) : []
const note = !edit && item && item.status !== 'rejected' && NOTED_FIELDS.has(field) ? item.note?.trim() ?? '' : ''
return (
<div className="grid gap-x-4 gap-y-1 px-3 py-2.5 sm:grid-cols-[7.5rem_minmax(0,1fr)]" data-field={field} data-status={item?.status}>
<div className={clsx('grid gap-x-4 gap-y-1 px-3 py-2.5 sm:grid-cols-[7.5rem_minmax(0,1fr)]', focused && FOCUS_RING)}
style={focused ? FOCUS_STYLE : undefined} data-field={field} data-status={item?.status}
data-review-focus={focused ? tablePath(field) : undefined} title={focused ? RT.focusMark : undefined}>
<dt className="pt-0.5 text-2xs text-faint" title={CATALOG_TABLE_FIELD_HINT[field]}>
{edit && field !== 'business_date'
? <label htmlFor={inputId}>{CATALOG_TABLE_FIELD_LABEL[field]}</label>
@@ -710,6 +786,12 @@ function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, o
)}
{changed && <span className="chip shrink-0 !text-2xs" style={{ color: 'var(--accent)' }} data-changed="">{CT.changedMark}</span>}
</div>
{note && (
<p className="mt-1 flex items-start gap-1 text-2xs leading-relaxed text-dim" data-field-note="">
<Info size={10} className="mt-[3px] shrink-0 text-faint" aria-hidden />
<span className="min-w-0 break-words"><span className="text-faint">{UT.fieldNote}:</span>{note}</span>
</p>
)}
{problem && <div className="mt-1 text-2xs text-[var(--err)]" role="alert">{problem}</div>}
{!problem && unknown.length > 0 && (
<div className="mt-1 text-2xs text-[var(--warn)]" data-keys-unknown="">{CT.keysUnknown(unknown.join('、'))}</div>
@@ -760,12 +842,14 @@ function FieldValue({ field, item }: { field: TableField; item: CatalogItem | un
// 关联关系
// ---------------------------------------------------------------------------
function RelationList({ relations, busy, targetOf, onOpen, onReview }: {
function RelationList({ relations, busy, targetOf, onOpen, onReview, focusPath = null }: {
relations: CatalogRelation[]
busy: boolean
targetOf: (name: string) => CatalogTableRow | undefined
onOpen: (table: string) => void
onReview: (t: ReviewTarget, a: CatalogReviewAction) => void
/** 从审阅队列打开的那一项 */
focusPath?: string | null
}) {
if (!relations.length) return <p className="rounded-lg border px-3 py-4 text-xs text-faint" data-relations-empty="">{CT.noRelations}</p>
// 窄框(< 40rem)下和列表格一样排成卡片:状态在第一行,下面每项一行「字段名 值」,不用横向滚动
@@ -789,8 +873,11 @@ function RelationList({ relations, busy, targetOf, onOpen, onReview }: {
const target = targetOf(r.to_table)
const where = CT.whereRelation(r.to_table)
const rejected = r.status === 'rejected'
const focused = focusPath === relationPath(r.id)
return (
<tr key={r.id} className={clsx('border-b border-hairline last:border-b-0', NARROW.row)} data-relation={r.id} data-status={r.status}>
<tr key={r.id} className={clsx('border-b border-hairline last:border-b-0', NARROW.row, focused && FOCUS_RING)}
style={focused ? FOCUS_STYLE : undefined} data-relation={r.id} data-status={r.status}
data-review-focus={focused ? relationPath(r.id) : undefined}>
<td className={clsx(cell, 'px-3')}>
{label(CT.relationHead.from)}
<span className={clsx('mono min-w-0 break-all', rejected && 'text-faint line-through')}>{r.columns.join(', ')}</span>
+7 -5
View File
@@ -10,7 +10,7 @@ import {
} from '../../lib/terms'
import { CountBadges } from './parts'
import { LIST_FILTERS, LIST_SORTS, progressOf } from './model'
import { lastBlankOf, taskReason } from './draftJob'
import { lastBlankInfo, taskReason } from './draftJob'
import type { ListFilter, ListSort } from './model'
// ===========================================================================
@@ -213,10 +213,12 @@ 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)
const blankTitle = r.in_schema ? `${DT.lastBlank(blank)}。${DT.lastBlankAdvice}` : DT.lastBlank(blank)
// 上次起草有留空的列:表照样写完了,只是模型对这几列拿不准。淡色的轻提示,和失败标记分开。最近一次停止了、没有新写入时
// 照旧按再之前那次写完的起草提示(悬停里写明)。表结构里已经没有的表起草不了:悬停里不建议重新起草
const blankInfo = lastBlankInfo(r.last_draft)
const blank = blankInfo.n
const blankTitle = [DT.lastBlank(blank), blankInfo.earlier ? DT.lastBlankEarlier : '', r.in_schema ? DT.lastBlankAdvice : '']
.filter(Boolean).join('。')
return (
<li
className={clsx('relative flex items-stretch border-b border-hairline', active ? 'bg-accent-soft' : 'hover:bg-hover')}
+90
View File
@@ -0,0 +1,90 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { History } from 'lucide-react'
import { api } from '../../api/client'
import type { CatalogTableUsage } from '../../types'
import { Skeleton, StatusPill } from '../../components/ui'
import { humanizeError } from '../../lib/errors'
import { formatDateTime, formatTime } from '../../lib/format'
import { CATALOG_USAGE_TEXT as T, RUN_CLASS_LABEL } from '../../lib/terms'
// ===========================================================================
// 表详情里「用过这张表的运行」(GET /catalog/{table}/usage,新的在前):审阅一项之前看看这张表都被哪些运行查过,
// 点进运行记录核对。默认不取,点「查看」才取(每张表都取一遍没必要);从审阅队列打开时直接取(autoLoad)。
// 使用次数是 0 的不取,直接写还没有运行查询过。
// ===========================================================================
const LIMIT = 20
export function TableUsageList({ sourceId, table, usage, autoLoad = false }: {
sourceId: string
table: string
/** 清单上的使用次数:0 就不取 */
usage: number
autoLoad?: boolean
}) {
const [data, setData] = useState<CatalogTableUsage | null>(null)
const [error, setError] = useState<unknown>(null)
const [wanted, setWanted] = useState(autoLoad)
const seq = useRef(0)
const load = useCallback(() => {
const mine = ++seq.current
setError(null)
api.dataCatalog.usage(sourceId, table, LIMIT)
.then((d) => { if (mine === seq.current) setData(d) })
.catch((e) => { if (mine === seq.current) setError(e) })
}, [sourceId, table])
// 换表:清掉上一张的,按需要重新取
useEffect(() => {
setData(null)
setError(null)
setWanted(autoLoad)
}, [sourceId, table, autoLoad])
useEffect(() => {
if (wanted && usage > 0) load()
}, [wanted, usage, load])
if (usage <= 0) return <p className="rounded-lg border px-3 py-4 text-xs text-faint" data-table-usage="0">{T.none}</p>
if (!wanted) {
return (
<button type="button" className="btn btn-sm" onClick={() => setWanted(true)} data-table-usage-show="">
<History size={11} aria-hidden /> {T.show}
</button>
)
}
if (error && !data) {
return (
<p className="flex flex-wrap items-center gap-2 text-2xs text-[var(--err)]" data-table-usage="error">
{T.error}:{humanizeError(error).title}
<button type="button" className="rounded px-1 text-dim hover:bg-hover hover:text-fg" onClick={load}>{T.retry}</button>
</p>
)
}
if (!data) return <div data-table-usage="loading"><Skeleton rows={2} height={18} gap={6} /></div>
if (!data.runs.length) return <p className="rounded-lg border px-3 py-4 text-xs text-faint" data-table-usage="0">{T.none}</p>
return (
<div data-table-usage={data.total}>
<p className="mb-1 text-2xs text-faint">{T.count(data.runs.length, data.total)}</p>
<ul className="divide-y divide-[var(--hairline)] rounded-lg border bg-panel">
{data.runs.map((r) => {
const name = r.workflow_name?.trim() || T.unnamed
return (
<li key={r.run_id} className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 px-3 py-1.5 text-xs" data-usage-run={r.run_id}>
<Link to={`/runs/${encodeURIComponent(r.run_id)}`} className="min-w-0 truncate font-medium hover:underline" title={T.open(name)}>
{name}
</Link>
{r.run_class && <span className="shrink-0 text-2xs text-faint">{RUN_CLASS_LABEL[r.run_class] ?? r.run_class}</span>}
<StatusPill status={r.status} short plain />
<span className="flex-1" />
{r.started_at && (
<span className="tnum shrink-0 text-2xs text-faint" title={formatDateTime(r.started_at)}>{formatTime(r.started_at)}</span>
)}
</li>
)
})}
</ul>
</div>
)
}
+33 -4
View File
@@ -42,12 +42,41 @@ export const DRAFT_PART_COLUMNS = 40
/** 模型拿不准、留空的列数(任务里的一张表、清单上的上次起草);老服务端没有这个键时为 0 */
export const blankOf = (t: { blank_columns?: number } | null | undefined): number => Math.max(0, t?.blank_columns ?? 0)
type LastDraftLike = {
status: CatalogDraftTaskStatus
blank_columns?: number
blank_columns_from?: number | null
parts_written?: number
}
/**
* 清单、表详情上「上次起草有 N 列模型拿不准、留空了」的 N:这张表的那次起草结束了才算(还在起草的那一次写到一半,
* 不说「上次」)
* 清单、表详情上「上次起草有 N 列模型拿不准、留空了」的 N 和它的出处:这张表的那次起草结束了才算(还在起草的那一次
* 写到一半,不说「上次」)。最近一次是停止、服务重启中断(表记已停止),又没有新写入(自己没记下留空的列、也没写进
* 任何一段)时,留空的列照旧是再之前那次写完的起草记下的(blank_columns_from),earlier 为真
*/
export const lastBlankOf = (d: { status: CatalogDraftTaskStatus; blank_columns?: number } | null | undefined): number =>
(d && TASK_ENDED.has(d.status) ? blankOf(d) : 0)
export function lastBlankInfo(d: LastDraftLike | null | undefined): { n: number; earlier: boolean } {
if (!d || !TASK_ENDED.has(d.status)) return { n: 0, earlier: false }
const own = blankOf(d)
if (d.status === 'cancelled' && own === 0 && (d.parts_written ?? 0) === 0) {
const from = Math.max(0, d.blank_columns_from ?? 0)
return { n: from, earlier: from > 0 }
}
return { n: own, earlier: false }
}
/**
* 宽表的块进度:段被缩小、拆半后实际分成的块比段多,这时才写「已结束几块 / 共几块」(没拆过的段进度已经说清了)。
* 不用模型、老服务端没有这两个键时为 null
*/
export function chunksOf(t: { parts_total: number | null; chunks_total?: number | null; chunks_done?: number | null }):
{ done: number; total: number } | null {
const total = t.chunks_total
if (total == null || total <= Math.max(1, t.parts_total ?? 1)) return null
return { done: Math.max(0, Math.min(total, t.chunks_done ?? 0)), total }
}
/** 已经写进目录的段数(老服务端没有这个键时为 0) */
export const writtenOf = (t: { parts_written?: number }): number => Math.max(0, t.parts_written ?? 0)
/** 一次起草里留空的列合计:服务端给了合计就用它,老服务端没有就按逐表加 */
export const jobBlank = (j: CatalogDraftJob): number => (j.blank_columns != null ? blankOf(j) : j.tasks.reduce((n, t) => n + blankOf(t), 0))
+4
View File
@@ -294,6 +294,10 @@ function ActionButton({ icon, children, disabled, onClick, action, title }: {
)
}
/** 从审阅队列打开的那一项(表级项、列的一格、一条关系):一圈强调色的框,淡淡的底 */
export const FOCUS_RING = 'rounded-md ring-2 ring-inset ring-[var(--accent)]'
export const FOCUS_STYLE = { background: 'color-mix(in srgb, var(--accent) 7%, transparent)' }
/** 导入表格的源:说明是系统生成的,只读 */
export function SystemNotesNotice() {
return (
+103 -6
View File
@@ -436,9 +436,12 @@ function newJob(state, src, { tables, use_model, note = null, retry_of = null })
const tasks = tables.map((name) => {
const t = state.tables[src].find((x) => x.name === name && !x.missing)
const cols = t?.columns?.length ?? 0
const partsTotal = t && use_model ? (cols > PART_COLUMNS ? 1 + Math.ceil(cols / PART_COLUMNS) : 1) : 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,
parts_total: partsTotal, parts_done: 0,
// 逐段写进目录:已写进的段数;段被缩小、拆半后实际的块数和已结束的块数(不用模型时为 null)
parts_written: 0, chunks_total: partsTotal, chunks_done: partsTotal == null ? null : 0,
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(),
@@ -507,7 +510,12 @@ function finishJob(job, status) {
*/
function stopJob(job, status = 'cancelled', error = null) {
for (const t of job.tasks) {
if (t.status === 'pending' || t.status === 'running') Object.assign(t, { status: 'cancelled', error, finished_at: isoNow() })
if (t.status !== 'pending' && t.status !== 'running') continue
// 写过段的表照旧记已停止,原因写一句已写进目录几段、剩下几段没起草(服务端停止时的说法)
const written = t.parts_written ?? 0
const modelError = written > 0 && t.parts_total
? `已停止:已写进目录 ${written} 段,剩下 ${t.parts_total - written} 段没起草` : t.model_error
Object.assign(t, { status: 'cancelled', error, model_error: modelError, finished_at: isoNow() })
}
if (error) job.error = error
delete job.stopAsked
@@ -524,15 +532,19 @@ function failJob(job, error) {
}
function lastDraftOf(state, src, name) {
for (const job of [...state.jobs].reverse()) {
if (job.source_id !== src) continue
const jobs = [...state.jobs].reverse().filter((j) => j.source_id === src)
const fixture = state.tables[src]?.find((x) => x.name === name)?.lastDraft ?? null
// 最近一次这张表结束于完成、部分完成的起草记下的留空列数(服务端 last_drafts 的 blank_columns_from)
const ended = jobs.map((j) => j.tasks.find((x) => x.table_name === name)).find((t) => t && (t.status === 'ok' || t.status === 'partial'))
const from = ended ? ended.blank_columns : fixture?.blank_columns_from ?? null
for (const job of jobs) {
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,
blank_columns: t.blank_columns }
blank_columns: t.blank_columns, blank_columns_from: from, parts_written: t.parts_written ?? 0 }
}
}
return state.tables[src]?.find((x) => x.name === name)?.lastDraft ?? null
return fixture
}
const jobOf = (state, key) => {
@@ -1544,6 +1556,91 @@ await section('数据目录 · 起草:模型拿不准、留空的列', async (
await close()
})
await section('数据目录 · 起草:逐段写进目录、块进度与停止后的留空提示', async () => {
const state = freshState()
// 上一次起草写完了这两张表,各留了几列空(之后停止、又没有新写入时,界面接着用这次记下的留空列数提示)
const old = newJob(state, S1, { tables: ['store_sales', 'members'], use_model: true })
settle(state, old, 'store_sales', { added: 10, model_items: 40, blank_columns: 6, blank_sample: BLANK_SAMPLE })
settle(state, old, 'members', { added: 2, model_items: 3, blank_columns: 3, blank_sample: ['member_no', 'joined_on'] })
finishJob(old)
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
await page.locator('[data-catalog-row]').first().waitFor()
check('开始起草(门店销售、会员 2 张,用模型)', await startDraft(page, { select: ['store_sales', 'members'], model: true }))
const job = state.jobs.at(-1)
const task = (n) => draftBox(page).locator(`[data-draft-task="${n}"]`)
const text = (n, sel) => task(n).locator(sel).first().innerText({ timeout: 500 }).then((t) => t.replace(/\s+/g, ' ').trim()).catch(() => '')
// 宽表第 1 段写完就写进目录:逐表写「已写进目录 1 段」;没拆过的段不写块进度(段进度已经说清了)
settle(state, job, 'store_sales', { status: 'running', parts_done: 1, parts_written: 1, chunks_total: 3, chunks_done: 1 })
check('宽表写完一段:逐表写「已写进目录 1 段」,段进度「1 / 3 段」,没拆过不写块进度',
await until(async () => (await text('store_sales', '[data-draft-task-written]')) === '已写进目录 1 段')
&& (await text('store_sales', '[data-draft-task-parts]')) === '1 / 3 段' && await task('store_sales').locator('[data-draft-task-chunks]').count() === 0,
`${await text('store_sales', '[data-draft-task-written]')} · ${await text('store_sales', '[data-draft-task-parts]')}`)
check('……「已写进目录」悬停写明停止、服务重启时保留', ((await task('store_sales').locator('[data-draft-task-written]').getAttribute('title')) ?? '').includes('停止或服务重启时已写进的段保留'))
// 额度用完、超时后段被拆半:块比段多,写「已结束几块 / 共几块」,悬停说明为什么变多
settle(state, job, 'store_sales', { status: 'running', parts_done: 1, parts_written: 1, chunks_total: 5, chunks_done: 2 })
const chunks = task('store_sales').locator('[data-draft-task-chunks]')
check('段被拆小:写块进度「2 / 5 块」,悬停说明实际分成 5 块', await until(async () => (await text('store_sales', '[data-draft-task-chunks]')) === '2 / 5 块')
&& ((await chunks.getAttribute('title')) ?? '').includes('实际分成 5 块'), await text('store_sales', '[data-draft-task-chunks]'))
settle(state, job, 'store_sales', { status: 'running', parts_done: 2, parts_written: 2, chunks_total: 5, chunks_done: 4 })
check('……接着写:「已写进目录 2 段」「2 / 3 段」「4 / 5 块」', await until(async () => (await text('store_sales', '[data-draft-task-written]')) === '已写进目录 2 段')
&& (await text('store_sales', '[data-draft-task-parts]')) === '2 / 3 段' && (await text('store_sales', '[data-draft-task-chunks]')) === '4 / 5 块')
check('……还没开始的表不写段进度、块进度、已写进目录', await task('members').locator('[data-draft-task-parts], [data-draft-task-chunks], [data-draft-task-written]').count() === 0)
await shot(page, 'catalog-draft-chunks')
// 停止:已写进的段保留。说法照服务端:写过段的表记已停止,原因写已写进几段、剩下几段没起草
await page.locator('[data-draft-stop]').click()
check('点「停止」:写明正在停止、已写进目录的段保留', await until(async () => (await draftText(page, '[data-draft-title]')).includes('已写进目录的段保留')),
await draftText(page, '[data-draft-title]'))
stopJob(job)
check('停下来:宽表记已停止,原因写「已写进目录 2 段,剩下 1 段没起草」', await until(async () => (await draftPhase(page)) === 'done')
&& (await text('store_sales', '[data-draft-task-status]')) === '已停止'
&& (await text('store_sales', '[data-draft-task-reason]')).includes('已写进目录 2 段,剩下 1 段没起草'),
`${await text('store_sales', '[data-draft-task-status]')} · ${await text('store_sales', '[data-draft-task-reason]')}`)
check('……服务端的原因已经写了,界面不再重复一行「已写进目录」', await task('store_sales').locator('[data-draft-task-written]').count() === 0)
const cancelled = await draftText(page, '[data-draft-cancelled]')
check('……汇总写明已写进的段保留:其中 1 张表已写进目录 2 段,只重试这些表时接着起草', cancelled.includes('每写完一段就写进目录')
&& cancelled.includes('其中 1 张表已写进目录 2 段') && cancelled.includes('接着起草'), cancelled)
check('……可以只重试这 2 张', (await page.locator('[data-draft-retry]').innerText().catch(() => '')).includes('(2 张)'))
await page.locator('[data-draft-close]').click()
// 老服务端停下来时原因里没写:界面自己补一句
const legacy = newJob(state, S1, { tables: ['store_sales'], use_model: true })
settle(state, legacy, 'store_sales', { status: 'running', parts_done: 1, parts_written: 1 })
stopJob(legacy)
legacy.tasks[0].model_error = null
await page.locator('[data-catalog-draft]').first().click()
await draftBox(page).waitFor()
await until(async () => (await draftBox(page).locator('[data-draft-last-open]').count()) === 1)
await draftBox(page).locator('[data-draft-last-open]').click()
check('服务端的原因里没写已写进几段:界面补「已写进目录 1 段,剩下 2 段没起草」', await until(async () => (await text('store_sales', '[data-draft-task-written]')) === '已写进目录 1 段,剩下 2 段没起草'),
await text('store_sales', '[data-draft-task-written]'))
await page.locator('[data-draft-close]').click()
state.jobs.pop()
// 清单:会员表这次还没开始就停了(没有新写入):接着用上一次写完时记下的留空列数提示;门店销售这次写进了 2 段,不再拿旧数提示
const rowBlank = (n) => page.locator(`[data-catalog-row="${n}"] [data-row-blank]`)
await goto(page, `/data/catalog/${S1}`)
check('清单:停止、没有新写入的表接着提示「上次起草有 3 列模型拿不准、留空了」', await until(async () => (await rowBlank('members').innerText({ timeout: 500 }).catch(() => '')).includes('上次起草有 3 列模型拿不准、留空了')),
await rowBlank('members').innerText({ timeout: 500 }).catch(() => ''))
check('……这次写进了段的宽表不拿旧的留空列数提示', await rowBlank('store_sales').count() === 0)
await page.locator('[data-catalog-row="members"] [data-catalog-open-row]').click()
const tip = page.locator('[data-catalog-last-draft]')
check('表详情:顶部照旧提示 3 列留空,写明是再之前那次起草记下的(最近一次已停止)',
await until(async () => (await tip.innerText({ timeout: 500 }).catch(() => '')).includes('上次起草有 3 列模型拿不准、留空了'))
&& (await tip.getAttribute('data-catalog-last-draft')) === 'cancelled'
&& (await tip.locator('[data-catalog-last-draft-earlier]').innerText().catch(() => '')).includes('最近一次起草已停止'),
await tip.innerText({ timeout: 500 }).catch(() => ''))
check('……给「重新起草这张表」「补充起草要求」,旁边是「和助手一起完善」', await tip.locator('[data-catalog-redraft]').count() === 1
&& await tip.locator('[data-catalog-draft-guidance]').count() === 1 && await tip.locator('[data-catalog-assistant-open="notice"]').count() === 1)
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 tOf = (n) => state.tables[S1].find((t) => t.name === n)