mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:01:58 +00:00
数据目录起草改成后台任务(前端):对话框选范围、写起草要求和本次补充说明后建任务,之后显示任务的进度和结果——总进度、新增 / 更新 / 删除合计、逐表状态、宽表的段进度、没写全的原因;可以停止、在后台继续,关掉对话框任务照常进行,目录页上方显示一条进度,点开回到对话框,结束时提示并可查看结果;结果里只列没写全的表,「只重试失败的表」新建只含失败、部分完成、已停止的表的任务;服务重启中断的任务写明原因、同样能重试;已有任务(409)转去显示进行中的那一个,起草模型用不了(409)留在设置阶段写明原因;起草要求改过先存再建任务,写明用的是哪个起草模型、数据源说明也一并交给模型;清单上标出上次起草失败、部分完成的表并写原因,表详情里给「重新起草这张表」;进行中每 1.5 秒取一次,取回的状态只往前走。check-catalog 用假的任务接口覆盖这些(任务怎么往前走由检查自己推),旧的同步起草检查换掉
This commit is contained in:
@@ -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
@@ -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: '已有起草任务在进行,结束后再重新起草这张表',
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 目录修改提案:用户在助手(画布)或问数据页说出一条数据事实时,助手提出的数据目录修改。
|
||||
//
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user