数据目录起草改成后台任务(前端):对话框选范围、写起草要求和本次补充说明后建任务,之后显示任务的进度和结果——总进度、新增 / 更新 / 删除合计、逐表状态、宽表的段进度、没写全的原因;可以停止、在后台继续,关掉对话框任务照常进行,目录页上方显示一条进度,点开回到对话框,结束时提示并可查看结果;结果里只列没写全的表,「只重试失败的表」新建只含失败、部分完成、已停止的表的任务;服务重启中断的任务写明原因、同样能重试;已有任务(409)转去显示进行中的那一个,起草模型用不了(409)留在设置阶段写明原因;起草要求改过先存再建任务,写明用的是哪个起草模型、数据源说明也一并交给模型;清单上标出上次起草失败、部分完成的表并写原因,表详情里给「重新起草这张表」;进行中每 1.5 秒取一次,取回的状态只往前走。check-catalog 用假的任务接口覆盖这些(任务怎么往前走由检查自己推),旧的同步起草检查换掉

This commit is contained in:
ThearchyHelios
2026-10-13 15:24:57 +08:00
parent 49583b273f
commit 90a560d07e
9 changed files with 1479 additions and 336 deletions
+34 -6
View File
@@ -1,5 +1,5 @@
import type {
ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogDetail, CatalogDraftOut, CatalogImpact, CatalogList, CatalogNotes,
ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogDetail, CatalogDraftJob, CatalogImpact, CatalogList, CatalogNotes,
CatalogPatchPreview, CatalogPatchSubmit, CatalogProfileOut, CatalogReviewAction, CommitOut, CurrentRecipe, EditPreview,
EditRequest, ImportRecord,
ManifestOut, PurgeRawBody, PurgeRawOut, QuestionAnswer, Recipe, RedraftRulesOut, RemovePeriodBody, RemovePeriodOut,
@@ -31,6 +31,9 @@ export type { ApiErrorKind, RequestOptions, UploadOptions, UploadProgress } from
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
/** 数据目录的接口前缀:/datasources/{id}/catalog */
const catalogBase = (sourceId: string) => `/datasources/${encodeURIComponent(sourceId)}/catalog`
/**
* 上传表格被退回、要用户先选怎么处理(422 带 decision)时,取出那份决定;别的错误返回 null。
*
@@ -409,12 +412,37 @@ export const api = {
post<CatalogDetail>(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/patch`,
{ changes, if_version: ifVersion }),
/**
* 同步起草(注释、外键、命名推断;use_model 时再请助手的模型起草)。不给 tables 时按使用次数取前 20 张。
* 人工确认、驳回过的项不动。没有表结构时回 409
* 建一个起草任务(后台进行:注释、外键、命名推断;use_model 时再请起草模型起草,带上数据源的起草要求和这次的 note)。
* 不给 tables 时按使用次数取前 20 张;表结构里没有的表照样建进任务、记 skipped。人工确认、驳回过的项不动。
* 409:同一个数据源已有排队、进行中的任务(code 为 catalog_draft_busy,改去取 activeDraftJob);起草模型用不了
* (catalog_draft_model_unavailable,不建任务);没有表结构
*/
draft: (sourceId: string, body: { tables?: string[]; use_model: boolean }, opts?: RequestOptions) =>
request<CatalogDraftOut>(`/datasources/${encodeURIComponent(sourceId)}/catalog/draft`,
{ method: 'POST', body: JSON.stringify(body), ...opts }),
createDraftJob: (sourceId: string, body: { tables?: string[]; use_model: boolean; note?: string }) =>
post<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs`, body),
/** 排队或进行中的那个起草任务;没有为 null */
activeDraftJob: (sourceId: string, opts?: RequestOptions) =>
get<{ job: CatalogDraftJob | null }>(`${catalogBase(sourceId)}/draft-jobs/active`, opts),
/** 最近建的一个起草任务(任何状态);没有为 null */
latestDraftJob: (sourceId: string, opts?: RequestOptions) =>
get<{ job: CatalogDraftJob | null }>(`${catalogBase(sourceId)}/draft-jobs/latest`, opts),
/** 一个起草任务的当前状态;不存在或不属于这个数据源回 404 */
draftJob: (sourceId: string, jobId: string, opts?: RequestOptions) =>
get<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs/${encodeURIComponent(jobId)}`, opts),
/**
* 停止起草:排队的直接停;进行中的停掉正在进行的模型调用,没开始和没写完的表记已停止,已经写进目录的不回滚。
* 已经结束的回 409(catalog_draft_finished)
*/
cancelDraftJob: (sourceId: string, jobId: string) =>
post<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs/${encodeURIComponent(jobId)}/cancel`),
/**
* 只重试失败的表:新任务只含原任务里失败、部分完成、已停止的表,用模型和补充说明沿用,起草要求取数据源现在的。
* 没有可重试的表 422;有进行中的任务 409(catalog_draft_busy)
*/
retryDraftJob: (sourceId: string, jobId: string) =>
post<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs/${encodeURIComponent(jobId)}/retry`),
/** 存数据源的起草要求(去首尾空白,最多 2000 字,空串清除)。上传表格建的源存在它自己的数据源上 */
putGuidance: (sourceId: string, guidance: string) =>
put<{ guidance: string | null }>(`${catalogBase(sourceId)}/guidance`, { guidance }),
/**
* 同步剖析:对业务库发少量只读查询,核对关系、取码值候选,结论写进目录。不给 tables 时按使用次数取前 10 张
* 有待核实关系的表。没开启、数据源停用、没有表结构、同一个数据源正在剖析、快照被改动时回 409(detail 是一句中文)
+125 -19
View File
@@ -43,10 +43,11 @@
*/
import type {
CatalogCardinality, CatalogMeasure, CatalogProfileNumberKey, CatalogSource, CatalogStatus, CatalogTableKind, NodeType, SnapshotReasonCode,
CatalogCardinality, CatalogDraftJobStatus, CatalogDraftTaskStatus, CatalogMeasure, CatalogProfileNumberKey, CatalogSource, CatalogStatus,
CatalogTableKind, NodeType, SnapshotReasonCode,
SqlCheckCode, SqlCheckLevel, ToolTrust,
} from '../types'
import { formatCost, formatNumber } from './format'
import { formatCost, formatNumber, formatTokens } from './format'
export const TERMS = {
workflow: '工作流',
@@ -2198,7 +2199,7 @@ export const CATALOG_TEXT = {
noSchemaBody: (note: string) => `${note.replace(/[。.]$/, '')}。数据目录按表结构逐表记录,请先回到数据源卡片点击「探查结构」。`,
noSchemaUploadBody: '这个表格还没有可用的表结构,请重新上传后再编写数据目录。',
emptyTitle: '还没有数据目录',
emptyBody: '先起草,再逐表确认。起草按数据库注释、外键约束和列名推断,也可以请助手的模型起草中文名和含义。',
emptyBody: '先起草,再逐表确认。起草按数据库注释、外键约束和列名推断,也可以用模型起草中文名和含义。',
// ---- 导入表格的说明
systemTitle: '导入时生成的说明',
systemBody: '这个数据源由导入表格生成,表和列的说明由系统按核对结果生成,只读,不能在数据目录中改写。',
@@ -2260,29 +2261,17 @@ export const CATALOG_TEXT = {
scopeAll: (n: number) => `全部 ${formatNumber(n)} 张表`,
draftBase: '按数据库注释、外键约束和列名推断起草。已确认、已驳回的项不会被改动;起草出的项都是推断,需要逐项确认。',
useModel: '用模型起草',
useModelHint: '请助手的模型按表结构起草中文名、说明、粒度和列的含义。只发送表结构(表名、列名、类型、约束、注释),不发送数据;耗时较长,并产生模型费用。',
useModelHint: '请起草模型按表结构起草中文名、说明、粒度和列的含义。只发送表结构(表名、列名、类型、约束、注释)和起草要求,不发送数据;在后台进行,耗时较长,并产生模型费用。',
draftStart: (n: number) => `起草 ${formatNumber(n)} 张表`,
drafting: '正在起草…',
draftProgress: (done: number, total: number) => `已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表`,
draftStop: '停止',
draftStopping: '当前这一批完成后停止…',
draftDone: '起草完成',
draftStopped: (done: number, total: number) => `已停止:完成 ${formatNumber(done)} / ${formatNumber(total)} 张表,其余未起草`,
draftFailed: '起草中断',
draftSummary: (added: number, updated: number, removed: number) =>
`新增 ${formatNumber(added)} 项、更新 ${formatNumber(updated)} 项${removed ? `、删除 ${formatNumber(removed)} 项` : ''}`,
// 只在起草确实给出了内容、只是和现有目录一致时用:有表的模型起草失败或没给出可用内容(model_error)时
// 不能说「没有变化」,改用 draftNothingWritten
// 只在起草确实给出了内容、只是和现有目录一致时用:模型没给出可用内容时说 draftNothingWritten,有表没写全时说
// draftNothingWrittenFailed,不能说「没有变化」
draftNoChange: '没有变化:起草出的内容与现有目录一致',
draftNothingWritten: '目录没有更新:模型没有给出可用的内容,原因见下方',
draftModelSkipped: (reason: string) => `模型未参与起草:${reason.replace(/[。.]$/, '')}。已按注释、外键和命名起草。`,
draftModelUsed: (model: string) => `模型:${model}`,
draftTableErrors: (n: number) => `${formatNumber(n)} 张表未能起草`,
draftModelErrors: (n: number) => `${formatNumber(n)} 张表的模型起草失败,已按注释、外键和命名起草`,
draftNothingWrittenFailed: '目录没有更新:部分表未写全,原因见下方',
draftClose: '完成',
moreTables: (n: number) => `…另有 ${formatNumber(n)} 张`,
cancel: '取消',
draftBackground: '起草仍在进行,当前这一批完成后停止',
// ---- 表详情
backToList: '返回表清单',
prev: '上一张',
@@ -2389,6 +2378,123 @@ export const CATALOG_TEXT = {
reloadDiscardAction: '放弃修改并重新载入',
}
// ===========================================================================
// 数据目录的起草任务(/api/datasources/{id}/catalog/draft-jobs):起草在服务端后台进行,关掉对话框不停;逐表起草,
// 每张表一结束就写进目录。
//
// 术语:
// - 「起草任务」的状态:排队中、正在起草、起草完成、部分表未写全、起草失败、已停止、服务重启中断。
// 一张表:等待、起草中、已完成、部分完成、失败、已跳过(表结构里没有)、已停止。
// - 「起草要求」:使用者补充给模型的业务背景和用词约定,存在数据源上,以后每次用模型起草都带上;
// 「本次补充说明」只用于这一次(重试时沿用)。两者都只交给模型,不改起草规则:冲突时以规则为准。
// - 「起草模型」:设置里单独的一项(偏好设置 → 起草模型),留空跟随助手的模型。
// - 宽表的「段」:超过 40 列的表拆成表级一段和若干列分段,逐段起草;写的是「已完成 3 / 9 段」。
// - 「只重试失败的表」:重新起草失败、部分完成和已停止的表;已经写进目录的内容不回滚、不受影响。
// ===========================================================================
/** 起草要求、本次补充说明的字数上限(服务端同一个数:超长 422) */
export const DRAFT_GUIDANCE_MAX = 2000
export const DRAFT_NOTE_MAX = 1000
export const CATALOG_DRAFT_TEXT = {
// ---- 设置阶段:起草要求、本次补充说明、起草模型
guidance: '起草要求',
guidanceHint: '写给模型的业务背景和用词约定,保存在这个数据源上,以后每次用模型起草都会带上。例如:「核销」指检票入园;金额单位是元',
guidanceRule: '起草要求只帮助模型理解业务、统一用词;与起草规则冲突时以规则为准,起草出的项仍需逐项确认',
guidancePlaceholder: '业务背景、常用说法、单位约定……',
note: '本次补充说明',
noteHint: '只用于这一次起草,例如「这一批是会员相关的表」;重试时沿用',
notePlaceholder: '可不填',
count: (n: number, max: number) => `${formatNumber(n)} / ${formatNumber(max)} 字`,
tooLong: (max: number) => `不超过 ${formatNumber(max)} 字`,
sourceDescription: (text: string) => `数据源说明也会一并交给模型:${text}`,
model: '起草模型',
/** 设置里没配起草模型:跟随助手的模型,写出当前是哪个(取不到就不写) */
modelFollow: (current: string | null) => (current ? `跟随助手的模型(当前:${current})` : '跟随助手的模型'),
/** 设置里配了:模型(没填模型就是那个接入的默认模型)和接入 */
modelNamed: (model: string | null, provider: string | null) => (model
? (provider ? `${model}(模型接入「${provider}」)` : model)
: `模型接入「${provider}」的默认模型`),
modelWhere: '可在「设置 → 偏好设置 → 起草模型」中更换',
tooMany: (max: number) => `一次最多起草 ${formatNumber(max)} 张表,请缩小范围`,
saveGuidanceFailed: (reason: string) => `起草要求未能保存,未开始起草:${reason}`,
startFailed: (reason: string) => `未能开始起草:${reason}`,
// ---- 上一次起草(设置阶段,没写全的才提)
last: (state: string, at: string) => `上一次起草(${at}):${state}`,
lastOpen: '查看',
// ---- 进行中
queued: '排队中,等待开始…',
running: '正在起草…',
stopping: '正在停止:当前的模型调用结束后停止…',
progress: (done: number, total: number) => `已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表`,
/** 结束以后:全部写出来的表有几张(停止、失败、部分完成的不算) */
progressDone: (ok: number, total: number) => `写完 ${formatNumber(ok)} / ${formatNumber(total)} 张表`,
parts: (done: number, total: number) => `${formatNumber(done)} / ${formatNumber(total)} 段`,
partsHint: '超过 40 列的表拆成表级一段和若干列分段,逐段起草',
hide: '在后台继续',
hideHint: '关闭对话框不会停止起草,进度显示在目录页上方;每张表起草完就写进目录',
stop: '停止',
busy: '这个数据源已有进行中的起草任务,下面显示的是它的进度',
modelUsed: (model: string) => `模型:${model}`,
usage: (calls: number, input: number, output: number) =>
`调用模型 ${formatNumber(calls)} 次,输入 ${formatTokens(input, { compact: true })}、输出 ${formatTokens(output, { compact: true })}`,
// ---- 结束
title: {
queued: '排队中,等待开始…',
running: '正在起草…',
succeeded: '起草完成',
partial: '起草完成,部分表未写全',
failed: '起草失败',
cancelled: '已停止',
interrupted: '服务重启,起草中断',
} as Record<CatalogDraftJobStatus, string>,
interrupted: '服务重启时这次起草还没有完成,没写完的表未起草。已写进目录的内容保留;可以只重试这些表,接着起草',
cancelled: '已写进目录的内容保留,没写完的表未起草',
jobFailed: (reason: string) => `起草任务出错:${reason}`,
/** 没写全的表:失败、部分完成、已停止各几张;跳过的(表结构里没有)另说 */
problems: (c: { failed: number; partial: number; cancelled: number; skipped: number }) => {
const parts = [
c.failed ? `失败 ${formatNumber(c.failed)} 张` : '',
c.partial ? `部分完成 ${formatNumber(c.partial)} 张` : '',
c.cancelled ? `已停止 ${formatNumber(c.cancelled)} 张` : '',
].filter(Boolean)
const bad = parts.length ? `${formatNumber(c.failed + c.partial + c.cancelled)} 张表未写全:${parts.join('、')}` : ''
const skipped = c.skipped ? `${formatNumber(c.skipped)} 张表已跳过(表结构里没有)` : ''
return [bad, skipped].filter(Boolean).join(';')
},
retry: (n: number) => `只重试失败的表(${formatNumber(n)} 张)`,
retryHint: '重新起草失败、部分完成和已停止的表;已写进目录的内容不受影响',
showAll: (n: number) => `显示全部 ${formatNumber(n)} 张表`,
showProblems: '只看未写全的表',
tasks: '逐表进度',
taskStatus: {
pending: '等待', running: '起草中', ok: '已完成', partial: '部分完成', failed: '失败', skipped: '已跳过', cancelled: '已停止',
} as Record<CatalogDraftTaskStatus, string>,
taskAdded: (added: number, updated: number) => (added || updated
? `+${formatNumber(added)}${updated ? ` · 改 ${formatNumber(updated)}` : ''}` : ''),
taskAddedHint: (added: number, updated: number, removed: number) =>
`新增 ${formatNumber(added)} 项、更新 ${formatNumber(updated)} 项${removed ? `、删除 ${formatNumber(removed)} 项` : ''}`,
// ---- 目录页上方的进度、结束提示
strip: (done: number, total: number) => `正在起草:已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表`,
stripQueued: (total: number) => `起草任务排队中,共 ${formatNumber(total)} 张表`,
stripOpen: '查看进度',
toast: {
succeeded: (summary: string) => `数据目录起草完成:${summary}`,
partial: (n: number) => `数据目录起草完成,${formatNumber(n)} 张表未写全`,
failed: (reason: string) => `数据目录起草失败:${reason}`,
cancelled: '数据目录起草已停止',
interrupted: '服务重启,数据目录起草中断',
},
viewResult: '查看结果',
// ---- 清单上的标记、表详情里的说明
lastFailed: '上次起草失败',
lastPartial: '上次起草部分完成',
lastWith: (label: string, reason: string) => `${label}:${reason}`,
redraft: '重新起草这张表',
redraftHint: '用模型重新起草这张表(不带补充说明,带上数据源的起草要求);人工确认、驳回过的项不会被改动',
redraftBusy: '已有起草任务在进行,结束后再重新起草这张表',
}
// ===========================================================================
// 目录修改提案:用户在助手(画布)或问数据页说出一条数据事实时,助手提出的数据目录修改。
//
+105 -14
View File
@@ -7,9 +7,13 @@ import { ApiError, api } from '../../api/client'
import type { CatalogDetail, CatalogList, CatalogTableRow } from '../../types'
import { EmptyState, ErrorState, PageHeader, Skeleton, Spinner, toast, useTicker } from '../../components/ui'
import { useCatalog, useDatasources, useOnReconnect } from '../../store/catalog'
import { CATALOG_TEXT as CT, PROFILE_STOP_LABEL, PROFILE_TEXT as PT, SCHEMA_PARTIAL_TEXT as SPT } from '../../lib/terms'
import {
CATALOG_DRAFT_TEXT as DT, CATALOG_TEXT as CT, PROFILE_STOP_LABEL, PROFILE_TEXT as PT, SCHEMA_PARTIAL_TEXT as SPT,
} from '../../lib/terms'
import { StatusLegend, SystemNotesNotice } from './parts'
import { DraftDialog } from './DraftDialog'
import { isJobActive, retryableCount, useDraftJob } from './draftJob'
import type { DraftStartBody } from './draftJob'
import { ProfileDialog } from './ProfileDialog'
import type { ProfileJob } from './ProfileDialog'
import { ProfileSettingsDialog } from './ProfileSettings'
@@ -99,7 +103,11 @@ export function CatalogPage() {
const search = writeListParams(params, list).toString()
searchRef.current = search ? `?${search}` : ''
const [selected, setSelected] = useState<Set<string>>(() => new Set())
const [drafting, setDrafting] = useState(false)
/**
* 起草对话框开着没有、打开时显示什么:setup 选范围开始;job 显示页面跟着的任务(进行中的进度、刚结束的结果)。
* busy:是撞上已有任务(409)转过去显示的
*/
const [draftOpen, setDraftOpen] = useState<{ view: 'setup' | 'job'; busy?: boolean } | null>(null)
/** 起草写进了这些表:详情据此重新载入(编辑中的不动,保存时服务端会用版本号拦下) */
const [drafted, setDrafted] = useState<{ seq: number; tables: Set<string> }>({ seq: 0, tables: new Set() })
const [profiling, setProfiling] = useState(false)
@@ -142,11 +150,59 @@ export function CatalogPage() {
}))
}, [])
const onDrafted = useCallback((tables: string[]) => {
setDrafted((cur) => ({ seq: cur.seq + 1, tables: new Set(tables) }))
setSelected(new Set())
void load()
}, [load])
/**
* 起草任务(后台进行,状态在页面上):每张表一结束就重取清单、正在看的表在里面就重新载入(编辑中的不动,保存时服务端
* 会用版本号拦下);结束时重取一次清单。对话框关着时结束的,弹提示并给「查看结果」
*/
const draftOpenRef = useRef(draftOpen)
draftOpenRef.current = draftOpen
const draft = useDraftJob(sourceId, data?.active_job, {
onTablesEnded: (tables) => {
setDrafted((cur) => ({ seq: cur.seq + 1, tables: new Set(tables) }))
void load()
},
onFinished: (job) => {
void load()
if (draftOpenRef.current) return
const src = job.source_id
const opts = {
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)
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)
},
})
const draftJob = draft.job
const draftActive = isJobActive(draftJob)
/** 打开起草对话框:有进行中的任务就直接显示它;上一次的结果看过了,再点就是重新开始 */
const openDraft = useCallback(() => setDraftOpen({ view: draftActive ? 'job' : 'setup' }), [draftActive])
const startDraft = useCallback(async (body: DraftStartBody) => {
const r = await draft.start(body)
// 选中的表已经交给起草任务了:清空,免得接着点「起草所选」又起草一遍
if (!r.busy) setSelected(new Set())
return r
}, [draft.start])
/** 详情里「重新起草这张表」:只含这张表、用模型、不带补充说明。模型用不了等开始不了的,直接提示原因 */
const redraftTable = useCallback(async (t: string) => {
try {
const r = await draft.start({ tables: [t], use_model: true })
setDraftOpen({ view: 'job', busy: r.busy })
} catch (e) {
toast.error(e)
}
}, [draft.start])
const onGuidanceSaved = useCallback((g: string | null) => {
setData((cur) => cur && ({ ...cur, guidance: g }))
}, [])
// 上方的进度:跟着的任务在进行就按它;跟着的就是清单说的那一个、已经结束了,就不信清单(清单还没重取回来)
const listActive = isJobActive(data?.active_job) ? data?.active_job ?? null : null
const stripJob: { done: number; total: number; status: string } | null = draftJob && (draftActive || draftJob.id === listActive?.id)
? (draftActive ? draftJob : null)
: listActive
const options = savedOptions?.sourceId === sourceId ? savedOptions.options : source?.options
const profileSettings = useMemo(() => profileSettingsOf(options), [options])
@@ -262,7 +318,7 @@ export function CatalogPage() {
const empty = rows.length > 0 && rows.every((r) => progressOf(r.counts) === 'none')
const hasTables = rows.length > 0
const draftButton = (primary = false) => (
<button type="button" className={clsx('btn btn-sm', primary && 'btn-primary')} onClick={() => setDrafting(true)} data-catalog-draft="">
<button type="button" className={clsx('btn btn-sm', primary && 'btn-primary')} onClick={openDraft} data-catalog-draft="">
<Sparkles size={11} aria-hidden /> {CT.draft}
</button>
)
@@ -281,7 +337,7 @@ export function CatalogPage() {
)
// 页头的「起草」窄屏只留图标,标题才放得下
const headerDraft = (
<button type="button" className="btn btn-sm" onClick={() => setDrafting(true)} aria-label={CT.draft} title={CT.draft} data-catalog-draft="">
<button type="button" className="btn btn-sm" onClick={openDraft} aria-label={CT.draft} title={CT.draft} data-catalog-draft="">
<Sparkles size={11} aria-hidden /> <span className="hidden sm:inline">{CT.draft}</span>
</button>
)
@@ -335,7 +391,7 @@ export function CatalogPage() {
onSelected={setSelected}
active={table}
onOpen={openTable}
onDraft={() => setDrafting(true)}
onDraft={openDraft}
onProfile={openProfile}
/>
</aside>
@@ -343,7 +399,8 @@ export function CatalogPage() {
{table
? (
<TableDetail sourceId={sourceId} table={table} rows={rows} drafted={drafted} fillCodes={fillCodes} prev={prev} next={next}
onFillDone={onFillDone} onOpen={openTable} onBack={backToList} onDetail={onDetail} />
onFillDone={onFillDone} onOpen={openTable} onBack={backToList} onDetail={onDetail}
draftBusy={!!stripJob} onRedraft={redraftTable} />
)
: <Overview rows={rows} empty={empty} systemNotes={data.system_notes} draft={draftButton(true)} onOpen={openTable} />}
</section>
@@ -373,16 +430,27 @@ export function CatalogPage() {
onGoSource={() => navigate(back)} />
)}
{hasTables && usage.used > 0 && !empty && <UsageSummary {...usage} active={usedOnly ? filter : null} onFocus={focusUsed} />}
{hasTables && stripJob && !draftOpen && <DraftStrip job={stripJob} onOpen={openDraft} />}
{body}
{drafting && data && (
{draftOpen && data && (
<DraftDialog
sourceId={sourceId}
sourceDescription={source?.description ?? ''}
guidance={data.guidance ?? ''}
rows={rows}
visible={visible}
selected={[...selected]}
filtered={filtered}
onClose={() => setDrafting(false)}
onDrafted={onDrafted}
job={draftJob}
initial={draftOpen.view}
initialBusy={!!draftOpen.busy}
stopping={draft.stopping}
onStart={startDraft}
onCancel={draft.cancel}
onRetry={draft.retry}
onTrack={draft.track}
onGuidanceSaved={onGuidanceSaved}
onClose={() => setDraftOpen(null)}
/>
)}
{profiling && data && (
@@ -412,6 +480,29 @@ export function CatalogPage() {
)
}
/**
* 起草任务在进行:目录页上方一条进度(关掉对话框任务照常进行,每张表起草完就写进目录)。点「查看进度」回到对话框
*/
function DraftStrip({ job, onOpen }: { job: { done: number; total: number; status: string }; onOpen: () => void }) {
const text = job.status === 'queued' ? DT.stripQueued(job.total) : DT.strip(job.done, job.total)
return (
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2 text-xs"
style={{ background: 'color-mix(in srgb, var(--accent) 6%, var(--bg-panel))' }}
data-catalog-draft-progress={`${job.done}/${job.total}`}>
<Spinner size={12} />
<div className="min-w-0 flex-1">
<p className="truncate font-medium" title={text}>{text}</p>
<div className="mt-1 h-1 max-w-xs overflow-hidden rounded-full bg-hover" role="progressbar" aria-label={text}
aria-valuemin={0} aria-valuemax={job.total} aria-valuenow={job.done}>
<div className="h-full rounded-full bg-[var(--accent)] transition-[width] duration-300"
style={{ width: `${job.total ? (job.done / job.total) * 100 : 0}%` }} />
</div>
</div>
<button type="button" className="btn btn-sm shrink-0" onClick={onOpen} data-catalog-draft-open="">{DT.stripOpen}</button>
</div>
)
}
/** 媒体查询的当前结果,跟着窗口大小变 */
function useMedia(query: string): boolean {
const [on, setOn] = useState(() => typeof matchMedia !== 'function' || matchMedia(query).matches)
+347 -161
View File
@@ -1,66 +1,41 @@
import { useMemo, useRef, useState } from 'react'
import { Sparkles } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { CircleCheck, CircleDashed, CircleMinus, CircleSlash, CircleX, RotateCcw, Sparkles, TriangleAlert } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import clsx from 'clsx'
import { api } from '../../api/client'
import type { CatalogDraftRow, CatalogTableRow } from '../../types'
import { Modal, Notice, Spinner } from '../../components/ui'
import { ApiError, api } from '../../api/client'
import type { CatalogDraftJob, CatalogDraftTask, CatalogDraftTaskStatus, CatalogTableRow } from '../../types'
import { Field, Modal, Notice, Spinner } from '../../components/ui'
import { errorMessage } from '../../lib/errors'
import { CATALOG_TEXT as CT } from '../../lib/terms'
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_TOP_N, RETRYABLE, isJobActive, nothingWritten, retryableCount, taskReason,
} from './draftJob'
import type { DraftStartBody } from './draftJob'
// ===========================================================================
// 起草数据目录。接口是同步的,一次请求起草完才返回;表多时分批调用,每批回来就更新进度和累计的新增、更新、删除,
// 中途可以停止(当前这一批做完后不再发下一批)。模型整体用不了时第一批就会知道:之后的批次不再请模型,结果里
// 写明原因。已经写进去的批次不回滚——起草只增改推断项,人工确认、驳回过的项服务端不会动。
// 起草数据目录。起草是服务端的后台任务:这里选范围、写起草要求和本次补充说明,建任务;之后显示页面跟着的那个任务
// (进行中的进度、结束后的结果),任务本身的状态在页面上(draftJob.ts),关掉对话框照常进行。
//
// 三段:设置(范围、用不用模型;用模型时写起草要求、补充说明,写明用哪个起草模型)→ 进行中(总进度、合计、逐表的
// 状态和宽表的段进度、没写全的原因;可以停止、在后台继续)→ 结束(汇总、没写全的表和原因、只重试失败的表)。
// 已经写进目录的内容不回滚——起草只增改推断项,人工确认、驳回过的项服务端不会动。
// ===========================================================================
/** 每批几张表。只按注释、外键和命名起草时只看探查缓存,很快;请模型时服务端每 4 张一批、最多 3 批并发 */
const BATCH_PLAIN = 40
const BATCH_MODEL = 12
/** 服务端不给 tables 时取前 20 张,这里给出同样的范围,进度才算得出来 */
const TOP_N = 20
/** 结果里逐张列出的表最多几张,其余合成一句 */
const LIST_MAX = 8
type Scope = 'selected' | 'filtered' | 'top' | 'all'
type Phase = 'setup' | 'running' | 'done'
interface Outcome {
done: number
total: number
added: number
updated: number
removed: number
tableErrors: CatalogDraftRow[]
modelErrors: CatalogDraftRow[]
modelSkipped: string | null
model: string | null
failure: string | null
stopped: boolean
/** 这一次勾了「用模型起草」 */
modelAsked: boolean
/** 模型给了可用内容(model_items > 0)的表有几张;服务端没给 model_items(老服务端)时 modelItemsKnown 为假 */
modelItems: number
modelItemsKnown: boolean
}
/** 字数按服务端的算法:去掉首尾空白后的字符数(按码位,不按 UTF-16 的长度) */
const charCount = (s: string) => [...s.trim()].length
const fresh = (total: number, modelAsked = false): Outcome => ({
done: 0, total, added: 0, updated: 0, removed: 0, tableErrors: [], modelErrors: [], modelSkipped: null, model: null,
failure: null, stopped: false, modelAsked, modelItems: 0, modelItemsKnown: false,
})
/**
* 一项都没写进去时怎么说。「没有变化」只在确实是「起草出了内容、和现有目录一致」时说:有表没起草、模型在某些表上
* 没给出内容(model_error),或者勾了用模型、却没有一张表拿到模型的可用内容(模型整体用不了、model_items 都是 0),
* 都说「目录没有更新」,原因写在下面
*/
function nothingWrittenText(out: Outcome): string {
const failed = out.modelErrors.length > 0 || out.tableErrors.length > 0
const modelEmpty = out.modelAsked && (!!out.modelSkipped || (out.modelItemsKnown && out.modelItems === 0))
return failed || modelEmpty ? CT.draftNothingWritten : CT.draftNoChange
}
export function DraftDialog({ sourceId, rows, visible, selected, filtered, onClose, onDrafted }: {
export function DraftDialog({
sourceId, sourceDescription, guidance, rows, visible, selected, filtered, job, initial, initialBusy = false, stopping,
onStart, onCancel, onRetry, onTrack, onGuidanceSaved, onClose,
}: {
sourceId: string
/** 数据源说明:用模型起草时连同起草要求一起交给模型 */
sourceDescription: string
/** 数据源存着的起草要求(没有为空串) */
guidance: string
/** 全部表(服务端的顺序:使用次数多的在前) */
rows: CatalogTableRow[]
/** 当前筛选结果 */
@@ -68,11 +43,25 @@ export function DraftDialog({ sourceId, rows, visible, selected, filtered, onClo
selected: string[]
/** 清单上有没有生效的搜索或筛选:有时才给「当前筛选结果」这一项 */
filtered: boolean
/** 页面跟着的起草任务(进行中的、刚结束的) */
job: CatalogDraftJob | null
/** 打开时显示什么:setup 选范围、开始;job 显示页面跟着的那个任务 */
initial: 'setup' | 'job'
/** 打开时显示的任务是撞上已有任务(409)转过来的:写明 */
initialBusy?: boolean
/** 点了停止、服务端还没停下来 */
stopping: boolean
/** 建任务;409 已有任务时页面改取进行中的那一个(busy 为真) */
onStart: (body: DraftStartBody) => Promise<{ job: CatalogDraftJob; busy: boolean }>
onCancel: () => Promise<void>
onRetry: (from: CatalogDraftJob) => Promise<{ job: CatalogDraftJob; busy: boolean }>
/** 设置阶段点开了上一次起草的结果(或者发现别处建的进行中的任务):页面改跟这个任务 */
onTrack: (job: CatalogDraftJob) => void
/** 起草要求存好了:页面记下新的这一份 */
onGuidanceSaved: (guidance: string | null) => void
onClose: () => void
/** 有批次写进去了:重取清单、刷新正在看的表 */
onDrafted: (tables: string[]) => void
}) {
// 表结构里已经没有的表起草不了(服务端会回「表结构里没有」),不算进范围
// 表结构里已经没有的表起草不了(服务端会记「已跳过」),不算进范围
const live = useMemo(() => rows.filter((r) => r.in_schema).map((r) => r.table_name), [rows])
const liveSet = useMemo(() => new Set(live), [live])
const scopes = useMemo(() => {
@@ -81,80 +70,156 @@ export function DraftDialog({ sourceId, rows, visible, selected, filtered, onClo
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)
if (filtered && vis.length && vis.length !== live.length) out.push({ key: 'filtered', label: CT.scopeFiltered(vis.length), tables: vis })
if (live.length > TOP_N) out.push({ key: 'top', label: CT.scopeTop(TOP_N), tables: live.slice(0, TOP_N) })
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])
const [scope, setScope] = useState<Scope>(() => scopes[0].key)
const [useModel, setUseModel] = useState(false)
const [phase, setPhase] = useState<Phase>('setup')
const [out, setOut] = useState<Outcome>(() => fresh(0))
const [stopping, setStopping] = useState(false)
const stopRef = useRef(false)
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 [guidanceText, setGuidanceText] = useState(guidance)
const [note, setNote] = useState('')
const guidanceLen = charCount(guidanceText)
const noteLen = charCount(note)
const guidanceOver = guidanceLen > DRAFT_GUIDANCE_MAX
const noteOver = noteLen > DRAFT_NOTE_MAX
const guidanceChanged = guidanceText.trim() !== guidance.trim()
const run = async () => {
stopRef.current = false
setStopping(false)
setPhase('running')
const acc = fresh(tables.length, useModel)
setOut({ ...acc })
const touched: string[] = []
let withModel = useModel
let i = 0
while (i < tables.length) {
if (stopRef.current) { acc.stopped = true; break }
const batch = tables.slice(i, i + (withModel ? BATCH_MODEL : BATCH_PLAIN))
try {
const r = await api.dataCatalog.draft(sourceId, { tables: batch, use_model: withModel })
acc.added += r.total.added
acc.updated += r.total.updated
acc.removed += r.total.removed
acc.tableErrors.push(...r.tables.filter((t) => t.error))
acc.modelErrors.push(...r.tables.filter((t) => !t.error && t.model_error))
acc.modelItems += r.tables.filter((t) => !t.error && (t.model_items ?? 0) > 0).length
if (r.tables.some((t) => t.model_items !== undefined)) acc.modelItemsKnown = true
if (r.model) acc.model = r.model
// 模型整体用不了(没配置、已停用……):后面的批次不再请模型,免得每批都撞一次同样的错
if (withModel && r.model_error && !r.model_used) { acc.modelSkipped = r.model_error; withModel = false }
touched.push(...r.tables.filter((t) => !t.error).map((t) => t.table_name))
} catch (e) {
acc.failure = errorMessage(e)
break
const [mode, setMode] = useState<'setup' | 'job'>(initial === 'job' && job ? 'job' : 'setup')
const [starting, setStarting] = useState(false)
const [startError, setStartError] = useState<string | null>(null)
/** 是 409(已有任务)转过来显示的那一个 */
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)
const onTrackRef = useRef(onTrack)
onTrackRef.current = onTrack
const inSetup = mode === 'setup' || !job
// 设置阶段:取上一次起草。别处(另一个标签页)建的任务还在进行:直接显示它
useEffect(() => {
if (!inSetup) return
let alive = true
api.dataCatalog.latestDraftJob(sourceId).then(({ job: latest }) => {
if (!alive || !latest) return
if (isJobActive(latest)) {
onTrackRef.current(latest)
setBusy(true)
setMode('job')
} else if (latest.status !== 'succeeded') {
setLast(latest)
}
i += batch.length
acc.done = i
setOut({ ...acc })
}, () => {})
return () => { alive = false }
}, [inSetup, sourceId])
// 起草模型:设置里的 catalog_draft 填了就是它;没填跟随助手的模型(写出当前是哪个,用不了时写原因)
useEffect(() => {
let alive = true
void Promise.allSettled([api.settings.get(), api.copilot.getModel()]).then(([s, c]) => {
if (!alive) return
const cfg = s.status === 'fulfilled' ? s.value?.catalog_draft : null
const provider = typeof cfg?.provider === 'string' && cfg.provider.trim() ? cfg.provider.trim() : null
const model = typeof cfg?.model === 'string' && cfg.model.trim() ? cfg.model.trim() : null
if (provider || model) {
setModelLine({ text: DT.modelNamed(model, provider), warn: null })
return
}
const assistant = c.status === 'fulfilled' ? c.value : null
setModelLine({ text: DT.modelFollow(assistant?.effective_model ?? null), warn: assistant?.error ?? null })
})
return () => { alive = false }
}, [])
const begin = async () => {
if (starting || !tables.length || tooMany || (useModel && (guidanceOver || noteOver))) return
setStarting(true)
setStartError(null)
try {
// 起草要求改过:先存(存不上就不开始,免得模型拿着旧的那份起草)
if (useModel && guidanceChanged) {
try {
const saved = await api.dataCatalog.putGuidance(sourceId, guidanceText.trim())
onGuidanceSaved(saved.guidance)
} catch (e) {
setStartError(DT.saveGuidanceFailed(errorMessage(e)))
return
}
}
const extra = useModel && note.trim() ? { note: note.trim() } : {}
const r = await onStart({ tables, use_model: useModel, ...extra })
setBusy(r.busy)
setMode('job')
} catch (e) {
// 起草模型用不了(没有接入、已停用、只有演示接入):服务端那句话已经写了原因和去处
setStartError(e instanceof ApiError && e.code === DRAFT_MODEL_UNAVAILABLE ? e.message : DT.startFailed(errorMessage(e)))
} finally {
setStarting(false)
}
setOut({ ...acc })
setPhase('done')
if (touched.length) onDrafted(touched)
}
const stop = () => {
stopRef.current = true
setStopping(true)
const act = async (fn: () => Promise<void>) => {
setActing(true)
setActionError(null)
try {
await fn()
} catch (e) {
setActionError(errorMessage(e))
} finally {
setActing(false)
}
}
const stop = () => act(onCancel)
const retry = (from: CatalogDraftJob) => act(async () => {
const r = await onRetry(from)
setBusy(r.busy)
})
const openLast = (j: CatalogDraftJob) => {
onTrack(j)
setBusy(false)
setMode('job')
}
// 进行中关窗 = 停止:当前这一批做完再关,结果照常显示
const requestClose = () => (phase === 'running' ? stop() : onClose())
const phase: 'setup' | 'running' | 'done' = inSetup ? 'setup' : isJobActive(job) ? 'running' : 'done'
const canStart = !!tables.length && !tooMany && !(useModel && (guidanceOver || noteOver)) && !starting
const retryable = job && phase === 'done' ? retryableCount(job) : 0
const footer = phase === 'setup'
? (
<>
<button className="btn" onClick={onClose}>{CT.cancel}</button>
<button className="btn btn-primary" onClick={() => void run()} disabled={!tables.length} data-draft-start="">
<Sparkles size={12} aria-hidden /> {CT.draftStart(tables.length)}
<button className="btn btn-primary" onClick={() => void begin()} disabled={!canStart} data-draft-start="">
{starting ? <Spinner size={11} /> : <Sparkles size={12} aria-hidden />} {CT.draftStart(tables.length)}
</button>
</>
)
: phase === 'running'
? <button className="btn" onClick={stop} disabled={stopping} data-draft-stop="">{CT.draftStop}</button>
: <button className="btn btn-primary" onClick={onClose} data-autofocus data-draft-close="">{CT.draftClose}</button>
? (
<>
<button className="btn" onClick={onClose} title={DT.hideHint} data-draft-hide="">{DT.hide}</button>
<button className="btn" onClick={() => void stop()} disabled={stopping || acting} data-draft-stop="">{DT.stop}</button>
</>
)
: (
<>
{retryable > 0 && job && (
<button className="btn" onClick={() => void retry(job)} disabled={acting} title={DT.retryHint} data-draft-retry="">
{acting ? <Spinner size={11} /> : <RotateCcw size={11} aria-hidden />} {DT.retry(retryable)}
</button>
)}
<button className="btn btn-primary" onClick={onClose} data-autofocus data-draft-close="">{CT.draftClose}</button>
</>
)
return (
<Modal open onClose={requestClose} title={CT.draftTitle} width={560} footer={footer}>
<div className="space-y-4" data-draft-dialog={phase}>
<Modal open onClose={onClose} title={CT.draftTitle} width={600} footer={footer}
dirty={phase === 'setup' && useModel && (guidanceChanged || !!note.trim())}>
<div className="min-w-0 space-y-4" data-draft-dialog={phase}>
{phase === 'setup' && (
<>
<fieldset>
@@ -168,71 +233,192 @@ export function DraftDialog({ sourceId, rows, visible, selected, filtered, onClo
</label>
))}
</div>
{tooMany && <p className="mt-1 text-2xs text-[var(--err)]" data-draft-too-many="">{DT.tooMany(DRAFT_MAX_TABLES)}</p>}
</fieldset>
<p className="text-2xs leading-relaxed text-faint">{CT.draftBase}</p>
<label className="flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2.5 text-xs hover:bg-hover" data-draft-model="">
<input type="checkbox" className="mt-0.5" checked={useModel} onChange={(e) => setUseModel(e.target.checked)} />
<span className="min-w-0">
<span className="font-medium">{CT.useModel}</span>
<span className="mt-0.5 block text-2xs leading-relaxed text-faint">{CT.useModelHint}</span>
</span>
</label>
<div className="rounded-md border">
<label className="flex cursor-pointer items-start gap-2 rounded-md px-3 py-2.5 text-xs hover:bg-hover" data-draft-model="">
<input type="checkbox" className="mt-0.5" checked={useModel} onChange={(e) => setUseModel(e.target.checked)} />
<span className="min-w-0">
<span className="font-medium">{CT.useModel}</span>
<span className="mt-0.5 block text-2xs leading-relaxed text-faint">{CT.useModelHint}</span>
</span>
</label>
{useModel && (
<div className="space-y-3 border-t px-3 py-3">
<div className="text-2xs leading-relaxed" data-draft-model-name="">
<span className="text-dim">{DT.model}:</span>
<span className="break-words font-medium">{modelLine ? modelLine.text : '…'}</span>
<span className="block text-faint">{DT.modelWhere}</span>
{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}
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}
placeholder={DT.guidancePlaceholder} onChange={(e) => setGuidanceText(e.target.value)} />
)}
</Field>
<Counter n={guidanceLen} max={DRAFT_GUIDANCE_MAX} />
</div>
<div data-draft-note="">
<Field label={DT.note} htmlFor="catalog-draft-note" hint={DT.noteHint}
error={noteOver ? DT.tooLong(DRAFT_NOTE_MAX) : undefined}>
{(p) => (
<textarea {...p} className="field min-h-[3rem] resize-y text-xs leading-relaxed" rows={2} value={note}
placeholder={DT.notePlaceholder} onChange={(e) => setNote(e.target.value)} />
)}
</Field>
<Counter n={noteLen} max={DRAFT_NOTE_MAX} />
</div>
{sourceDescription.trim() && (
<p className="break-words text-2xs leading-relaxed text-dim" data-draft-source-description="">
{DT.sourceDescription(sourceDescription.trim())}
</p>
)}
<p className="text-2xs leading-relaxed text-faint">{DT.guidanceRule}</p>
</div>
)}
</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>
<button type="button" className="btn btn-sm" onClick={() => openLast(last)} data-draft-last-open="">{DT.lastOpen}</button>
</div>
)}
{startError && <Notice tone="err" attr={{ 'data-draft-start-error': '' }}>{startError}</Notice>}
</>
)}
{phase !== 'setup' && (
<div className="space-y-3" aria-live="polite">
<div className="flex items-center gap-2 text-xs">
{phase === 'running' && <Spinner size={13} />}
<span className="font-medium" data-draft-title="">
{phase === 'running'
? (stopping ? CT.draftStopping : CT.drafting)
: out.failure ? CT.draftFailed : out.stopped ? CT.draftStopped(out.done, out.total) : CT.draftDone}
</span>
<span className="flex-1" />
<span className="tnum text-2xs text-faint" data-draft-progress={`${out.done}/${out.total}`}>{CT.draftProgress(out.done, out.total)}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-hover" role="progressbar" aria-label={CT.draftProgress(out.done, out.total)}
aria-valuemin={0} aria-valuemax={out.total} aria-valuenow={out.done}>
<div className="h-full rounded-full transition-[width] duration-300"
style={{ width: `${out.total ? (out.done / out.total) * 100 : 0}%`, background: out.failure ? 'var(--err)' : 'var(--accent)' }} />
</div>
<p className="tnum text-sm" data-draft-summary="">
{out.added + out.updated + out.removed === 0 && phase === 'done' && !out.failure
? nothingWrittenText(out)
: CT.draftSummary(out.added, out.updated, out.removed)}
</p>
{out.model && <p className="text-2xs text-faint">{CT.draftModelUsed(out.model)}</p>}
{out.modelSkipped && (
<Notice tone="warn" attr={{ 'data-draft-model-error': '' }}>{CT.draftModelSkipped(out.modelSkipped)}</Notice>
)}
{out.failure && <Notice tone="err" attr={{ 'data-draft-failure': '' }}>{out.failure}</Notice>}
{out.tableErrors.length > 0 && (
<Notice tone="err" attr={{ 'data-draft-table-errors': String(out.tableErrors.length) }}>
<p>{CT.draftTableErrors(out.tableErrors.length)}</p>
<ErrorList rows={out.tableErrors} pick={(r) => r.error ?? ''} />
</Notice>
)}
{out.modelErrors.length > 0 && (
<Notice tone="warn" attr={{ 'data-draft-table-model-errors': String(out.modelErrors.length) }}>
<p>{CT.draftModelErrors(out.modelErrors.length)}</p>
<ErrorList rows={out.modelErrors} pick={(r) => r.model_error ?? ''} />
</Notice>
)}
</div>
{phase !== 'setup' && job && (
<JobView key={job.id} job={job} running={phase === 'running'} stopping={stopping} busy={busy} />
)}
{phase !== 'setup' && actionError && <Notice tone="err" attr={{ 'data-draft-action-error': '' }}>{actionError}</Notice>}
</div>
</Modal>
)
}
function ErrorList({ rows, pick }: { rows: CatalogDraftRow[]; pick: (r: CatalogDraftRow) => string }) {
function Counter({ n, max }: { n: number; max: number }) {
return (
<ul className="mt-1 space-y-0.5 text-dim">
{rows.slice(0, LIST_MAX).map((r) => (
<li key={r.table_name} className="break-words"><span className="mono">{r.table_name}</span>:{pick(r)}</li>
))}
{rows.length > LIST_MAX && <li>{CT.moreTables(rows.length - LIST_MAX)}</li>}
</ul>
<div className={clsx('mt-0.5 text-right text-2xs tnum', n > max ? 'text-[var(--err)]' : 'text-faint')} aria-hidden>
{DT.count(n, max)}
</div>
)
}
/** 任务的进度或结果:标题、总进度、合计、模型和用量、没写全的表、逐表列表 */
function JobView({ job, running, stopping, busy }: { job: CatalogDraftJob; running: boolean; stopping: boolean; busy: boolean }) {
const problems = job.tasks.filter((t) => RETRYABLE.has(t.status))
const [showAll, setShowAll] = useState(false)
// 结束后有没写全的表时默认只列这些(几百张表里找失败的太费劲),可以展开全部
const onlyProblems = !running && problems.length > 0 && !showAll
const tasks = onlyProblems ? problems : 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
: empty === 'failed' ? CT.draftNothingWrittenFailed
: empty === 'same' ? CT.draftNoChange
: CT.draftSummary(job.added, job.updated, job.removed)
const problemText = !running ? DT.problems({
failed: job.tasks.filter((t) => t.status === 'failed').length,
partial: job.tasks.filter((t) => t.status === 'partial').length,
cancelled: job.tasks.filter((t) => t.status === 'cancelled').length,
skipped: job.tasks.filter((t) => t.status === 'skipped').length,
}) : ''
// 进行中按已结束的表数走;结束以后写全部写出来的有几张(停止、失败的不算「完成」)
const ok = job.tasks.filter((t) => t.status === 'ok').length
const value = running ? job.done : ok
const progress = running ? DT.progress(job.done, job.total) : DT.progressDone(ok, job.total)
const tone = job.status === 'failed' ? 'var(--err)' : running ? 'var(--accent)' : problems.length ? 'var(--warn)' : 'var(--st-done)'
const pct = job.total ? (value / job.total) * 100 : 0
return (
<div className="min-w-0 space-y-3">
{busy && <Notice tone="info" attr={{ 'data-draft-busy': '' }}>{DT.busy}</Notice>}
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs" role="status" aria-live="polite">
{running && <Spinner size={13} />}
<span className="font-medium" data-draft-title="">{title}</span>
<span className="flex-1" />
<span className="tnum text-2xs text-faint" data-draft-progress={`${job.done}/${job.total}`}>{progress}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-hover" role="progressbar" aria-label={progress}
aria-valuemin={0} aria-valuemax={job.total} aria-valuenow={value}>
<div className="h-full rounded-full transition-[width] duration-300" style={{ width: `${pct}%`, background: tone }} />
</div>
<p className="tnum text-sm" data-draft-summary="">{summary}</p>
{(job.model || job.usage.calls > 0) && (
<p className="flex flex-wrap gap-x-3 gap-y-0.5 text-2xs text-faint">
{job.model && <span className="break-all" data-draft-model-used="">{DT.modelUsed(job.model)}</span>}
{job.usage.calls > 0 && (
<span className="tnum" data-draft-usage="">{DT.usage(job.usage.calls, job.usage.input_tokens, job.usage.output_tokens)}</span>
)}
</p>
)}
{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': '' }}>{DT.jobFailed(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>}
{problemText && (
<p className="text-xs leading-relaxed" data-draft-problems="">{problemText}</p>
)}
<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 && (
<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)}
</button>
)}
</div>
<ul className="max-h-64 divide-y divide-[var(--hairline)] overflow-y-auto overflow-x-hidden rounded-md border"
aria-labelledby="catalog-draft-tasks-label" data-draft-tasks="">
{tasks.map((t) => <TaskRow key={t.table_name} task={t} />)}
</ul>
</div>
</div>
)
}
const TASK_ICON: Record<CatalogDraftTaskStatus, { Icon: LucideIcon | null; color: string }> = {
pending: { Icon: CircleDashed, color: 'var(--text-faint)' },
running: { Icon: null, color: 'var(--accent)' },
ok: { Icon: CircleCheck, color: 'var(--st-done)' },
partial: { Icon: TriangleAlert, color: 'var(--warn)' },
failed: { Icon: CircleX, color: 'var(--err)' },
skipped: { Icon: CircleMinus, color: 'var(--text-faint)' },
cancelled: { Icon: CircleSlash, color: 'var(--text-faint)' },
}
function TaskRow({ task: t }: { task: CatalogDraftTask }) {
const { Icon, color } = TASK_ICON[t.status]
const reason = 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)
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">
{Icon ? <Icon size={12} className="shrink-0" style={{ color }} aria-hidden /> : <Spinner size={12} />}
<span className="mono min-w-0 flex-1 truncate" title={t.table_name}>{t.table_name}</span>
{parts && (
<span className="tnum shrink-0 text-2xs text-faint" title={DT.partsHint} data-draft-task-parts="">
{DT.parts(t.parts_done, t.parts_total!)}
</span>
)}
{changes && (
<span className="tnum shrink-0 text-2xs text-faint" title={DT.taskAddedHint(t.added, t.updated, t.removed)}>{changes}</span>
)}
<span className="shrink-0 text-2xs" style={{ color: t.status === 'pending' || t.status === 'skipped' || t.status === 'cancelled' ? undefined : color }}
data-draft-task-status="">
{DT.taskStatus[t.status]}
</span>
</div>
{reason && (
<p className="mt-0.5 break-words pl-5 text-2xs leading-relaxed text-dim" data-draft-task-reason="">{reason}</p>
)}
</li>
)
}
+32 -3
View File
@@ -1,6 +1,6 @@
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, X } from 'lucide-react'
import { AlertTriangle, ArrowLeft, ChevronLeft, ChevronRight, GitBranch, ListChecks, Lock, Pencil, Plus, RotateCw, Search, Sparkles, X } from 'lucide-react'
import clsx from 'clsx'
import { ApiError, api } from '../../api/client'
import type {
@@ -11,7 +11,7 @@ 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_IMPACT_TEXT, CATALOG_KIND_HINT, CATALOG_KIND_LABEL, CATALOG_TABLE_FIELD_HINT,
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,
} from '../../lib/terms'
import { CatalogImpactList } from '../../components/CatalogImpact'
@@ -21,6 +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 {
COLUMN_FIELDS, DATE_KEYS, TABLE_FIELDS, buildNotes, changeCount, columnPath, confirmProposed, countsOf, formFromNotes,
relationDraft, relationPath, relationRemoval, tablePath, unknownKeys, validateForm,
@@ -51,7 +52,9 @@ const PROBLEM_TEXT = {
type Editing = { initial: EditForm; form: EditForm }
type Conflict = 'edit' | 'review'
export function TableDetail({ sourceId, table, rows, drafted, fillCodes, onFillDone, prev, next, onOpen, onBack, onDetail }: {
export function TableDetail({
sourceId, table, rows, drafted, fillCodes, onFillDone, prev, next, onOpen, onBack, onDetail, draftBusy = false, onRedraft,
}: {
sourceId: string
table: string
/** 全部表:关联关系的目标表能点就跳过去,编辑时给目标表做候选 */
@@ -72,6 +75,10 @@ export function TableDetail({ sourceId, table, rows, drafted, fillCodes, onFillD
onBack: () => void
/** 载入或写入之后的单表目录:清单据此更新这一行 */
onDetail: (d: CatalogDetail) => void
/** 这个数据源有排队、进行中的起草任务:「重新起草这张表」等它结束 */
draftBusy?: boolean
/** 上次起草失败、部分完成时的「重新起草这张表」:建只含这张表的起草任务(用模型、不带补充说明) */
onRedraft?: (table: string) => Promise<void>
}) {
const [detail, setDetail] = useState<CatalogDetail | null>(null)
const [loadError, setLoadError] = useState<unknown>(null)
@@ -101,6 +108,7 @@ export function TableDetail({ sourceId, table, rows, drafted, fillCodes, onFillD
*/
const [savedAt, setSavedAt] = useState<number | null>(null)
const [impact, setImpact] = useState<{ version: number; n: number } | null>(null)
const [redrafting, setRedrafting] = useState(false)
const loadSeq = useRef(0)
const onDetailRef = useRef(onDetail)
onDetailRef.current = onDetail
@@ -308,6 +316,8 @@ export function TableDetail({ sourceId, table, rows, drafted, fillCodes, onFillD
const notes = detail.notes
const counts = countsOf(notes)
/** 这张表最近一次起草的结果(清单上那一行带的) */
const lastDraft = rows.find((r) => r.table_name === table)?.last_draft ?? null
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
@@ -446,6 +456,25 @@ export function TableDetail({ sourceId, table, rows, drafted, fillCodes, onFillD
</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>}
{onRedraft && (
<>
<button type="button" className="btn btn-sm mt-2" disabled={draftBusy || redrafting} 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>
<p className="mt-1 text-2xs text-faint">{draftBusy ? DT.redraftBusy : DT.redraftHint}</p>
</>
)}
</Notice>
)}
{!detail.in_schema && (
<Notice tone="warn" attr={{ 'data-catalog-missing': '' }}>{CT.missingBody}</Notice>
)}
+18 -3
View File
@@ -5,16 +5,20 @@ import clsx from 'clsx'
import type { CatalogTableRow } from '../../types'
import { useRadioGroup } from '../../components/ui'
import { formatNumber } from '../../lib/format'
import { CATALOG_KIND_LABEL, CATALOG_TEXT as CT, CATALOG_UI_TEXT as UT, PROFILE_TEXT as PT } from '../../lib/terms'
import {
CATALOG_DRAFT_TEXT as DT, CATALOG_KIND_LABEL, CATALOG_TEXT as CT, CATALOG_UI_TEXT as UT, PROFILE_TEXT as PT,
} from '../../lib/terms'
import { CountBadges } from './parts'
import { LIST_FILTERS, LIST_SORTS, progressOf } from './model'
import { taskReason } from './draftJob'
import type { ListFilter, ListSort } from './model'
// ===========================================================================
// 表清单(数据目录页的左栏):搜索、按审阅进度筛选、排序、多选后批量起草。
// 一行两层:上面是中文名和各状态的项数,下面是表名、类型、关联数和使用次数;还没填中文名的表上面直接写表名,
// 下面淡淡地写「未填写中文名」——一屏几十行「未填写中文名」认不出是哪张表。键盘:↑↓ 在行之间移动,
// Home / End 到两头,Enter 打开;复选框按住 Shift 点选可以连选一段。
// 下面淡淡地写「未填写中文名」——一屏几十行「未填写中文名」认不出是哪张表。上次起草失败、部分完成的表再多一行,
// 写明原因(全文在悬停里,点开表还有「重新起草这张表」)。键盘:↑↓ 在行之间移动,Home / End 到两头,Enter 打开;
// 复选框按住 Shift 点选可以连选一段。
// ===========================================================================
export function TableIndex({
@@ -205,6 +209,10 @@ const Row = memo(function Row({ row: r, index, active, checked, onToggle, onOpen
onKey: (i: number, e: ReactKeyboardEvent<HTMLButtonElement>) => void
}) {
const progress = progressOf(r.counts)
// 上次起草没写全:失败、部分完成各一种说法,后面接原因
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) : ''
return (
<li
className={clsx('relative flex items-stretch border-b border-hairline', active ? 'bg-accent-soft' : 'hover:bg-hover')}
@@ -247,6 +255,13 @@ const Row = memo(function Row({ row: r, index, active, checked, onToggle, onOpen
<AlertTriangle size={10} aria-hidden /> {CT.missing}
</span>
)}
{last && (
<span className="mt-0.5 flex min-w-0 items-center gap-1 text-2xs" title={lastText} data-row-last-draft={last.status}
style={{ color: last.status === 'failed' ? 'var(--err)' : 'var(--warn)' }}>
<AlertTriangle size={10} className="shrink-0" aria-hidden />
<span className="min-w-0 truncate">{lastText}</span>
</span>
)}
</button>
</li>
)
+192
View File
@@ -0,0 +1,192 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ApiError, api } from '../../api/client'
import type { CatalogActiveDraftJob, CatalogDraftJob, CatalogDraftTask, CatalogDraftTaskStatus } from '../../types'
// ===========================================================================
// 起草任务(/api/datasources/{id}/catalog/draft-jobs)在页面上的状态:建、停、重试,进行中每 1.5 秒取一次进度,
// 结束就停。放在页面上而不是对话框里:关掉对话框任务照常进行,目录页上方的进度、结束时的提示都靠它;再打开对话框
// 看到的还是同一个任务。
//
// 取回来的状态只往前走:已经结束的任务不会被一个晚到的「进行中」翻回去,已结束的表数不减少(停止接口的回答和
// 正在路上的那次取进度谁先到都一样)。
// ===========================================================================
/** 进行中多久取一次进度 */
export const DRAFT_POLL_MS = 1500
/** 一次最多起草几张表(服务端同一个上限) */
export const DRAFT_MAX_TABLES = 500
/** 不选表时起草使用次数最多的前几张(服务端不给 tables 时也是这个数) */
export const DRAFT_TOP_N = 20
/** 服务端的机读码:同一个数据源已有排队、进行中的任务;起草模型用不了 */
export const DRAFT_BUSY = 'catalog_draft_busy'
export const DRAFT_MODEL_UNAVAILABLE = 'catalog_draft_model_unavailable'
const DRAFT_FINISHED = 'catalog_draft_finished'
export const isJobActive = (j: { status: string } | null | undefined): boolean =>
!!j && (j.status === 'queued' || j.status === 'running')
const TASK_ENDED = new Set<CatalogDraftTaskStatus>(['ok', 'partial', 'failed', 'skipped', 'cancelled'])
/** 「只重试失败的表」带上的:失败、部分完成、已停止 */
export const RETRYABLE = new Set<CatalogDraftTaskStatus>(['failed', 'partial', 'cancelled'])
export const isTaskEnded = (t: CatalogDraftTask): boolean => TASK_ENDED.has(t.status)
export const retryableCount = (j: CatalogDraftJob): number => j.tasks.filter((t) => RETRYABLE.has(t.status)).length
/** 一张表这次为什么没写全:整张表没起草的原因在前,模型那部分的原因在后 */
export const taskReason = (t: { error: string | null; model_error: string | null }): string => t.error ?? t.model_error ?? ''
/**
* 一项都没写进去的任务该怎么说(只对写完了的任务:完成、部分完成)。null:照常写合计。
* 用模型、却没有一张表拿到模型的可用内容:目录没有更新、模型没给内容;有表没写全:目录没有更新、原因见下方;
* 都写完了只是和现有目录一致:没有变化
*/
export function nothingWritten(j: CatalogDraftJob): 'model' | 'failed' | 'same' | null {
if (j.added + j.updated + j.removed > 0 || (j.status !== 'succeeded' && j.status !== 'partial')) return null
const live = j.tasks.filter((t) => t.status !== 'skipped')
if (j.use_model && live.length && !live.some((t) => (t.model_items ?? 0) > 0)) return 'model'
if (j.tasks.some((t) => RETRYABLE.has(t.status))) return 'failed'
return 'same'
}
export interface DraftStartBody { tables?: string[]; use_model: boolean; note?: string }
export function useDraftJob(sourceId: string, active: CatalogActiveDraftJob | null | undefined, on: {
/** 这一次取回来新结束的表(写进目录了,或者失败、停止了):重取清单,正在看的表在里面就重新载入 */
onTablesEnded: (tables: string[]) => void
/** 看着它从排队、进行中变成结束(这一页上亲眼看到的,进页面时已经结束的不算) */
onFinished: (job: CatalogDraftJob) => void
}) {
// 任务挂在数据源上:换了数据源,上一个源的任务不再显示(那边照常进行)
const [held, setHeld] = useState<{ src: string; job: CatalogDraftJob } | null>(null)
const job = held?.src === sourceId ? held.job : null
const jobRef = useRef(job)
jobRef.current = job
const sourceRef = useRef(sourceId)
sourceRef.current = sourceId
const onRef = useRef(on)
onRef.current = on
/** 点了停止、服务端还没停下来的任务 */
const [stopping, setStopping] = useState<string | null>(null)
/** 取进度失败(网络抖了一下)、或取回来的是旧状态时,原样再排一拍 */
const [tick, setTick] = useState(0)
/** 换上取回来的状态。不往回走;返回是否换上了 */
const accept = useCallback((next: CatalogDraftJob, src: string): boolean => {
if (src !== sourceRef.current) return false
const cur = jobRef.current
const same = cur?.id === next.id
if (same && cur) {
if (!isJobActive(cur) && isJobActive(next)) return false
if (isJobActive(next) && next.done < cur.done) return false
}
// 新结束的表:同一个任务比上一次多结束的;头一回见到的进行中任务(别处建的、刷新页面前建的)已经结束的那些也算,
// 清单可能还没跟上。头一回见到的已结束任务(点开上一次的结果)不算,那些早就写进去了
const before = new Map((same && cur ? cur.tasks : []).map((t) => [t.table_name, t.status]))
const ended = same || isJobActive(next)
? next.tasks
.filter((t) => isTaskEnded(t) && t.status !== 'skipped' && !TASK_ENDED.has(before.get(t.table_name) ?? 'pending'))
.map((t) => t.table_name)
: []
jobRef.current = next
setHeld({ src, job: next })
if (ended.length) onRef.current.onTablesEnded(ended)
if (same && cur && isJobActive(cur) && !isJobActive(next)) onRef.current.onFinished(next)
return true
}, [])
/** 换成另一个任务(刚建的、重试建的、409 时进行中的那个、对话框里点开的上一次)。同一个任务照常只往前走 */
const track = useCallback((next: CatalogDraftJob) => {
accept(next, sourceRef.current)
}, [accept])
// 进行中:隔 1.5 秒取一次,结束就停
const jobId = job?.id
const jobActive = isJobActive(job)
useEffect(() => {
if (!jobId || !jobActive) return
const src = sourceId
let alive = true
const t = setTimeout(async () => {
try {
const { job: next } = await api.dataCatalog.draftJob(src, jobId)
if (alive && !accept(next, src)) setTick((n) => n + 1)
} catch (e) {
if (!alive) return
// 任务没了(不属于这个数据源、库被换掉了):不再跟着它;别的失败(网络抖动、服务重启中)下一拍再取
if (e instanceof ApiError && e.status === 404) setHeld((h) => (h?.job.id === jobId ? null : h))
else setTick((n) => n + 1)
}
}, DRAFT_POLL_MS)
return () => {
alive = false
clearTimeout(t)
}
}, [sourceId, jobId, jobActive, job, tick, accept])
// 清单说有进行中的任务、这一页还不知道(别的标签页建的、刷新了页面):取来跟着它
const activeId = isJobActive(active) ? active?.id : undefined
useEffect(() => {
if (!activeId) return
const cur = jobRef.current
if (cur?.id === activeId) return
const src = sourceId
let alive = true
api.dataCatalog.draftJob(src, activeId).then(({ job: next }) => { if (alive) accept(next, src) }, () => {})
return () => { alive = false }
}, [sourceId, activeId, accept])
/** 建任务。409 已有任务时改去取进行中的那一个(busy 为真);其余的错误照原样抛给调用方 */
const start = useCallback(async (body: DraftStartBody): Promise<{ job: CatalogDraftJob; busy: boolean }> => {
const src = sourceRef.current
try {
const { job: next } = await api.dataCatalog.createDraftJob(src, body)
accept(next, src)
return { job: next, busy: false }
} catch (e) {
if (!(e instanceof ApiError && e.status === 409 && e.code === DRAFT_BUSY)) throw e
const { job: current } = await api.dataCatalog.activeDraftJob(src)
if (!current) throw e
accept(current, src)
return { job: current, busy: true }
}
}, [accept])
/** 只重试失败的表:同 start,409 已有任务时改去显示进行中的那一个 */
const retry = useCallback(async (from: CatalogDraftJob): Promise<{ job: CatalogDraftJob; busy: boolean }> => {
const src = sourceRef.current
try {
const { job: next } = await api.dataCatalog.retryDraftJob(src, from.id)
accept(next, src)
return { job: next, busy: false }
} catch (e) {
if (!(e instanceof ApiError && e.status === 409 && e.code === DRAFT_BUSY)) throw e
const { job: current } = await api.dataCatalog.activeDraftJob(src)
if (!current) throw e
accept(current, src)
return { job: current, busy: true }
}
}, [accept])
/** 停止。服务端停下正在进行的调用之前任务还是进行中:这期间 stopping 为这个任务。已经结束了(409)就取一次最新的 */
const cancel = useCallback(async () => {
const cur = jobRef.current
if (!cur || !isJobActive(cur)) return
const src = sourceRef.current
setStopping(cur.id)
try {
const { job: next } = await api.dataCatalog.cancelDraftJob(src, cur.id)
accept(next, src)
} catch (e) {
if (e instanceof ApiError && e.status === 409 && e.code === DRAFT_FINISHED) {
const { job: next } = await api.dataCatalog.draftJob(src, cur.id)
accept(next, src)
return
}
setStopping(null)
throw e
}
}, [accept])
return { job, track, start, retry, cancel, stopping: !!job && stopping === job.id && isJobActive(job) }
}
+85 -14
View File
@@ -2790,6 +2790,8 @@ export interface CatalogTableRow {
version: number
updated_at: string | null
updated_by: string | null
/** 这张表最近一次起草任务里的那一项(任务还在进行就是进行中的状态);没起草过为 null。老服务端没有这个键 */
last_draft?: CatalogLastDraft | null
}
/** GET /datasources/{id}/catalog */
@@ -2804,6 +2806,10 @@ export interface CatalogList {
schema_truncated?: boolean
/** 数据库里一共几张表(含视图);没截断时等于探查到的张数 */
schema_total?: number
/** 这个数据源的起草要求(使用者补充给模型的背景和要求);没填为 null。老服务端没有这一项 */
guidance?: string | null
/** 排队或进行中的起草任务(页面据此显示进度);没有为 null。老服务端没有这一项 */
active_job?: CatalogActiveDraftJob | null
}
/** 表结构里的一列 */
@@ -2851,31 +2857,96 @@ export interface CatalogDetail {
/** 单项审阅:确认、驳回、撤销审阅(回到来源的初始状态;人工填写的项直接删掉) */
export type CatalogReviewAction = 'confirm' | 'reject' | 'reset'
/** 起草结果里的一张表 */
export interface CatalogDraftRow {
// ---------------------------------------------------------------------------
// 起草任务(/api/datasources/{id}/catalog/draft-jobs):起草在服务端后台进行,逐表起草、每张表一结束就并入目录。
// 关掉对话框不停任务;服务重启时还没结束的任务记 interrupted,不自动续跑,由人点「重试」接着起草。
// ---------------------------------------------------------------------------
/**
* 起草任务的状态:排队、进行中、完成、部分完成(有失败、部分完成或停止的表)、失败(任务整体出错)、已停止(用户停止)、
* 服务重启中断
*/
export type CatalogDraftJobStatus = 'queued' | 'running' | 'succeeded' | 'partial' | 'failed' | 'cancelled' | 'interrupted'
/**
* 任务里一张表的状态。ok:写入成功(或没有东西要写),用模型时各段都写出来了;partial:用模型,有的段没写出来;
* failed:整张表没起草(error),或用模型时一段都没写出来(model_error),按注释、外键、命名起草的部分照常写入;
* skipped:表结构里没有这张表;cancelled:停止或服务重启时还没开始、没写完
*/
export type CatalogDraftTaskStatus = 'pending' | 'running' | 'ok' | 'partial' | 'failed' | 'skipped' | 'cancelled'
/** 起草任务里的一张表(按起草顺序) */
export interface CatalogDraftTask {
table_name: string
status: CatalogDraftTaskStatus
/** 列数 */
columns: number
/** 模型分几段起草(超过 40 列的宽表拆成表级一段加列分段);不用模型时为 null */
parts_total: number | null
parts_done: number
added: number
updated: number
removed: number
version: number
/** 这张表没有起草(表结构里没有、写入一直冲突) */
error: string | null
/** 只是模型那部分失败,其余来源照常写入。模型给了这张表、却一项可用内容都没有,也记在这里 */
model_error: string | null
/**
* 模型给了这张表几项可用内容(并入目录之前)。没用模型、模型这部分失败时为 null(老后端没有这个键)。
* 模型给了这张表几项可用内容(并入目录之前)。没用模型、模型这部分失败时为 null。
* 大于 0 而新增、更新、删除都是 0,才是「模型给了内容、只是和现有目录一致」
*/
model_items?: number | null
model_items: number | null
/** 整张表没起草的原因:表结构里没有、写入一直冲突、服务重启中断 */
error: string | null
/** 模型那部分没写出来的原因和办法(第几段、哪些列、为什么) */
model_error: string | null
started_at: string | null
finished_at: string | null
}
/** POST /datasources/{id}/catalog/draft */
export interface CatalogDraftOut {
tables: CatalogDraftRow[]
total: { added: number; updated: number; removed: number }
model_used: boolean
/** 一次起草任务 */
export interface CatalogDraftJob {
id: string
source_id: string
status: CatalogDraftJobStatus
use_model: boolean
/** 实际用的模型 id;不用模型、还没开始时为 null */
model: string | null
/** 模型整体用不了(没有配置、已停用……):只按注释、外键和命名起草 */
/** 这次用到的起草要求(数据源级那份的快照) */
guidance: string | null
/** 这次的补充说明 */
note: string | null
created_at: string
started_at: string | null
finished_at: string | null
/** 谁建的(X-Actor) */
created_by: string | null
/** 表数 */
total: number
/** 已结束的表数 */
done: number
counts: Record<CatalogDraftTaskStatus, number>
added: number
updated: number
removed: number
/** 模型调用的用量;拿不到用量时照实是 0 */
usage: { calls: number; input_tokens: number; output_tokens: number }
/** 任务整体出错的原因 */
error: string | null
tasks: CatalogDraftTask[]
}
/** 目录清单顶上的进行中任务(GET /datasources/{id}/catalog 的 active_job) */
export interface CatalogActiveDraftJob {
id: string
status: CatalogDraftJobStatus
total: number
done: number
}
/** 清单一行上这张表最近一次起草的结果 */
export interface CatalogLastDraft {
status: CatalogDraftTaskStatus
job_id: string
finished_at: string | null
error: string | null
model_error: string | null
}
+541 -116
View File
@@ -2,8 +2,10 @@
//
// 数据目录的写操作都会改变助手和将来的 SQL 检查读到的东西:确认一项、驳回一项、整份提交都不是随手可以撤回的。
// 所以这里查的是「请求里带没带读到的版本」「批量确认只动推断项、来源不变」「编辑时没动的项原样交回(被驳回的照旧
// 驳回)」「别人刚改过时有没有停下来让人重新载入,而不是悄悄覆盖」「起草按批调用、能停、出错时说得清」——
// 正常路径的页面检查一个字都不会说。
// 驳回)」「别人刚改过时有没有停下来让人重新载入,而不是悄悄覆盖」——正常路径的页面检查一个字都不会说。
// 起草是后台任务:查的是「起草要求和补充说明有没有随请求发出」「关掉对话框任务照常进行、页面上有进度、再打开就是它」
// 「能停」「哪张表没写全、为什么写得清」「能只重试失败的」「已有任务、模型用不了这两种 409 各有去处」「清单上标出上次
// 起草失败的表、能单独重起草」。任务怎么往前走由检查自己推,页面每 1.5 秒取一次。
// 数据剖析(阶段 4B)会对业务库发查询:剖析接口、保存数据源一律在浏览器层答掉,查的是「确认前写没写预算」「进行中是否
// 明说停不下来」「报告说没说清用了几条查询、为什么停、跳过了什么」「各种 409 有没有下一步」「设置被拒时报错落没落到那一项」。
//
@@ -249,8 +251,10 @@ const manual = (id, name, count) => ({
...blank, id, name, origin: 'manual', database: `/tmp/check/${name}.db`, table_count: count, tools: [`db_query__${name}`],
schema_synced_at: count ? T0 : null,
})
/** 景区库的数据源说明:起草时连同起草要求一起交给模型,起草对话框里写明 */
const S1_DESCRIPTION = '虚构的景区票务与客流库,供检查使用'
const SOURCES = [
manual(S1, 'zzcatscenic', 151),
{ ...manual(S1, 'zzcatscenic', 151), description: S1_DESCRIPTION },
{
...blank, id: S2, name: 'zzcatupload', origin: 'upload', table_count: 2, tools: ['db_query__zzcatupload'], schema_synced_at: T0,
database: `/tmp/check/uploads/${S2}.db`, import_mode: 'simple', current_recipe: null, open_staging: null,
@@ -261,6 +265,9 @@ const SOURCES = [
]
const NAMES = SOURCES.map((s) => s.name)
/** 景区库存着的起草要求(数据源级,PUT /catalog/guidance 改它) */
const S1_GUIDANCE = '景区票务库:「核销」指闸机检票入园;金额单位是元,人数单位是人'
const freshState = () => ({
sources: SOURCES,
tables: {
@@ -269,6 +276,13 @@ const freshState = () => ({
[S3]: [],
[S4]: ['parking_lots', 'parking_records', 'shuttle_trips'].map((n, i) => table(n, 3 - i, [pk(), col('name', 'TEXT')], null)),
},
// 起草任务(后台任务):建的先后就是数组的先后
jobs: [],
jobSeq: 0,
guidance: { [S1]: S1_GUIDANCE },
// 设置里的起草模型(没配:跟随助手的模型)和助手当前用的模型
draftModel: { provider: null, model: null },
assistantModel: { configured: true, provider: null, model: null, effective_provider: 'check-gateway', effective_model: 'check-assistant-model', error: null },
})
// ---------------------------------------------------------------------------
@@ -354,11 +368,15 @@ function listOf(state, src) {
const all = state.tables[src]
const rows = all.filter((t) => !t.missing).sort((a, b) => b.usage - a.usage).map(rowOf)
rows.push(...all.filter((t) => t.missing).map(rowOf))
// 每行带这张表最近一次起草任务里的那一项(夹具可以直接给 lastDraft,任务里有这张表时以任务为准)
for (const r of rows) r.last_draft = lastDraftOf(state, src, r.table_name)
// 探查结构截断了的源(state.schemaTotal 里给了数据库里的总数):清单只有探查到的那部分
const explored = all.filter((t) => !t.missing).length
const total = state.schemaTotal?.[src] ?? explored
const active = state.jobs.find((j) => j.source_id === src && JOB_ACTIVE.has(j.status))
return { tables: rows, system_notes: isUpload(src), schema_note: all.length ? null : '尚未探查结构',
schema_truncated: total > explored, schema_total: total }
schema_truncated: total > explored, schema_total: total, guidance: state.guidance[src] ?? null,
active_job: active ? (({ id, status, total: n, done }) => ({ id, status, total: n, done }))(jobOut(active)) : null }
}
const detailOf = (src, t) => ({
table_name: t.name, in_schema: !t.missing, notes: t.notes ?? {}, version: t.version, updated_at: t.updated_at, updated_by: t.updated_by,
@@ -381,18 +399,172 @@ const writeEntry = (t, notes) => {
t.updated_by = ACTOR
}
/** 起草的默认回答:每张表新增 2 项;没有目录的表顺手填上一个推断的中文名,清单重取后看得出来 */
function draftReply(state) {
return (route, { key, body }) => {
const src = parts(key)[2]
const rows = (body.tables ?? []).map((name) => {
const t = state.tables[src].find((x) => x.name === name)
if (t && !t.notes) writeEntry(t, { label: item(`${name} 的推断名`, 'name') })
return { table_name: name, added: 2, updated: 0, removed: 0, version: t?.version ?? 0, error: null, model_error: null }
})
const total = { added: rows.length * 2, updated: 0, removed: 0 }
return json({ tables: rows, total, model_used: !!body.use_model, model: body.use_model ? 'check-model' : null, model_error: null })(route)
// ---------------------------------------------------------------------------
// 起草任务(后台任务,/api/datasources/{id}/catalog/draft-jobs)。任务怎么往前走由检查自己推(settle、finishJob、
// stopJob):页面每 1.5 秒取一次,看到的就是推到的那一步。不按时间自己走,结果不受机器快慢影响
// ---------------------------------------------------------------------------
const JOB_ACTIVE = new Set(['queued', 'running'])
const TASK_END = new Set(['ok', 'partial', 'failed', 'skipped', 'cancelled'])
const RETRYABLE = new Set(['failed', 'partial', 'cancelled'])
const isoNow = () => new Date().toISOString()
/** 服务端的两种 409(机读码 + 一句中文) */
const BUSY = { detail: '这个数据源已有进行中的起草任务,请等它结束或停止后再开始', code: 'catalog_draft_busy' }
const MODEL_DOWN = { detail: '未配置模型接入,无法由助手起草数据目录。请到「设置 → 模型接入」添加', code: 'catalog_draft_model_unavailable' }
/** 起草时每段最多几列(服务端的常量):超过的宽表拆成「表级一段 + 列分段」 */
const PART_COLUMNS = 40
/** 建一个排队的任务:表结构里没有的表照样建进任务、记 skipped */
function newJob(state, src, { tables, use_model, note = null }) {
const id = `check-job-${++state.jobSeq}`
const tasks = tables.map((name) => {
const t = state.tables[src].find((x) => x.name === name && !x.missing)
const cols = t?.columns?.length ?? 0
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,
error: t ? null : `表结构里没有 ${name},请先重新探查结构`, model_error: null,
started_at: null, finished_at: t ? null : isoNow(),
}
})
const job = {
id, source_id: src, status: 'queued', use_model: !!use_model, model: use_model ? 'check-draft-model' : null,
guidance: state.guidance[src] ?? null, note, created_at: isoNow(), started_at: null, finished_at: null, created_by: ACTOR,
usage: { calls: 0, input_tokens: 0, output_tokens: 0 }, error: null, tasks,
}
state.jobs.push(job)
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') })
}
/**
* 推进一张表:outcome.status 默认 ok(写完),给 running 就是还在起草(可带 parts_done)。写进了东西的表给它
* 一个起草出的中文名,清单重取后看得出来。用模型的每次推进记一次调用
*/
function settle(state, job, name, outcome = {}) {
const task = job.tasks.find((t) => t.table_name === name)
if (job.status === 'queued') Object.assign(job, { status: 'running', started_at: isoNow() })
const status = outcome.status ?? 'ok'
Object.assign(task, { started_at: task.started_at ?? isoNow() }, outcome, { status })
if (TASK_END.has(status)) {
task.finished_at = isoNow()
if (status === 'ok' && task.parts_total != null && outcome.parts_done == null) task.parts_done = task.parts_total
if (task.added > 0) {
const t = state.tables[job.source_id].find((x) => x.name === name)
if (t) {
writeEntry(t, { ...(t.notes ?? {}), label: item(`${name} 的起草名`) })
task.version = t.version
}
}
}
if (job.use_model) {
job.usage.calls += 1
job.usage.input_tokens += 1200
job.usage.output_tokens += 2400
}
}
/** 排队的任务开始起草(服务端轮到它了) */
const runJob = (job) => Object.assign(job, { status: 'running', started_at: isoNow() })
/** 收尾:有失败、部分完成、停止的表就是 partial */
function finishJob(job, status) {
const bad = job.tasks.some((t) => RETRYABLE.has(t.status))
Object.assign(job, { status: status ?? (bad ? 'partial' : 'succeeded'), finished_at: isoNow() })
job.started_at ??= job.finished_at
}
/** 停下:没开始和没写完的表记 cancelled,已经写进目录的不动 */
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() })
}
delete job.stopAsked
finishJob(job, status)
}
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 }
}
return state.tables[src]?.find((x) => x.name === name)?.lastDraft ?? null
}
const jobOf = (state, key) => {
const [, , src, , , id] = parts(key)
return state.jobs.find((j) => j.id === id && j.source_id === src) ?? null
}
const activeOf = (state, src) => state.jobs.find((j) => j.source_id === src && JOB_ACTIVE.has(j.status)) ?? null
function jobHandlers(state, reply) {
const NO_JOB = json({ detail: '找不到这次起草任务' }, 404)
return [
// 起草要求(数据源级):空串清除,超长 422
[/^PUT \/datasources\/check-cat-[a-z]+\/catalog\/guidance$/, reply('guidance', (route, { key, body }) => {
const g = String(body?.guidance ?? '').trim()
if ([...g].length > 2000) return json({ detail: '起草要求不能超过 2,000 字' }, 422)(route)
state.guidance[parts(key)[2]] = g || null
return json({ guidance: g || null })(route)
})],
// 建任务:同一个源已有排队、进行中的 409;不给 tables 取使用次数前 20 张
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs$/, reply('createJob', (route, { key, body }) => {
const src = parts(key)[2]
if (activeOf(state, src)) return json(BUSY, 409)(route)
const tables = body?.tables?.length ? body.tables
: state.tables[src].filter((t) => !t.missing).sort((a, b) => b.usage - a.usage).slice(0, 20).map((t) => t.name)
const job = newJob(state, src, { tables, use_model: !!body?.use_model, note: body?.note?.trim() || null })
return json({ job: jobOut(job) }, 202)(route)
})],
[/^GET \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs\/active$/, (route, { key }) => {
const job = activeOf(state, parts(key)[2])
return json({ job: job && jobOut(job) })(route)
}],
[/^GET \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs\/latest$/, (route, { key }) => {
const job = state.jobs.filter((j) => j.source_id === parts(key)[2]).at(-1) ?? null
return json({ job: job && jobOut(job) })(route)
}],
// 停止:排队的直接停;进行中的记下来,等检查用 stopJob 推到底(这期间页面应当写「正在停止」)
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs\/[^/]+\/cancel$/, reply('cancel', (route, { key }) => {
const job = jobOf(state, key)
if (!job) return NO_JOB(route)
if (!JOB_ACTIVE.has(job.status)) return json({ detail: '这次起草已经结束', code: 'catalog_draft_finished' }, 409)(route)
if (job.status === 'queued') stopJob(job)
else job.stopAsked = true
return json({ job: jobOut(job) })(route)
})],
// 重试:新任务只含失败、部分完成、停止的表,沿用用模型和补充说明
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs\/[^/]+\/retry$/, reply('retry', (route, { key }) => {
const old = jobOf(state, key)
if (!old) return NO_JOB(route)
if (activeOf(state, old.source_id)) return json(BUSY, 409)(route)
const tables = old.tasks.filter((t) => RETRYABLE.has(t.status)).map((t) => t.table_name)
if (!tables.length) return json({ detail: '这次起草没有需要重试的表' }, 422)(route)
const job = newJob(state, old.source_id, { tables, use_model: old.use_model, note: old.note })
return json({ job: jobOut(job) }, 202)(route)
})],
[/^GET \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs\/[^/]+$/, (route, { key }) => {
const job = jobOf(state, key)
return job ? json({ job: jobOut(job) })(route) : NO_JOB(route)
}],
// 起草对话框写明用哪个起草模型:设置里的 catalog_draft(其余设置照真服务端的)、助手当前用的模型
[/^GET \/settings$/, async (route) => {
const real = await route.fetch()
const body = await real.json().catch(() => ({}))
return route.fulfill({ response: real, json: { ...body, catalog_draft: state.draftModel } })
}],
[/^GET \/copilot\/model$/, (route) => json(state.assistantModel)(route)],
]
}
function catalogHandlers(state, replies = {}) {
@@ -407,7 +579,10 @@ function catalogHandlers(state, replies = {}) {
[/^GET \/datasources\/check-cat-[a-z]+\/schema$/, (route, { key }) =>
json({ tables: state.tables[parts(key)[2]].filter((t) => !t.missing).map((t) => t.name), summary: '', synced_at: T0 })(route)],
[/^GET \/datasources\/check-cat-[a-z]+\/catalog$/, (route, { key }) => json(listOf(state, parts(key)[2]))(route)],
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/draft$/, reply('draft', draftReply(state))],
// 起草任务、起草要求:放在单表的 GET / PUT 前面(catalog/guidance 也对得上单表 PUT 的写法)
...jobHandlers(state, reply),
// 旧的同步起草接口:前端不该再用它,调了就答 410,下面的检查会数它
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/draft$/, json({ detail: '检查脚本:前端不该再调用同步起草接口' }, 410)],
// 影响面:引用这张表的已发布、受管模板(只读)。没配的表答空
[/^GET \/datasources\/check-cat-[a-z]+\/catalog\/[^/]+\/impact$/, reply('impact', (route, { key }) => {
const name = parts(key)[4]
@@ -450,6 +625,32 @@ const confirmBox = (page) => page.locator('[role="dialog"]').filter({ hasNot: pa
.filter({ hasNot: page.locator('[data-item-panel]') }).last()
const consequences = async (page) => (await confirmBox(page).locator('ul li').allInnerTexts()).map((t) => t.trim())
const draftBox = (page) => page.locator('[data-draft-dialog]')
/** 起草对话框在哪一步:setup / running / done;没开着为 null */
const draftPhase = (page) => draftBox(page).getAttribute('data-draft-dialog', { timeout: 500 }).catch(() => null)
/** 对话框里某一处的文字(空白压成一个空格);没有这一处为空串 */
const draftText = async (page, sel) => (await draftBox(page).locator(sel).first().innerText({ timeout: 500 }).catch(() => '')).replace(/\s+/g, ' ').trim()
/** 选几张表(第一张点、最后一张 Shift 点)或者不选,打开起草对话框,按需勾用模型、写补充说明,开始;等到进入进行中 */
async function startDraft(page, { select = null, model = false, note = '' } = {}) {
if (select) {
await page.locator(`[data-catalog-select="${select[0]}"]`).click()
if (select.length > 1) await page.locator(`[data-catalog-select="${select.at(-1)}"]`).click({ modifiers: ['Shift'] })
await page.locator('[data-catalog-draft-selected]').click()
} else {
await page.locator('[data-catalog-draft]').first().click()
}
await draftBox(page).waitFor()
if (model) await draftBox(page).locator('[data-draft-model] input').check()
if (note) await draftBox(page).locator('[data-draft-note] textarea').fill(note)
await page.locator('[data-draft-start]').click()
return until(async () => (await draftPhase(page)) === 'running')
}
/** 起草用的几句话(虚构) */
const GUIDANCE2 = '景区票务库:「核销」指闸机检票入园;金额单位是元。会员相关的表统一写「会员」,不写「用户」'
const DRAFT_NOTE = '这一批是闸机和会员相关的表'
const WIDE_ERR = '这张表 50 列,分 3 段起草,第 2 段没写出来(共 10 列):模型的思考和输出用完了额度(32768 个 token),已拆小重试仍没写出。可以只重试这张表'
const CALL_ERR = '调用模型出错:服务暂时不可用(已重试 2 次)'
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]')
@@ -691,10 +892,9 @@ await section('数据目录 · 用到但没确认(顶部摘要)', async () =
await close()
})
await section('数据目录 · 起草', async () => {
await section('数据目录 · 起草:设置与开始', async () => {
const state = freshState()
const MODEL_ERR = '未配置模型接入,无法由助手起草数据目录。请到「设置 → 模型接入」添加'
const replies = { draft: [] }
const replies = { createJob: [] }
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state, replies) })
await page.locator('[data-catalog-row]').first().waitFor()
@@ -707,119 +907,318 @@ await section('数据目录 · 起草', async () => {
check('起草范围默认是「已选的 3 张表」', await draftBox(page).locator('[data-draft-scope="selected"] input').isChecked()
&& (await draftBox(page).locator('[data-draft-scope="selected"]').innerText()).includes('已选的 3 张表'))
check('……说明只发送表结构、不发送数据', (await draftBox(page).locator('[data-draft-model]').innerText()).includes('不发送数据'))
check('没勾「用模型起草」:不显示起草要求和补充说明(只对模型有用)',
await draftBox(page).locator('[data-draft-guidance], [data-draft-note]').count() === 0)
await draftBox(page).locator('[data-draft-model] input').check()
// 模型整体用不了:第一批就知道,结果里写明原因
replies.draft.push((route, ctx) => {
const rows = ctx.body.tables.map((n) => ({ table_name: n, added: 4, updated: 1, removed: 0, version: 1, error: null, model_error: null }))
for (const n of ctx.body.tables) writeEntry(state.tables[S1].find((t) => t.name === n), { label: item(`${n} 的推断名`, 'name') })
return json({ tables: rows, total: { added: 12, updated: 3, removed: 0 }, model_used: false, model: null, model_error: MODEL_ERR })(route)
})
const listsBefore = count(sent, /^GET \/datasources\/check-cat-scenic\/catalog$/)
await page.locator('[data-draft-start]').click()
check('起草完成:写出「新增 12 项、更新 3 项」', await until(async () => (await draftBox(page).locator('[data-draft-summary]').innerText().catch(() => '')) === '新增 12 项、更新 3 项'),
await draftBox(page).locator('[data-draft-summary]').innerText().catch(() => ''))
const body = lastBody(sent, /catalog\/draft$/)
check('……请求带所选的表(清单顺序)和「用模型起草」', JSON.stringify(body) === JSON.stringify({ tables: ['gates', 'store_sales', 'members'], use_model: true }), JSON.stringify(body))
check('……模型用不了时写明原因', (await draftBox(page).locator('[data-draft-model-error]').innerText().catch(() => '')).includes('模型未参与起草:未配置模型接入'))
check('……进度写「已完成 3 / 3 张表」', (await draftBox(page).locator('[data-draft-progress]').innerText()) === '已完成 3 / 3 张表')
check('……写请求带本机署名', sent.filter((s) => /catalog\/draft$/.test(s.key)).every((s) => decodeURIComponent(s.actor ?? '') === ACTOR))
await shot(page, 'catalog-draft-done')
await page.locator('[data-draft-close]').click()
check('关掉后重取清单、清空选择,起草出的中文名出现在清单上', await until(async () => count(sent, /^GET \/datasources\/check-cat-scenic\/catalog$/) > listsBefore)
&& (await page.locator('[data-catalog-count]').innerText()).endsWith('张表')
&& await until(async () => (await page.locator('[data-catalog-row="gates"] [data-row-label]').innerText()) === 'gates 的推断名'))
const guidance = draftBox(page).locator('[data-draft-guidance] textarea')
const note = draftBox(page).locator('[data-draft-note] textarea')
check('勾上以后:起草要求显示数据源现在存的那一份,可以改', await guidance.inputValue().catch(() => '') === S1_GUIDANCE
&& await guidance.isEditable().catch(() => false), await guidance.inputValue().catch(() => '—'))
check('……本次补充说明是空的', await note.inputValue().catch(() => '—') === '')
check('……写明用的是哪个起草模型:设置里没配,跟随助手的模型(写出当前是哪个)',
(await draftText(page, '[data-draft-model-name]')).includes('跟随助手的模型(当前:check-assistant-model)'), await draftText(page, '[data-draft-model-name]'))
check('……数据源说明也一并交给模型:写出来', (await draftText(page, '[data-draft-source-description]')).includes(S1_DESCRIPTION),
await draftText(page, '[data-draft-source-description]'))
await shot(page, 'catalog-draft-setup')
// 全部 151 张(只剩目录的那张起草不了,不算进范围),不请模型时每批 40 张
// 超长:写明上限,不能开始
await guidance.fill('长'.repeat(2001))
check('起草要求超过 2,000 字:写明上限,「起草」不可用', (await draftText(page, '[data-draft-guidance]')).includes('不超过 2,000 字')
&& await page.locator('[data-draft-start]').isDisabled(), await draftText(page, '[data-draft-guidance]'))
await guidance.fill(GUIDANCE2)
await note.fill(DRAFT_NOTE)
// 起草模型用不了:409 带机读码,留在设置阶段写明原因,不进入进行中、不去取任务
replies.createJob.push(json(MODEL_DOWN, 409))
await page.locator('[data-draft-start]').click()
check('起草模型用不了(409):留在设置阶段,写明原因', await until(async () => (await draftText(page, '[data-draft-start-error]')).includes('未配置模型接入'))
&& (await draftPhase(page)) === 'setup', await draftText(page, '[data-draft-start-error]'))
const keys = writes(sent, /./).map((s) => s.key.replace(/^(\w+) .*\/catalog\//, '$1 '))
check('……起草要求改过:先存起草要求、再建任务', keys.join(',') === 'PUT guidance,POST draft-jobs', keys.join(','))
check('……存的是改过的那一份', JSON.stringify(lastBody(sent, /catalog\/guidance$/)) === JSON.stringify({ guidance: GUIDANCE2 }),
JSON.stringify(lastBody(sent, /catalog\/guidance$/)))
check('……没有去取任务进度', count(sent, /draft-jobs\/check-job/) === 0)
// 再点一次:这回建得起来;起草要求已经存过、没再改,不再存一次
await page.locator('[data-draft-start]').click()
check('建起来以后进入进行中', await until(async () => (await draftPhase(page)) === 'running'))
const body = lastBody(sent, /draft-jobs$/)
check('建任务的请求:所选的表(清单顺序)、用模型、本次补充说明',
JSON.stringify(body) === JSON.stringify({ tables: ['gates', 'store_sales', 'members'], use_model: true, note: DRAFT_NOTE }), JSON.stringify(body))
check('……起草要求只存了一次', count(sent, /^PUT .*catalog\/guidance$/) === 1)
check('……写请求都带本机署名', writes(sent, /catalog\/(draft-jobs|guidance)/).every((s) => decodeURIComponent(s.actor ?? '') === ACTOR))
check('进行中:逐表列出 3 张,都在等待', (await draftBox(page).locator('[data-draft-task]').count()) === 3
&& (await draftText(page, '[data-draft-task="gates"] [data-draft-task-status]')) === '等待', await draftText(page, '[data-draft-tasks]'))
check('……进度写「已完成 0 / 3 张表」', (await draftText(page, '[data-draft-progress]')) === '已完成 0 / 3 张表', await draftText(page, '[data-draft-progress]'))
check('……清单上的选择清空(这几张已经交给起草任务)', (await page.locator('[data-catalog-count]').innerText()).endsWith('张表'))
await shot(page, 'catalog-draft-running')
stopJob(state.jobs.at(-1))
check('没有调用旧的同步起草接口', count(sent, /catalog\/draft$/) === 0)
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
})
await section('数据目录 · 起草:进度、结果与重试', async () => {
const state = freshState()
const { page, sent, errors, 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, note: DRAFT_NOTE }))
const job = state.jobs.at(-1)
// 一张写完、宽表起草到第 1 段
settle(state, job, 'gates', { added: 3, model_items: 6 })
settle(state, job, 'store_sales', { status: 'running', parts_done: 1 })
check('进度:已完成 1 / 3 张表', await until(async () => (await draftBox(page).locator('[data-draft-progress]').getAttribute('data-draft-progress', { timeout: 500 }).catch(() => '')) === '1/3'),
await draftText(page, '[data-draft-progress]'))
const bar = draftBox(page).locator('[role="progressbar"]').first()
check('……进度条读屏可读:名称、当前值、上限', (await bar.getAttribute('aria-valuenow')) === '1' && (await bar.getAttribute('aria-valuemax')) === '3'
&& !!(await bar.getAttribute('aria-label')))
check('……宽表写出段进度「1 / 3 段」,状态是「起草中」', (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-parts]')) === '1 / 3 段'
&& (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-status]')) === '起草中', await draftText(page, '[data-draft-task="store_sales"]'))
check('……写完的那张写「已完成」', (await draftText(page, '[data-draft-task="gates"] [data-draft-task-status]')) === '已完成')
check('……合计写「新增 3 项、更新 0 项」', (await draftText(page, '[data-draft-summary]')) === '新增 3 项、更新 0 项', await draftText(page, '[data-draft-summary]'))
check('……写明用的模型', (await draftText(page, '[data-draft-model-used]')).includes('check-draft-model'), await draftText(page, '[data-draft-model-used]'))
check('写完的表马上并入目录:清单重取,起草出的中文名出现',
await until(async () => (await page.locator('[data-catalog-row="gates"] [data-row-label]').innerText({ timeout: 500 }).catch(() => '')) === 'gates 的起草名'))
// 宽表有一段没写出来;会员表一段都没写出来
settle(state, job, 'store_sales', { status: 'partial', parts_done: 2, added: 4, model_items: 8, model_error: WIDE_ERR })
settle(state, job, 'members', { status: 'failed', model_items: 0, model_error: CALL_ERR })
finishJob(job)
check('结束:标题写「起草完成,部分表未写全」', await until(async () => (await draftPhase(page)) === 'done')
&& (await draftText(page, '[data-draft-title]')) === '起草完成,部分表未写全', await draftText(page, '[data-draft-title]'))
const problems = await draftText(page, '[data-draft-problems]')
check('……写明哪些没写全:失败 1 张、部分完成 1 张', problems.includes('失败 1 张') && problems.includes('部分完成 1 张'), problems)
const shown = await draftBox(page).locator('[data-draft-task]').evaluateAll((els) => els.map((e) => e.getAttribute('data-draft-task')))
check('……逐表只列没写全的 2 张,各自写原因', shown.join(',') === 'store_sales,members'
&& (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-reason]')).includes('第 2 段没写出来')
&& (await draftText(page, '[data-draft-task="members"] [data-draft-task-reason]')).includes('已重试 2 次'), shown.join(','))
await draftBox(page).locator('[data-draft-show-all]').click()
check('……点「显示全部」列出 3 张', (await draftBox(page).locator('[data-draft-task]').count()) === 3)
check('……合计、用量照实写', (await draftText(page, '[data-draft-summary]')) === '新增 7 项、更新 0 项'
&& (await draftText(page, '[data-draft-usage]')).includes('调用模型 4 次'), `${await draftText(page, '[data-draft-summary]')} · ${await draftText(page, '[data-draft-usage]')}`)
check('……清单上失败的表标「上次起草失败」', await until(async () => (await page.locator('[data-catalog-row="members"] [data-row-last-draft="failed"]').count()) === 1))
await shot(page, 'catalog-draft-partial')
// 结束以后不再取进度
const polls = () => count(sent, new RegExp(`^GET .*draft-jobs/${job.id}$`))
const n = polls()
await sleep(3200)
check('结束以后不再取进度', polls() === n, `${n} → ${polls()}`)
// 只重试失败的表
const retry = page.locator('[data-draft-retry]')
check('给「只重试失败的表(2 张)」', (await retry.innerText().catch(() => '')).includes('只重试失败的表(2 张)'), await retry.innerText().catch(() => ''))
await retry.click()
check('重试:调重试接口,进入新任务的进行中,只含没写全的 2 张', await until(async () => (await draftPhase(page)) === 'running'
&& (await draftBox(page).locator('[data-draft-task]').count()) === 2) && count(sent, new RegExp(`^POST .*draft-jobs/${job.id}/retry$`)) === 1)
const job2 = state.jobs.at(-1)
check('……新任务沿用用模型和补充说明', job2.id !== job.id && job2.use_model && job2.note === DRAFT_NOTE)
settle(state, job2, 'store_sales', { added: 1, model_items: 3 })
settle(state, job2, 'members', { added: 5, model_items: 5 })
finishJob(job2)
check('……都写完:「起草完成」,不再给重试', await until(async () => (await draftText(page, '[data-draft-title]')) === '起草完成')
&& await page.locator('[data-draft-retry]').count() === 0, await draftText(page, '[data-draft-title]'))
check('……没有没写全的表时逐表全部列出', (await draftBox(page).locator('[data-draft-task]').count()) === 2)
await page.locator('[data-draft-close]').click()
check('关掉以后清单上的标记跟着最新一次起草:会员表不再标失败', await until(async () => (await page.locator('[data-catalog-row="members"] [data-row-last-draft]').count()) === 0))
// 一项都没写进去:只有「模型给了内容、只是和现有目录一致」才说「没有变化」
const nothing = async (outcome) => {
await startDraft(page, { select: ['parks'], model: true })
const j = state.jobs.at(-1)
settle(state, j, 'parks', outcome)
finishJob(j)
await until(async () => (await draftPhase(page)) === 'done')
const out = await draftText(page, '[data-draft-summary]')
await page.locator('[data-draft-close]').click()
return out
}
check('模型一项可用内容都没给:不说「没有变化」,说「目录没有更新」', (await nothing({ status: 'failed', model_items: 0, model_error: '模型没有给出这张表的可用内容' }))
=== '目录没有更新:模型没有给出可用的内容,原因见下方')
check('……模型给了内容、只是和现有目录一致:才说「没有变化」', (await nothing({ model_items: 3 })) === '没有变化:起草出的内容与现有目录一致')
check('没有调用旧的同步起草接口', count(sent, /catalog\/draft$/) === 0)
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
})
await section('数据目录 · 起草:停止、后台继续与再打开', async () => {
const state = freshState()
const { page, sent, errors, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
await page.locator('[data-catalog-row]').first().waitFor()
// 页头「起草」:没选表,范围默认是使用次数最多的 20 张
await page.locator('[data-catalog-draft]').first().click()
await draftBox(page).waitFor()
const scopes = await draftBox(page).locator('[data-draft-scope]').evaluateAll((els) => els.map((e) => e.getAttribute('data-draft-scope')))
check('没选表时:范围是「使用次数最多的 20 张」「全部 151 张」,默认前者', scopes.join(',') === 'top,all'
&& await draftBox(page).locator('[data-draft-scope="top"] input').isChecked()
&& (await draftBox(page).locator('[data-draft-scope="all"]').innerText()).includes('全部 151 张表'), scopes.join(','))
await draftBox(page).locator('[data-draft-scope="all"]').click()
const n0 = count(sent, /catalog\/draft$/)
await page.locator('[data-draft-start]').click()
await until(async () => (await draftBox(page).getAttribute('data-draft-dialog')) === 'done', 8000)
const batches = sent.filter((s) => /catalog\/draft$/.test(s.key)).slice(n0).map((s) => s.body)
check('表多时分批调用:40、40、40、31 张,都不请模型', batches.map((b) => b.tables.length).join(',') === '40,40,40,31'
&& batches.every((b) => b.use_model === false) && !batches.flatMap((b) => b.tables).includes('legacy_coupons'),
batches.map((b) => b.tables.length).join(','))
check('……进度写满、合计各批的结果', (await draftBox(page).locator('[data-draft-progress]').innerText()) === '已完成 151 / 151 张表'
&& (await draftBox(page).locator('[data-draft-summary]').innerText()) === '新增 302 项、更新 0 项')
check('……进度条读屏可读(progressbar 带当前值)', (await draftBox(page).locator('[role="progressbar"]').getAttribute('aria-valuenow')) === '151')
await page.locator('[data-draft-close]').click()
// 请模型时每批 12 张
await page.locator('[data-catalog-draft]').first().click()
check('……没有上一次起草时不写「上一次」', await draftBox(page).locator('[data-draft-last]').count() === 0)
await draftBox(page).locator('[data-draft-model] input').check()
const n1 = count(sent, /catalog\/draft$/)
await page.locator('[data-draft-start]').click()
await until(async () => (await draftBox(page).getAttribute('data-draft-dialog')) === 'done', 8000)
const modelBatches = sent.filter((s) => /catalog\/draft$/.test(s.key)).slice(n1).map((s) => s.body)
check('用模型起草前 20 张:每批 12 张(12、8),都请模型', modelBatches.map((b) => `${b.tables.length}:${b.use_model}`).join(',') === '12:true,8:true',
modelBatches.map((b) => `${b.tables.length}:${b.use_model}`).join(','))
check('……写出用的是哪个模型', (await draftBox(page).innerText()).includes('模型:check-model'))
await page.locator('[data-draft-close]').click()
await until(async () => (await draftPhase(page)) === 'running')
const body = lastBody(sent, /draft-jobs$/) ?? {}
check('请求带使用次数最多的 20 张表(不含只剩目录的那张),补充说明空着就不带', body.tables?.length === 20 && !body.tables.includes('legacy_coupons')
&& body.use_model === true && !('note' in body), JSON.stringify(body).slice(0, 160))
const job = state.jobs.at(-1)
check('刚建好还在排队:标题写「排队中」', (await draftText(page, '[data-draft-title]')).startsWith('排队中'), await draftText(page, '[data-draft-title]'))
runJob(job)
check('……轮到它了:「正在起草…」', await until(async () => (await draftText(page, '[data-draft-title]')) === '正在起草…'), await draftText(page, '[data-draft-title]'))
// 一项都没写进去:只有「模型给了内容、只是和现有目录一致」才说「没有变化」,模型没给出内容要说「目录没有更新」
const emptyReply = ({ items, modelError }) => (route, ctx) => json({
tables: ctx.body.tables.map((n) => ({ table_name: n, added: 0, updated: 0, removed: 0, version: 1, error: null,
model_error: modelError, model_items: items })),
total: { added: 0, updated: 0, removed: 0 }, model_used: true, model: 'check-model', model_error: null,
})(route)
const draftOnce = async (reply) => {
replies.draft.push(reply, reply)
await page.locator('[data-catalog-draft]').first().click()
await draftBox(page).locator('[data-draft-model] input').check()
await page.locator('[data-draft-start]').click()
await until(async () => (await draftBox(page).getAttribute('data-draft-dialog')) === 'done', 8000)
const summary = await draftBox(page).locator('[data-draft-summary]').innerText()
const notice = await draftBox(page).locator('[data-draft-table-model-errors]').innerText().catch(() => '')
await page.locator('[data-draft-close]').click()
return { summary, notice }
}
const noContent = await draftOnce(emptyReply({ items: null, modelError: '模型没有给出这张表的可用内容' }))
check('模型没给出内容(每张表记 model_error):不说「没有变化」,说「目录没有更新」并列出原因',
noContent.summary === '目录没有更新:模型没有给出可用的内容,原因见下方' && noContent.notice.includes('模型没有给出这张表的可用内容'),
JSON.stringify(noContent))
const zeroItems = await draftOnce(emptyReply({ items: 0, modelError: null }))
check('……模型一项可用内容都没给(model_items 都是 0):同样不说「没有变化」', zeroItems.summary === '目录没有更新:模型没有给出可用的内容,原因见下方',
JSON.stringify(zeroItems))
const same = await draftOnce(emptyReply({ items: 3, modelError: null }))
check('……模型给了内容、只是和现有目录一致:才说「没有变化」', same.summary === '没有变化:起草出的内容与现有目录一致', JSON.stringify(same))
// 中途停止:当前这一批做完后不再发下一批
replies.draft.push(async (route, ctx) => { await sleep(900); return draftReply(state)(route, ctx) })
await page.locator('[data-catalog-draft]').first().click()
await draftBox(page).locator('[data-draft-scope="all"]').click()
const n2 = count(sent, /catalog\/draft$/)
await page.locator('[data-draft-start]').click()
// 关掉对话框:任务不停,目录页上方显示进度
await page.locator('[data-draft-hide]').click()
check('「在后台继续」:对话框关上,没有发停止', await until(async () => (await draftBox(page).count()) === 0) && count(sent, /\/cancel$/) === 0)
const strip = page.locator('[data-catalog-draft-progress]')
check('……目录页上方显示一条进度', await until(async () => (await strip.count()) === 1) && (await strip.innerText()).includes('已完成 0 / 20 张表'),
await strip.innerText({ timeout: 500 }).catch(() => ''))
settle(state, job, 'visits', { added: 2 })
settle(state, job, 'orders', { added: 1 })
check('……进度跟着任务走', await until(async () => (await strip.getAttribute('data-catalog-draft-progress', { timeout: 500 }).catch(() => '')) === '2/20'),
await strip.getAttribute('data-catalog-draft-progress', { timeout: 500 }).catch(() => ''))
check('……进度条读屏可读', (await strip.locator('[role="progressbar"]').getAttribute('aria-valuenow').catch(() => '')) === '2')
await shot(page, 'catalog-draft-strip')
// 点开回到对话框:直接是进行中的那个任务
await strip.locator('[data-catalog-draft-open]').click()
check('点开:回到对话框,直接显示进行中的任务', await until(async () => (await draftPhase(page)) === 'running')
&& (await draftText(page, '[data-draft-progress]')) === '已完成 2 / 20 张表', await draftText(page, '[data-draft-progress]'))
// 停止:调停止接口;服务端停下正在进行的调用之前写「正在停止」
await page.locator('[data-draft-stop]').click()
check('点「停止」后写「当前这一批完成后停止…」', (await draftBox(page).locator('[data-draft-title]').innerText()) === '当前这一批完成后停止…')
await until(async () => (await draftBox(page).getAttribute('data-draft-dialog')) === 'done', 5000)
check('……这一批做完就停:只发了 1 批,写明完成了多少', count(sent, /catalog\/draft$/) - n2 === 1
&& (await draftBox(page).locator('[data-draft-title]').innerText()) === '已停止:完成 40 / 151 张表,其余未起草')
check('点「停止」:调停止接口,写「正在停止」,停止按钮不能再点', await until(async () => (await draftText(page, '[data-draft-title]')).startsWith('正在停止'))
&& count(sent, new RegExp(`^POST .*draft-jobs/${job.id}/cancel$`)) === 1 && await page.locator('[data-draft-stop]').isDisabled(),
await draftText(page, '[data-draft-title]'))
stopJob(job)
check('……停下来:标题写「已停止」,没开始的 18 张记已停止', await until(async () => (await draftPhase(page)) === 'done')
&& (await draftText(page, '[data-draft-title]')) === '已停止' && (await draftText(page, '[data-draft-problems]')).includes('已停止 18 张'),
`${await draftText(page, '[data-draft-title]')} · ${await draftText(page, '[data-draft-problems]')}`)
check('……已经写进目录的不回滚:合计照实写', (await draftText(page, '[data-draft-summary]')) === '新增 3 项、更新 0 项', await draftText(page, '[data-draft-summary]'))
check('……可以只重试没写的 18 张', (await page.locator('[data-draft-retry]').innerText().catch(() => '')).includes('(18 张)'))
await page.locator('[data-draft-close]').click()
// 第二批出错:停下来,写服务端原话;个别表没起草、模型只在个别表上失败的分开列
replies.draft.push((route, ctx) => {
const rows = ctx.body.tables.map((n, i) => ({ table_name: n, added: 1, updated: 0, removed: 0, version: 1,
error: i === 0 ? '这张表的数据目录正被其他人修改,请稍后再起草' : null, model_error: i === 1 ? '模型返回的内容无法解析' : null }))
return json({ tables: rows, total: { added: 39, updated: 0, removed: 0 }, model_used: true, model: 'check-model', model_error: null })(route)
})
replies.draft.push(json({ detail: '服务端处理起草时出错,请稍后重试' }, 500))
// 再起一个(不用模型),按 Esc 关掉对话框,重新载入页面:清单带着进行中的任务,打开就是它
await page.locator('[data-catalog-draft]').first().click()
await draftBox(page).locator('[data-draft-scope="all"]').click()
await draftBox(page).waitFor()
check('上一次停止了:设置阶段写明上一次的结果,可以点开看', (await draftText(page, '[data-draft-last]')).includes('已停止')
&& await draftBox(page).locator('[data-draft-last-open]').count() === 1, await draftText(page, '[data-draft-last]'))
await page.locator('[data-draft-start]').click()
await until(async () => (await draftBox(page).getAttribute('data-draft-dialog')) === 'done', 5000)
const text = await draftBox(page).innerText()
check('第二批出错:写「起草中断」和服务端原话,进度停在 40', text.includes('起草中断') && text.includes('服务端处理起草时出错')
&& (await draftBox(page).locator('[data-draft-progress]').innerText()) === '已完成 40 / 151 张表', text.replace(/\s+/g, ' ').slice(0, 160))
check('……没起草的表单独列出(1 张)', (await draftBox(page).locator('[data-draft-table-errors]').innerText()).includes('1 张表未能起草'))
check('……模型只在个别表上失败的另列(1 张),说明已按其余来源起草', (await draftBox(page).locator('[data-draft-table-model-errors]').innerText()).includes('1 张表的模型起草失败'))
await shot(page, 'catalog-draft-failed')
check('只发过起草请求,没有别的写请求', writes(sent, /./).every((s) => /catalog\/draft$/.test(s.key)), JSON.stringify(writes(sent, /./).filter((s) => !/catalog\/draft$/.test(s.key)).map((s) => s.key)))
check('没有原生对话框', natives.length === 0, natives.join(' | '))
await until(async () => (await draftPhase(page)) === 'running')
const job3 = state.jobs.at(-1)
check('不用模型:请求的用模型为假,逐表不写段进度', lastBody(sent, /draft-jobs$/)?.use_model === false
&& await draftBox(page).locator('[data-draft-task-parts]').count() === 0)
await page.keyboard.press('Escape')
check('按 Esc 关掉对话框也不停任务', await until(async () => (await draftBox(page).count()) === 0)
&& count(sent, new RegExp(`^POST .*draft-jobs/${job3.id}/cancel$`)) === 0)
await goto(page, `/data/catalog/${S1}`)
check('重新载入页面:清单带着进行中的任务,上方照样显示进度', await until(async () => (await page.locator('[data-catalog-draft-progress]').count()) === 1))
await page.locator('[data-catalog-draft]').first().click()
check('……点「起草」直接显示进行中的任务,不是重新选范围', await until(async () => (await draftPhase(page)) === 'running')
&& await draftBox(page).locator('[data-draft-scope]').count() === 0)
await page.locator('[data-draft-hide]').click()
// 关着对话框时结束:提示,带「查看结果」
for (const t of job3.tasks) if (t.status === 'pending') settle(state, job3, t.table_name, { added: 1 })
finishJob(job3)
const toastBox = page.locator('[data-toast]').filter({ hasText: '起草完成' })
check('关着对话框时结束:提示「数据目录起草完成」,带「查看结果」', await until(async () => (await toastBox.count()) > 0),
await page.locator('[data-toast]').allInnerTexts().then((t) => t.join(' | ')).catch(() => ''))
check('……上方的进度收起', (await page.locator('[data-catalog-draft-progress]').count()) === 0)
await toastBox.getByRole('button', { name: '查看结果' }).click()
check('……点「查看结果」打开这一次的结果', await until(async () => (await draftPhase(page)) === 'done')
&& (await draftText(page, '[data-draft-title]')) === '起草完成', await draftText(page, '[data-draft-title]'))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
})
await section('数据目录 · 起草:已有任务(409)与服务重启中断', async () => {
const state = freshState()
// 上一次起草因服务重启中断:没结束的表记已停止,写明原因
const old = newJob(state, S1, { tables: ['visits', 'orders', 'parks'], use_model: true, note: DRAFT_NOTE })
settle(state, old, 'visits', { added: 2 })
stopJob(old, 'interrupted', '服务重启,起草中断')
const { page, sent, errors, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
await page.locator('[data-catalog-row]').first().waitFor()
await page.locator('[data-catalog-draft]').first().click()
await draftBox(page).waitFor()
check('上一次起草因服务重启中断:设置阶段写明,给「查看」', await until(async () => (await draftText(page, '[data-draft-last]')).includes('服务重启')),
await draftText(page, '[data-draft-last]'))
await draftBox(page).locator('[data-draft-last-open]').click()
check('……点开:结果写明服务重启中断,可以接着重试', await until(async () => (await draftPhase(page)) === 'done')
&& (await draftText(page, '[data-draft-title]')) === '服务重启,起草中断' && (await draftText(page, '[data-draft-interrupted]')).includes('重试'),
`${await draftText(page, '[data-draft-title]')} · ${await draftText(page, '[data-draft-interrupted]')}`)
check('……没写的 2 张写原因', (await draftText(page, '[data-draft-task="orders"] [data-draft-task-reason]')).includes('服务重启,起草中断'))
await shot(page, 'catalog-draft-interrupted')
await page.locator('[data-draft-retry]').click()
check('……「只重试失败的表」接着起草没写的 2 张', await until(async () => (await draftPhase(page)) === 'running')
&& JSON.stringify(state.jobs.at(-1).tasks.map((t) => t.table_name)) === JSON.stringify(['orders', 'parks']))
const again = state.jobs.at(-1)
settle(state, again, 'orders', { added: 1 })
settle(state, again, 'parks', { added: 1 })
finishJob(again)
await until(async () => (await draftPhase(page)) === 'done')
await page.locator('[data-draft-close]').click()
// 别处(另一个标签页)又起了一个任务,这一页还不知道:点开始时服务端回 409,转去显示那一个
await page.locator('[data-catalog-draft]').first().click()
await draftBox(page).waitFor()
const other = newJob(state, S1, { tables: ['gates', 'members'], use_model: true })
settle(state, other, 'gates', { added: 1 })
await page.locator('[data-draft-start]').click()
check('已有进行中的任务(409):转去显示那一个,并写明', await until(async () => (await draftPhase(page)) === 'running')
&& (await draftText(page, '[data-draft-busy]')).includes('已有进行中的起草任务')
&& (await draftText(page, '[data-draft-progress]')) === '已完成 1 / 2 张表', `${await draftText(page, '[data-draft-busy]')} · ${await draftText(page, '[data-draft-progress]')}`)
check('……取的是进行中的那一个', count(sent, /draft-jobs\/active$/) >= 1 && count(sent, new RegExp(`^GET .*draft-jobs/${other.id}$`)) >= 0)
stopJob(other)
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
})
await section('数据目录 · 起草:清单上的失败标记与单表重起草', async () => {
const state = freshState()
const replies = { createJob: [] }
const FAIL = '模型 420 秒内没有写完这一段'
const tOf = (n) => state.tables[S1].find((t) => t.name === n)
tOf('orders').lastDraft = { status: 'failed', job_id: 'check-job-old', finished_at: T0, error: null, model_error: FAIL }
tOf('order_items').lastDraft = { status: 'partial', job_id: 'check-job-old', finished_at: T0, error: null, model_error: WIDE_ERR }
tOf('parks').lastDraft = { status: 'ok', job_id: 'check-job-old', finished_at: T0, error: null, model_error: null }
const { page, sent, errors, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state, replies) })
await page.locator('[data-catalog-row]').first().waitFor()
const mark = (n) => page.locator(`[data-catalog-row="${n}"] [data-row-last-draft]`)
check('上次起草失败的表:清单上标「上次起草失败」并写原因', (await mark('orders').getAttribute('data-row-last-draft', { timeout: 1000 }).catch(() => '')) === 'failed'
&& (await mark('orders').innerText()).includes('上次起草失败') && (await mark('orders').innerText()).includes(FAIL),
await mark('orders').innerText({ timeout: 500 }).catch(() => ''))
check('……部分完成的标「上次起草部分完成」', (await mark('order_items').getAttribute('data-row-last-draft', { timeout: 1000 }).catch(() => '')) === 'partial'
&& (await mark('order_items').innerText()).includes('上次起草部分完成'))
check('……原因全文在悬停里', (await mark('order_items').getAttribute('title', { timeout: 500 }).catch(() => '') ?? '').includes(WIDE_ERR))
check('……写完了的、没起草过的不标', await mark('parks').count() === 0 && await mark('visits').count() === 0)
await shot(page, 'catalog-last-draft-marks')
// 打开这张表:写明原因,给「重新起草这张表」
await page.locator('[data-catalog-row="orders"] [data-catalog-open-row]').click()
const note = page.locator('[data-catalog-last-draft]')
check('打开这张表:写明上次起草失败和原因', await until(async () => (await note.count()) === 1)
&& (await note.innerText()).includes('上次起草失败') && (await note.innerText()).includes(FAIL), await note.innerText({ timeout: 500 }).catch(() => ''))
await note.locator('[data-catalog-redraft]').click()
check('「重新起草这张表」:建只含这张表、用模型、不带补充说明的任务', await until(async () => !!lastBody(sent, /draft-jobs$/))
&& JSON.stringify(lastBody(sent, /draft-jobs$/)) === JSON.stringify({ tables: ['orders'], use_model: true }), JSON.stringify(lastBody(sent, /draft-jobs$/)))
check('……打开对话框显示这个任务的进度', await until(async () => (await draftPhase(page)) === 'running')
&& (await draftBox(page).locator('[data-draft-task]').count()) === 1)
const job = state.jobs.at(-1)
settle(state, job, 'orders', { added: 2 })
finishJob(job)
await until(async () => (await draftPhase(page)) === 'done')
await page.locator('[data-draft-close]').click()
check('……写完以后:清单上的标记和详情里的说明都收起', await until(async () => (await mark('orders').count()) === 0
&& (await page.locator('[data-catalog-last-draft]').count()) === 0))
// 模型用不了:单表重起草直接提示原因,不打开对话框
await page.locator('[data-catalog-row="order_items"] [data-catalog-open-row]').click()
await until(async () => (await page.locator('[data-catalog-last-draft]').count()) === 1)
check('部分完成的表:详情里写「上次起草部分完成」', (await page.locator('[data-catalog-last-draft]').innerText()).includes('上次起草部分完成'))
replies.createJob.push(json(MODEL_DOWN, 409))
await page.locator('[data-catalog-redraft]').click()
check('模型用不了:提示原因,不打开对话框', await until(async () => (await page.locator('[data-toast]').filter({ hasText: '未配置模型接入' }).count()) > 0)
&& await draftBox(page).count() === 0, await page.locator('[data-toast]').allInnerTexts().then((t) => t.join(' | ')).catch(() => ''))
check('只发过起草任务相关的写请求', writes(sent, /./).every((s) => /catalog\/(draft-jobs|guidance)/.test(s.key)),
JSON.stringify(writes(sent, /./).map((s) => s.key)))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
})
@@ -1899,6 +2298,32 @@ await section('数据目录 · 窄屏', async () => {
const inner = await profileBox(page).evaluate((el) => el.scrollWidth - el.clientWidth)
check('……数据剖析弹窗在视口里,正文不横向滚动', !!dlg && dlg.x >= 0 && dlg.x + dlg.width <= 390 && inner <= 1, `${JSON.stringify(dlg)} ${inner}`)
await page.keyboard.press('Escape')
// 起草:设置阶段、进行中(宽表的段进度、长原因)弹窗都在视口里、不横向滚动;关掉以后上方那条进度也不撑宽页面
const draftFits = async () => {
const d = await page.locator('[role="dialog"]').last().boundingBox()
const scroll = await draftBox(page).evaluate((el) => el.scrollWidth - el.clientWidth)
return { ok: !!d && d.x >= 0 && d.x + d.width <= 390 && scroll <= 1, d, scroll }
}
await page.locator('[data-catalog-draft]').first().click()
await draftBox(page).waitFor()
await draftBox(page).locator('[data-draft-model] input').check()
const setupFit = await draftFits()
check('……起草弹窗(设置阶段)在视口里,正文不横向滚动', setupFit.ok, JSON.stringify(setupFit))
await page.locator('[data-draft-start]').click()
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 })
await until(async () => (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-reason]')).includes('第 2 段'))
const runFit = await draftFits()
check('……起草进行中:段进度和长原因折行,弹窗不横向滚动', runFit.ok, 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)
check('……上方的起草进度不撑宽页面', await overflow() <= 1 && !!(await page.locator('[data-catalog-draft-progress]').boundingBox())
&& (await page.locator('[data-catalog-draft-progress]').boundingBox()).width <= 390, String(await overflow()))
stopJob(narrowJob)
await until(async () => (await page.locator('[data-catalog-draft-progress]').count()) === 0)
// 宽屏照旧是表格(表头在,列表格 860px 起,框里横向滚动)
await page.setViewportSize({ width: 1440, height: 900 })
await openDetail(page, S1, 'visits')