mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:31:58 +00:00
合入数据目录闭环第一步(前端):表详情页的表助手面板(流式对话、只读查询、提案、待保存的修改一次保存)、用过这张表的运行、有效行过滤与去重规则的说明;审阅队列视图(排序、筛选、批量审阅、冲突、进度);导出、导入预览与导入;起草的块进度、已写进目录的段、停止后的留空提示;新检查 check-catalog-loop
This commit is contained in:
+127
-3
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogDetail, CatalogDraftJob, CatalogImpact, CatalogList, CatalogNotes,
|
||||
CatalogPatchPreview, CatalogPatchSubmit, CatalogProfileOut, CatalogReviewAction, CommitOut, CurrentRecipe, EditPreview,
|
||||
ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogAssistantEvent, CatalogAssistantRequest, CatalogDetail, CatalogDraftJob,
|
||||
CatalogImpact, CatalogImportReport, CatalogList, CatalogNotes, CatalogPatchPreview, CatalogPatchSubmit, CatalogProfileOut,
|
||||
CatalogReviewAction, CatalogReviewBatchItem, CatalogReviewBatchResult, CatalogReviewKind, CatalogReviewProgress, CatalogReviewQueue,
|
||||
CatalogTableUsage, CommitOut, CurrentRecipe, EditPreview,
|
||||
EditRequest, ImportRecord,
|
||||
ManifestOut, PurgeRawBody, PurgeRawOut, QuestionAnswer, Recipe, RedraftRulesOut, RemovePeriodBody, RemovePeriodOut,
|
||||
RevokeAcceptanceBody, RevokeAcceptanceOut, SnapshotOut, Staging,
|
||||
@@ -15,7 +17,7 @@ import {
|
||||
ApiError, BASE, NETWORK_MESSAGE, actorHeader, codeOf, del, describeDetail, failure, get, isGatewayFailure, parseBody,
|
||||
patch, post, put, qs, report, request, upload, type RequestOptions, type UploadOptions,
|
||||
} from './http'
|
||||
import { bundle } from './bundle'
|
||||
import { bundle, dispositionName } from './bundle'
|
||||
import { compare } from './compare'
|
||||
import { inquiry } from './inquiry'
|
||||
import { schedules } from './schedules'
|
||||
@@ -451,6 +453,51 @@ export const api = {
|
||||
profile: (sourceId: string, body: { tables?: string[] }, opts?: RequestOptions) =>
|
||||
request<CatalogProfileOut>(`/datasources/${encodeURIComponent(sourceId)}/catalog/profile`,
|
||||
{ method: 'POST', body: JSON.stringify(body), ...opts }),
|
||||
// ---- 审阅队列(只列推断项,按表的使用次数、这一项对数字的影响和来源排好)
|
||||
/** 一页待确认项。kind:全部 / 影响数字的项 / 名称和含义;table 只看这一张表 */
|
||||
reviewQueue: (sourceId: string, params: { limit?: number; offset?: number; kind?: CatalogReviewKind; table?: string | null },
|
||||
opts?: RequestOptions) =>
|
||||
get<CatalogReviewQueue>(`${catalogBase(sourceId)}/review-queue${qs({ limit: 50, offset: 0, kind: 'all', ...params })}`, opts),
|
||||
/**
|
||||
* 成批确认、驳回、撤销审阅(最多 200 条):逐表按 if_version 乐观锁,一张表冲突不影响别的表,同一张表的几条一次写入。
|
||||
* 逐条回结果:成功的带这张表写入后的版本,冲突、不合法的带原因和机读码
|
||||
*/
|
||||
reviewBatch: (sourceId: string, items: CatalogReviewBatchItem[]) =>
|
||||
post<{ results: CatalogReviewBatchResult[] }>(`${catalogBase(sourceId)}/review/batch`, { items }),
|
||||
/** 审阅进度:使用次数前 top_n 张表、全部表、影响数字的项 */
|
||||
reviewProgress: (sourceId: string, opts?: RequestOptions) =>
|
||||
get<CatalogReviewProgress>(`${catalogBase(sourceId)}/review-progress`, opts),
|
||||
/** 用过这张表的运行,新的在前 */
|
||||
usage: (sourceId: string, table: string, limit = 20, opts?: RequestOptions) =>
|
||||
get<CatalogTableUsage>(`${catalogBase(sourceId)}/${encodeURIComponent(table)}/usage${qs({ limit })}`, opts),
|
||||
// ---- 导出导入
|
||||
/**
|
||||
* 导出这个数据源的数据目录(GET /catalog/export,附件):回来的是文件本身和服务端给的文件名,没给就是 null,由调用方
|
||||
* 按数据源名补。原样存下,不解析:导入时交回的就是这份
|
||||
*/
|
||||
exportFile: async (sourceId: string): Promise<{ blob: Blob; name: string | null }> => {
|
||||
const path = `${catalogBase(sourceId)}/export`
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(BASE + path, { headers: actorHeader() })
|
||||
} catch (e: any) {
|
||||
const err = new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: `${e?.name ?? 'Error'}: ${e?.message ?? e}` })
|
||||
report(false, err)
|
||||
throw err
|
||||
}
|
||||
if (!res.ok) throw failure(res.status, res.statusText, await res.text().catch(() => ''), path)
|
||||
report(true)
|
||||
return { blob: await res.blob(), name: dispositionName(res.headers.get('Content-Disposition')) }
|
||||
},
|
||||
/** 导入的预览(只读):哪些表对上了,各有几项新增、更新、保留、冲突、跳过,冲突逐条列出 */
|
||||
importPreview: (sourceId: string, body: { content: unknown; include_guidance: boolean }) =>
|
||||
post<CatalogImportReport>(`${catalogBase(sourceId)}/import/preview`, body),
|
||||
/**
|
||||
* 导入:规则同预览,逐表乐观锁,一张表失败不影响别的。有排队、进行中的起草任务时 409(catalog_draft_busy);
|
||||
* 文件格式不对 422(detail 是一句中文)
|
||||
*/
|
||||
importCatalog: (sourceId: string, body: { content: unknown; include_guidance: boolean }) =>
|
||||
post<CatalogImportReport>(`${catalogBase(sourceId)}/import`, body),
|
||||
},
|
||||
|
||||
runs: {
|
||||
@@ -867,6 +914,83 @@ export function streamCopilot(
|
||||
return () => controller.abort()
|
||||
}
|
||||
|
||||
/** 表助手一轮结束时的情况:error 是给人看的那句话(开流前被拒时带状态码和机读码);aborted 是点了停止 */
|
||||
export interface CatalogAssistantEnd {
|
||||
error?: string
|
||||
status?: number
|
||||
code?: string
|
||||
aborted?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 表助手的一轮(POST /datasources/{id}/catalog/{table}/assistant):回答是事件流,每个事件一行 data: <JSON>,到一个回调
|
||||
* 一个(text 增量、query 只读查询、proposal 修改提案、done、error)。返回取消函数,就是「停止回答」。
|
||||
*
|
||||
* 开流之前就被拒(表不在 404、起草模型用不了 409 catalog_draft_model_unavailable、请求太长 422)时 onEnd 带服务端那句话、
|
||||
* 状态码和机读码;流到一半断了和一开始就连不上一样,说无法连接服务。认不出的行(心跳、半截)跳过
|
||||
*/
|
||||
export function streamCatalogAssistant(
|
||||
sourceId: string,
|
||||
table: string,
|
||||
body: CatalogAssistantRequest,
|
||||
onEvent: (event: CatalogAssistantEvent) => void,
|
||||
onEnd: (end: CatalogAssistantEnd) => void,
|
||||
): () => void {
|
||||
const controller = new AbortController()
|
||||
const path = `${catalogBase(sourceId)}/${encodeURIComponent(table)}/assistant`
|
||||
const emit = (line: string) => {
|
||||
const text = line.replace(/\r$/, '')
|
||||
if (!text.startsWith('data:')) return
|
||||
const payload = text.slice(5).trim()
|
||||
if (!payload) return
|
||||
try {
|
||||
const event = parseBody(payload)
|
||||
if (event && typeof event === 'object' && typeof event.type === 'string') onEvent(event as CatalogAssistantEvent)
|
||||
} catch { /* 认不出的一行,跳过 */ }
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(BASE + path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', ...actorHeader() },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok || !res.body) {
|
||||
const err = failure(res.status, res.statusText, await res.text().catch(() => ''), path)
|
||||
onEnd(err.kind === 'network' ? { error: NETWORK_MESSAGE } : { error: err.message, status: res.status, code: err.code })
|
||||
return
|
||||
}
|
||||
report(true)
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
let idx: number
|
||||
while ((idx = buffer.indexOf('\n')) >= 0) {
|
||||
emit(buffer.slice(0, idx))
|
||||
buffer = buffer.slice(idx + 1)
|
||||
}
|
||||
}
|
||||
buffer += decoder.decode()
|
||||
if (buffer) emit(buffer)
|
||||
onEnd({})
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') { onEnd({ aborted: true }); return }
|
||||
if (e instanceof TypeError) {
|
||||
report(false, new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: String(e) }))
|
||||
onEnd({ error: NETWORK_MESSAGE })
|
||||
return
|
||||
}
|
||||
onEnd({ error: e?.message ?? NETWORK_MESSAGE })
|
||||
}
|
||||
})()
|
||||
return () => controller.abort()
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅一次运行的事件流。
|
||||
*
|
||||
|
||||
+344
-5
@@ -2445,14 +2445,24 @@ export const CATALOG_DRAFT_TEXT = {
|
||||
queued: '排队中,等待开始…',
|
||||
running: '正在起草…',
|
||||
// 服务端点了停止就取消进行中的模型调用(不等它答完,一次调用可能要几分钟),只等手上正在写入目录的那张表写完
|
||||
stopping: '正在停止:取消进行中的模型调用,正在写入目录的表写完后停止…',
|
||||
// 宽表每写完一段就写进目录:停下来时已写进的段保留,只停掉进行中的调用
|
||||
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 列的表拆成表级一段和若干列分段,逐段起草',
|
||||
partsHint: '超过 40 列的表拆成表级一段和若干列分段,逐段起草,每写完一段就写进目录',
|
||||
/** 段被缩小、拆半后实际分成的块(比段多时才写) */
|
||||
chunks: (done: number, total: number) => `${formatNumber(done)} / ${formatNumber(total)} 块`,
|
||||
chunksHint: (total: number) => `模型用完额度或超时后,段被缩小、拆半:这张表实际分成 ${formatNumber(total)} 块起草`,
|
||||
/** 已经写进目录的段(停止、服务重启时保留) */
|
||||
partsWritten: (n: number) => `已写进目录 ${formatNumber(n)} 段`,
|
||||
partsWrittenHint: '每写完一段就写进目录,停止或服务重启时已写进的段保留',
|
||||
/** 停下来的宽表:服务端的原因里没写时,界面补这一句 */
|
||||
partsWrittenStopped: (n: number, rest: number) => (rest > 0
|
||||
? `已写进目录 ${formatNumber(n)} 段,剩下 ${formatNumber(rest)} 段没起草` : `已写进目录 ${formatNumber(n)} 段`),
|
||||
hide: '在后台继续',
|
||||
hideHint: '关闭对话框不会停止起草,进度显示在目录页上方;每张表起草完就写进目录',
|
||||
hideHint: '关闭对话框不会停止起草,进度显示在目录页上方;每写完一段就写进目录',
|
||||
stop: '停止',
|
||||
busy: '这个数据源已有进行中的起草任务,下面显示的是它的进度',
|
||||
modelUsed: (model: string) => `模型:${model}`,
|
||||
@@ -2490,8 +2500,13 @@ export const CATALOG_DRAFT_TEXT = {
|
||||
cancelled: '已停止',
|
||||
interrupted: '服务重启,起草中断',
|
||||
} as Record<CatalogDraftJobStatus, string>,
|
||||
interrupted: '服务重启时这次起草还没有完成,没写完的表未起草。已写进目录的内容保留;可以只重试这些表,接着起草',
|
||||
cancelled: '已写进目录的内容保留,没写完的表未起草',
|
||||
// 重试不是续跑:服务端把这些表整张重新分段起草(不跳过已写进目录的段),已写进目录的内容不会丢。别写成「接着起草
|
||||
// 剩下的段」——宽表重试花的时间和额度和第一次差不多
|
||||
interrupted: '服务重启时这次起草还没有完成,没写完的部分未起草。已写进目录的内容保留(每写完一段就写进目录);可以只重试这些表:会重新起草整张表,已写进目录的内容不会丢',
|
||||
cancelled: '已写进目录的内容保留(每写完一段就写进目录),没写完的部分未起草',
|
||||
/** 停下来时有表已经写进了一部分段 */
|
||||
cancelledParts: (tables: number, parts: number) =>
|
||||
`其中 ${formatNumber(tables)} 张表已写进目录 ${formatNumber(parts)} 段;只重试这些表时会重新起草整张表,已写进的不会丢`,
|
||||
// 任务整体出错的原因(job.error)照服务端的原话写在标题「起草失败」下面,不再加前缀:服务端的原因有的已经是整句
|
||||
// (「数据源不存在……」),有的自带「起草任务出错:」开头。没结束的表记失败、原因同任务的,逐表不再重复
|
||||
/** 没写全的表:失败、部分完成、已停止各几张;跳过的(表结构里没有)另说 */
|
||||
@@ -2539,6 +2554,8 @@ export const CATALOG_DRAFT_TEXT = {
|
||||
/** 上次起草有留空的列:清单上是轻提示(不是失败标记),表详情顶部同一句 */
|
||||
lastBlank: (n: number) => `上次起草有 ${formatNumber(n)} 列模型拿不准、留空了`,
|
||||
lastBlankAdvice: '可以在起草要求里补充缩写和术语的含义,再重新起草这张表,已有的内容不会丢',
|
||||
/** 最近一次起草停止了、没有新写入:留空的列是再之前那次写完的起草记下的 */
|
||||
lastBlankEarlier: '最近一次起草已停止,留空的列按再之前那次起草记下的',
|
||||
draftGuidance: '补充起草要求',
|
||||
draftGuidanceHint: '打开起草设置,范围是这张表:先在起草要求里补充缩写和术语的含义,再开始起草',
|
||||
redraft: '重新起草这张表',
|
||||
@@ -2953,6 +2970,328 @@ export const CATALOG_UI_TEXT = {
|
||||
cardinalityCheckedShort: '已核实',
|
||||
cardinalityCheckedHint: '基数由数据剖析用数据核实(子表一侧确有重复或确实唯一),一对多关联后重复计算的检查据此报错误',
|
||||
emptyTables: (n: number) => `挑表时跳过 ${formatNumber(n)} 张空表`,
|
||||
// ---- 表详情里有效记录条件、去重规则的说明(模型起草时附的依据、数据剖析核对的排除比例)
|
||||
fieldNote: '说明',
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 表助手(表详情里「和助手一起完善」):和助手对话完善一张表的数据目录。
|
||||
//
|
||||
// 术语:
|
||||
// - 面板叫「表助手」,入口写「和助手一起完善」。助手只改数据目录、不改业务库;只记数据事实,不写公式。
|
||||
// - 助手提出的修改叫「修改提案」,每一项写「改前 / 改后 / 依据」,可以逐项「去掉」「加回」。
|
||||
// - 「待保存的修改」:跨多轮累积,同一项以最后一次提案为准;「保存 N 项修改」一次写入,改动的项记为人工填写、已确认。
|
||||
// - 只读查询只在开了数据剖析的源上有:写「查询了数据」,SQL 和前几行可以展开;遮罩列的值写「已遮罩」(服务端给的)。
|
||||
// - 对话不保存在服务端:关闭表助手、换表、离开页面后就没有了。
|
||||
// ===========================================================================
|
||||
|
||||
export const CATALOG_ASSISTANT_TEXT = {
|
||||
open: '和助手一起完善',
|
||||
openHint: '和表助手对话完善这张表的数据目录:说出业务事实,助手整理成修改提案,确认后再保存',
|
||||
openMissing: '表结构中已没有这张表,不能和助手一起完善',
|
||||
title: '表助手',
|
||||
subtitle: (table: string) => `完善「${table}」的数据目录`,
|
||||
close: '关闭表助手',
|
||||
closeTitle: '关闭表助手?',
|
||||
closeConsequences: ['待保存的修改和这段对话都会丢失'],
|
||||
closeConfirm: '放弃并关闭',
|
||||
leaveTitle: '表助手有未保存的修改',
|
||||
leaveConsequences: ['离开后待保存的修改和这段对话都会丢失'],
|
||||
leaveConfirm: '放弃并离开',
|
||||
noProfile: '这个数据源没开数据剖析,助手只能根据表结构和目录推断,不能查数据',
|
||||
profileOn: '这个数据源开了数据剖析:助手可以发少量只读查询核对数据,遮罩列不取值',
|
||||
notStored: '对话不保存在服务端,关闭表助手或离开页面后就没有了',
|
||||
greeting: '说出你知道的业务事实(某一列的含义、码值、有效记录条件……),助手整理成修改提案,你确认后才写进数据目录。助手只记数据事实,不写计算公式。',
|
||||
/**
|
||||
* 上次起草有留空的列:开场引导先问这几列(assistant.ts 的 blankLeadOf)。例子只举起草时记下的留空列里还没补上的;
|
||||
* 拿不到起草时记下的列名时,改说目录里还没有中文名和含义的列——那里面还有没写出来的列,不能说成留空的列
|
||||
*/
|
||||
blankLead: (b: { from: 'draft' | 'catalog'; n: number; examples: string[]; remaining: number | null; unnamed: number }) => {
|
||||
const head = `上次起草有 ${formatNumber(b.n)} 列模型拿不准、留空了`
|
||||
const cols = b.examples.join('、')
|
||||
if (b.from === 'catalog') {
|
||||
return b.unnamed > 0
|
||||
? `${head}。目录里还有 ${formatNumber(b.unnamed)} 列没有中文名和含义${cols ? `,例如 ${cols}` : ''},可以先从这些列开始。`
|
||||
: `${head},现在目录里的列都已有中文名或含义。`
|
||||
}
|
||||
if (b.remaining === 0) return `${head},现在都已补上中文名或含义。`
|
||||
if (!cols) return `${head}。`
|
||||
if (b.remaining == null) return `${head},例如 ${cols}。可以先从这几列开始。`
|
||||
return b.remaining < b.n
|
||||
? `${head},还有 ${formatNumber(b.remaining)} 列没补上:${cols}。可以先从这几列开始。`
|
||||
: `${head}:${cols}。可以先从这几列开始。`
|
||||
},
|
||||
suggestLabel: '可以这样开始',
|
||||
/** 「补上……」按钮:上面的数字就是点名的列数(不知道一共几列时不写数字) */
|
||||
suggestBlank: (from: 'draft' | 'catalog', total: number | null) => (from === 'catalog'
|
||||
? `补上没有中文名和含义的 ${formatNumber(total ?? 0)} 列`
|
||||
: total == null ? '补上留空的列' : `补上留空的 ${formatNumber(total)} 列`),
|
||||
suggestBlankText: (from: 'draft' | 'catalog', cols: string[], total: number | null) => {
|
||||
const named = cols.join('、')
|
||||
if (from === 'draft' && total == null) return `请帮我补上上次起草时模型留空的列的中文名和含义,例如 ${named}。拿不准的地方先问我。`
|
||||
const more = total != null && total > cols.length ? ` 等 ${formatNumber(total)} 列` : ''
|
||||
return `请帮我补上这几列的中文名和含义:${named}${more}。拿不准的地方先问我。`
|
||||
},
|
||||
suggestFacts: '核对有效记录条件和去重规则',
|
||||
suggestFactsText: '这张表的有效记录条件和去重规则应该怎么写?拿不准的地方先问我。',
|
||||
suggestPending: '看看哪些推断可以确认',
|
||||
suggestPendingText: '这张表还没确认的推断项里,哪些可以直接确认、哪些需要我补充?',
|
||||
you: '你',
|
||||
assistant: '助手',
|
||||
logLabel: '和表助手的对话',
|
||||
inputLabel: '给表助手的消息',
|
||||
inputPlaceholder: '例如:status 为 9 表示作废,统计时要排除',
|
||||
inputHint: 'Enter 发送,Shift+Enter 换行',
|
||||
count: (n: number, max: number) => `${formatNumber(n)} / ${formatNumber(max)} 字`,
|
||||
tooLong: (max: number) => `一条消息不超过 ${formatNumber(max)} 字`,
|
||||
send: '发送',
|
||||
stop: '停止回答',
|
||||
answering: '助手正在回答…',
|
||||
answered: '助手已回答',
|
||||
stopped: '已停止回答',
|
||||
interrupted: '连接中断,这一轮没有答完',
|
||||
failed: (reason: string) => `这一轮没有完成:${reason}`,
|
||||
usage: (input: number, output: number) =>
|
||||
`输入 ${formatTokens(input, { compact: true })}、输出 ${formatTokens(output, { compact: true })}`,
|
||||
queries: (n: number) => `查询 ${formatNumber(n)} 次`,
|
||||
// ---- 只读查询(rows 是取回的行数;truncated:后面还有行,取回的只是前一截,不是结果的总行数)
|
||||
query: (rows: number, truncated = false) => (truncated
|
||||
? `查询了数据(取回前 ${formatNumber(rows)} 行,后面还有)` : `查询了数据(${formatNumber(rows)} 行)`),
|
||||
queryFailed: '查询没有成功',
|
||||
queryRows: (shown: number, total: number, truncated = false) => {
|
||||
const head = truncated ? `取回前 ${formatNumber(total)} 行(后面还有)` : `共 ${formatNumber(total)} 行`
|
||||
return total > shown ? `${head},显示前 ${formatNumber(shown)} 行` : head
|
||||
},
|
||||
queryEmpty: '没有返回行',
|
||||
// ---- 修改提案
|
||||
proposal: (n: number) => `修改提案(${formatNumber(n)} 项)`,
|
||||
before: '改前',
|
||||
after: '改后',
|
||||
reason: '依据',
|
||||
drop: '去掉',
|
||||
dropLabel: (where: string) => `去掉:${where}`,
|
||||
restore: '加回',
|
||||
restoreLabel: (where: string) => `加回:${where}`,
|
||||
state: {
|
||||
pending: '待保存',
|
||||
replaced: '已改用另一条提案的值',
|
||||
dropped: '已去掉',
|
||||
saved: '已保存',
|
||||
} as Record<'pending' | 'replaced' | 'dropped' | 'saved', string>,
|
||||
stateConfirm: '值不变,保存即确认',
|
||||
// ---- 待保存的修改
|
||||
pending: (n: number) => `待保存的修改(${formatNumber(n)} 项)`,
|
||||
pendingEmpty: '还没有待保存的修改',
|
||||
pendingHint: '跨多轮累积,同一项以最后一次提案为准。保存后改动的项记为人工填写、已确认',
|
||||
pendingShow: '展开待保存的修改',
|
||||
pendingHide: '收起待保存的修改',
|
||||
save: (n: number) => `保存 ${formatNumber(n)} 项修改`,
|
||||
saving: '正在保存…',
|
||||
clear: '全部去掉',
|
||||
clearTitle: (n: number) => `去掉全部 ${formatNumber(n)} 项待保存的修改?`,
|
||||
clearConfirm: '全部去掉',
|
||||
tooMany: (n: number, max: number) => `有 ${formatNumber(n)} 项,一次最多保存 ${formatNumber(max)} 项,请先去掉一些`,
|
||||
editing: '正在编辑这张表,保存或取消后再保存助手的修改',
|
||||
saved: (n: number, version: number) => `已保存 ${formatNumber(n)} 项修改(第 ${formatNumber(version)} 版)`,
|
||||
savedToast: (n: number, table: string) => `已保存 ${formatNumber(n)} 项修改到「${table}」的数据目录`,
|
||||
conflictTitle: '这张表刚被修改过',
|
||||
conflictBody: '已按最新内容重算改前和改后,请核对后再保存。',
|
||||
conflictFailed: '无法按最新内容重算改前和改后,请稍后重试',
|
||||
problems: '以下几项已无法保存,已从待保存的修改中去掉:',
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 审阅队列(数据目录页的「审阅队列」视图):跨表列出推断项,按表的使用次数、这一项对数字的影响和来源排好,
|
||||
// 可以成批确认、驳回、撤销审阅。
|
||||
//
|
||||
// 术语:
|
||||
// - 「影响数字的项」:有效记录条件、去重规则、业务日期、关联关系、业务主键、度量类型——写错了查出来的数就不对,排在最前;
|
||||
// 「名称和含义」:中文名、含义、单位、码值、粒度、表类型。
|
||||
// - 审阅过的项原地标出结果(已确认、已驳回、未生效),刷新队列后才移除;「撤销审阅」回到起草时的状态(推断)。
|
||||
// - 每一项的位置写「列 status 的码值」「表的有效记录条件」「指向 parks 的关联关系」(同提案卡片),不只写字段名。
|
||||
// - 「常用 N 张表」:被运行用过、使用次数最多的前几张(最多 20 张),N 写实际张数;没有运行用过的表时写明。
|
||||
// - 进度写「已确认 42%」,三行同一个口径:已确认 ÷(已确认 + 待确认);已驳回的、有确证的(外键约束)不计入。
|
||||
// - 「使用 N 次」是所在表被运行查询过的次数(按查询算,同表清单);「用过这张表的运行」写「共 N 次运行」(按运行算),
|
||||
// 两个数不一样,各自写明单位。
|
||||
// ===========================================================================
|
||||
|
||||
export const CATALOG_REVIEW_TEXT = {
|
||||
viewLabel: '数据目录的视图',
|
||||
view: { list: '表清单', review: '审阅队列' } as Record<'list' | 'review', string>,
|
||||
viewHint: {
|
||||
list: '逐表查看和编辑数据目录',
|
||||
review: '跨表列出待确认的推断项,按使用次数和对数字的影响排好,可以成批确认、驳回',
|
||||
} as Record<'list' | 'review', string>,
|
||||
queueLabel: '待确认的推断项',
|
||||
kindLabel: '按项筛选',
|
||||
kind: { all: '全部', facts: '影响数字的项', names: '名称和含义' } as Record<'all' | 'facts' | 'names', string>,
|
||||
kindHint: {
|
||||
all: '全部待确认的推断项',
|
||||
facts: '有效记录条件、去重规则、业务日期、关联关系、业务主键、度量类型:写错了查出来的数就不对',
|
||||
names: '中文名、含义、单位、码值、粒度、表类型',
|
||||
} as Record<'all' | 'facts' | 'names', string>,
|
||||
tableLabel: '按表筛选',
|
||||
tableAll: '全部表',
|
||||
tableOption: (label: string | null, table: string, n: number) => `${label ? `${label}(${table})` : table} · ${formatNumber(n)} 项`,
|
||||
total: (n: number) => `${formatNumber(n)} 项待确认`,
|
||||
shown: (shown: number, total: number) => `显示 ${formatNumber(shown)} / ${formatNumber(total)} 项`,
|
||||
empty: '没有待确认的推断项',
|
||||
emptyBody: '推断的项都审阅过了。之后起草出的新推断会出现在这里。',
|
||||
emptyFiltered: '没有符合条件的待确认项',
|
||||
emptyFilteredBody: '请换个筛选条件。',
|
||||
loadFailed: '无法读取审阅队列',
|
||||
retry: '重试',
|
||||
refresh: '刷新队列',
|
||||
refreshHint: '审阅过的项刷新后从队列中移除',
|
||||
more: (n: number) => `再载入 ${formatNumber(n)} 项`,
|
||||
loading: '正在载入…',
|
||||
selectAll: '全选当前列表',
|
||||
select: (where: string, table: string) => `选择:${table} ${where}`,
|
||||
selected: (n: number) => `已选 ${formatNumber(n)} 项`,
|
||||
clearSelection: '取消选择',
|
||||
usage: (n: number) => `使用 ${formatNumber(n)} 次`,
|
||||
usageHint: '所在表被运行查询过的次数,同一次运行中结果相同的重复查询只算一次',
|
||||
open: (where: string, table: string) => `打开「${table}」,查看${where}`,
|
||||
reasonsLabel: '排在这里的原因',
|
||||
// ---- 成批审阅
|
||||
confirm: (n: number) => `确认(${formatNumber(n)})`,
|
||||
reject: (n: number) => `驳回(${formatNumber(n)})`,
|
||||
reset: (n: number) => `撤销审阅(${formatNumber(n)})`,
|
||||
resetHint: '把这一页上审阅过的项回到起草时的状态(推断)',
|
||||
confirmTitle: (n: number) => `确认 ${formatNumber(n)} 项推断?`,
|
||||
confirmConsequences: ['确认后这些项参与 SQL 检查', '来源保持不变,状态改为已确认', '之后仍可撤销审阅'],
|
||||
confirmAction: (n: number) => `确认 ${formatNumber(n)} 项`,
|
||||
rejectTitle: (n: number) => `驳回 ${formatNumber(n)} 项推断?`,
|
||||
rejectConsequences: ['驳回后不提供给助手、不参与 SQL 检查', '之后起草也不会再提出', '之后仍可撤销审阅'],
|
||||
rejectAction: (n: number) => `驳回 ${formatNumber(n)} 项`,
|
||||
tooMany: (max: number) => `一次最多审阅 ${formatNumber(max)} 项,请少选一些`,
|
||||
working: '正在提交…',
|
||||
done: { confirm: '已确认', reject: '已驳回', reset: '已撤销审阅' } as Record<'confirm' | 'reject' | 'reset', string>,
|
||||
conflict: '未生效:这张表刚被修改过,请刷新队列后再审阅',
|
||||
failed: '未生效',
|
||||
/** 一批的结果:成功几项、冲突几项、别的原因没成功几项 */
|
||||
summary: (action: 'confirm' | 'reject' | 'reset', ok: number, conflicts: number, failed: number) => {
|
||||
const head = `${{ confirm: '已确认', reject: '已驳回', reset: '已撤销审阅' }[action]} ${formatNumber(ok)} 项`
|
||||
const bad = [
|
||||
conflicts ? `${formatNumber(conflicts)} 项未生效(所在表刚被修改过)` : '',
|
||||
failed ? `${formatNumber(failed)} 项未生效` : '',
|
||||
].filter(Boolean)
|
||||
return bad.length ? `${head};${bad.join('、')},请刷新队列后再审阅` : head
|
||||
},
|
||||
failedAll: (reason: string) => `未能提交:${reason}`,
|
||||
// ---- 进度
|
||||
progressTitle: '审阅进度',
|
||||
/** 常用表:被运行用过、使用次数最多的前几张(最多 topN 张),n 是实际张数 */
|
||||
progressTop: (n: number) => `常用 ${formatNumber(n)} 张表`,
|
||||
progressTopHint: (n: number, topN: number) => (n > 0
|
||||
? `被运行用过、使用次数最多的 ${formatNumber(n)} 张表里的项(最多取 ${formatNumber(topN)} 张)`
|
||||
: `被运行用过、使用次数最多的表里的项(最多取 ${formatNumber(topN)} 张)`),
|
||||
progressTopNone: '常用表',
|
||||
progressTopEmpty: '还没有运行用过的表',
|
||||
progressAll: '全部表',
|
||||
progressAllHint: '这个数据源全部表里的项',
|
||||
progressFacts: '影响数字的项',
|
||||
progressFactsHint: '有效记录条件、去重规则、业务日期、关联关系、业务主键、度量类型',
|
||||
progressRule: '已确认 ÷(已确认 + 待确认):已驳回的、有确证的(外键约束)不用再确认,不计入',
|
||||
/** 一行进度:已确认百分比和待确认几项;不知道待确认几项时只写已确认几项 */
|
||||
progressText: (pct: number | null, confirmed: number, proposed: number | null, total: number | null) => {
|
||||
if (total === 0) return '还没有目录项'
|
||||
if (proposed == null) return `已确认 ${formatNumber(confirmed)} 项`
|
||||
if (pct == null) return '没有待确认的项'
|
||||
return `已确认 ${formatNumber(pct)}% · 待确认 ${formatNumber(proposed)} 项`
|
||||
},
|
||||
progressFailed: '无法读取审阅进度',
|
||||
// ---- 没选中项时的右栏
|
||||
overviewTitle: '从左侧队列挑一项开始审阅',
|
||||
overviewBody: '队列按所在表的使用次数、这一项对数字的影响和来源排序:常用表里影响数字的推断排在最前,模型起草、命名推断的比数据库注释的靠前。点开一项可以看它所在表的详情和用过这张表的运行。',
|
||||
// ---- 从队列打开的项
|
||||
focusMark: '从审阅队列打开',
|
||||
// ---- 表清单视图概览里的入口
|
||||
openQueue: '打开审阅队列',
|
||||
openQueueHint: '跨表按使用次数和对数字的影响排好,可以成批确认、驳回',
|
||||
}
|
||||
|
||||
/** 表详情里「用过这张表的运行」(GET /catalog/{table}/usage) */
|
||||
export const CATALOG_USAGE_TEXT = {
|
||||
title: '用过这张表的运行',
|
||||
hint: '新的在前',
|
||||
show: '查看用过这张表的运行',
|
||||
hide: '收起',
|
||||
none: '还没有运行查询过这张表',
|
||||
/** 按运行算(一次运行查询了几次这张表都只算一次),和表头按查询算的「使用 N 次」不是一个数 */
|
||||
count: (shown: number, total: number) => (total > shown
|
||||
? `共 ${formatNumber(total)} 次运行,显示最近 ${formatNumber(shown)} 次` : `共 ${formatNumber(total)} 次运行`),
|
||||
countHint: '按运行算:一次运行查询了几次这张表都只算一次;表头的使用次数按查询算,两个数可能不一样',
|
||||
error: '无法读取用过这张表的运行',
|
||||
retry: '重试',
|
||||
unnamed: '未保存的工作流',
|
||||
open: (name: string) => `打开运行记录:${name}`,
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 导出导入数据目录(数据目录页的「导出目录」「导入目录」):本机整理好的目录带到另一个实例。
|
||||
//
|
||||
// 术语:
|
||||
// - 导出的是一个 .json 文件,含全部状态的项和起草要求。导入先「预览」(只读),确认后才写入。
|
||||
// - 预览逐表写「新增 / 更新 / 保留 / 冲突 / 跳过」:冲突是这里已确认、已驳回的项和文件里不一样,以这里为准;
|
||||
// 跳过是表结构里没有的列、两端对不上的关联关系。
|
||||
// ===========================================================================
|
||||
|
||||
export const CATALOG_TRANSFER_TEXT = {
|
||||
export: '导出目录',
|
||||
exportHint: '把这个数据源的数据目录导出成文件(含全部状态的项和起草要求),可以导入到另一个实例',
|
||||
exporting: '正在导出…',
|
||||
exported: (name: string) => `已导出「${name}」`,
|
||||
exportFailed: (reason: string) => `未能导出数据目录:${reason}`,
|
||||
fileName: (source: string) => `${source}-数据目录.json`,
|
||||
import: '导入目录',
|
||||
importHint: '从导出的文件导入数据目录:先预览,确认后再写入',
|
||||
title: '导入数据目录',
|
||||
chooseLabel: '导出的数据目录文件',
|
||||
chooseHint: '「导出目录」得到的 .json 文件',
|
||||
choose: '选择文件',
|
||||
chosen: (name: string) => `已选择:${name}`,
|
||||
includeGuidance: '同时导入起草要求',
|
||||
includeGuidanceHint: '这个数据源还没有起草要求时才写入,已有的不覆盖',
|
||||
rulesTitle: '导入的规矩',
|
||||
rules: [
|
||||
'表按名字对上:不分大小写;带 schema 前缀的对不上时去掉前缀再对',
|
||||
'文件中已确认、已驳回的项照原样带入,来源和状态不变',
|
||||
'这里已确认、已驳回的项和文件中的不一样时以这里为准,记为冲突',
|
||||
'文件中推断、已验证的项只填补这里的空缺,不覆盖已有的',
|
||||
'表结构里没有的列、两端对不上的关联关系跳过,写明原因',
|
||||
],
|
||||
preview: '预览',
|
||||
previewing: '正在预览…',
|
||||
notJson: '文件不是有效的 JSON,请选择「导出目录」得到的文件',
|
||||
readFailed: '无法读取这个文件',
|
||||
fileMeta: (source: string, at: string) => `导出自「${source}」 · ${at}`,
|
||||
totals: (t: { tables: number; add: number; update: number; keep: number; conflicts: number; skipped: number }) =>
|
||||
`${formatNumber(t.tables)} 张表:新增 ${formatNumber(t.add)} 项、更新 ${formatNumber(t.update)} 项、保留 ${formatNumber(t.keep)} 项、`
|
||||
+ `冲突 ${formatNumber(t.conflicts)} 项、跳过 ${formatNumber(t.skipped)} 项`,
|
||||
head: { table: '表', add: '新增', update: '更新', keep: '保留', conflicts: '冲突', skipped: '跳过' },
|
||||
matchedAs: (name: string) => `对上「${name}」`,
|
||||
unmatched: (n: number) => `${formatNumber(n)} 张表在这个数据源里对不上,不导入:`,
|
||||
conflictsTitle: (n: number) => `冲突 ${formatNumber(n)} 项:以这里为准,不导入`,
|
||||
conflictFile: '文件中',
|
||||
conflictTarget: '这里',
|
||||
skippedTitle: (n: number) => `跳过 ${formatNumber(n)} 项`,
|
||||
guidance: {
|
||||
replace: '起草要求:写入文件中的起草要求(这个数据源还没有)',
|
||||
keep: '起草要求:保留这个数据源现有的,不覆盖',
|
||||
none: '起草要求:不导入',
|
||||
} as Record<'keep' | 'replace' | 'none', string>,
|
||||
confirm: (n: number) => `导入 ${formatNumber(n)} 张表`,
|
||||
importing: '正在导入…',
|
||||
nothing: '没有可以导入的内容:文件中的表在这个数据源里都对不上',
|
||||
done: '导入完成',
|
||||
doneTotals: (t: { tables: number; add: number; update: number; conflicts: number }) =>
|
||||
`写入 ${formatNumber(t.tables)} 张表:新增 ${formatNumber(t.add)} 项、更新 ${formatNumber(t.update)} 项;冲突 ${formatNumber(t.conflicts)} 项以这里为准`,
|
||||
version: (v: number) => `第 ${formatNumber(v)} 版`,
|
||||
tableFailed: (reason: string) => `这张表未导入:${reason}`,
|
||||
back: '重新选择文件',
|
||||
cancel: '取消',
|
||||
close: '完成',
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { ArrowLeft, BookMarked, Database, ListFilter as ListFilterIcon, ScanSearch, Sparkles, TriangleAlert } from 'lucide-react'
|
||||
import {
|
||||
ArrowLeft, BookMarked, Database, Download, ListFilter as ListFilterIcon, ScanSearch, Sparkles, TriangleAlert, Upload,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
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 type { CatalogDetail, CatalogList, CatalogReviewItem, CatalogReviewKind, CatalogTableRow } from '../../types'
|
||||
import { EmptyState, ErrorState, PageHeader, Skeleton, Spinner, rovingTarget, toast, useTicker } from '../../components/ui'
|
||||
import { useCatalog, useDatasources, useOnReconnect } from '../../store/catalog'
|
||||
import { errorMessage } from '../../lib/errors'
|
||||
import { formatNumber } from '../../lib/format'
|
||||
import {
|
||||
CATALOG_DRAFT_TEXT as DT, CATALOG_TEXT as CT, PROFILE_STOP_LABEL, PROFILE_TEXT as PT, SCHEMA_PARTIAL_TEXT as SPT,
|
||||
CATALOG_DRAFT_TEXT as DT, CATALOG_REVIEW_TEXT as RT, CATALOG_TEXT as CT, CATALOG_TRANSFER_TEXT as XT, PROFILE_STOP_LABEL,
|
||||
PROFILE_TEXT as PT, SCHEMA_PARTIAL_TEXT as SPT,
|
||||
} from '../../lib/terms'
|
||||
import { StatusLegend, SystemNotesNotice } from './parts'
|
||||
import { DraftDialog } from './DraftDialog'
|
||||
@@ -20,6 +25,10 @@ import { ProfileSettingsDialog } from './ProfileSettings'
|
||||
import { fillKey, profileBlockOf, profileSettingsOf, secondsText } from './profile'
|
||||
import { TableDetail } from './TableDetail'
|
||||
import { TableIndex } from './TableIndex'
|
||||
import { ReviewOverview, ReviewQueue } from './ReviewQueue'
|
||||
import { ImportDialog } from './TransferDialog'
|
||||
import { itemKey, reviewParamsOf, writeReviewParams } from './review'
|
||||
import type { CatalogView } from './review'
|
||||
import { filterCounts, listParamsOf, progressOf, rowFromDetail, usageSummary, visibleRows, writeListParams } from './model'
|
||||
import type { ListFilter, ListParams, ListSort } from './model'
|
||||
|
||||
@@ -34,6 +43,10 @@ import type { ListFilter, ListParams, ListSort } from './model'
|
||||
//
|
||||
// 清单的搜索词、筛选、排序和「只看运行中查询过的表」记在地址的查询参数里(q / filter / sort / used),刷新、前进后退
|
||||
// 都不丢;在清单和表详情之间跳转时带着它们走。
|
||||
//
|
||||
// 左栏有两个视图:表清单(逐表看)和审阅队列(view=review:跨表按优先级列出推断项,成批确认、驳回,筛选记在 kind /
|
||||
// rtable 里)。从队列点开一项,右栏是所在表的详情,地址带着那一项(item),详情滚到那一项、列出用过这张表的运行。
|
||||
// 页头的「导出目录」「导入目录」把整理好的目录带到另一个实例(导入先预览,确认后才写)。
|
||||
// ===========================================================================
|
||||
|
||||
export function CatalogPage() {
|
||||
@@ -98,10 +111,20 @@ export function CatalogPage() {
|
||||
const setFilter = useCallback((f: ListFilter) => setList((c) => ({ ...c, filter: f })), [])
|
||||
const setSort = useCallback((v: ListSort) => setList((c) => ({ ...c, sort: v })), [])
|
||||
const setUsedOnly = useCallback((v: boolean) => setList((c) => ({ ...c, used: v })), [])
|
||||
/** 跳到别的表、回到清单时带上清单的查询参数(按页面上的算:搜索词可能还没写进地址) */
|
||||
/**
|
||||
* 跳到别的表、回到清单时带上清单的查询参数(按页面上的算:搜索词可能还没写进地址)。审阅视图的参数(view、kind、rtable)
|
||||
* 照旧带着,从审阅队列打开的那一项(item)不带:换了表就不是那一项了
|
||||
*/
|
||||
const searchRef = useRef('')
|
||||
const search = writeListParams(params, list).toString()
|
||||
const carried = writeListParams(params, list)
|
||||
carried.delete('item')
|
||||
const search = carried.toString()
|
||||
searchRef.current = search ? `?${search}` : ''
|
||||
const carriedRef = useRef(carried)
|
||||
carriedRef.current = carried
|
||||
/** 审阅视图:view=review 时左栏是审阅队列;kind、rtable 是队列的筛选;item 是右栏从队列打开的那一项 */
|
||||
const review = useMemo(() => reviewParamsOf(params), [params])
|
||||
const inReview = review.view === 'review'
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set())
|
||||
/**
|
||||
* 起草对话框开着没有、打开时显示什么:setup 选范围开始;job 显示页面跟着的任务(进行中的进度、刚结束的结果)。
|
||||
@@ -142,12 +165,15 @@ export function CatalogPage() {
|
||||
const backToList = useCallback(() => navigate(`${base}${searchRef.current}`), [navigate, base])
|
||||
const back = source?.origin === 'upload' ? '/data/tables' : '/data/databases'
|
||||
|
||||
/** 右栏的表详情载入、改过之后的目录:审阅队列里这张表的项跟着换状态 */
|
||||
const [detailSync, setDetailSync] = useState<{ seq: number; detail: CatalogDetail } | null>(null)
|
||||
/** 写入、审阅之后按返回的单表目录更新清单里那一行 */
|
||||
const onDetail = useCallback((d: CatalogDetail) => {
|
||||
setData((cur) => cur && ({
|
||||
...cur,
|
||||
tables: cur.tables.map((r) => (r.table_name === d.table_name ? rowFromDetail(d, r) : r)),
|
||||
}))
|
||||
setDetailSync((cur) => ({ seq: (cur?.seq ?? 0) + 1, detail: d }))
|
||||
}, [])
|
||||
|
||||
/**
|
||||
@@ -306,6 +332,52 @@ export function CatalogPage() {
|
||||
return [list[i - 1]?.table_name ?? null, list[i + 1]?.table_name ?? null]
|
||||
}, [table, visible, rows, sort])
|
||||
|
||||
/**
|
||||
* 改地址的查询参数,按地址栏现在的样子改:setSearchParams 的函数式写法拿的是上一次渲染时的参数,连着改两样(先点
|
||||
* 「全部」、紧接着换表)时后一次会把前一次盖掉
|
||||
*/
|
||||
const patchSearch = useCallback((fn: (p: URLSearchParams) => URLSearchParams, opts?: { replace?: boolean }) => {
|
||||
const next = fn(new URLSearchParams(window.location.search)).toString()
|
||||
navigate({ search: next ? `?${next}` : '' }, opts)
|
||||
}, [navigate])
|
||||
/** 换视图(表清单 / 审阅队列):记进地址(后退回到上一个视图),右栏正在看的表不动 */
|
||||
const setView = useCallback((v: CatalogView) => {
|
||||
patchSearch((prev) => writeReviewParams(prev, { view: v, item: '' }))
|
||||
}, [patchSearch])
|
||||
/** 审阅队列的筛选:替换当前这条历史(筛选不该占满后退键) */
|
||||
const setReviewParams = useCallback((p: { kind?: CatalogReviewKind; table?: string }) => {
|
||||
patchSearch((prev) => writeReviewParams(prev, p), { replace: true })
|
||||
}, [patchSearch])
|
||||
/** 从审阅队列打开一项:右栏是所在表的详情,地址带着这一项 */
|
||||
const openReviewItem = useCallback((i: CatalogReviewItem) => {
|
||||
const q = writeReviewParams(carriedRef.current, { view: 'review', item: i.path }).toString()
|
||||
navigate(`${base}/${encodeURIComponent(i.table_name)}${q ? `?${q}` : ''}`)
|
||||
}, [navigate, base])
|
||||
/** 成批审阅、导入写进了这些表:重取清单,正在看的表重新载入(编辑中的不动,保存时服务端会用版本号拦下) */
|
||||
const onTablesWritten = useCallback((tables: string[]) => {
|
||||
setDrafted((cur) => ({ seq: cur.seq + 1, tables: new Set(tables) }))
|
||||
void load()
|
||||
}, [load])
|
||||
const activeReviewKey = inReview && table && review.item ? itemKey({ table_name: table, path: review.item }) : null
|
||||
const proposedTotal = useMemo(() => rows.reduce((n, r) => n + (r.in_schema ? r.counts.proposed : 0), 0), [rows])
|
||||
|
||||
/** 导出目录:下载服务端给的文件,文件名按服务端的,没给就按数据源名补 */
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const exportCatalog = useCallback(async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const { blob, name } = await api.dataCatalog.exportFile(sourceId)
|
||||
const file = name ?? XT.fileName(source?.name ?? sourceId)
|
||||
saveFile(file, blob)
|
||||
toast.ok(XT.exported(file))
|
||||
} catch (e) {
|
||||
toast.error(XT.exportFailed(errorMessage(e)))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}, [sourceId, source?.name])
|
||||
|
||||
const usage = useMemo(() => usageSummary(rows), [rows])
|
||||
const wideHeader = useMedia('(min-width: 640px)')
|
||||
/**
|
||||
@@ -375,42 +447,58 @@ export function CatalogPage() {
|
||||
body = (
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<aside className={clsx('min-h-0 w-full flex-col border-r lg:flex lg:w-[380px] lg:shrink-0 xl:w-[420px]', table ? 'hidden' : 'flex')}>
|
||||
{empty && (
|
||||
// 窄屏没有右栏的概览,空目录的下一步写在清单上面
|
||||
<div className="border-b px-3 py-2.5 text-xs lg:hidden" data-catalog-empty-banner="">
|
||||
<p className="font-medium">{CT.emptyTitle}</p>
|
||||
<p className="mt-0.5 text-2xs leading-relaxed text-faint">{CT.emptyBody}</p>
|
||||
<div className="mt-2">{draftButton(true)}</div>
|
||||
</div>
|
||||
)}
|
||||
<TableIndex
|
||||
rows={visible}
|
||||
total={rows.length}
|
||||
counts={counts}
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
filter={filter}
|
||||
onFilter={setFilter}
|
||||
usedOnly={usedOnly}
|
||||
onUsedOnly={setUsedOnly}
|
||||
sort={sort}
|
||||
onSort={setSort}
|
||||
selected={selected}
|
||||
onSelected={setSelected}
|
||||
active={table}
|
||||
onOpen={openTable}
|
||||
onDraft={openDraft}
|
||||
onProfile={openProfile}
|
||||
/>
|
||||
<ViewTabs view={review.view} proposed={proposedTotal} onChange={setView} />
|
||||
<div role="tabpanel" id="catalog-view-panel" aria-labelledby={`catalog-view-tab-${review.view}`} className="flex min-h-0 flex-1 flex-col">
|
||||
{inReview
|
||||
? (
|
||||
<ReviewQueue sourceId={sourceId} rows={rows} kind={review.kind} table={review.table} activeKey={activeReviewKey}
|
||||
detailSync={detailSync} onParams={setReviewParams} onOpenItem={openReviewItem} onChanged={onTablesWritten} />
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{empty && (
|
||||
// 窄屏没有右栏的概览,空目录的下一步写在清单上面
|
||||
<div className="border-b px-3 py-2.5 text-xs lg:hidden" data-catalog-empty-banner="">
|
||||
<p className="font-medium">{CT.emptyTitle}</p>
|
||||
<p className="mt-0.5 text-2xs leading-relaxed text-faint">{CT.emptyBody}</p>
|
||||
<div className="mt-2">{draftButton(true)}</div>
|
||||
</div>
|
||||
)}
|
||||
<TableIndex
|
||||
rows={visible}
|
||||
total={rows.length}
|
||||
counts={counts}
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
filter={filter}
|
||||
onFilter={setFilter}
|
||||
usedOnly={usedOnly}
|
||||
onUsedOnly={setUsedOnly}
|
||||
sort={sort}
|
||||
onSort={setSort}
|
||||
selected={selected}
|
||||
onSelected={setSelected}
|
||||
active={table}
|
||||
onOpen={openTable}
|
||||
onDraft={openDraft}
|
||||
onProfile={openProfile}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<section className={clsx('min-h-0 min-w-0 flex-1 flex-col lg:flex', table ? 'flex' : 'hidden')} aria-label={table ?? CT.overviewTitle}>
|
||||
{table
|
||||
? (
|
||||
<TableDetail sourceId={sourceId} table={table} rows={rows} drafted={drafted} fillCodes={fillCodes} prev={prev} next={next}
|
||||
onFillDone={onFillDone} onOpen={openTable} onBack={backToList} onDetail={onDetail}
|
||||
draftBusy={!!stripJob} onRedraft={redraftTable} onDraftGuidance={draftWithGuidance} />
|
||||
draftBusy={!!stripJob} onRedraft={redraftTable} onDraftGuidance={draftWithGuidance}
|
||||
profileEnabled={profileSettings.enabled} focusPath={inReview ? review.item || null : null} usageOpen={inReview} />
|
||||
)
|
||||
: <Overview rows={rows} empty={empty} systemNotes={data.system_notes} draft={draftButton(true)} onOpen={openTable} />}
|
||||
: inReview
|
||||
? <ReviewOverview />
|
||||
: <Overview rows={rows} empty={empty} systemNotes={data.system_notes} draft={draftButton(true)} onOpen={openTable}
|
||||
onReview={() => setView('review')} />}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
@@ -427,6 +515,18 @@ export function CatalogPage() {
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{hasTables && headerProfile}
|
||||
{hasTables && headerDraft}
|
||||
{hasTables && (
|
||||
<button type="button" className="btn btn-sm" onClick={() => void exportCatalog()} disabled={exporting} aria-label={XT.export}
|
||||
title={XT.exportHint} data-catalog-export="">
|
||||
{exporting ? <Spinner size={11} /> : <Download size={11} aria-hidden />} <span className="hidden md:inline">{XT.export}</span>
|
||||
</button>
|
||||
)}
|
||||
{hasTables && (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setImporting(true)} aria-label={XT.import} title={XT.importHint}
|
||||
data-catalog-import="">
|
||||
<Upload size={11} aria-hidden /> <span className="hidden md:inline">{XT.import}</span>
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-sm btn-ghost" onClick={() => navigate(back)} aria-label={CT.back} data-catalog-back="">
|
||||
<ArrowLeft size={12} aria-hidden /> <span className="hidden sm:inline">{CT.back}</span>
|
||||
</button>
|
||||
@@ -481,6 +581,9 @@ export function CatalogPage() {
|
||||
onReset={() => setJob(null)}
|
||||
/>
|
||||
)}
|
||||
{importing && (
|
||||
<ImportDialog sourceId={sourceId} onClose={() => setImporting(false)} onImported={onTablesWritten} />
|
||||
)}
|
||||
{profileSettingsOpen && source && (
|
||||
<ProfileSettingsDialog row={{ id: source.id, name: source.name, options }} onClose={() => setProfileSettingsOpen(false)}
|
||||
onSaved={onProfileSettingsSaved} />
|
||||
@@ -524,6 +627,51 @@ function DraftStrip({ job, notice, onOpen }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** 存成文件(同运行记录里的下载):浏览器下载,文件名照给的 */
|
||||
function saveFile(name: string, blob: Blob) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = name
|
||||
a.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
const VIEWS: CatalogView[] = ['list', 'review']
|
||||
|
||||
/**
|
||||
* 左栏上方的两个视图:表清单(逐表看)、审阅队列(跨表按优先级看推断项,带着还有几项待确认)。标签页语义,
|
||||
* ←→ Home End 切换
|
||||
*/
|
||||
function ViewTabs({ view, proposed, onChange }: { view: CatalogView; proposed: number; onChange: (v: CatalogView) => void }) {
|
||||
const refs = useRef<Record<string, HTMLButtonElement | null>>({})
|
||||
const move = (e: ReactKeyboardEvent, i: number) => {
|
||||
const j = rovingTarget(e, i, VIEWS.length, 'horizontal')
|
||||
if (j < 0) return
|
||||
e.preventDefault()
|
||||
onChange(VIEWS[j])
|
||||
refs.current[VIEWS[j]]?.focus()
|
||||
}
|
||||
return (
|
||||
<div className="flex shrink-0 gap-1 border-b px-2" role="tablist" aria-label={RT.viewLabel} data-catalog-view={view}>
|
||||
{VIEWS.map((v, i) => {
|
||||
const selected = view === v
|
||||
return (
|
||||
<button key={v} ref={(el) => { refs.current[v] = el }} type="button" role="tab" id={`catalog-view-tab-${v}`} aria-selected={selected}
|
||||
aria-controls="catalog-view-panel" tabIndex={selected ? 0 : -1} title={RT.viewHint[v]}
|
||||
onClick={() => onChange(v)} onKeyDown={(e) => move(e, i)} data-catalog-view-tab={v}
|
||||
className={clsx('relative px-3 py-2 text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--accent)]',
|
||||
selected ? 'text-fg' : 'text-faint hover:text-dim')}>
|
||||
{RT.view[v]}
|
||||
{v === 'review' && proposed > 0 && <span className="tnum ml-1.5 text-2xs text-faint">{formatNumber(proposed)}</span>}
|
||||
{selected && <span className="absolute inset-x-2 -bottom-px h-0.5 rounded bg-[var(--accent)]" aria-hidden />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 媒体查询的当前结果,跟着窗口大小变 */
|
||||
function useMedia(query: string): boolean {
|
||||
const [on, setOn] = useState(() => typeof matchMedia !== 'function' || matchMedia(query).matches)
|
||||
@@ -601,13 +749,14 @@ function UsageSummary({ used, pending, none, active, onFocus }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** 没有选中表时的右栏:空目录时引导起草;否则写审阅进度,给出下一张该审的表 */
|
||||
function Overview({ rows, empty, systemNotes, draft, onOpen }: {
|
||||
/** 没有选中表时的右栏:空目录时引导起草;否则写审阅进度,给出下一张该审的表,也可以去审阅队列跨表成批审阅 */
|
||||
function Overview({ rows, empty, systemNotes, draft, onOpen, onReview }: {
|
||||
rows: CatalogTableRow[]
|
||||
empty: boolean
|
||||
systemNotes: boolean
|
||||
draft: ReactNode
|
||||
onOpen: (table: string) => void
|
||||
onReview: () => void
|
||||
}) {
|
||||
const pendingNext = rows.find((r) => r.in_schema && r.counts.proposed > 0)
|
||||
const stats = { pending: 0, done: 0, none: 0 }
|
||||
@@ -634,6 +783,12 @@ function Overview({ rows, empty, systemNotes, draft, onOpen }: {
|
||||
<span className="text-2xs text-faint">{CT.reviewNextHint}</span>
|
||||
</div>
|
||||
)}
|
||||
{pendingNext && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={onReview} data-catalog-open-review="">{RT.openQueue}</button>
|
||||
<span className="text-2xs text-faint">{RT.openQueueHint}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{systemNotes && <SystemNotesNotice />}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { IconButton } from '../../components/ui'
|
||||
import {
|
||||
CATALOG_COLUMN_FIELD_LABEL, CATALOG_MEASURE_HINT, CATALOG_MEASURE_LABEL, CATALOG_TEXT as CT, CODES_TEXT as KT,
|
||||
} from '../../lib/terms'
|
||||
import { ItemMark } from './parts'
|
||||
import { FOCUS_RING, FOCUS_STYLE, ItemMark } from './parts'
|
||||
import type { ReviewTarget } from './parts'
|
||||
import { COLUMN_FIELDS, codesComplete, columnPath, completeOf } from './model'
|
||||
import type { ColumnDraft, ColumnField } from './model'
|
||||
@@ -50,7 +50,16 @@ export interface ColumnModel {
|
||||
|
||||
type ColumnTexts = ColumnDraft
|
||||
|
||||
export function ColumnTable({ columns, form, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange, onCodesComplete }: {
|
||||
/** 审阅路径 columns.<列名>.<字段> → [列名, 字段](列名里可以有句点,按最后一个句点切);不是列级项为 null */
|
||||
function columnOfPath(path: string | null | undefined): [string, string] | null {
|
||||
if (!path?.startsWith('columns.')) return null
|
||||
const rest = path.slice('columns.'.length)
|
||||
const at = rest.lastIndexOf('.')
|
||||
return at > 0 ? [rest.slice(0, at), rest.slice(at + 1)] : null
|
||||
}
|
||||
|
||||
export function ColumnTable({ columns, form, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange, onCodesComplete,
|
||||
focusPath = null }: {
|
||||
columns: ColumnModel[]
|
||||
/** 编辑中:列名 → 字段 → 文字(form 是当前的,initial 是进入编辑时的);不在编辑时为 null */
|
||||
form: Record<string, ColumnTexts> | null
|
||||
@@ -67,7 +76,10 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote
|
||||
onChange: (col: string, field: ColumnField, value: string) => void
|
||||
/** 编辑中勾选或取消码值的「已列出全部取值」 */
|
||||
onCodesComplete: (col: string, value: boolean) => void
|
||||
/** 从审阅队列打开的那一项(列级项的审阅路径):那一格标出来 */
|
||||
focusPath?: string | null
|
||||
}) {
|
||||
const focus = columnOfPath(focusPath)
|
||||
return (
|
||||
<div className="@container relative overflow-x-auto rounded-lg border" data-catalog-columns="">
|
||||
<table className={clsx('w-full border-collapse text-xs', NARROW.table)}>
|
||||
@@ -89,7 +101,7 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote
|
||||
{columns.map((c) => (
|
||||
<ColumnRow key={c.name} col={c} editing={!!form} draft={form?.[c.name]} initial={initial?.[c.name]} problems={problems} busy={busy}
|
||||
systemNotes={systemNotes} onReview={onReview} onConfirmColumn={onConfirmColumn} onFillCodes={onFillCodes} onChange={onChange}
|
||||
onCodesComplete={onCodesComplete} />
|
||||
onCodesComplete={onCodesComplete} focusField={focus && focus[0] === c.name ? focus[1] : null} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -98,7 +110,7 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote
|
||||
}
|
||||
|
||||
const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange,
|
||||
onCodesComplete }: {
|
||||
onCodesComplete, focusField }: {
|
||||
col: ColumnModel
|
||||
editing: boolean
|
||||
/** 这一列的表单。只有改了这一列时引用才会变,别的行不重画 */
|
||||
@@ -112,6 +124,8 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble
|
||||
onFillCodes: (col: string) => void
|
||||
onChange: (col: string, field: ColumnField, value: string) => void
|
||||
onCodesComplete: (col: string, value: boolean) => void
|
||||
/** 从审阅队列打开的是这一列的哪一项 */
|
||||
focusField: string | null
|
||||
}) {
|
||||
const { name, structure, items } = col
|
||||
const pending = COLUMN_FIELDS.filter((f) => items?.[f]?.status === 'proposed').length
|
||||
@@ -121,9 +135,12 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble
|
||||
const where = CT.whereColumn(name, CATALOG_COLUMN_FIELD_LABEL[f])
|
||||
const problem = problems[`c:${name}:${f}`]
|
||||
const changed = editing && ((draft?.[f] ?? '') !== (initial?.[f] ?? '') || (f === 'codes' && completeOf(draft) !== completeOf(initial)))
|
||||
const focused = focusField === f
|
||||
return (
|
||||
// 窄框下没填的项不占行(编辑时照常列出来好填)
|
||||
<td className={clsx('px-2 py-1.5 align-top', NARROW.cell, !it && !editing && '@max-[40rem]:hidden')} data-cell={f} data-status={it?.status}>
|
||||
<td className={clsx('px-2 py-1.5 align-top', NARROW.cell, !it && !editing && '@max-[40rem]:hidden', focused && FOCUS_RING)}
|
||||
style={focused ? FOCUS_STYLE : undefined} data-cell={f} data-status={it?.status}
|
||||
data-review-focus={focused ? columnPath(name, f) : undefined}>
|
||||
<span className={NARROW.label}>{CT.columnHead[f]}</span>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-1.5">
|
||||
<div className="min-w-0 flex-1">{editing ? input : view}</div>
|
||||
@@ -223,6 +240,7 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble
|
||||
</tr>
|
||||
)
|
||||
}, (a, b) => a.col === b.col && a.editing === b.editing && a.draft === b.draft && a.initial === b.initial && a.busy === b.busy
|
||||
&& a.focusField === b.focusField
|
||||
&& a.systemNotes === b.systemNotes && a.onReview === b.onReview && a.onConfirmColumn === b.onConfirmColumn && a.onFillCodes === b.onFillCodes
|
||||
&& a.onChange === b.onChange && a.onCodesComplete === b.onCodesComplete
|
||||
// 格式问题每次都是新对象:只比这一列的几格
|
||||
|
||||
@@ -9,8 +9,8 @@ import { errorMessage } from '../../lib/errors'
|
||||
import { formatTime } from '../../lib/format'
|
||||
import { CATALOG_DRAFT_TEXT as DT, CATALOG_TEXT as CT, DRAFT_GUIDANCE_MAX, DRAFT_NOTE_MAX } from '../../lib/terms'
|
||||
import {
|
||||
DRAFT_MAX_TABLES, DRAFT_MODEL_UNAVAILABLE, DRAFT_PART_COLUMNS, DRAFT_TOP_N, RETRYABLE, blankOf, isJobActive, jobBlank, needsLook,
|
||||
nothingWritten, retryableCount, taskReason,
|
||||
DRAFT_MAX_TABLES, DRAFT_MODEL_UNAVAILABLE, DRAFT_PART_COLUMNS, DRAFT_TOP_N, RETRYABLE, blankOf, chunksOf, isJobActive, jobBlank, needsLook,
|
||||
nothingWritten, retryableCount, taskReason, writtenOf,
|
||||
} from './draftJob'
|
||||
import type { DraftPreset, DraftStartBody } from './draftJob'
|
||||
|
||||
@@ -435,6 +435,9 @@ function JobView({ job, running, stopping, busy, blankTables, onRedraftBlank }:
|
||||
// 额度用完、超时多了,之后的段缩小了:进行中写一句,段数为什么变多看得明白
|
||||
const partColumns = running && job.use_model && job.part_columns != null && job.part_columns < DRAFT_PART_COLUMNS ? job.part_columns : null
|
||||
const blankTotal = jobBlank(job)
|
||||
// 停下来时已经写进了一部分段的表(宽表每写完一段就写进目录):几张、合计几段
|
||||
const stopped = job.tasks.filter((t) => t.status === 'cancelled' && writtenOf(t) > 0)
|
||||
const stoppedParts = { tables: stopped.length, parts: stopped.reduce((n, t) => n + writtenOf(t), 0) }
|
||||
return (
|
||||
<div className="min-w-0 space-y-3">
|
||||
{busy && <Notice tone="info" attr={{ 'data-draft-busy': '' }}>{DT.busy}</Notice>}
|
||||
@@ -468,7 +471,12 @@ function JobView({ job, running, stopping, busy, blankTables, onRedraftBlank }:
|
||||
{running && <p className="text-2xs leading-relaxed text-faint">{DT.hideHint}</p>}
|
||||
{job.status === 'failed' && job.error && <Notice tone="err" attr={{ 'data-draft-job-error': '' }}>{job.error}</Notice>}
|
||||
{job.status === 'interrupted' && <Notice tone="warn" attr={{ 'data-draft-interrupted': '' }}>{DT.interrupted}</Notice>}
|
||||
{job.status === 'cancelled' && <p className="text-2xs text-dim" data-draft-cancelled="">{DT.cancelled}</p>}
|
||||
{job.status === 'cancelled' && (
|
||||
<p className="text-2xs leading-relaxed text-dim" data-draft-cancelled="">
|
||||
{DT.cancelled}
|
||||
{stoppedParts.tables > 0 && <span className="block">{DT.cancelledParts(stoppedParts.tables, stoppedParts.parts)}</span>}
|
||||
</p>
|
||||
)}
|
||||
{problemText && (
|
||||
<p className="text-xs leading-relaxed" data-draft-problems="">{problemText}</p>
|
||||
)}
|
||||
@@ -523,7 +531,15 @@ const TASK_ICON: Record<CatalogDraftTaskStatus, { Icon: LucideIcon | null; color
|
||||
function TaskRow({ task: t, jobError }: { task: CatalogDraftTask; jobError: string | null }) {
|
||||
const { Icon, color } = TASK_ICON[t.status]
|
||||
const reason = jobError && t.error === jobError ? (t.model_error ?? '') : taskReason(t)
|
||||
const parts = t.parts_total != null && t.parts_total > 1 && t.status !== 'pending' && t.status !== 'skipped'
|
||||
const started = t.status !== 'pending' && t.status !== 'skipped'
|
||||
const parts = t.parts_total != null && t.parts_total > 1 && started
|
||||
// 段被缩小、拆半后实际的块(比段多时才写);已经写进目录的段:起草中、部分完成时写着(停止也不丢,部分完成的
|
||||
// 段进度是「都结束了」,看不出写进了几段),停下来时补一句剩下几段没起草。服务端的原因里已经写了的不重复,写完了的不写
|
||||
const chunks = started ? chunksOf(t) : null
|
||||
const written = writtenOf(t)
|
||||
const writtenText = written <= 0 || reason.includes(DT.partsWritten(written)) ? ''
|
||||
: t.status === 'running' || t.status === 'partial' ? DT.partsWritten(written)
|
||||
: t.status === 'cancelled' ? DT.partsWrittenStopped(written, Math.max(0, (t.parts_total ?? written) - written)) : ''
|
||||
const changes = DT.taskAdded(t.added, t.updated)
|
||||
const blank = blankOf(t)
|
||||
const sample = t.blank_sample?.filter(Boolean) ?? []
|
||||
@@ -537,6 +553,11 @@ function TaskRow({ task: t, jobError }: { task: CatalogDraftTask; jobError: stri
|
||||
{DT.parts(t.parts_done, t.parts_total!)}
|
||||
</span>
|
||||
)}
|
||||
{chunks && (
|
||||
<span className="tnum shrink-0 text-2xs text-faint" title={DT.chunksHint(chunks.total)} data-draft-task-chunks="">
|
||||
{DT.chunks(chunks.done, chunks.total)}
|
||||
</span>
|
||||
)}
|
||||
{changes && (
|
||||
<span className="tnum shrink-0 text-2xs text-faint" title={DT.taskAddedHint(t.added, t.updated, t.removed)}>{changes}</span>
|
||||
)}
|
||||
@@ -545,6 +566,11 @@ function TaskRow({ task: t, jobError }: { task: CatalogDraftTask; jobError: stri
|
||||
{DT.taskStatus[t.status]}
|
||||
</span>
|
||||
</div>
|
||||
{writtenText && (
|
||||
<p className="mt-0.5 break-words pl-5 text-2xs leading-relaxed text-faint" title={DT.partsWrittenHint} data-draft-task-written="">
|
||||
{writtenText}
|
||||
</p>
|
||||
)}
|
||||
{reason && (
|
||||
<p className="mt-0.5 break-words pl-5 text-2xs leading-relaxed text-dim" data-draft-task-reason="">{reason}</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AlertTriangle, CheckCheck, RotateCcw, RotateCw, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { api } from '../../api/client'
|
||||
import type {
|
||||
CatalogDetail, CatalogReviewAction, CatalogReviewItem, CatalogReviewKind, CatalogReviewProgress, CatalogTableRow,
|
||||
} from '../../types'
|
||||
import { EmptyState, ErrorState, Notice, Skeleton, Spinner, confirmDialog, toast, useRadioGroup } from '../../components/ui'
|
||||
import { errorMessage } from '../../lib/errors'
|
||||
import { CATALOG_REVIEW_TEXT as RT } from '../../lib/terms'
|
||||
import { StatusChip } from './parts'
|
||||
import {
|
||||
REVIEW_BATCH_MAX, REVIEW_KINDS, REVIEW_PAGE, applyResults, batchTargets, confirmProgress, itemKey, reviewWhere, statusOf,
|
||||
syncFromDetail,
|
||||
} from './review'
|
||||
import type { ItemOutcome } from './review'
|
||||
|
||||
// ===========================================================================
|
||||
// 审阅队列(数据目录页的「审阅队列」视图,左栏):跨表列出推断项,顺序由服务端排好(所在表的使用次数 × 这一项对数字的
|
||||
// 影响 × 来源),每一项写为什么排在这里。可以按项(影响数字的项 / 名称和含义)和表筛选;多选后成批确认、驳回,审阅过的
|
||||
// 原地标出结果(冲突的标出来),选中可以撤销审阅;刷新队列后审阅过的才移除。顶上是审阅进度:常用表(使用次数最多、
|
||||
// 被运行用过的前 20 张)、全部表、影响数字的项各确认了多少,三行同一个口径。点开一项,右栏是所在表的详情(滚到那一项、
|
||||
// 标出来)和用过这张表的运行。
|
||||
// ===========================================================================
|
||||
|
||||
export function ReviewQueue({ sourceId, rows, kind, table, activeKey, detailSync, onParams, onOpenItem, onChanged }: {
|
||||
sourceId: string
|
||||
/** 目录清单的各行:按表筛选的候选(有推断项的表)、表的中文名 */
|
||||
rows: CatalogTableRow[]
|
||||
kind: CatalogReviewKind
|
||||
/** 只看这一张表;空串是全部表 */
|
||||
table: string
|
||||
/** 右栏正打开的那一项(itemKey) */
|
||||
activeKey: string | null
|
||||
/** 右栏的表详情载入、改过之后的目录:队列里这张表的项跟着换状态 */
|
||||
detailSync: { seq: number; detail: CatalogDetail } | null
|
||||
onParams: (p: { kind?: CatalogReviewKind; table?: string }) => void
|
||||
onOpenItem: (item: CatalogReviewItem) => void
|
||||
/** 成批审阅写进了这些表:页面重取清单、正在看的表重新载入 */
|
||||
onChanged: (tables: string[]) => void
|
||||
}) {
|
||||
const [items, setItems] = useState<CatalogReviewItem[] | null>(null)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loadError, setLoadError] = useState<unknown>(null)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [outcomes, setOutcomes] = useState<Map<string, ItemOutcome>>(() => new Map())
|
||||
const [versions, setVersions] = useState<Map<string, number>>(() => new Map())
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set())
|
||||
const [acting, setActing] = useState<CatalogReviewAction | null>(null)
|
||||
const [summary, setSummary] = useState<{ action: CatalogReviewAction; ok: number; conflicts: number; failed: number } | null>(null)
|
||||
const [batchError, setBatchError] = useState<string | null>(null)
|
||||
const [progress, setProgress] = useState<CatalogReviewProgress | null>(null)
|
||||
const [progressError, setProgressError] = useState(false)
|
||||
const [reloadSeq, setReloadSeq] = useState(0)
|
||||
const loadSeq = useRef(0)
|
||||
const itemsRef = useRef(items)
|
||||
itemsRef.current = items
|
||||
const outcomesRef = useRef(outcomes)
|
||||
outcomesRef.current = outcomes
|
||||
const versionsRef = useRef(versions)
|
||||
versionsRef.current = versions
|
||||
const allRef = useRef<HTMLInputElement>(null)
|
||||
const radio = useRadioGroup(REVIEW_KINDS, kind, (k) => onParams({ kind: k }))
|
||||
|
||||
const loadProgress = useCallback(() => {
|
||||
api.dataCatalog.reviewProgress(sourceId).then((p) => { setProgress(p); setProgressError(false) }, () => setProgressError(true))
|
||||
}, [sourceId])
|
||||
useEffect(() => { loadProgress() }, [loadProgress])
|
||||
|
||||
// 第一页:筛选变了、点了刷新就从头取,审阅过的标记、选择、汇总都清掉
|
||||
useEffect(() => {
|
||||
const mine = ++loadSeq.current
|
||||
setItems(null)
|
||||
setLoadError(null)
|
||||
api.dataCatalog.reviewQueue(sourceId, { limit: REVIEW_PAGE, offset: 0, kind, table: table || null }).then((q) => {
|
||||
if (mine !== loadSeq.current) return
|
||||
setItems(q.items)
|
||||
setTotal(q.total)
|
||||
setOutcomes(new Map())
|
||||
setVersions(new Map())
|
||||
setSelected(new Set())
|
||||
setSummary(null)
|
||||
setBatchError(null)
|
||||
}, (e) => { if (mine === loadSeq.current) setLoadError(e) })
|
||||
}, [sourceId, kind, table, reloadSeq])
|
||||
|
||||
const refresh = () => {
|
||||
setReloadSeq((n) => n + 1)
|
||||
loadProgress()
|
||||
}
|
||||
|
||||
/**
|
||||
* 再载入一页。服务端的队列里已经没有审阅成功、不再是推断的项:偏移按「这一页上还在服务端队列里的项」数,
|
||||
* 不会跳过没载入的项
|
||||
*/
|
||||
const loadMore = async () => {
|
||||
const list = itemsRef.current
|
||||
if (!list || loadingMore) return
|
||||
const gone = list.filter((i) => statusOf(i, outcomes.get(itemKey(i))) !== 'proposed').length
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const q = await api.dataCatalog.reviewQueue(sourceId, { limit: REVIEW_PAGE, offset: list.length - gone, kind, table: table || null })
|
||||
setItems((cur) => {
|
||||
const have = new Set((cur ?? []).map(itemKey))
|
||||
return [...(cur ?? []), ...q.items.filter((i) => !have.has(itemKey(i)))]
|
||||
})
|
||||
setTotal(q.total + gone)
|
||||
} catch (e) {
|
||||
toast.error(e)
|
||||
} finally {
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 右栏的表详情载入、改过:这张表的项按最新的目录换状态
|
||||
const syncSeen = useRef(detailSync?.seq ?? 0)
|
||||
useEffect(() => {
|
||||
if (!detailSync || detailSync.seq === syncSeen.current) return
|
||||
syncSeen.current = detailSync.seq
|
||||
const list = itemsRef.current
|
||||
if (!list) return
|
||||
const d = detailSync.detail
|
||||
if (!list.some((i) => i.table_name === d.table_name)) return
|
||||
const next = syncFromDetail(list, d.table_name, d.notes, d.version, outcomesRef.current, versionsRef.current)
|
||||
setOutcomes(next.outcomes)
|
||||
setVersions(next.versions)
|
||||
loadProgress()
|
||||
}, [detailSync, loadProgress])
|
||||
|
||||
const act = async (action: CatalogReviewAction) => {
|
||||
const list = itemsRef.current ?? []
|
||||
const targets = batchTargets(list, selected, action, outcomes)
|
||||
if (!targets.length || acting) return
|
||||
if (targets.length > REVIEW_BATCH_MAX) {
|
||||
setBatchError(RT.tooMany(REVIEW_BATCH_MAX))
|
||||
return
|
||||
}
|
||||
if (action !== 'reset') {
|
||||
const ok = await confirmDialog(action === 'confirm'
|
||||
? { title: RT.confirmTitle(targets.length), consequences: RT.confirmConsequences, confirmLabel: RT.confirmAction(targets.length) }
|
||||
: { title: RT.rejectTitle(targets.length), consequences: RT.rejectConsequences, confirmLabel: RT.rejectAction(targets.length), danger: true })
|
||||
if (!ok) return
|
||||
}
|
||||
setActing(action)
|
||||
setBatchError(null)
|
||||
try {
|
||||
const { results } = await api.dataCatalog.reviewBatch(sourceId, targets.map((i) => ({
|
||||
table_name: i.table_name, path: i.path, action, if_version: versions.get(i.table_name) ?? i.version,
|
||||
})))
|
||||
const next = applyResults(results, action, outcomes, versions)
|
||||
setOutcomes(next.outcomes)
|
||||
setVersions(next.versions)
|
||||
setSelected(new Set())
|
||||
const ok = results.filter((r) => r.ok)
|
||||
const conflicts = results.filter((r) => !r.ok && next.outcomes.get(itemKey(r))?.kind === 'conflict').length
|
||||
setSummary({ action, ok: ok.length, conflicts, failed: results.length - ok.length - conflicts })
|
||||
if (ok.length) onChanged([...new Set(ok.map((r) => r.table_name))])
|
||||
loadProgress()
|
||||
} catch (e) {
|
||||
setBatchError(RT.failedAll(errorMessage(e)))
|
||||
} finally {
|
||||
setActing(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 按表筛选的候选:有推断项的表(使用次数多的在前),正在筛的那张不在里面也列上
|
||||
const tableOptions = useMemo(() => {
|
||||
const list = rows.filter((r) => r.counts.proposed > 0)
|
||||
if (table && !list.some((r) => r.table_name === table)) {
|
||||
const row = rows.find((r) => r.table_name === table)
|
||||
return [...list, row ?? ({ table_name: table, label: null, counts: { proposed: 0 } } as unknown as CatalogTableRow)]
|
||||
}
|
||||
return list
|
||||
}, [rows, table])
|
||||
const labelOf = useMemo(() => new Map(rows.map((r) => [r.table_name, r.label])), [rows])
|
||||
|
||||
const loaded = items ?? []
|
||||
const picked = loaded.filter((i) => selected.has(itemKey(i))).length
|
||||
const all = loaded.length > 0 && picked === loaded.length
|
||||
useEffect(() => {
|
||||
if (allRef.current) allRef.current.indeterminate = picked > 0 && !all
|
||||
}, [picked, all])
|
||||
const toggleAll = () => setSelected(all ? new Set() : new Set(loaded.map(itemKey)))
|
||||
const toggle = useCallback((key: string) => setSelected((cur) => {
|
||||
const next = new Set(cur)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
}), [])
|
||||
const counts = {
|
||||
confirm: batchTargets(loaded, selected, 'confirm', outcomes).length,
|
||||
reject: batchTargets(loaded, selected, 'reject', outcomes).length,
|
||||
reset: batchTargets(loaded, selected, 'reset', outcomes).length,
|
||||
}
|
||||
const filtered = kind !== 'all' || !!table
|
||||
const remaining = Math.max(0, total - loaded.length)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-review-queue="" aria-label={RT.queueLabel}>
|
||||
<ProgressBlock progress={progress} failed={progressError} />
|
||||
<div className="space-y-2 border-b px-3 py-2.5">
|
||||
<div role="radiogroup" aria-label={RT.kindLabel} className="flex flex-wrap gap-1" data-review-kinds={kind}>
|
||||
{REVIEW_KINDS.map((k) => (
|
||||
<button key={k} type="button" {...radio(k)} title={RT.kindHint[k]} onClick={() => onParams({ kind: k })} data-review-kind={k}
|
||||
className={clsx(
|
||||
'inline-flex items-center rounded-md border px-2 py-0.5 text-2xs outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]',
|
||||
kind === k ? 'border-[var(--accent)] bg-accent-soft text-fg' : 'text-dim hover:bg-hover hover:text-fg',
|
||||
)}>
|
||||
{RT.kind[k]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="sr-only" htmlFor="catalog-review-table">{RT.tableLabel}</label>
|
||||
<select id="catalog-review-table" className="field min-w-0 flex-1 !py-0.5 !text-2xs" value={table}
|
||||
onChange={(e) => onParams({ table: e.target.value })} data-review-table="">
|
||||
<option value="">{RT.tableAll}</option>
|
||||
{tableOptions.map((r) => (
|
||||
<option key={r.table_name} value={r.table_name}>{RT.tableOption(r.label, r.table_name, r.counts.proposed)}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="btn btn-sm btn-ghost shrink-0 !px-1.5" onClick={refresh} aria-label={RT.refresh} title={RT.refreshHint}
|
||||
data-review-refresh="">
|
||||
<RotateCw size={12} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-2xs text-faint">
|
||||
<input ref={allRef} type="checkbox" checked={all} onChange={toggleAll} disabled={!loaded.length}
|
||||
aria-label={RT.selectAll} title={RT.selectAll} data-review-select-all="" />
|
||||
<span className="tnum min-w-0 flex-1 truncate" data-review-total="">
|
||||
{selected.size ? RT.selected(selected.size) : items ? RT.total(total) : RT.loading}
|
||||
</span>
|
||||
{selected.size > 0 && (
|
||||
<button type="button" className="inline-flex items-center gap-0.5 rounded px-1 hover:bg-hover hover:text-fg"
|
||||
onClick={() => setSelected(new Set())} data-review-clear="">
|
||||
<X size={10} aria-hidden /> {RT.clearSelection}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<p className={clsx('flex items-start gap-1.5 border-b px-3 py-2 text-2xs leading-relaxed', summary.conflicts || summary.failed ? 'text-[var(--warn)]' : 'text-dim')}
|
||||
role="status" data-review-summary="">
|
||||
{summary.conflicts || summary.failed
|
||||
? <AlertTriangle size={11} className="mt-0.5 shrink-0" aria-hidden />
|
||||
: <CheckCheck size={11} className="mt-0.5 shrink-0" style={{ color: 'var(--st-done)' }} aria-hidden />}
|
||||
<span className="min-w-0">{RT.summary(summary.action, summary.ok, summary.conflicts, summary.failed)}</span>
|
||||
</p>
|
||||
)}
|
||||
{batchError && <div className="border-b px-3 py-2"><Notice tone="err" attr={{ 'data-review-batch-error': '' }}>{batchError}</Notice></div>}
|
||||
|
||||
<div className="relative min-h-0 flex-1 overflow-y-auto">
|
||||
{loadError
|
||||
? <div className="p-3"><ErrorState error={loadError} onRetry={refresh} compact /></div>
|
||||
: !items
|
||||
? <div className="p-3"><Skeleton rows={6} height={44} gap={8} /></div>
|
||||
: !items.length
|
||||
? (
|
||||
<EmptyState icon={<CheckCheck size={20} />} title={filtered ? RT.emptyFiltered : RT.empty}
|
||||
body={filtered ? RT.emptyFilteredBody : RT.emptyBody} offline={false} className="!py-10" />
|
||||
)
|
||||
: (
|
||||
<ul aria-label={RT.queueLabel}>
|
||||
{items.map((i) => {
|
||||
const key = itemKey(i)
|
||||
return (
|
||||
<QueueRow key={key} item={i} itemKeyText={key} tableLabel={labelOf.get(i.table_name) ?? null}
|
||||
outcome={outcomes.get(key)} checked={selected.has(key)} active={activeKey === key}
|
||||
onToggle={toggle} onOpen={onOpenItem} />
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{items && remaining > 0 && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<button type="button" className="btn btn-sm w-full" onClick={() => void loadMore()} disabled={loadingMore} data-review-more="">
|
||||
{loadingMore ? <><Spinner size={11} /> {RT.loading}</> : RT.more(Math.min(REVIEW_PAGE, remaining))}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 border-t px-3 py-2" data-review-batch="">
|
||||
<span className="tnum text-2xs text-faint">{RT.selected(selected.size)}</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" className="btn btn-sm btn-primary" disabled={!counts.confirm || !!acting} onClick={() => void act('confirm')}
|
||||
data-review-action="confirm">
|
||||
{acting === 'confirm' ? <Spinner size={11} /> : null}{RT.confirm(counts.confirm)}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" disabled={!counts.reject || !!acting} onClick={() => void act('reject')}
|
||||
data-review-action="reject">
|
||||
{acting === 'reject' ? <Spinner size={11} /> : null}{RT.reject(counts.reject)}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm btn-ghost" disabled={!counts.reset || !!acting} onClick={() => void act('reset')}
|
||||
title={RT.resetHint} data-review-action="reset">
|
||||
{acting === 'reset' ? <Spinner size={11} /> : <RotateCcw size={11} aria-hidden />} {RT.reset(counts.reset)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 队列里的一行:复选框、位置和所在表、值、说明、为什么排在这里、来源和状态(审阅过的写结果)、所在表的使用次数 */
|
||||
const QueueRow = memo(function QueueRow({ item: i, itemKeyText, tableLabel, outcome, checked, active, onToggle, onOpen }: {
|
||||
item: CatalogReviewItem
|
||||
itemKeyText: string
|
||||
tableLabel: string | null
|
||||
outcome: ItemOutcome | undefined
|
||||
checked: boolean
|
||||
active: boolean
|
||||
onToggle: (key: string) => void
|
||||
onOpen: (item: CatalogReviewItem) => void
|
||||
}) {
|
||||
const status = statusOf(i, outcome)
|
||||
// 位置按 path 说(「列 status 的码值」):服务端的 field_label 只是字段的叫法,列级的项只写它分不清是哪一列
|
||||
const where = reviewWhere(i)
|
||||
const tableText = tableLabel ? `${tableLabel}(${i.table_name})` : i.table_name
|
||||
const mono = i.field === 'valid_filter' || i.field === 'keys'
|
||||
return (
|
||||
<li className={clsx('relative flex items-stretch border-b border-hairline', active ? 'bg-accent-soft' : 'hover:bg-hover')}
|
||||
data-review-item={`${i.table_name}::${i.path}`} data-review-result={outcome?.kind}>
|
||||
{active && <span className="absolute inset-y-0 left-0 w-0.5 bg-[var(--accent)]" aria-hidden />}
|
||||
<span className="flex shrink-0 items-start pl-3 pr-1 pt-2.5">
|
||||
<input type="checkbox" checked={checked} onChange={() => onToggle(itemKeyText)} aria-label={RT.select(where, i.table_name)}
|
||||
data-review-select="" />
|
||||
</span>
|
||||
<button type="button" className="min-w-0 flex-1 px-2 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--accent)]"
|
||||
onClick={() => onOpen(i)} aria-current={active ? 'true' : undefined} title={RT.open(where, i.table_name)} data-review-open="">
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-fg" data-review-where="">{where}</span>
|
||||
<span className="tnum shrink-0 text-2xs text-faint" title={RT.usageHint}>{RT.usage(i.table_usage)}</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-2xs text-faint" title={tableText}>{tableText}</span>
|
||||
<span className={clsx('mt-1 block break-words text-2xs text-dim', mono && 'mono', status === 'rejected' && 'line-through')}
|
||||
data-review-value="">{i.display || '—'}</span>
|
||||
{i.note && <span className="mt-0.5 block break-words text-2xs leading-relaxed text-faint" data-review-note="">{i.note}</span>}
|
||||
{i.reasons.length > 0 && (
|
||||
<span className="mt-1 block text-2xs leading-relaxed text-faint" data-review-reasons="">
|
||||
<span className="sr-only">{RT.reasonsLabel}:</span>{i.reasons.join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-1 flex flex-wrap items-center gap-1.5" data-review-outcome="">
|
||||
<StatusChip status={status} source={i.source} />
|
||||
{outcome?.kind === 'ok' && <span className="text-2xs" style={{ color: 'var(--st-done)' }}>{RT.done[outcome.action]}</span>}
|
||||
{outcome?.kind === 'conflict' && (
|
||||
<span className="text-2xs text-[var(--warn)]" title={outcome.message ?? undefined}>{RT.conflict}</span>
|
||||
)}
|
||||
{outcome?.kind === 'error' && (
|
||||
<span className="text-2xs text-[var(--err)]">{outcome.message ? `${RT.failed}:${outcome.message}` : RT.failed}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 审阅进度:常用表、全部表、影响数字的项,三行同一个口径——已确认 ÷(已确认 + 待确认),已驳回的、有确证的不计入,
|
||||
* 审完了就是 100%。常用表只算被运行用过的(最多 top_n 张),标签写实际几张,一张都没有时写明还没有运行用过的表。
|
||||
* 数字写在字里,色条只给眼睛看
|
||||
*/
|
||||
function ProgressBlock({ progress, failed }: { progress: CatalogReviewProgress | null; failed: boolean }) {
|
||||
if (!progress) {
|
||||
return (
|
||||
<div className="border-b px-3 py-2 text-2xs text-faint" data-review-progress={failed ? 'error' : 'loading'}>
|
||||
{failed ? RT.progressFailed : <Skeleton rows={2} height={10} gap={6} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const { top, all, facts } = progress
|
||||
const topTables = Math.max(0, top.tables ?? 0)
|
||||
const topLine = confirmProgress(top.items_confirmed, top.items_proposed)
|
||||
const allLine = confirmProgress(all.items_confirmed, all.items_proposed)
|
||||
const factsLine = confirmProgress(facts.confirmed, facts.proposed)
|
||||
return (
|
||||
<section className="space-y-1.5 border-b px-3 py-2.5" aria-label={RT.progressTitle} data-review-progress="">
|
||||
{topTables > 0
|
||||
? (
|
||||
<ProgressLine label={RT.progressTop(topTables)} hint={RT.progressTopHint(topTables, progress.top_n)} line={topLine}
|
||||
total={top.items_total} attr="top" />
|
||||
)
|
||||
: (
|
||||
<div className="flex items-baseline gap-2 text-2xs" data-review-progress-line="top" data-review-progress-top="" data-review-progress-none="">
|
||||
<span className="shrink-0 font-medium text-dim" title={RT.progressTopHint(0, progress.top_n)}>{RT.progressTopNone}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-right text-faint">{RT.progressTopEmpty}</span>
|
||||
</div>
|
||||
)}
|
||||
<ProgressLine label={RT.progressAll} hint={RT.progressAllHint} line={allLine} total={all.items_total} attr="all" />
|
||||
<ProgressLine label={RT.progressFacts} hint={RT.progressFactsHint} line={factsLine} total={null} attr="facts"
|
||||
warn={facts.proposed > 0} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressLine({ label, hint, line, total, attr, warn = false }: {
|
||||
label: string
|
||||
hint: string
|
||||
line: ReturnType<typeof confirmProgress>
|
||||
/** 这一行一共几项(全部状态):0 时写还没有目录项;不知道时为 null */
|
||||
total: number | null
|
||||
attr: string
|
||||
warn?: boolean
|
||||
}) {
|
||||
const text = RT.progressText(line.pct, line.confirmed, line.proposed, total)
|
||||
return (
|
||||
<div className="text-2xs" data-review-progress-line={attr} {...{ [`data-review-progress-${attr}`]: '' }}
|
||||
data-review-progress-pct={line.pct ?? ''}>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="shrink-0 font-medium text-dim" title={`${hint}\n${RT.progressRule}`}>{label}</span>
|
||||
<span className={clsx('tnum min-w-0 flex-1 truncate text-right', warn ? 'text-[var(--st-waiting)]' : 'text-faint')}
|
||||
title={`${text}\n${RT.progressRule}`}>{text}</span>
|
||||
</div>
|
||||
{line.pct != null && (
|
||||
<div className="mt-0.5 h-1 overflow-hidden rounded-full bg-hover" aria-hidden>
|
||||
<div className="h-full rounded-full bg-[var(--st-done)]" style={{ width: `${line.pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 审阅视图里没打开项时的右栏:怎么排的序、点开能看到什么 */
|
||||
export function ReviewOverview() {
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-xl space-y-3 p-6" data-review-overview="">
|
||||
<h2 className="text-sm font-semibold">{RT.overviewTitle}</h2>
|
||||
<p className="text-xs leading-relaxed text-dim">{RT.overviewBody}</p>
|
||||
<p className="tnum text-2xs text-faint">{RT.kindHint.facts}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||||
import { ArrowUp, BookMarked, Database, Info, MessagesSquare, Minus, Plus, Square, Trash2, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { ApiError, api, streamCatalogAssistant } from '../../api/client'
|
||||
import type { CatalogAssistantEnd } from '../../api/client'
|
||||
import type { CatalogDetail } from '../../types'
|
||||
import { Notice, Spinner, confirmDialog, isComposing, toast } from '../../components/ui'
|
||||
import { useLeaveGuard } from '../../lib/leave'
|
||||
import { CATALOG_ASSISTANT_TEXT as T, CATALOG_STATUS_LABEL } from '../../lib/terms'
|
||||
import { Markdown } from '../../run/Markdown'
|
||||
import { patchValueText } from '../../run/catalogPatch'
|
||||
import {
|
||||
ASSISTANT_MAX_CHARS, ASSISTANT_MAX_SAVE, QUERY_ROWS_SHOWN, applyEvent, applyPreview, assistantChangesOf, blankColumnsOf, blankLeadOf,
|
||||
changeStateOf, changeWhere, dropPending, historyOf, mergeProposal, pendingForAssistant, pendingSubmit, pendingVersion, savedKey,
|
||||
} from './assistant'
|
||||
import type { AssistantTurn, ChangeState, PendingChange, Turn, TurnPart } from './assistant'
|
||||
|
||||
// ===========================================================================
|
||||
// 表助手:在表详情里和助手对话完善这一张表的数据目录(POST /catalog/{table}/assistant,事件流)。
|
||||
//
|
||||
// 回答流式显示,能停止;助手跑过的只读查询(只有开了数据剖析的源才有)写 SQL 和前几行,默认收起;修改提案逐项写改前、
|
||||
// 改后、依据,可以逐项去掉、加回。提案并进「待保存的修改」:跨多轮累积,同一项以最后一次提案为准,一键保存(最多 200 项)。
|
||||
// 保存带待保存的修改里最早那条提案对照的版本:别人在那之后改过就 409,按最新内容重算(预览接口,只读)后停下来让人再看
|
||||
// 一眼;只重算交去保存的那几项,保存期间回答里又到的提案原样留着。对话不存服务端:每一轮把前面的对话和待保存的修改
|
||||
// (最多 200 项)一起交上去;关闭、换表、离开页面就没有了,有待保存的修改时先问。
|
||||
// 开场:上次起草有留空的列时,引导先问这几列——列名取那次起草记下的(任务里这张表的 blank_sample),拿不到时改说目录里
|
||||
// 还没有中文名和含义的列。
|
||||
// ===========================================================================
|
||||
|
||||
type SaveState =
|
||||
| { phase: 'idle'; error?: string | null; saved?: { n: number; version: number } | null }
|
||||
| { phase: 'saving' }
|
||||
| { phase: 'reloading' }
|
||||
| { phase: 'conflict'; problems: string[] }
|
||||
|
||||
const charCount = (s: string) => [...s.trim()].length
|
||||
|
||||
export function TableAssistant({ sourceId, table, tableLabel, detail, profileEnabled, lastBlank, editing, onSaved, onClose }: {
|
||||
sourceId: string
|
||||
table: string
|
||||
/** 表的中文名(没有就是表名):标题、提示里用 */
|
||||
tableLabel: string
|
||||
/** 页面上这张表最新的目录:留空的列、保存后更新 */
|
||||
detail: CatalogDetail
|
||||
/** 数据源开了数据剖析:助手才能发只读查询 */
|
||||
profileEnabled: boolean
|
||||
/**
|
||||
* 上次起草模型拿不准、留空的列数(n)和出处:开场引导先问这几列。jobId 是那次起草的任务(从中取起草时记下的列名);
|
||||
* earlier:最近一次停止了,n 是再之前那次起草记下的(不知道是哪一次,取不到列名)
|
||||
*/
|
||||
lastBlank: { n: number; earlier: boolean; jobId: string | null }
|
||||
/** 正在编辑这张表:先不让保存(同一张表两处同时改,保存会撞上版本冲突) */
|
||||
editing: boolean
|
||||
/** 保存成功:页面换上返回的目录 */
|
||||
onSaved: (d: CatalogDetail) => void
|
||||
/** 关上面板(有待保存的修改时这里先问过了) */
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [turns, setTurns] = useState<Turn[]>([])
|
||||
const [pending, setPending] = useState<PendingChange[]>([])
|
||||
const [saved, setSaved] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const [text, setText] = useState('')
|
||||
const [save, setSave] = useState<SaveState>({ phase: 'idle' })
|
||||
const [trayOpen, setTrayOpen] = useState(true)
|
||||
const turnsRef = useRef(turns)
|
||||
turnsRef.current = turns
|
||||
const pendingRef = useRef(pending)
|
||||
pendingRef.current = pending
|
||||
const stopRef = useRef<(() => void) | null>(null)
|
||||
const seq = useRef(0)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const logRef = useRef<HTMLDivElement>(null)
|
||||
const titleId = useId()
|
||||
const streaming = turns.some((t) => t.role === 'assistant' && t.state === 'streaming')
|
||||
const dirty = pending.length > 0 || streaming
|
||||
|
||||
// 打开时焦点给输入框;卸下时停掉进行中的回答
|
||||
useEffect(() => { inputRef.current?.focus() }, [])
|
||||
useEffect(() => () => { stopRef.current?.() }, [])
|
||||
|
||||
useLeaveGuard(pending.length > 0, () => confirmDialog({
|
||||
title: T.leaveTitle, consequences: T.leaveConsequences, confirmLabel: T.leaveConfirm, danger: true,
|
||||
}))
|
||||
|
||||
// 新的内容到了:滚到底(人往上翻着看的时候不打扰)
|
||||
const lastLen = useRef(0)
|
||||
useEffect(() => {
|
||||
const el = logRef.current
|
||||
if (!el) return
|
||||
const size = turns.reduce((n, t) => n + (t.role === 'user' ? 1 : t.parts.length + 1), 0)
|
||||
const near = el.scrollHeight - el.scrollTop - el.clientHeight < 160
|
||||
if (size !== lastLen.current || near) el.scrollTop = el.scrollHeight
|
||||
lastLen.current = size
|
||||
}, [turns])
|
||||
|
||||
// 上次起草记下的留空列名:从那次起草的任务里取(null 是还没取回来;取不到是空数组,改说目录里还没有中文名和含义的列)
|
||||
const blankN = lastBlank.n
|
||||
const blankJob = blankN > 0 && !lastBlank.earlier ? lastBlank.jobId : null
|
||||
const [blankSample, setBlankSample] = useState<string[] | null>(null)
|
||||
useEffect(() => {
|
||||
if (!blankJob) return
|
||||
let live = true
|
||||
setBlankSample(null)
|
||||
api.dataCatalog.draftJob(sourceId, blankJob)
|
||||
.then(({ job }) => job.tasks.find((t) => t.table_name === table)?.blank_sample?.filter(Boolean) ?? [])
|
||||
.catch(() => [] as string[])
|
||||
.then((cols) => { if (live) setBlankSample(cols) })
|
||||
return () => { live = false }
|
||||
}, [sourceId, table, blankJob])
|
||||
// 没有留空的列、还在取那次起草记下的列名时不写
|
||||
const blankLead = useMemo(() => (blankN <= 0 || (blankJob && blankSample == null)
|
||||
? null : blankLeadOf(blankN, blankJob ? blankSample : null, blankColumnsOf(detail))), [blankN, blankJob, blankSample, detail])
|
||||
const hasProposed = useMemo(() => {
|
||||
const n = detail.notes
|
||||
const items = [n.label, n.description, n.grain, n.keys, n.kind, n.business_date, n.valid_filter, n.dedup,
|
||||
...Object.values(n.columns ?? {}).flatMap((c) => Object.values(c ?? {})), ...(n.relations ?? [])]
|
||||
return items.some((it) => !!it && (it as { status?: string }).status === 'proposed')
|
||||
}, [detail])
|
||||
|
||||
const updateTurn = useCallback((id: string, fn: (t: AssistantTurn) => AssistantTurn) => {
|
||||
setTurns((ts) => ts.map((t) => (t.id === id && t.role === 'assistant' ? fn(t) : t)))
|
||||
}, [])
|
||||
|
||||
const send = useCallback((raw: string) => {
|
||||
const question = raw.trim()
|
||||
if (!question || charCount(question) > ASSISTANT_MAX_CHARS) return
|
||||
if (turnsRef.current.some((t) => t.role === 'assistant' && t.state === 'streaming')) return
|
||||
const n = ++seq.current
|
||||
const userTurn: Turn = { id: `u${n}`, role: 'user', text: question }
|
||||
const reply: AssistantTurn = { id: `a${n}`, role: 'assistant', parts: [], state: 'streaming' }
|
||||
const messages = historyOf(turnsRef.current, question)
|
||||
setTurns((ts) => [...ts, userTurn, reply])
|
||||
setText('')
|
||||
let proposals = 0
|
||||
// 这一轮已经收尾(done、error):之后再来的事件不认
|
||||
let ended = false
|
||||
stopRef.current = streamCatalogAssistant(sourceId, table, { messages, pending: pendingForAssistant(pendingRef.current) }, (ev) => {
|
||||
if (ended) return
|
||||
if (ev.type === 'done' || ev.type === 'error') ended = true
|
||||
if (ev.type === 'proposal') {
|
||||
const pid = `${reply.id}.p${++proposals}`
|
||||
const changes = assistantChangesOf(ev.changes)
|
||||
updateTurn(reply.id, (t) => applyEvent(t, ev, pid))
|
||||
if (changes.length) {
|
||||
const version = typeof ev.version === 'number' && Number.isFinite(ev.version) ? ev.version : detail.version
|
||||
setPending((cur) => mergeProposal(cur, changes, pid, version))
|
||||
// 待保存的修改变了:上一次保存的结果、冲突的说明都不再对。正在保存、重算的不动(按钮照旧不能点,结果出来再说)
|
||||
setSave((s) => (s.phase === 'saving' || s.phase === 'reloading' ? s : { phase: 'idle' }))
|
||||
}
|
||||
return
|
||||
}
|
||||
updateTurn(reply.id, (t) => applyEvent(t, ev, ''))
|
||||
}, (end: CatalogAssistantEnd) => {
|
||||
stopRef.current = null
|
||||
updateTurn(reply.id, (t) => {
|
||||
if (t.state !== 'streaming') return t
|
||||
if (end.aborted) return { ...t, state: 'stopped' }
|
||||
if (end.error) return { ...t, state: 'error', error: end.error, code: end.code ?? null }
|
||||
// 流结束了却没有 done:这一轮没答完
|
||||
return { ...t, state: 'error', error: T.interrupted }
|
||||
})
|
||||
})
|
||||
}, [sourceId, table, detail.version, updateTurn])
|
||||
|
||||
const stop = () => stopRef.current?.()
|
||||
|
||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key !== 'Enter' || e.shiftKey || isComposing(e)) return
|
||||
e.preventDefault()
|
||||
if (!streaming) send(text)
|
||||
}
|
||||
|
||||
// 两个回调保持不变:流式的正文每来一段只重画正在回答的那一轮
|
||||
const drop = useCallback((path: string) => setPending((cur) => dropPending(cur, path)), [])
|
||||
const restore = useCallback((part: Extract<TurnPart, { kind: 'proposal' }>, path: string) => {
|
||||
const change = part.changes.find((c) => c.path === path)
|
||||
if (change) setPending((cur) => mergeProposal(cur, [change], part.id, part.version))
|
||||
}, [])
|
||||
const clearAll = async () => {
|
||||
if (!pendingRef.current.length) return
|
||||
if (!(await confirmDialog({ title: T.clearTitle(pendingRef.current.length), confirmLabel: T.clearConfirm, danger: true }))) return
|
||||
setPending([])
|
||||
setSave({ phase: 'idle' })
|
||||
}
|
||||
|
||||
const doSave = async () => {
|
||||
const list = pendingRef.current
|
||||
if (!list.length || list.length > ASSISTANT_MAX_SAVE || editing) return
|
||||
setSave({ phase: 'saving' })
|
||||
try {
|
||||
const d = await api.dataCatalog.patch(sourceId, table, pendingSubmit(list), pendingVersion(list))
|
||||
setSaved((cur) => new Set([...cur, ...list.map((p) => savedKey(p.origin, p.path))]))
|
||||
setPending((cur) => cur.filter((p) => !list.includes(p)))
|
||||
setSave({ phase: 'idle', saved: { n: list.length, version: d.version } })
|
||||
onSaved(d)
|
||||
toast.ok(T.savedToast(list.length, tableLabel))
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
// 别人刚改过:按最新内容重算(只读),停下来让人再看一眼
|
||||
setSave({ phase: 'reloading' })
|
||||
try {
|
||||
const p = await api.dataCatalog.patchPreview(sourceId, table, pendingSubmit(list))
|
||||
// 只重算交去保存的那几项:保存、重算期间回答里又到的提案不在预览里,原样留着
|
||||
setPending((cur) => applyPreview(cur, p, list))
|
||||
setSave({ phase: 'conflict', problems: p.problems })
|
||||
} catch {
|
||||
setSave({ phase: 'idle', error: T.conflictFailed })
|
||||
}
|
||||
} else if (e instanceof ApiError && e.kind === 'http' && (e.status === 422 || e.status === 404)) {
|
||||
setSave({ phase: 'idle', error: e.message })
|
||||
} else {
|
||||
setSave({ phase: 'idle' })
|
||||
toast.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const close = async () => {
|
||||
if (dirty && !(await confirmDialog({ title: T.closeTitle, consequences: T.closeConsequences, confirmLabel: T.closeConfirm, danger: true }))) return
|
||||
stopRef.current?.()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const onPanelKey = (e: ReactKeyboardEvent<HTMLElement>) => {
|
||||
if (e.key !== 'Escape' || isComposing(e) || e.defaultPrevented) return
|
||||
e.preventDefault()
|
||||
void close()
|
||||
}
|
||||
|
||||
const fresh = turns.length === 0
|
||||
const len = charCount(text)
|
||||
const over = len > ASSISTANT_MAX_CHARS
|
||||
const lastAssistant = [...turns].reverse().find((t): t is AssistantTurn => t.role === 'assistant')
|
||||
const status = streaming ? T.answering : lastAssistant?.state === 'stopped' ? T.stopped : lastAssistant ? T.answered : ''
|
||||
|
||||
return (
|
||||
<aside
|
||||
// 贴着详情的右边:窄一些的屏幕盖在详情上面(手机上铺满);宽屏(xl 起)详情让出这一栏的宽度,不带阴影
|
||||
className={clsx('absolute inset-y-0 right-0 z-30 flex w-full min-h-0 flex-col border-l bg-panel shadow-elev-2 sm:w-[400px]',
|
||||
'xl:z-10 xl:w-[380px] xl:shadow-none 2xl:w-[420px]')}
|
||||
aria-labelledby={titleId}
|
||||
onKeyDown={onPanelKey}
|
||||
data-table-assistant={table}
|
||||
data-streaming={streaming ? 'true' : 'false'}
|
||||
>
|
||||
<header className="flex items-start gap-2 border-b px-3 py-2.5">
|
||||
<MessagesSquare size={14} className="mt-0.5 shrink-0" style={{ color: 'var(--accent)' }} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 id={titleId} className="text-xs font-semibold">{T.title}</h3>
|
||||
<p className="truncate text-2xs text-faint" title={T.subtitle(tableLabel)}>{T.subtitle(tableLabel)}</p>
|
||||
</div>
|
||||
<button type="button" className="shrink-0 rounded p-1 text-faint outline-none hover:bg-hover hover:text-fg focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
onClick={() => void close()} aria-label={T.close} title={T.close} data-assistant-close="">
|
||||
<X size={13} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="border-b px-3 py-2 text-2xs leading-relaxed">
|
||||
{profileEnabled
|
||||
? (
|
||||
<p className="flex items-start gap-1.5 text-faint" data-assistant-profile-on="">
|
||||
<Database size={11} className="mt-0.5 shrink-0" aria-hidden /> <span className="min-w-0">{T.profileOn}</span>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<p className="flex items-start gap-1.5 text-dim" data-assistant-no-profile="">
|
||||
<Info size={11} className="mt-0.5 shrink-0" style={{ color: 'var(--st-waiting)' }} aria-hidden /> <span className="min-w-0">{T.noProfile}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={logRef} className="min-h-0 flex-1 space-y-3 overflow-y-auto px-3 py-3" role="log" aria-live="polite" aria-relevant="additions"
|
||||
aria-busy={streaming} aria-label={T.logLabel} data-assistant-log="">
|
||||
<div className="space-y-2 rounded-lg border bg-bg px-3 py-2.5 text-xs leading-relaxed" data-assistant-msg="intro">
|
||||
<p className="text-dim" data-assistant-greeting="">{T.greeting}</p>
|
||||
{blankLead && <p className="text-dim" data-assistant-blank={blankLead.from}>{T.blankLead(blankLead)}</p>}
|
||||
<p className="text-2xs text-faint">{T.notStored}</p>
|
||||
{fresh && (
|
||||
<div className="space-y-1 pt-0.5">
|
||||
<p className="text-2xs text-faint">{T.suggestLabel}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{blankLead?.ask && (
|
||||
<SuggestButton kind="blank" label={T.suggestBlank(blankLead.from, blankLead.ask.total)}
|
||||
onClick={() => send(T.suggestBlankText(blankLead.from, blankLead.ask!.columns, blankLead.ask!.total))} />
|
||||
)}
|
||||
<SuggestButton kind="facts" label={T.suggestFacts} onClick={() => send(T.suggestFactsText)} />
|
||||
{hasProposed && <SuggestButton kind="pending" label={T.suggestPending} onClick={() => send(T.suggestPendingText)} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{turns.map((t) => (t.role === 'user'
|
||||
? (
|
||||
<div key={t.id} className="flex justify-end" data-assistant-msg="user">
|
||||
<span className="sr-only">{T.you}:</span>
|
||||
<p className="max-w-[88%] whitespace-pre-wrap break-words rounded-lg bg-accent-soft px-3 py-2 text-xs leading-relaxed"
|
||||
data-assistant-user-text="">
|
||||
{t.text}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: <AssistantTurnView key={t.id} turn={t} pending={pending} saved={saved} onDrop={drop} onRestore={restore} />))}
|
||||
</div>
|
||||
<p className="sr-only" role="status" data-assistant-status="">{status}</p>
|
||||
|
||||
<SaveNotices save={save} pendingCount={pending.length} />
|
||||
{pending.length > 0 && (
|
||||
<PendingTray pending={pending} open={trayOpen} onToggle={() => setTrayOpen((v) => !v)} save={save} editing={editing}
|
||||
onDrop={drop} onClear={() => void clearAll()} onSave={() => void doSave()} />
|
||||
)}
|
||||
|
||||
<div className="border-t px-3 py-2.5">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="field min-h-[2.75rem] flex-1 resize-none text-xs leading-relaxed"
|
||||
rows={2}
|
||||
value={text}
|
||||
placeholder={T.inputPlaceholder}
|
||||
aria-label={T.inputLabel}
|
||||
aria-invalid={over ? true : undefined}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
data-assistant-input=""
|
||||
/>
|
||||
{streaming
|
||||
? (
|
||||
<button type="button" className="btn btn-sm shrink-0" onClick={stop} aria-label={T.stop} title={T.stop} data-assistant-stop="">
|
||||
<Square size={11} aria-hidden /> <span className="hidden sm:inline">{T.stop}</span>
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<button type="button" className="btn btn-sm btn-primary shrink-0" onClick={() => send(text)} disabled={!text.trim() || over}
|
||||
aria-label={T.send} title={T.inputHint} data-assistant-send="">
|
||||
<ArrowUp size={12} aria-hidden /> <span className="hidden sm:inline">{T.send}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-2xs text-faint">
|
||||
<span className="min-w-0 flex-1 truncate">{over ? <span className="text-[var(--err)]" role="alert">{T.tooLong(ASSISTANT_MAX_CHARS)}</span> : T.inputHint}</span>
|
||||
<span className={clsx('tnum shrink-0', over && 'text-[var(--err)]')} aria-hidden>{T.count(len, ASSISTANT_MAX_CHARS)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function SuggestButton({ kind, label, onClick }: { kind: string; label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" className="btn btn-sm !text-2xs" onClick={onClick} data-assistant-suggest={kind}>{label}</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 一轮回答:正文、只读查询、修改提案按到来的先后排;末尾写这一轮的结局(出错、停止、用量)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AssistantTurnView = memo(function AssistantTurnView({ turn, pending, saved, onDrop, onRestore }: {
|
||||
turn: AssistantTurn
|
||||
pending: PendingChange[]
|
||||
saved: ReadonlySet<string>
|
||||
onDrop: (path: string) => void
|
||||
onRestore: (part: Extract<TurnPart, { kind: 'proposal' }>, path: string) => void
|
||||
}) {
|
||||
const usage = turn.usage
|
||||
return (
|
||||
<div className="space-y-2" data-assistant-msg="assistant" data-turn-state={turn.state}>
|
||||
<span className="sr-only">{T.assistant}:</span>
|
||||
{turn.parts.map((p, i) => {
|
||||
if (p.kind === 'text') {
|
||||
return (
|
||||
<div key={i} className="min-w-0 break-words text-xs" data-assistant-part="text" data-assistant-text="">
|
||||
<Markdown text={p.text} dense />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (p.kind === 'query') return <QueryBlock key={i} part={p} />
|
||||
return <ProposalCard key={p.id} part={p} pending={pending} saved={saved} onDrop={onDrop} onRestore={onRestore} />
|
||||
})}
|
||||
{turn.state === 'streaming' && (
|
||||
<p className="flex items-center gap-1.5 text-2xs text-faint" aria-hidden><Spinner size={11} /> {T.answering}</p>
|
||||
)}
|
||||
{turn.state === 'error' && (
|
||||
<Notice tone="err" attr={{ 'data-assistant-error': '' }}>
|
||||
{turn.error ?? T.interrupted}
|
||||
</Notice>
|
||||
)}
|
||||
{turn.state === 'stopped' && (
|
||||
<p className="text-2xs text-faint" data-assistant-turn-status="stopped">{T.stopped}</p>
|
||||
)}
|
||||
{turn.state === 'done' && (!!usage || (turn.queries ?? 0) > 0) && (
|
||||
<p className="tnum text-2xs text-faint" data-assistant-turn-status="done">
|
||||
{[usage ? T.usage(usage.input_tokens, usage.output_tokens) : '', turn.queries ? T.queries(turn.queries) : ''].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** 助手跑过的一条只读查询:摘要写行数(失败写原因),展开看 SQL 和前几行 */
|
||||
function QueryBlock({ part }: { part: Extract<TurnPart, { kind: 'query' }> }) {
|
||||
const failed = !!part.error
|
||||
return (
|
||||
<details className="group rounded-lg border bg-bg text-2xs" data-assistant-part="query" data-assistant-query={failed ? 'error' : 'ok'}>
|
||||
<summary className="flex cursor-pointer select-none items-start gap-1.5 px-2.5 py-1.5 text-dim outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-[var(--accent)]">
|
||||
<Database size={11} className="mt-0.5 shrink-0" style={{ color: failed ? 'var(--err)' : 'var(--accent)' }} aria-hidden />
|
||||
<span className="min-w-0 break-words">{failed ? `${T.queryFailed}:${part.error}` : T.query(part.rowCount, part.truncated)}</span>
|
||||
</summary>
|
||||
<div className="space-y-1.5 border-t px-2.5 py-2">
|
||||
<pre className="mono max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-panel px-2 py-1.5 text-2xs" data-assistant-query-sql="">{part.sql}</pre>
|
||||
{!failed && (part.rows.length > 0 && part.columns.length > 0
|
||||
? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-2xs" data-assistant-query-rows="">
|
||||
<caption className="pb-1 text-left text-faint">
|
||||
{T.queryRows(Math.min(part.rows.length, QUERY_ROWS_SHOWN), part.rowCount, part.truncated)}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="border-b text-left text-faint">
|
||||
{part.columns.map((c) => <th key={c} scope="col" className="mono px-1.5 py-1 font-medium">{c}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{part.rows.slice(0, QUERY_ROWS_SHOWN).map((r, i) => (
|
||||
<tr key={i} className="border-b border-hairline last:border-b-0">
|
||||
{part.columns.map((c, j) => <td key={c} className="mono px-1.5 py-1 align-top break-all">{cellText(r[j])}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
: <p className="text-faint">{part.rowCount > 0 ? T.queryRows(0, part.rowCount, part.truncated) : T.queryEmpty}</p>)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
const cellText = (v: unknown): string => (v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v))
|
||||
|
||||
/** 一项的值写成一行(码值写成「1=已入园、9=已作废」);没有写「未填写」 */
|
||||
const valueText = (path: string, v: unknown) => patchValueText(path, v)
|
||||
|
||||
/** 一条修改提案:逐项写改前、改后、依据;可以逐项去掉、加回。已保存的项不能再加回 */
|
||||
function ProposalCard({ part, pending, saved, onDrop, onRestore }: {
|
||||
part: Extract<TurnPart, { kind: 'proposal' }>
|
||||
pending: PendingChange[]
|
||||
saved: ReadonlySet<string>
|
||||
onDrop: (path: string) => void
|
||||
onRestore: (part: Extract<TurnPart, { kind: 'proposal' }>, path: string) => void
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-lg border bg-bg" aria-label={T.proposal(part.changes.length)} data-assistant-part="proposal" data-assistant-proposal={part.id}>
|
||||
<header className="flex items-center gap-1.5 border-b px-2.5 py-1.5 text-2xs font-semibold">
|
||||
<BookMarked size={11} className="shrink-0" style={{ color: 'var(--accent)' }} aria-hidden />
|
||||
{T.proposal(part.changes.length)}
|
||||
</header>
|
||||
<ol className="divide-y divide-[var(--hairline)]">
|
||||
{part.changes.map((c) => {
|
||||
const state = changeStateOf(pending, saved, part.id, c.path)
|
||||
return (
|
||||
<ChangeRow key={c.path} change={c} state={state}
|
||||
onToggle={state === 'saved' ? undefined : () => (state === 'pending' ? onDrop(c.path) : onRestore(part, c.path))} />
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const STATE_TONE: Record<ChangeState, string> = {
|
||||
pending: 'var(--accent)', replaced: 'var(--text-faint)', dropped: 'var(--text-faint)', saved: 'var(--st-done)',
|
||||
}
|
||||
|
||||
function ChangeRow({ change: c, state, onToggle }: {
|
||||
change: Parameters<typeof changeWhere>[0]
|
||||
state: ChangeState
|
||||
onToggle?: () => void
|
||||
}) {
|
||||
const where = changeWhere(c)
|
||||
const off = state === 'dropped' || state === 'replaced'
|
||||
return (
|
||||
<li className="px-2.5 py-2 text-2xs" data-assistant-change={c.path} data-change-state={state}>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className={clsx('min-w-0 flex-1 break-words text-xs font-medium', off && 'text-faint line-through')}>{where}</span>
|
||||
<span className="shrink-0" style={{ color: STATE_TONE[state] }} data-change-state-label="">{T.state[state]}</span>
|
||||
{onToggle && (
|
||||
<button type="button" className="shrink-0 rounded px-1 text-dim outline-none hover:bg-hover hover:text-fg focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
onClick={onToggle} aria-label={state === 'pending' ? T.dropLabel(where) : T.restoreLabel(where)} data-assistant-change-toggle="">
|
||||
{state === 'pending' ? <Minus size={10} className="mr-0.5 inline" aria-hidden /> : <Plus size={10} className="mr-0.5 inline" aria-hidden />}
|
||||
{state === 'pending' ? T.drop : T.restore}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<dl className={clsx('mt-1 grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5 leading-relaxed', off && 'opacity-70')}>
|
||||
<dt className="text-faint">{T.before}</dt>
|
||||
<dd className={clsx('break-words', c.before == null ? 'text-faint' : 'text-dim')} data-change-before="">
|
||||
{valueText(c.path, c.before)}
|
||||
{c.before != null && c.before_status && <span className="text-faint">({CATALOG_STATUS_LABEL[c.before_status]})</span>}
|
||||
</dd>
|
||||
<dt className="text-faint">{T.after}</dt>
|
||||
<dd className="break-words text-fg" data-change-after="">
|
||||
{valueText(c.path, c.after)}
|
||||
{c.state === 'confirm' && <span className="ml-1 text-faint">{T.stateConfirm}</span>}
|
||||
</dd>
|
||||
{c.reason && (
|
||||
<>
|
||||
<dt className="text-faint">{T.reason}</dt>
|
||||
<dd className="break-words text-dim" data-change-reason="">{c.reason}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
{c.note && <p className="mt-1 break-words text-faint" data-change-note="">{c.note}</p>}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 待保存的修改:跨多轮累积,可以逐项去掉;一键保存(最多 200 项)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 保存的结果:别人刚改过(409)后按最新内容重算了的说明(已经不合法、去掉了的项列出来)、保存被拒的原话、存上了几项。
|
||||
* 不放在「待保存的修改」里:重算之后可能一项都不剩,那一栏就收起了
|
||||
*/
|
||||
function SaveNotices({ save, pendingCount }: { save: SaveState; pendingCount: number }) {
|
||||
if (save.phase === 'conflict') {
|
||||
return (
|
||||
<div className="border-t px-3 py-2">
|
||||
<Notice tone="warn" attr={{ 'data-assistant-conflict': '' }}>
|
||||
<p className="font-medium">{T.conflictTitle}</p>
|
||||
<p className="text-dim">{T.conflictBody}</p>
|
||||
{save.problems.length > 0 && (
|
||||
<div data-assistant-problems={save.problems.length}>
|
||||
<p className="mt-1">{T.problems}</p>
|
||||
<ul className="list-disc pl-4 text-dim">{save.problems.map((x) => <li key={x}>{x}</li>)}</ul>
|
||||
</div>
|
||||
)}
|
||||
</Notice>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (save.phase !== 'idle') return null
|
||||
if (save.error) return <div className="border-t px-3 py-2"><Notice tone="err" attr={{ 'data-assistant-save-error': '' }}>{save.error}</Notice></div>
|
||||
if (save.saved && !pendingCount) {
|
||||
return <div className="border-t px-3 py-2"><Notice tone="info" attr={{ 'data-assistant-saved': '' }}>{T.saved(save.saved.n, save.saved.version)}</Notice></div>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function PendingTray({ pending, open, onToggle, save, editing, onDrop, onClear, onSave }: {
|
||||
pending: PendingChange[]
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
save: SaveState
|
||||
editing: boolean
|
||||
onDrop: (path: string) => void
|
||||
onClear: () => void
|
||||
onSave: () => void
|
||||
}) {
|
||||
const n = pending.length
|
||||
const tooMany = n > ASSISTANT_MAX_SAVE
|
||||
const busy = save.phase === 'saving' || save.phase === 'reloading'
|
||||
const listId = useId()
|
||||
return (
|
||||
<section className="border-t bg-elev" aria-label={T.pending(n)} data-assistant-pending={n}>
|
||||
<div className="flex items-center gap-2 px-3 pt-2">
|
||||
<button type="button" className="min-w-0 flex-1 truncate rounded text-left text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
onClick={onToggle} aria-expanded={open} aria-controls={listId} title={open ? T.pendingHide : T.pendingShow}
|
||||
data-assistant-pending-toggle="">
|
||||
{T.pending(n)}
|
||||
</button>
|
||||
<button type="button" className="shrink-0 rounded px-1 text-2xs text-faint outline-none hover:bg-hover hover:text-fg focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
onClick={onClear} disabled={busy} data-assistant-clear="">
|
||||
<Trash2 size={10} className="mr-0.5 inline" aria-hidden />{T.clear}
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<ul id={listId} className="mx-3 mt-1.5 max-h-48 divide-y divide-[var(--hairline)] overflow-y-auto rounded-md border bg-panel">
|
||||
{pending.map((p) => {
|
||||
const where = changeWhere(p)
|
||||
return (
|
||||
<li key={p.path} className="flex items-start gap-2 px-2 py-1.5 text-2xs" data-assistant-pending-item={p.path}>
|
||||
<div className="min-w-0 flex-1 leading-relaxed">
|
||||
<p className="break-words font-medium text-fg">{where}</p>
|
||||
<p className="break-words text-dim">
|
||||
<span className="text-faint">{T.before} </span>{valueText(p.path, p.before)}
|
||||
<span className="text-faint"> → {T.after} </span><span className="text-fg">{valueText(p.path, p.after)}</span>
|
||||
</p>
|
||||
{p.reason && <p className="break-words text-faint"><span>{T.reason}:</span>{p.reason}</p>}
|
||||
</div>
|
||||
<button type="button" className="shrink-0 rounded px-1 text-dim outline-none hover:bg-hover hover:text-fg focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
onClick={() => onDrop(p.path)} disabled={busy} aria-label={T.dropLabel(where)} title={T.dropLabel(where)}
|
||||
data-assistant-pending-remove="">
|
||||
{T.drop}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<div className="space-y-1.5 px-3 py-2">
|
||||
{tooMany && <p className="text-2xs text-[var(--err)]" role="alert" data-assistant-too-many="">{T.tooMany(n, ASSISTANT_MAX_SAVE)}</p>}
|
||||
{editing && <p className="text-2xs text-[var(--warn)]" data-assistant-editing="">{T.editing}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 text-2xs leading-relaxed text-faint">{T.pendingHint}</span>
|
||||
<button type="button" className="btn btn-sm btn-primary shrink-0" onClick={onSave} disabled={busy || tooMany || editing}
|
||||
data-assistant-save="">
|
||||
{busy ? <><Spinner size={11} /> {T.saving}</> : T.save(n)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertTriangle, ArrowLeft, ChevronLeft, ChevronRight, GitBranch, ListChecks, Lock, Pencil, Plus, RotateCw, Search, Sparkles, X } from 'lucide-react'
|
||||
import {
|
||||
AlertTriangle, ArrowLeft, ChevronLeft, ChevronRight, GitBranch, Info, ListChecks, Lock, MessagesSquare, Pencil, Plus, RotateCw, Search, Sparkles, X,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { ApiError, api } from '../../api/client'
|
||||
import type {
|
||||
@@ -11,17 +13,20 @@ import { DeleteButton, EmptyState, ErrorState, Notice, Skeleton, Spinner, confir
|
||||
import { useLeaveGuard } from '../../lib/leave'
|
||||
import { formatDateTime, formatNumber, formatTime } from '../../lib/format'
|
||||
import {
|
||||
CATALOG_CARDINALITY_LABEL, CATALOG_DRAFT_TEXT as DT, CATALOG_IMPACT_TEXT, CATALOG_KIND_HINT, CATALOG_KIND_LABEL, CATALOG_TABLE_FIELD_HINT,
|
||||
CATALOG_TABLE_FIELD_LABEL, CATALOG_TEXT as CT, CATALOG_UI_TEXT as UT, CODES_TEXT as KT,
|
||||
CATALOG_ASSISTANT_TEXT as AT, CATALOG_CARDINALITY_LABEL, CATALOG_DRAFT_TEXT as DT, CATALOG_IMPACT_TEXT, CATALOG_KIND_HINT, CATALOG_KIND_LABEL,
|
||||
CATALOG_REVIEW_TEXT as RT, CATALOG_TABLE_FIELD_HINT, CATALOG_TABLE_FIELD_LABEL, CATALOG_TEXT as CT, CATALOG_UI_TEXT as UT,
|
||||
CATALOG_USAGE_TEXT, CODES_TEXT as KT,
|
||||
} from '../../lib/terms'
|
||||
import { CatalogImpactList } from '../../components/CatalogImpact'
|
||||
import { CountBadges, ItemMark, StatusChip, StatusLegend, SystemNotesNotice } from './parts'
|
||||
import { CountBadges, FOCUS_RING, FOCUS_STYLE, ItemMark, StatusChip, StatusLegend, SystemNotesNotice } from './parts'
|
||||
import type { ReviewTarget } from './parts'
|
||||
import { ColumnTable, NARROW, Value, pendingCodes } from './ColumnTable'
|
||||
import type { ColumnModel } from './ColumnTable'
|
||||
import { CodesDialog } from './CodesDialog'
|
||||
import { coverageText } from './profile'
|
||||
import { lastBlankOf, taskReason } from './draftJob'
|
||||
import { lastBlankInfo, taskReason } from './draftJob'
|
||||
import { TableAssistant } from './TableAssistant'
|
||||
import { TableUsageList } from './TableUsage'
|
||||
import {
|
||||
COLUMN_FIELDS, DATE_KEYS, TABLE_FIELDS, buildNotes, changeCount, columnPath, confirmProposed, countsOf, formFromNotes,
|
||||
relationDraft, relationPath, relationRemoval, tablePath, unknownKeys, validateForm,
|
||||
@@ -54,6 +59,7 @@ type Conflict = 'edit' | 'review'
|
||||
|
||||
export function TableDetail({
|
||||
sourceId, table, rows, drafted, fillCodes, onFillDone, prev, next, onOpen, onBack, onDetail, draftBusy = false, onRedraft, onDraftGuidance,
|
||||
profileEnabled = false, focusPath = null, usageOpen = false,
|
||||
}: {
|
||||
sourceId: string
|
||||
table: string
|
||||
@@ -84,6 +90,12 @@ export function TableDetail({
|
||||
onRedraft?: (table: string) => Promise<void>
|
||||
/** 上次起草有留空列时的「补充起草要求」:打开起草设置,范围只有这张表,先补起草要求再开始。表结构里已经没有的表不给 */
|
||||
onDraftGuidance?: (table: string) => void
|
||||
/** 数据源开了数据剖析:表助手才能发只读查询 */
|
||||
profileEnabled?: boolean
|
||||
/** 从审阅队列打开的那一项(审阅路径):载入后滚到那一项、标出来 */
|
||||
focusPath?: string | null
|
||||
/** 「用过这张表的运行」直接取(从审阅队列打开时) */
|
||||
usageOpen?: boolean
|
||||
}) {
|
||||
const [detail, setDetail] = useState<CatalogDetail | null>(null)
|
||||
const [loadError, setLoadError] = useState<unknown>(null)
|
||||
@@ -114,6 +126,10 @@ export function TableDetail({
|
||||
const [savedAt, setSavedAt] = useState<number | null>(null)
|
||||
const [impact, setImpact] = useState<{ version: number; n: number } | null>(null)
|
||||
const [redrafting, setRedrafting] = useState(false)
|
||||
/** 表助手开着没有:换表时照旧开着(对话按表重新开始) */
|
||||
const [assistantOpen, setAssistantOpen] = useState(false)
|
||||
const assistantEntry = useRef<HTMLButtonElement>(null)
|
||||
const focusAssistant = () => document.querySelector<HTMLElement>('[data-table-assistant] [data-assistant-input]')?.focus()
|
||||
const loadSeq = useRef(0)
|
||||
const onDetailRef = useRef(onDetail)
|
||||
onDetailRef.current = onDetail
|
||||
@@ -176,6 +192,18 @@ export function TableDetail({
|
||||
onFillDoneRef.current?.(fillCodes.seq, null)
|
||||
}, [fillCodes, table, detail])
|
||||
|
||||
// 从审阅队列打开的那一项:载入后滚过去(标出来的样式在各行上)
|
||||
const focusSeen = useRef('')
|
||||
useEffect(() => {
|
||||
if (!focusPath || !detail || detail.table_name !== table) return
|
||||
const key = `${table}\u0000${focusPath}`
|
||||
if (focusSeen.current === key) return
|
||||
focusSeen.current = key
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelector(`[data-review-focus="${CSS.escape(focusPath)}"]`)?.scrollIntoView({ block: 'center' })
|
||||
})
|
||||
}, [focusPath, detail, table])
|
||||
|
||||
const changes = edit ? changeCount(edit.initial, edit.form) : 0
|
||||
const problems = useMemo(() => (edit ? validateForm(edit.form, PROBLEM_TEXT) : {}), [edit])
|
||||
const problemCount = Object.keys(problems).length
|
||||
@@ -325,7 +353,8 @@ export function TableDetail({
|
||||
const lastDraft = rows.find((r) => r.table_name === table)?.last_draft ?? null
|
||||
/** 没写全(失败、部分完成);有留空的列(模型拿不准,表照样写完了) */
|
||||
const lastBad = lastDraft && (lastDraft.status === 'failed' || lastDraft.status === 'partial') ? lastDraft : null
|
||||
const lastBlank = lastBlankOf(lastDraft)
|
||||
const blankInfo = lastBlankInfo(lastDraft)
|
||||
const lastBlank = blankInfo.n
|
||||
const label = notes.label && notes.label.status !== 'rejected' ? notes.label.value : null
|
||||
const kind = notes.kind && notes.kind.status !== 'rejected' ? notes.kind.value : null
|
||||
const st = detail.structure
|
||||
@@ -384,8 +413,9 @@ export function TableDetail({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-catalog-detail={table} data-version={detail.version}
|
||||
data-editing={edit ? 'true' : 'false'}>
|
||||
// 表助手开着时:宽屏(xl 起)停在右边,详情让出那一栏的宽度;窄一些的屏幕盖在详情上面
|
||||
<div className={clsx('relative flex min-h-0 flex-1 flex-col', assistantOpen && detail.in_schema && 'xl:pr-[380px] 2xl:pr-[420px]')}
|
||||
data-catalog-detail={table} data-version={detail.version} data-editing={edit ? 'true' : 'false'}>
|
||||
<header className="space-y-2 border-b bg-panel px-4 py-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<button type="button" className="btn btn-sm btn-ghost -ml-1 shrink-0 lg:hidden" onClick={onBack} aria-label={CT.backToList} data-catalog-detail-back="">
|
||||
@@ -446,6 +476,14 @@ export function TableDetail({
|
||||
<button type="button" className="btn btn-sm" disabled={!!busy} onClick={startEdit} data-catalog-edit="">
|
||||
<Pencil size={11} aria-hidden /> {CT.edit}
|
||||
</button>
|
||||
{detail.in_schema && (
|
||||
// 开着时再点:焦点回到表助手的输入框(关上走面板自己的「关闭」,有待保存的修改时先问)
|
||||
<button ref={assistantEntry} type="button" className={clsx('btn btn-sm', assistantOpen && 'border-[var(--accent)] text-fg')}
|
||||
onClick={() => (assistantOpen ? focusAssistant() : setAssistantOpen(true))} aria-expanded={assistantOpen} title={AT.openHint}
|
||||
data-catalog-assistant-open="header">
|
||||
<MessagesSquare size={11} aria-hidden /> {AT.open}
|
||||
</button>
|
||||
)}
|
||||
<span className="text-2xs text-faint">{CT.editHint}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -478,6 +516,7 @@ export function TableDetail({
|
||||
{lastBlank > 0 && (
|
||||
<>
|
||||
<p className={lastBad ? 'mt-0.5 text-dim' : 'font-medium'} data-catalog-last-draft-blank={lastBlank}>{DT.lastBlank(lastBlank)}</p>
|
||||
{blankInfo.earlier && <p className="mt-0.5 text-2xs text-faint" data-catalog-last-draft-earlier="">{DT.lastBlankEarlier}</p>}
|
||||
{detail.in_schema && <p className="mt-0.5 text-dim">{DT.lastBlankAdvice}</p>}
|
||||
</>
|
||||
)}
|
||||
@@ -498,6 +537,11 @@ export function TableDetail({
|
||||
<Pencil size={11} aria-hidden /> {DT.draftGuidance}
|
||||
</button>
|
||||
)}
|
||||
{/* 和助手一起完善:对话补上留空、拿不准的列,不用重新起草 */}
|
||||
<button type="button" className="btn btn-sm btn-ghost" onClick={() => (assistantOpen ? focusAssistant() : setAssistantOpen(true))}
|
||||
title={AT.openHint} data-catalog-assistant-open="notice">
|
||||
<MessagesSquare size={11} aria-hidden /> {AT.open}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-2xs text-faint">{edit ? DT.redraftEditing : draftBusy ? DT.redraftBusy : DT.redraftHint}</p>
|
||||
</>
|
||||
@@ -529,7 +573,8 @@ export function TableDetail({
|
||||
<dl className="divide-y divide-[var(--hairline)]">
|
||||
{TABLE_FIELDS.map((f) => (
|
||||
<TableFieldRow key={f} field={f} item={notes[f] as CatalogItem | undefined} edit={edit} busy={!!busy}
|
||||
problems={problems} columns={st?.columns ?? []} onChange={setTable} onReview={review} />
|
||||
problems={problems} columns={st?.columns ?? []} onChange={setTable} onReview={review}
|
||||
focused={focusPath === tablePath(f)} />
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
@@ -553,7 +598,7 @@ export function TableDetail({
|
||||
{shownColumns.length
|
||||
? (
|
||||
<ColumnTable columns={shownColumns} form={edit?.form.columns ?? null} initial={edit?.initial.columns ?? null}
|
||||
problems={problems} busy={!!busy} systemNotes={detail.system_notes}
|
||||
problems={problems} busy={!!busy} systemNotes={detail.system_notes} focusPath={focusPath}
|
||||
onReview={review} onConfirmColumn={confirmColumn} onFillCodes={fillCodesOf} onChange={setColumn}
|
||||
onCodesComplete={setCodesComplete} />
|
||||
)
|
||||
@@ -568,7 +613,8 @@ export function TableDetail({
|
||||
{edit
|
||||
? <RelationEditor drafts={edit.form.relations} initial={edit.initial.relations} problems={problems} rows={rows}
|
||||
onChange={setRelations} />
|
||||
: <RelationList relations={notes.relations ?? []} busy={!!busy} targetOf={targetOf} onOpen={onOpen} onReview={review} />}
|
||||
: <RelationList relations={notes.relations ?? []} busy={!!busy} targetOf={targetOf} onOpen={onOpen} onReview={review}
|
||||
focusPath={focusPath} />}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="catalog-sec-impact" data-catalog-section="impact">
|
||||
@@ -579,6 +625,14 @@ export function TableDetail({
|
||||
<CatalogImpactList sourceId={sourceId} table={detail.table_name} refreshKey={detail.version}
|
||||
onLoad={(d) => setImpact({ version: detail.version, n: d.templates.length })} />
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="catalog-sec-usage" data-catalog-section="usage">
|
||||
<div className="mb-2 flex flex-wrap items-baseline gap-2">
|
||||
<h3 id="catalog-sec-usage" className="text-xs font-semibold">{CATALOG_USAGE_TEXT.title}</h3>
|
||||
<span className="text-2xs text-faint">{CATALOG_USAGE_TEXT.hint}</span>
|
||||
</div>
|
||||
<TableUsageList sourceId={sourceId} table={detail.table_name} usage={detail.usage} autoLoad={usageOpen} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -605,6 +659,21 @@ export function TableDetail({
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
{assistantOpen && detail.in_schema && (
|
||||
<TableAssistant key={`${sourceId}\u0000${table}`} sourceId={sourceId} table={table} tableLabel={label ?? detail.table_name} detail={detail}
|
||||
profileEnabled={profileEnabled} lastBlank={{ n: lastBlank, earlier: blankInfo.earlier, jobId: lastDraft?.job_id ?? null }}
|
||||
editing={!!edit}
|
||||
onSaved={(d) => {
|
||||
setDetail(d)
|
||||
setConflict(null)
|
||||
onDetailRef.current(d)
|
||||
setSavedAt(d.version)
|
||||
}}
|
||||
onClose={() => {
|
||||
setAssistantOpen(false)
|
||||
requestAnimationFrame(() => assistantEntry.current?.focus())
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -626,7 +695,10 @@ function blankCodes(notes: CatalogDetail['notes'], pick: (path: string) => boole
|
||||
// 表级各项
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, onReview }: {
|
||||
/** 值下面写说明的项:有效记录条件、去重规则(模型起草时附的依据、数据剖析核对的排除比例或没有核对的原因) */
|
||||
const NOTED_FIELDS = new Set<TableField>(['valid_filter', 'dedup'])
|
||||
|
||||
function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, onReview, focused = false }: {
|
||||
field: TableField
|
||||
item: CatalogItem | undefined
|
||||
edit: Editing | null
|
||||
@@ -636,6 +708,8 @@ function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, o
|
||||
columns: { name: string }[]
|
||||
onChange: (k: TableKey, v: string) => void
|
||||
onReview: (t: ReviewTarget, a: CatalogReviewAction) => void
|
||||
/** 从审阅队列打开的就是这一项:标出来 */
|
||||
focused?: boolean
|
||||
}) {
|
||||
const where = CT.whereTable(CATALOG_TABLE_FIELD_LABEL[field])
|
||||
const keys: TableKey[] = field === 'business_date' ? [...DATE_KEYS] : [field as TableKey]
|
||||
@@ -692,9 +766,12 @@ function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, o
|
||||
}
|
||||
}
|
||||
const unknown = edit && field === 'keys' ? unknownKeys(edit.form.table.keys ?? '', columns) : []
|
||||
const note = !edit && item && item.status !== 'rejected' && NOTED_FIELDS.has(field) ? item.note?.trim() ?? '' : ''
|
||||
|
||||
return (
|
||||
<div className="grid gap-x-4 gap-y-1 px-3 py-2.5 sm:grid-cols-[7.5rem_minmax(0,1fr)]" data-field={field} data-status={item?.status}>
|
||||
<div className={clsx('grid gap-x-4 gap-y-1 px-3 py-2.5 sm:grid-cols-[7.5rem_minmax(0,1fr)]', focused && FOCUS_RING)}
|
||||
style={focused ? FOCUS_STYLE : undefined} data-field={field} data-status={item?.status}
|
||||
data-review-focus={focused ? tablePath(field) : undefined} title={focused ? RT.focusMark : undefined}>
|
||||
<dt className="pt-0.5 text-2xs text-faint" title={CATALOG_TABLE_FIELD_HINT[field]}>
|
||||
{edit && field !== 'business_date'
|
||||
? <label htmlFor={inputId}>{CATALOG_TABLE_FIELD_LABEL[field]}</label>
|
||||
@@ -710,6 +787,12 @@ function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, o
|
||||
)}
|
||||
{changed && <span className="chip shrink-0 !text-2xs" style={{ color: 'var(--accent)' }} data-changed="">{CT.changedMark}</span>}
|
||||
</div>
|
||||
{note && (
|
||||
<p className="mt-1 flex items-start gap-1 text-2xs leading-relaxed text-dim" data-field-note="">
|
||||
<Info size={10} className="mt-[3px] shrink-0 text-faint" aria-hidden />
|
||||
<span className="min-w-0 break-words"><span className="text-faint">{UT.fieldNote}:</span>{note}</span>
|
||||
</p>
|
||||
)}
|
||||
{problem && <div className="mt-1 text-2xs text-[var(--err)]" role="alert">{problem}</div>}
|
||||
{!problem && unknown.length > 0 && (
|
||||
<div className="mt-1 text-2xs text-[var(--warn)]" data-keys-unknown="">{CT.keysUnknown(unknown.join('、'))}</div>
|
||||
@@ -760,12 +843,14 @@ function FieldValue({ field, item }: { field: TableField; item: CatalogItem | un
|
||||
// 关联关系
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RelationList({ relations, busy, targetOf, onOpen, onReview }: {
|
||||
function RelationList({ relations, busy, targetOf, onOpen, onReview, focusPath = null }: {
|
||||
relations: CatalogRelation[]
|
||||
busy: boolean
|
||||
targetOf: (name: string) => CatalogTableRow | undefined
|
||||
onOpen: (table: string) => void
|
||||
onReview: (t: ReviewTarget, a: CatalogReviewAction) => void
|
||||
/** 从审阅队列打开的那一项 */
|
||||
focusPath?: string | null
|
||||
}) {
|
||||
if (!relations.length) return <p className="rounded-lg border px-3 py-4 text-xs text-faint" data-relations-empty="">{CT.noRelations}</p>
|
||||
// 窄框(< 40rem)下和列表格一样排成卡片:状态在第一行,下面每项一行「字段名 值」,不用横向滚动
|
||||
@@ -789,8 +874,11 @@ function RelationList({ relations, busy, targetOf, onOpen, onReview }: {
|
||||
const target = targetOf(r.to_table)
|
||||
const where = CT.whereRelation(r.to_table)
|
||||
const rejected = r.status === 'rejected'
|
||||
const focused = focusPath === relationPath(r.id)
|
||||
return (
|
||||
<tr key={r.id} className={clsx('border-b border-hairline last:border-b-0', NARROW.row)} data-relation={r.id} data-status={r.status}>
|
||||
<tr key={r.id} className={clsx('border-b border-hairline last:border-b-0', NARROW.row, focused && FOCUS_RING)}
|
||||
style={focused ? FOCUS_STYLE : undefined} data-relation={r.id} data-status={r.status}
|
||||
data-review-focus={focused ? relationPath(r.id) : undefined}>
|
||||
<td className={clsx(cell, 'px-3')}>
|
||||
{label(CT.relationHead.from)}
|
||||
<span className={clsx('mono min-w-0 break-all', rejected && 'text-faint line-through')}>{r.columns.join(', ')}</span>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../../lib/terms'
|
||||
import { CountBadges } from './parts'
|
||||
import { LIST_FILTERS, LIST_SORTS, progressOf } from './model'
|
||||
import { lastBlankOf, taskReason } from './draftJob'
|
||||
import { lastBlankInfo, taskReason } from './draftJob'
|
||||
import type { ListFilter, ListSort } from './model'
|
||||
|
||||
// ===========================================================================
|
||||
@@ -213,10 +213,12 @@ const Row = memo(function Row({ row: r, index, active, checked, onToggle, onOpen
|
||||
const last = r.last_draft && (r.last_draft.status === 'failed' || r.last_draft.status === 'partial') ? r.last_draft : null
|
||||
const lastLabel = last ? (last.status === 'failed' ? DT.lastFailed : DT.lastPartial) : ''
|
||||
const lastText = last ? (taskReason(last) ? DT.lastWith(lastLabel, taskReason(last)) : lastLabel) : ''
|
||||
// 上次起草有留空的列:表照样写完了,只是模型对这几列拿不准。淡色的轻提示,和失败标记分开。表结构里已经没有的表
|
||||
// 起草不了:悬停里不建议重新起草
|
||||
const blank = lastBlankOf(r.last_draft)
|
||||
const blankTitle = r.in_schema ? `${DT.lastBlank(blank)}。${DT.lastBlankAdvice}` : DT.lastBlank(blank)
|
||||
// 上次起草有留空的列:表照样写完了,只是模型对这几列拿不准。淡色的轻提示,和失败标记分开。最近一次停止了、没有新写入时
|
||||
// 照旧按再之前那次写完的起草提示(悬停里写明)。表结构里已经没有的表起草不了:悬停里不建议重新起草
|
||||
const blankInfo = lastBlankInfo(r.last_draft)
|
||||
const blank = blankInfo.n
|
||||
const blankTitle = [DT.lastBlank(blank), blankInfo.earlier ? DT.lastBlankEarlier : '', r.in_schema ? DT.lastBlankAdvice : '']
|
||||
.filter(Boolean).join('。')
|
||||
return (
|
||||
<li
|
||||
className={clsx('relative flex items-stretch border-b border-hairline', active ? 'bg-accent-soft' : 'hover:bg-hover')}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { History } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import type { CatalogTableUsage } from '../../types'
|
||||
import { Skeleton, StatusPill } from '../../components/ui'
|
||||
import { humanizeError } from '../../lib/errors'
|
||||
import { formatDateTime, formatTime } from '../../lib/format'
|
||||
import { CATALOG_USAGE_TEXT as T, RUN_CLASS_LABEL } from '../../lib/terms'
|
||||
|
||||
// ===========================================================================
|
||||
// 表详情里「用过这张表的运行」(GET /catalog/{table}/usage,新的在前):审阅一项之前看看这张表都被哪些运行查过,
|
||||
// 点进运行记录核对。默认不取,点「查看」才取(每张表都取一遍没必要);从审阅队列打开时直接取(autoLoad)。
|
||||
// 使用次数是 0 的不取,直接写还没有运行查询过。
|
||||
// ===========================================================================
|
||||
|
||||
const LIMIT = 20
|
||||
|
||||
export function TableUsageList({ sourceId, table, usage, autoLoad = false }: {
|
||||
sourceId: string
|
||||
table: string
|
||||
/** 清单上的使用次数:0 就不取 */
|
||||
usage: number
|
||||
autoLoad?: boolean
|
||||
}) {
|
||||
const [data, setData] = useState<CatalogTableUsage | null>(null)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [wanted, setWanted] = useState(autoLoad)
|
||||
const seq = useRef(0)
|
||||
|
||||
const load = useCallback(() => {
|
||||
const mine = ++seq.current
|
||||
setError(null)
|
||||
api.dataCatalog.usage(sourceId, table, LIMIT)
|
||||
.then((d) => { if (mine === seq.current) setData(d) })
|
||||
.catch((e) => { if (mine === seq.current) setError(e) })
|
||||
}, [sourceId, table])
|
||||
|
||||
// 换表:清掉上一张的,按需要重新取
|
||||
useEffect(() => {
|
||||
setData(null)
|
||||
setError(null)
|
||||
setWanted(autoLoad)
|
||||
}, [sourceId, table, autoLoad])
|
||||
useEffect(() => {
|
||||
if (wanted && usage > 0) load()
|
||||
}, [wanted, usage, load])
|
||||
|
||||
if (usage <= 0) return <p className="rounded-lg border px-3 py-4 text-xs text-faint" data-table-usage="0">{T.none}</p>
|
||||
if (!wanted) {
|
||||
return (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setWanted(true)} data-table-usage-show="">
|
||||
<History size={11} aria-hidden /> {T.show}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (error && !data) {
|
||||
return (
|
||||
<p className="flex flex-wrap items-center gap-2 text-2xs text-[var(--err)]" data-table-usage="error">
|
||||
{T.error}:{humanizeError(error).title}
|
||||
<button type="button" className="rounded px-1 text-dim hover:bg-hover hover:text-fg" onClick={load}>{T.retry}</button>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (!data) return <div data-table-usage="loading"><Skeleton rows={2} height={18} gap={6} /></div>
|
||||
if (!data.runs.length) return <p className="rounded-lg border px-3 py-4 text-xs text-faint" data-table-usage="0">{T.none}</p>
|
||||
return (
|
||||
<div data-table-usage={data.total}>
|
||||
<p className="mb-1 text-2xs text-faint" title={T.countHint} data-table-usage-count="">{T.count(data.runs.length, data.total)}</p>
|
||||
<ul className="divide-y divide-[var(--hairline)] rounded-lg border bg-panel">
|
||||
{data.runs.map((r) => {
|
||||
const name = r.workflow_name?.trim() || T.unnamed
|
||||
return (
|
||||
<li key={r.run_id} className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 px-3 py-1.5 text-xs" data-usage-run={r.run_id}>
|
||||
<Link to={`/runs/${encodeURIComponent(r.run_id)}`} className="min-w-0 truncate font-medium hover:underline" title={T.open(name)}>
|
||||
{name}
|
||||
</Link>
|
||||
{r.run_class && <span className="shrink-0 text-2xs text-faint">{RUN_CLASS_LABEL[r.run_class] ?? r.run_class}</span>}
|
||||
<StatusPill status={r.status} short plain />
|
||||
<span className="flex-1" />
|
||||
{r.started_at && (
|
||||
<span className="tnum shrink-0 text-2xs text-faint" title={formatDateTime(r.started_at)}>{formatTime(r.started_at)}</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useState } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { AlertTriangle, ArrowRight, FileJson, Upload } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { api } from '../../api/client'
|
||||
import type { CatalogImportReport, CatalogImportTable } from '../../types'
|
||||
import { Modal, Notice, Spinner } from '../../components/ui'
|
||||
import { errorMessage } from '../../lib/errors'
|
||||
import { formatDateTime, formatNumber } from '../../lib/format'
|
||||
import { CATALOG_TRANSFER_TEXT as T } from '../../lib/terms'
|
||||
import { patchTarget, patchValueText, patchWhere } from '../../run/catalogPatch'
|
||||
|
||||
// ===========================================================================
|
||||
// 导入数据目录(数据目录页的「导入目录」):选文件 → 预览(只读:对上了哪些表,各有几项新增、更新、保留、冲突、跳过,
|
||||
// 冲突逐条列出)→ 确认导入 → 结果(逐表写入后的版本)。规矩由服务端定(data/catalog_transfer.py),这里照预览写出来,
|
||||
// 写入前让人看清楚哪些会动、哪些以这里为准。
|
||||
//
|
||||
// 导出不在这里:页头的「导出目录」直接下载文件(CatalogPage)。
|
||||
// ===========================================================================
|
||||
|
||||
type Step = 'choose' | 'preview' | 'done'
|
||||
|
||||
interface Chosen {
|
||||
name: string
|
||||
content: unknown
|
||||
/** 文件里写的来源数据源和导出时间(认得出才有) */
|
||||
source: string | null
|
||||
exportedAt: string | null
|
||||
}
|
||||
|
||||
const COUNT_KEYS = ['add', 'update', 'keep', 'conflicts', 'skipped'] as const
|
||||
|
||||
/** 跳过的那一项写成界面上的说法(「列 gate_code 的中文名」);关联关系只有编号,照原样写 */
|
||||
const pathText = (path: string) => {
|
||||
const t = patchTarget(path)
|
||||
return t && t.kind !== 'relation' ? patchWhere({ path, before: null, after: null }) : path
|
||||
}
|
||||
|
||||
export function ImportDialog({ sourceId, onClose, onImported }: {
|
||||
sourceId: string
|
||||
onClose: () => void
|
||||
/** 导入写进了这些表(这个数据源里的表名):页面重取清单、正在看的表重新载入 */
|
||||
onImported: (tables: string[]) => void
|
||||
}) {
|
||||
const [step, setStep] = useState<Step>('choose')
|
||||
const [file, setFile] = useState<Chosen | null>(null)
|
||||
const [includeGuidance, setIncludeGuidance] = useState(true)
|
||||
const [report, setReport] = useState<CatalogImportReport | null>(null)
|
||||
const [result, setResult] = useState<CatalogImportReport | null>(null)
|
||||
const [busy, setBusy] = useState<'preview' | 'import' | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const choose = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!f) return
|
||||
setError(null)
|
||||
setFile(null)
|
||||
let text: string
|
||||
try {
|
||||
text = await f.text()
|
||||
} catch {
|
||||
setError(T.readFailed)
|
||||
return
|
||||
}
|
||||
let content: unknown
|
||||
try {
|
||||
content = JSON.parse(text)
|
||||
} catch {
|
||||
setError(T.notJson)
|
||||
return
|
||||
}
|
||||
const meta = content && typeof content === 'object' ? content as { source?: { name?: unknown }; exported_at?: unknown } : {}
|
||||
setFile({
|
||||
name: f.name, content,
|
||||
source: typeof meta.source?.name === 'string' ? meta.source.name : null,
|
||||
exportedAt: typeof meta.exported_at === 'string' ? meta.exported_at : null,
|
||||
})
|
||||
}
|
||||
|
||||
const preview = async () => {
|
||||
if (!file || busy) return
|
||||
setBusy('preview')
|
||||
setError(null)
|
||||
try {
|
||||
const r = await api.dataCatalog.importPreview(sourceId, { content: file.content, include_guidance: includeGuidance })
|
||||
setReport(r)
|
||||
setStep('preview')
|
||||
} catch (e) {
|
||||
// 文件格式不对(422)、没有表结构……服务端那句话照原样写,留在这一步
|
||||
setError(errorMessage(e))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const doImport = async () => {
|
||||
if (!file || busy) return
|
||||
setBusy('import')
|
||||
setError(null)
|
||||
try {
|
||||
const r = await api.dataCatalog.importCatalog(sourceId, { content: file.content, include_guidance: includeGuidance })
|
||||
setResult(r)
|
||||
setStep('done')
|
||||
onImported(r.tables.filter((t) => !t.error).map((t) => t.matched_as ?? t.table_name))
|
||||
} catch (e) {
|
||||
// 有起草任务在进行(409)、别的原因:写服务端那句话,留在预览
|
||||
setError(errorMessage(e))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const back = () => {
|
||||
setStep('choose')
|
||||
setReport(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const importable = report ? report.tables.length : 0
|
||||
const footer = step === 'choose'
|
||||
? (
|
||||
<>
|
||||
<button type="button" className="btn" onClick={onClose}>{T.cancel}</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void preview()} disabled={!file || !!busy} data-import-preview="">
|
||||
{busy === 'preview' ? <><Spinner size={11} /> {T.previewing}</> : T.preview}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: step === 'preview'
|
||||
? (
|
||||
<>
|
||||
<button type="button" className="btn" onClick={back} disabled={!!busy} data-import-back="">{T.back}</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void doImport()} disabled={!importable || !!busy} data-import-confirm="">
|
||||
{busy === 'import' ? <><Spinner size={11} /> {T.importing}</> : T.confirm(importable)}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: <button type="button" className="btn btn-primary" onClick={onClose} data-autofocus data-import-close="">{T.close}</button>
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={T.title} width={640} footer={footer}>
|
||||
<div className="min-w-0 space-y-3 text-xs" data-import-dialog="" data-step={step}>
|
||||
{step === 'choose' && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<p className="label">{T.chooseLabel}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="btn btn-sm cursor-pointer focus-within:ring-2 focus-within:ring-[var(--accent)]">
|
||||
<Upload size={11} aria-hidden /> {T.choose}
|
||||
<input type="file" accept=".json,application/json" className="sr-only" onChange={(e) => void choose(e)}
|
||||
aria-label={T.chooseLabel} data-import-file="" />
|
||||
</label>
|
||||
<span className="text-2xs text-faint">{T.chooseHint}</span>
|
||||
</div>
|
||||
{file && (
|
||||
<p className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-dim" data-import-file-meta="">
|
||||
<FileJson size={11} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 break-all">{T.chosen(file.name)}</span>
|
||||
{file.source && <span className="text-faint">{T.fileMeta(file.source, file.exportedAt ? formatDateTime(file.exportedAt) : '—')}</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2" data-import-guidance="">
|
||||
<input type="checkbox" className="mt-0.5" checked={includeGuidance} onChange={(e) => setIncludeGuidance(e.target.checked)} />
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium">{T.includeGuidance}</span>
|
||||
<span className="mt-0.5 block text-2xs text-faint">{T.includeGuidanceHint}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="rounded-md border bg-bg px-3 py-2">
|
||||
<p className="text-2xs font-medium text-dim">{T.rulesTitle}</p>
|
||||
<ul className="mt-1 list-disc space-y-0.5 pl-4 text-2xs leading-relaxed text-faint" data-import-rules="">
|
||||
{T.rules.map((r) => <li key={r}>{r}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'preview' && report && <ReportView report={report} />}
|
||||
|
||||
{step === 'done' && result && (
|
||||
<>
|
||||
<Notice tone="info" attr={{ 'data-import-done': '' }}>
|
||||
<p className="font-medium">{T.done}</p>
|
||||
<p className="mt-0.5 text-dim">{T.doneTotals(result.totals)}</p>
|
||||
</Notice>
|
||||
<ReportView report={result} done />
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <Notice tone="err" attr={{ 'data-import-error': '' }}>{error}</Notice>}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/** 预览、导入结果:合计、逐表的计数(导入后写第几版)、对不上的表、冲突逐条、跳过的原因、起草要求怎么处理 */
|
||||
function ReportView({ report, done = false }: { report: CatalogImportReport; done?: boolean }) {
|
||||
const conflicts = report.tables.flatMap((t) => t.conflicts.map((c) => ({ t, c })))
|
||||
const skipped = report.tables.flatMap((t) => t.skipped.map((s) => ({ t, s })))
|
||||
return (
|
||||
<div className="min-w-0 space-y-3">
|
||||
{!done && <p className="tnum text-sm" data-import-totals="">{T.totals(report.totals)}</p>}
|
||||
{report.tables.length > 0
|
||||
? (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full border-collapse text-2xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-elev text-left text-faint">
|
||||
<th scope="col" className="px-2.5 py-1.5 font-medium">{T.head.table}</th>
|
||||
{COUNT_KEYS.map((k) => <th key={k} scope="col" className="px-2 py-1.5 text-right font-medium">{T.head[k]}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.tables.map((t) => <TableRow key={t.table_name} t={t} done={done} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
: <p className="text-faint" data-import-nothing="">{T.nothing}</p>}
|
||||
{report.unmatched_tables.length > 0 && (
|
||||
<p className="break-words text-2xs leading-relaxed text-dim" data-import-unmatched="">
|
||||
{T.unmatched(report.unmatched_tables.length)}
|
||||
<span className="mono">{report.unmatched_tables.join('、')}</span>
|
||||
</p>
|
||||
)}
|
||||
{conflicts.length > 0 && (
|
||||
<section aria-label={T.conflictsTitle(conflicts.length)}>
|
||||
<p className="mb-1 flex items-center gap-1 text-2xs font-medium text-[var(--warn)]">
|
||||
<AlertTriangle size={11} aria-hidden /> {T.conflictsTitle(conflicts.length)}
|
||||
</p>
|
||||
<ul className="divide-y divide-[var(--hairline)] rounded-md border">
|
||||
{conflicts.map(({ t, c }) => (
|
||||
<li key={`${t.table_name}:${c.path}`} className="px-2.5 py-1.5 text-2xs leading-relaxed" data-import-conflict={`${t.table_name}:${c.path}`}>
|
||||
<p className="font-medium text-fg"><span className="mono">{t.table_name}</span> · {c.label || c.path}</p>
|
||||
<p className="flex flex-wrap items-center gap-x-1.5 text-dim">
|
||||
<span><span className="text-faint">{T.conflictFile}:</span>{patchValueText(c.path, c.file_value)}</span>
|
||||
<ArrowRight size={10} className="text-faint" aria-hidden />
|
||||
<span><span className="text-faint">{T.conflictTarget}:</span>{patchValueText(c.path, c.target_value)}</span>
|
||||
</p>
|
||||
{c.why && <p className="text-faint">{c.why}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{skipped.length > 0 && (
|
||||
<section aria-label={T.skippedTitle(skipped.length)}>
|
||||
<p className="mb-1 text-2xs font-medium text-dim">{T.skippedTitle(skipped.length)}</p>
|
||||
<ul className="divide-y divide-[var(--hairline)] rounded-md border">
|
||||
{skipped.map(({ t, s }) => (
|
||||
<li key={`${t.table_name}:${s.path}`} className="px-2.5 py-1.5 text-2xs leading-relaxed text-dim" data-import-skipped={`${t.table_name}:${s.path}`}>
|
||||
<span className="text-faint"><span className="mono">{t.table_name}</span> · {pathText(s.path)}</span>:{s.why}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
<p className="text-2xs text-dim" data-import-guidance-action={report.guidance.action}>{T.guidance[report.guidance.action] ?? T.guidance.none}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ t, done }: { t: CatalogImportTable; done: boolean }) {
|
||||
const renamed = t.matched_as && t.matched_as !== t.table_name ? t.matched_as : null
|
||||
const n = (k: typeof COUNT_KEYS[number]) => (k === 'conflicts' || k === 'skipped' ? t[k].length : t[k])
|
||||
return (
|
||||
<tr className="border-b border-hairline last:border-b-0" data-import-table={t.table_name}>
|
||||
<th scope="row" className="px-2.5 py-1.5 text-left align-top font-normal">
|
||||
<span className="mono break-all text-fg">{t.table_name}</span>
|
||||
{renamed && <span className="ml-1.5 text-faint">{T.matchedAs(renamed)}</span>}
|
||||
{done && t.error && <span className="block text-[var(--err)]">{T.tableFailed(t.error)}</span>}
|
||||
{done && !t.error && typeof t.version === 'number' && <span className="block text-faint">{T.version(t.version)}</span>}
|
||||
</th>
|
||||
{COUNT_KEYS.map((k) => (
|
||||
<td key={k} className={clsx('tnum px-2 py-1.5 text-right align-top', k === 'conflicts' && n(k) > 0 ? 'text-[var(--warn)]' : 'text-dim')}
|
||||
data-import-count={k}>
|
||||
{formatNumber(n(k))}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import type {
|
||||
CatalogAssistantChange, CatalogAssistantEvent, CatalogAssistantRequest, CatalogDetail, CatalogPatchChange, CatalogPatchPreview,
|
||||
CatalogPatchSubmit,
|
||||
} from '../../types'
|
||||
import { patchChangesOf, patchWhere } from '../../run/catalogPatch'
|
||||
|
||||
// ===========================================================================
|
||||
// 表助手的纯函数:一轮回答怎么由事件拼起来、交给服务端的对话怎么截、待保存的修改怎么累积(同一项以最后一次提案为准)、
|
||||
// 保存遇到 409 后怎么按预览重算。界面在 TableAssistant.tsx。
|
||||
//
|
||||
// 对话不存服务端:每一轮把前面的对话(最多 40 条、每条最多 4000 字)和界面上还没保存的修改一起交上去,服务端据此
|
||||
// 知道已经提过什么、用户说过什么。
|
||||
// ===========================================================================
|
||||
|
||||
/** 每一轮最多带几条对话(服务端同一个上限) */
|
||||
export const ASSISTANT_MAX_MESSAGES = 40
|
||||
/** 每条对话最多几个字(服务端同一个上限) */
|
||||
export const ASSISTANT_MAX_CHARS = 4000
|
||||
/** 一次最多保存几项(服务端 PATCH_MAX_CHANGES) */
|
||||
export const ASSISTANT_MAX_SAVE = 200
|
||||
/** 每一轮最多交几项待保存的修改(服务端同一个上限,多了整个请求 422):多出来的只交最近的这么多项 */
|
||||
export const ASSISTANT_MAX_PENDING = 200
|
||||
/** 查询结果在界面上显示前几行(服务端最多给 10 行) */
|
||||
export const QUERY_ROWS_SHOWN = 10
|
||||
|
||||
/** 一轮回答里按先后到来的几块:正文、它跑的只读查询、修改提案 */
|
||||
export type TurnPart =
|
||||
| { kind: 'text'; text: string }
|
||||
/** rowCount 是取回的行数;truncated 为真时后面还有行(取回的只是前一截) */
|
||||
| { kind: 'query'; sql: string; columns: string[]; rows: unknown[][]; rowCount: number; truncated: boolean; error: string | null }
|
||||
| { kind: 'proposal'; id: string; version: number; changes: CatalogAssistantChange[] }
|
||||
|
||||
export interface UserTurn { id: string; role: 'user'; text: string }
|
||||
|
||||
export interface AssistantTurn {
|
||||
id: string
|
||||
role: 'assistant'
|
||||
parts: TurnPart[]
|
||||
/** streaming 还在回答;done 答完;stopped 点了停止;error 出错(开流前被拒、流里的 error 事件、连接中断) */
|
||||
state: 'streaming' | 'done' | 'stopped' | 'error'
|
||||
error?: string | null
|
||||
code?: string | null
|
||||
usage?: { input_tokens: number; output_tokens: number } | null
|
||||
queries?: number
|
||||
}
|
||||
|
||||
export type Turn = UserTurn | AssistantTurn
|
||||
|
||||
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
|
||||
const num = (v: unknown, fallback = 0): number => (typeof v === 'number' && Number.isFinite(v) ? v : fallback)
|
||||
|
||||
/** 提案里的各项:形状同助手流里的 catalog_patch(认不出的项跳过),多一个给人看的位置 */
|
||||
export function assistantChangesOf(raw: unknown): CatalogAssistantChange[] {
|
||||
const list = Array.isArray(raw) ? raw : []
|
||||
const parsed = patchChangesOf(list)
|
||||
return parsed.map((c) => {
|
||||
const src = list.find((x) => isObj(x) && x.path === c.path) as Record<string, unknown> | undefined
|
||||
const label = typeof src?.label === 'string' && src.label.trim() ? src.label.trim() : undefined
|
||||
return label ? { ...c, label } : c
|
||||
})
|
||||
}
|
||||
|
||||
/** 一项给人看的位置:服务端写了就用它,没写按路径说 */
|
||||
export const changeWhere = (c: CatalogAssistantChange): string => c.label ?? patchWhere(c)
|
||||
|
||||
/** 一个事件并进这一轮回答(返回新的一轮,不改入参)。proposalId:提案的编号(卡片、待保存的修改按它认出处) */
|
||||
export function applyEvent(turn: AssistantTurn, ev: CatalogAssistantEvent, proposalId: string): AssistantTurn {
|
||||
const parts = [...turn.parts]
|
||||
switch (ev.type) {
|
||||
case 'text': {
|
||||
const delta = typeof ev.delta === 'string' ? ev.delta : ''
|
||||
if (!delta) return turn
|
||||
const last = parts.at(-1)
|
||||
if (last?.kind === 'text') parts[parts.length - 1] = { kind: 'text', text: last.text + delta }
|
||||
else parts.push({ kind: 'text', text: delta })
|
||||
return { ...turn, parts }
|
||||
}
|
||||
case 'query': {
|
||||
const columns = Array.isArray(ev.columns) ? ev.columns.map(String) : []
|
||||
const rows = Array.isArray(ev.rows) ? ev.rows.filter(Array.isArray).slice(0, QUERY_ROWS_SHOWN) as unknown[][] : []
|
||||
parts.push({
|
||||
kind: 'query', sql: typeof ev.sql === 'string' ? ev.sql : '', columns, rows, rowCount: num(ev.row_count, rows.length),
|
||||
truncated: ev.truncated === true, error: typeof ev.error === 'string' && ev.error.trim() ? ev.error : null,
|
||||
})
|
||||
return { ...turn, parts }
|
||||
}
|
||||
case 'proposal': {
|
||||
const changes = assistantChangesOf(ev.changes)
|
||||
if (!changes.length) return turn
|
||||
parts.push({ kind: 'proposal', id: proposalId, version: num(ev.version), changes })
|
||||
return { ...turn, parts }
|
||||
}
|
||||
case 'done': {
|
||||
const usage = isObj(ev.usage) ? { input_tokens: num(ev.usage.input_tokens), output_tokens: num(ev.usage.output_tokens) } : null
|
||||
return { ...turn, state: 'done', usage, queries: num(ev.queries) }
|
||||
}
|
||||
case 'error':
|
||||
return { ...turn, state: 'error', error: typeof ev.message === 'string' && ev.message ? ev.message : null, code: ev.code ?? null }
|
||||
default:
|
||||
return turn
|
||||
}
|
||||
}
|
||||
|
||||
/** 这一轮回答的正文(各段正文接起来) */
|
||||
export const turnText = (turn: AssistantTurn): string =>
|
||||
turn.parts.filter((p): p is Extract<TurnPart, { kind: 'text' }> => p.kind === 'text').map((p) => p.text).join('')
|
||||
|
||||
/** 这一轮里的提案 */
|
||||
export const turnProposals = (turn: AssistantTurn) =>
|
||||
turn.parts.filter((p): p is Extract<TurnPart, { kind: 'proposal' }> => p.kind === 'proposal')
|
||||
|
||||
/**
|
||||
* 没有正文的一轮回答交给服务端时写成什么(只提了修改、只查了数据、没答完就停了):保持一问一答交替。这几句只给模型看
|
||||
*/
|
||||
function placeholderOf(turn: AssistantTurn): string {
|
||||
const proposals = turnProposals(turn).reduce((n, p) => n + p.changes.length, 0)
|
||||
if (proposals) return `(提出了 ${proposals} 项修改提案)`
|
||||
if (turn.parts.some((p) => p.kind === 'query')) return '(查询了数据,没有写出回答)'
|
||||
return '(没有回答)'
|
||||
}
|
||||
|
||||
/** 截到上限:每条最多 4000 字 */
|
||||
const clip = (s: string) => ([...s].length > ASSISTANT_MAX_CHARS ? [...s].slice(0, ASSISTANT_MAX_CHARS).join('') : s)
|
||||
|
||||
/**
|
||||
* 交给服务端的对话:前面的对话加上这一条新问题,最多 40 条、每条最多 4000 字。超出时去掉最早的几条,并且总从用户的话
|
||||
* 开始(去掉打头的助手回答)
|
||||
*/
|
||||
export function historyOf(turns: Turn[], next: string): CatalogAssistantRequest['messages'] {
|
||||
const out: CatalogAssistantRequest['messages'] = []
|
||||
for (const t of turns) {
|
||||
if (t.role === 'user') out.push({ role: 'user', content: clip(t.text) })
|
||||
else out.push({ role: 'assistant', content: clip(turnText(t).trim() || placeholderOf(t)) })
|
||||
}
|
||||
out.push({ role: 'user', content: clip(next) })
|
||||
let tail = out.slice(-ASSISTANT_MAX_MESSAGES)
|
||||
while (tail.length > 1 && tail[0].role !== 'user') tail = tail.slice(1)
|
||||
return tail
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 待保存的修改:跨多轮累积,同一项(路径)以最后一次提案为准;可以逐项去掉、加回。保存成功后清空
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PendingChange extends CatalogAssistantChange {
|
||||
/** 来自哪一条提案 */
|
||||
origin: string
|
||||
/** 提案对照的目录版本:保存时带最早的那个,别人在那之后改过就 409,按最新内容重算后再保存 */
|
||||
version: number
|
||||
}
|
||||
|
||||
/** 一条提案并进待保存的修改:同一路径的换成这一条,位置不动;已经是这个值且已确认的(same)不收 */
|
||||
export function mergeProposal(pending: PendingChange[], changes: CatalogAssistantChange[], origin: string, version: number): PendingChange[] {
|
||||
const out = [...pending]
|
||||
for (const c of changes) {
|
||||
if (c.state === 'same') continue
|
||||
const next: PendingChange = { ...c, origin, version }
|
||||
const i = out.findIndex((p) => p.path === c.path)
|
||||
if (i >= 0) out[i] = next
|
||||
else out.push(next)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export const dropPending = (pending: PendingChange[], path: string): PendingChange[] => pending.filter((p) => p.path !== path)
|
||||
|
||||
/** 交回保存、交给助手的那一份:路径、原样取值、理由 */
|
||||
export function pendingSubmit(pending: PendingChange[]): CatalogPatchSubmit[] {
|
||||
return pending.map((p) => ({ path: p.path, value: p.value, ...(p.reason ? { reason: p.reason } : {}) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 交给助手的待保存的修改:最多 ASSISTANT_MAX_PENDING 项(服务端同一个上限,多了整个请求 422)。超出时交最近的那些
|
||||
* (列表按头一次提出的先后排,后面的是新近提出的);没交上去的模型不知道,再提同一项时以新的提案为准,不会丢
|
||||
*/
|
||||
export const pendingForAssistant = (pending: PendingChange[]): CatalogPatchSubmit[] =>
|
||||
pendingSubmit(pending.length > ASSISTANT_MAX_PENDING ? pending.slice(-ASSISTANT_MAX_PENDING) : pending)
|
||||
|
||||
/** 保存时带的版本:待保存的修改里最早的那条提案对照的版本 */
|
||||
export const pendingVersion = (pending: PendingChange[]): number =>
|
||||
(pending.length ? Math.min(...pending.map((p) => p.version)) : 0)
|
||||
|
||||
/** 提案卡片上一项的处境:待保存(就是这一条)、改用了另一条提案的值、去掉了、已保存 */
|
||||
export type ChangeState = 'pending' | 'replaced' | 'dropped' | 'saved'
|
||||
|
||||
export function changeStateOf(pending: PendingChange[], saved: ReadonlySet<string>, origin: string, path: string): ChangeState {
|
||||
if (saved.has(savedKey(origin, path))) return 'saved'
|
||||
const p = pending.find((x) => x.path === path)
|
||||
if (!p) return 'dropped'
|
||||
return p.origin === origin ? 'pending' : 'replaced'
|
||||
}
|
||||
|
||||
export const savedKey = (origin: string, path: string) => `${origin}\u0000${path}`
|
||||
|
||||
/**
|
||||
* 保存遇到 409(别人刚改过这张表)后按预览重算:预览里有的项换上最新的改前、改后和状态,版本记成预览的;
|
||||
* 预览里没有的(已经不合法了,原因在 problems 里)去掉;已经是这个值且已确认的(same)也去掉,不用再存。
|
||||
*
|
||||
* submitted:交去保存、预览的那几项(同一个对象)。只重算它们:保存、重算期间又到的提案(新的项、同一项换成的新
|
||||
* 提案)不在预览里,原样留着,不能当成「已经不合法」悄悄去掉。不给时重算全部
|
||||
*/
|
||||
export function applyPreview(pending: PendingChange[], preview: CatalogPatchPreview, submitted?: readonly PendingChange[]): PendingChange[] {
|
||||
const byPath = new Map(preview.changes.map((c) => [c.path, c]))
|
||||
const mine = submitted ? new Set(submitted) : null
|
||||
const out: PendingChange[] = []
|
||||
for (const p of pending) {
|
||||
if (mine && !mine.has(p)) {
|
||||
out.push(p)
|
||||
continue
|
||||
}
|
||||
const c: CatalogPatchChange | undefined = byPath.get(p.path)
|
||||
if (!c || c.state === 'same') continue
|
||||
out.push({ ...p, before: c.before, before_status: c.before_status, after: c.after, value: c.value, state: c.state,
|
||||
...(c.note ? { note: c.note } : {}), version: preview.version })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 开场:上次起草有留空的列时,引导先问这几列
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 「补上……」按钮一次最多点名几列(再多的写「等 N 列」) */
|
||||
export const BLANK_ASK_MAX = 20
|
||||
/** 开场里最多举几列做例子 */
|
||||
export const BLANK_EXAMPLES = 5
|
||||
|
||||
/**
|
||||
* 开场引导的写法。留空的列(模型列出了、中文名和含义却都没给)只有起草时记下的那几个列名(blank_sample,最多 5 个)
|
||||
* 靠得住:目录里还没有中文名和含义的列里,还有没写出来的列(部分完成的宽表)、库注释已经说清的列,不能当成留空的列举例。
|
||||
* - draft:拿到了起草时记下的列名。例子是其中还没补上的。记全了(列名数不少于留空的列数)时 remaining 是还没补上的
|
||||
* 几列,按钮写这个数、点名的正是这几列;没记全时 remaining 为 null,按钮不写数字、只点名这几列(模型那边另有背景)。
|
||||
* - catalog:拿不到(最近一次停止了、用的是再之前那次的留空列数;取任务失败;老服务端没有列名)。例子和按钮都改说
|
||||
* 「目录里还没有中文名和含义的列」,按钮上的数字就是这些列的总数,点名其中前 BLANK_ASK_MAX 列、再写「等 N 列」。
|
||||
* ask 为 null:没有可以点名的列,不给按钮
|
||||
*/
|
||||
export interface BlankLead {
|
||||
from: 'draft' | 'catalog'
|
||||
/** 上次起草留空的列数 */
|
||||
n: number
|
||||
examples: string[]
|
||||
/** 记全了时,留空的列里还没补上的几列;没记全、拿不到列名时为 null */
|
||||
remaining: number | null
|
||||
/** 目录里还没有中文名和含义的列数 */
|
||||
unnamed: number
|
||||
ask: { columns: string[]; total: number | null } | null
|
||||
}
|
||||
|
||||
export function blankLeadOf(n: number, sample: readonly string[] | null, unnamed: readonly string[]): BlankLead {
|
||||
const named = sample?.filter((c) => !!c) ?? []
|
||||
if (named.length) {
|
||||
const open = new Set(unnamed)
|
||||
const still = named.filter((c) => open.has(c))
|
||||
const remaining = named.length >= n ? still.length : null
|
||||
return {
|
||||
from: 'draft', n, examples: still.slice(0, BLANK_EXAMPLES), remaining, unnamed: unnamed.length,
|
||||
ask: still.length ? { columns: still, total: remaining } : null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
from: 'catalog', n, examples: unnamed.slice(0, BLANK_EXAMPLES), remaining: null, unnamed: unnamed.length,
|
||||
ask: unnamed.length ? { columns: unnamed.slice(0, BLANK_ASK_MAX), total: unnamed.length } : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** 还没有中文名和含义的列(表结构里的顺序;被驳回的不算有) */
|
||||
export function blankColumnsOf(detail: CatalogDetail): string[] {
|
||||
const live = (it: { status: string } | undefined) => !!it && it.status !== 'rejected'
|
||||
return (detail.structure?.columns ?? []).map((c) => c.name)
|
||||
.filter((name) => {
|
||||
const items = detail.notes.columns?.[name]
|
||||
return !live(items?.label) && !live(items?.meaning)
|
||||
})
|
||||
}
|
||||
@@ -42,12 +42,41 @@ export const DRAFT_PART_COLUMNS = 40
|
||||
/** 模型拿不准、留空的列数(任务里的一张表、清单上的上次起草);老服务端没有这个键时为 0 */
|
||||
export const blankOf = (t: { blank_columns?: number } | null | undefined): number => Math.max(0, t?.blank_columns ?? 0)
|
||||
|
||||
type LastDraftLike = {
|
||||
status: CatalogDraftTaskStatus
|
||||
blank_columns?: number
|
||||
blank_columns_from?: number | null
|
||||
parts_written?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 清单、表详情上「上次起草有 N 列模型拿不准、留空了」的 N:这张表的那次起草结束了才算(还在起草的那一次写到一半,
|
||||
* 不说「上次」)
|
||||
* 清单、表详情上「上次起草有 N 列模型拿不准、留空了」的 N 和它的出处:这张表的那次起草结束了才算(还在起草的那一次
|
||||
* 写到一半,不说「上次」)。最近一次是停止、服务重启中断(表记已停止),又没有新写入(自己没记下留空的列、也没写进
|
||||
* 任何一段)时,留空的列照旧是再之前那次写完的起草记下的(blank_columns_from),earlier 为真
|
||||
*/
|
||||
export const lastBlankOf = (d: { status: CatalogDraftTaskStatus; blank_columns?: number } | null | undefined): number =>
|
||||
(d && TASK_ENDED.has(d.status) ? blankOf(d) : 0)
|
||||
export function lastBlankInfo(d: LastDraftLike | null | undefined): { n: number; earlier: boolean } {
|
||||
if (!d || !TASK_ENDED.has(d.status)) return { n: 0, earlier: false }
|
||||
const own = blankOf(d)
|
||||
if (d.status === 'cancelled' && own === 0 && (d.parts_written ?? 0) === 0) {
|
||||
const from = Math.max(0, d.blank_columns_from ?? 0)
|
||||
return { n: from, earlier: from > 0 }
|
||||
}
|
||||
return { n: own, earlier: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* 宽表的块进度:段被缩小、拆半后实际分成的块比段多,这时才写「已结束几块 / 共几块」(没拆过的段进度已经说清了)。
|
||||
* 不用模型、老服务端没有这两个键时为 null
|
||||
*/
|
||||
export function chunksOf(t: { parts_total: number | null; chunks_total?: number | null; chunks_done?: number | null }):
|
||||
{ done: number; total: number } | null {
|
||||
const total = t.chunks_total
|
||||
if (total == null || total <= Math.max(1, t.parts_total ?? 1)) return null
|
||||
return { done: Math.max(0, Math.min(total, t.chunks_done ?? 0)), total }
|
||||
}
|
||||
|
||||
/** 已经写进目录的段数(老服务端没有这个键时为 0) */
|
||||
export const writtenOf = (t: { parts_written?: number }): number => Math.max(0, t.parts_written ?? 0)
|
||||
|
||||
/** 一次起草里留空的列合计:服务端给了合计就用它,老服务端没有就按逐表加 */
|
||||
export const jobBlank = (j: CatalogDraftJob): number => (j.blank_columns != null ? blankOf(j) : j.tasks.reduce((n, t) => n + blankOf(t), 0))
|
||||
|
||||
@@ -294,6 +294,10 @@ function ActionButton({ icon, children, disabled, onClick, action, title }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** 从审阅队列打开的那一项(表级项、列的一格、一条关系):一圈强调色的框,淡淡的底 */
|
||||
export const FOCUS_RING = 'rounded-md ring-2 ring-inset ring-[var(--accent)]'
|
||||
export const FOCUS_STYLE = { background: 'color-mix(in srgb, var(--accent) 7%, transparent)' }
|
||||
|
||||
/** 导入表格的源:说明是系统生成的,只读 */
|
||||
export function SystemNotesNotice() {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import type {
|
||||
CatalogNotes, CatalogReviewAction, CatalogReviewBatchResult, CatalogReviewItem, CatalogReviewKind, CatalogStatus,
|
||||
} from '../../types'
|
||||
import { CATALOG_TEXT } from '../../lib/terms'
|
||||
import { patchTarget, patchWhere } from '../../run/catalogPatch'
|
||||
import { slotsOf } from './model'
|
||||
|
||||
// ===========================================================================
|
||||
// 审阅队列的纯函数:地址里的视图和筛选、队列一项的键、成批审阅之后逐条的结果、表详情里改过之后队列跟着变、进度的百分比。
|
||||
// 界面在 ReviewQueue.tsx。
|
||||
//
|
||||
// 队列只列推断项,顺序由服务端排好(表的使用次数 × 风险 × 来源)。审阅过的项不马上从列表里拿掉:原地标出结果
|
||||
// (已确认、已驳回、冲突),可以选中撤销;刷新队列后才移除。
|
||||
// ===========================================================================
|
||||
|
||||
/** 数据目录页的两个视图:表清单(逐表看)、审阅队列(跨表按优先级看推断项) */
|
||||
export type CatalogView = 'list' | 'review'
|
||||
|
||||
export const REVIEW_KINDS: readonly CatalogReviewKind[] = ['all', 'facts', 'names']
|
||||
/** 一页几项、一次成批审阅最多几条(服务端同一个上限) */
|
||||
export const REVIEW_PAGE = 50
|
||||
export const REVIEW_BATCH_MAX = 200
|
||||
|
||||
/**
|
||||
* 审阅视图记在地址的查询参数里:view=review;kind 筛选(默认全部不写);rtable 只看一张表;item 从队列打开的那一项
|
||||
* (表详情据此滚到那一项、标出来)。和清单的 q / filter / sort / used 互不相干,各自只动自己的键
|
||||
*/
|
||||
export interface ReviewParams { view: CatalogView; kind: CatalogReviewKind; table: string; item: string }
|
||||
|
||||
export function reviewParamsOf(p: URLSearchParams): ReviewParams {
|
||||
const kind = p.get('kind') as CatalogReviewKind | null
|
||||
return {
|
||||
view: p.get('view') === 'review' ? 'review' : 'list',
|
||||
kind: kind && REVIEW_KINDS.includes(kind) ? kind : 'all',
|
||||
table: p.get('rtable') ?? '',
|
||||
item: p.get('item') ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
export function writeReviewParams(p: URLSearchParams, s: Partial<ReviewParams>): URLSearchParams {
|
||||
const next = new URLSearchParams(p)
|
||||
const put = (k: string, v: string | null | undefined) => (v ? next.set(k, v) : next.delete(k))
|
||||
if ('view' in s) put('view', s.view === 'review' ? 'review' : null)
|
||||
if ('kind' in s) put('kind', s.kind === 'all' ? null : s.kind)
|
||||
if ('table' in s) put('rtable', s.table)
|
||||
if ('item' in s) put('item', s.item)
|
||||
return next
|
||||
}
|
||||
|
||||
/** 队列一项的键(表名里、路径里都可能有句点,中间用一个不会出现的字符隔开) */
|
||||
export const itemKey = (i: { table_name: string; path: string }) => `${i.table_name}\u0000${i.path}`
|
||||
|
||||
/**
|
||||
* 队列里一项的位置,说法同提案卡片:「列 status 的码值」「表的有效记录条件」「指向 parks 的关联关系」。服务端的
|
||||
* field_label 只是字段的叫法(「码值」),列级的项占了队列的大半,只写它分不清是哪一列,所以按 path 说;认不出的
|
||||
* 路径再用 column 加 field_label
|
||||
*/
|
||||
export function reviewWhere(i: Pick<CatalogReviewItem, 'path' | 'value' | 'field_label'> & { column?: string | null }): string {
|
||||
if (patchTarget(i.path)) return patchWhere({ path: i.path, before: i.value ?? null, after: i.value ?? null })
|
||||
const label = i.field_label?.trim()
|
||||
if (i.column && label) return CATALOG_TEXT.whereColumn(i.column, label)
|
||||
return label || i.path
|
||||
}
|
||||
|
||||
/** 一项审阅过之后的处境:成功(现在的状态、这张表写入后的版本)、冲突(这张表刚被改过)、没成功(服务端的原因) */
|
||||
export type ItemOutcome =
|
||||
| { kind: 'ok'; action: CatalogReviewAction; status: CatalogStatus }
|
||||
| { kind: 'conflict'; message: string | null }
|
||||
| { kind: 'error'; message: string | null }
|
||||
|
||||
/** 成批审阅之后这一项现在的状态:确认 → 已确认;驳回 → 已驳回;撤销审阅 → 回到来源的初始状态(队列里都是推断) */
|
||||
const STATUS_AFTER: Record<CatalogReviewAction, CatalogStatus> = { confirm: 'confirmed', reject: 'rejected', reset: 'proposed' }
|
||||
|
||||
/** 服务端的机读码说的是不是版本冲突 */
|
||||
export const isConflict = (r: Pick<CatalogReviewBatchResult, 'code'>): boolean => !!r.code && /conflict|version/i.test(r.code)
|
||||
|
||||
/**
|
||||
* 逐条的结果并进去:outcomes 按项的键记处境,versions 按表记最新的版本(之后再审阅这张表的项带它)。返回新的两份,不改入参
|
||||
*/
|
||||
export function applyResults(
|
||||
results: CatalogReviewBatchResult[], action: CatalogReviewAction,
|
||||
outcomes: ReadonlyMap<string, ItemOutcome>, versions: ReadonlyMap<string, number>,
|
||||
): { outcomes: Map<string, ItemOutcome>; versions: Map<string, number> } {
|
||||
const o = new Map(outcomes)
|
||||
const v = new Map(versions)
|
||||
for (const r of results) {
|
||||
const key = itemKey(r)
|
||||
if (r.ok) {
|
||||
o.set(key, { kind: 'ok', action, status: STATUS_AFTER[action] })
|
||||
if (typeof r.version === 'number') v.set(r.table_name, Math.max(v.get(r.table_name) ?? 0, r.version))
|
||||
} else {
|
||||
o.set(key, isConflict(r) ? { kind: 'conflict', message: r.error } : { kind: 'error', message: r.error })
|
||||
}
|
||||
}
|
||||
return { outcomes: o, versions: v }
|
||||
}
|
||||
|
||||
/** 一项现在显示的状态:审阅成功过的按结果,否则是队列里给的 */
|
||||
export function statusOf(item: CatalogReviewItem, outcome: ItemOutcome | undefined): CatalogStatus {
|
||||
return outcome?.kind === 'ok' ? outcome.status : item.status
|
||||
}
|
||||
|
||||
/**
|
||||
* 一批选中的项里,这个动作要发哪几条:确认发还不是已确认的,驳回发还不是已驳回的,撤销审阅只发这一页上审阅成功过的
|
||||
* (队列里本来都是推断,没审阅过的撤销了也没有变化)。冲突过的照旧可以再发
|
||||
*/
|
||||
export function batchTargets(items: CatalogReviewItem[], selected: ReadonlySet<string>, action: CatalogReviewAction,
|
||||
outcomes: ReadonlyMap<string, ItemOutcome>): CatalogReviewItem[] {
|
||||
return items.filter((i) => {
|
||||
if (!selected.has(itemKey(i))) return false
|
||||
const o = outcomes.get(itemKey(i))
|
||||
const status = statusOf(i, o)
|
||||
if (action === 'reset') return o?.kind === 'ok' && o.action !== 'reset'
|
||||
return action === 'confirm' ? status !== 'confirmed' : status !== 'rejected'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 表详情里改过这张表(单项审阅、编辑、助手的修改):队列里这张表的项按最新的目录换状态——不再是推断的记成审阅成功,
|
||||
* 版本换成最新的。目录里已经没有的项不动(刷新后服务端不会再列)
|
||||
*/
|
||||
export function syncFromDetail(items: CatalogReviewItem[], table: string, notes: CatalogNotes, version: number,
|
||||
outcomes: ReadonlyMap<string, ItemOutcome>, versions: ReadonlyMap<string, number>,
|
||||
): { outcomes: Map<string, ItemOutcome>; versions: Map<string, number> } {
|
||||
const o = new Map(outcomes)
|
||||
const v = new Map(versions)
|
||||
v.set(table, version)
|
||||
const slots = new Map(slotsOf(notes).map((s) => [s.path, s.item.status]))
|
||||
for (const i of items) {
|
||||
if (i.table_name !== table) continue
|
||||
const status = slots.get(i.path)
|
||||
if (!status) continue
|
||||
const key = itemKey(i)
|
||||
if (status === 'proposed') {
|
||||
// 回到推断:之前审阅成功(确认、驳回)过的记成撤销审阅;冲突的照旧标着
|
||||
const cur = o.get(key)
|
||||
if (cur?.kind === 'ok' && cur.status !== 'proposed') o.set(key, { kind: 'ok', action: 'reset', status: 'proposed' })
|
||||
} else {
|
||||
o.set(key, { kind: 'ok', action: status === 'rejected' ? 'reject' : 'confirm', status })
|
||||
}
|
||||
}
|
||||
return { outcomes: o, versions: v }
|
||||
}
|
||||
|
||||
/** 百分比(四舍五入到整数);分母为 0 时为 null */
|
||||
export const percentOf = (n: number, total: number): number | null => (total > 0 ? Math.round((n / total) * 100) : null)
|
||||
|
||||
/**
|
||||
* 审阅进度的一行(常用表、全部表、影响数字的项同一个口径):已确认 ÷(已确认 + 待确认)。已驳回的、有确证的(外键约束)
|
||||
* 不用再确认,不计入,审完了就是 100%。不知道待确认几项(服务端没给)时 pct 为 null,只写已确认几项
|
||||
*/
|
||||
export function confirmProgress(confirmed: number, proposed: number | null | undefined): {
|
||||
pct: number | null; confirmed: number; proposed: number | null
|
||||
} {
|
||||
const c = Math.max(0, confirmed || 0)
|
||||
if (typeof proposed !== 'number' || !Number.isFinite(proposed)) return { pct: null, confirmed: c, proposed: null }
|
||||
const p = Math.max(0, proposed)
|
||||
return { pct: percentOf(c, c + p), confirmed: c, proposed: p }
|
||||
}
|
||||
@@ -65,10 +65,15 @@ function changeOf(x: unknown): CatalogPatchChange | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** 提案里的各项(catalog_patch、表助手的 proposal 事件同一个形状):认不出的项跳过 */
|
||||
export function patchChangesOf(raw: unknown): CatalogPatchChange[] {
|
||||
return (Array.isArray(raw) ? raw : []).map(changeOf).filter((c): c is CatalogPatchChange => !!c)
|
||||
}
|
||||
|
||||
/** catalog_patch 操作 → 提案。认不出的形状返回 null */
|
||||
export function catalogPatchOf(op: Record<string, any>, key = ''): CatalogPatch | null {
|
||||
if (typeof op.source !== 'string' || typeof op.source_id !== 'string' || typeof op.table !== 'string') return null
|
||||
const changes = (Array.isArray(op.changes) ? op.changes : []).map(changeOf).filter((c): c is CatalogPatchChange => !!c)
|
||||
const changes = patchChangesOf(op.changes)
|
||||
if (!changes.length) return null
|
||||
const version = typeof op.version === 'number' && Number.isFinite(op.version) ? op.version : 0
|
||||
return {
|
||||
|
||||
+198
-1
@@ -2859,7 +2859,8 @@ export type CatalogReviewAction = 'confirm' | 'reject' | 'reset'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 起草任务(/api/datasources/{id}/catalog/draft-jobs):起草在服务端后台进行,逐表起草、每张表一结束就并入目录。
|
||||
// 关掉对话框不停任务;服务重启时还没结束的任务记 interrupted,不自动续跑,由人点「重试」接着起草。
|
||||
// 关掉对话框不停任务;服务重启时还没结束的任务记 interrupted,不自动续跑,由人点「重试」把没写全的表重新起草一遍
|
||||
// (重试不跳过已写进目录的段,整张表重新分段起草;已写进目录的内容不会丢)。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -2884,6 +2885,16 @@ export interface CatalogDraftTask {
|
||||
/** 模型分几段起草(超过 40 列的宽表拆成表级一段加列分段);不用模型时为 null */
|
||||
parts_total: number | null
|
||||
parts_done: number
|
||||
/**
|
||||
* 已经写进目录的段数:每写完一段就并入目录,停止、服务重启时已写进的段保留。老服务端没有这个键
|
||||
*/
|
||||
parts_written?: number
|
||||
/**
|
||||
* 这张表实际分成的块数和已结束的块数:额度用完、超时后段被缩小、拆半,块会比段多。不用模型时为 null;
|
||||
* 老服务端没有这两个键
|
||||
*/
|
||||
chunks_total?: number | null
|
||||
chunks_done?: number | null
|
||||
added: number
|
||||
updated: number
|
||||
removed: number
|
||||
@@ -2984,6 +2995,13 @@ export interface CatalogLastDraft {
|
||||
model_error: string | null
|
||||
/** 那次起草模型拿不准、留空的列数(同 CatalogDraftTask.blank_columns)。老服务端没有这个键 */
|
||||
blank_columns?: number
|
||||
/**
|
||||
* 最近一次这张表结束于完成、部分完成的起草记下的留空列数(没有这样的起草为 null)。最近一次是停止、服务重启中断、
|
||||
* 又没有新写入时,界面用它接着提示留空的列。老服务端没有这个键
|
||||
*/
|
||||
blank_columns_from?: number | null
|
||||
/** 那次起草已写进目录的段数(同 CatalogDraftTask.parts_written)。接口约定里没有这一项,服务端给了才用 */
|
||||
parts_written?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3046,6 +3064,185 @@ export interface CatalogImpactTemplate {
|
||||
nodes: CatalogImpactNode[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 审阅队列(/api/datasources/{id}/catalog/review-queue、review/batch、review-progress、{table}/usage,后端
|
||||
// data/catalog_review.py):跨表列出推断项,按表的使用次数、这一项对数字的影响和来源排好,可以成批确认、驳回。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 队列的筛选:全部、影响数字的项(有效记录条件、去重、业务日期、关联关系、业务主键、度量类型)、名称和含义 */
|
||||
export type CatalogReviewKind = 'all' | 'facts' | 'names'
|
||||
|
||||
/** 队列里的一项(只列推断状态的项) */
|
||||
export interface CatalogReviewItem {
|
||||
table_name: string
|
||||
/** 审阅接口的路径写法 */
|
||||
path: string
|
||||
/** 字段名(valid_filter、label……;关联关系是 relation) */
|
||||
field: string
|
||||
/**
|
||||
* 字段的界面叫法(服务端写好的「中文名」「码值」「有效记录条件」「关联关系」),不含是哪一列:界面上的位置按 path
|
||||
* 说(「列 status 的码值」,review.ts 的 reviewWhere)
|
||||
*/
|
||||
field_label: string
|
||||
/** 列级的项是哪一列;表级的项、关联关系为 null。接口约定里没有写明,服务端给了才用 */
|
||||
column?: string | null
|
||||
value: unknown
|
||||
/** 值写成一行字(服务端写好的) */
|
||||
display: string
|
||||
source: CatalogSource
|
||||
status: CatalogStatus
|
||||
note: string | null
|
||||
/** 越大越靠前:表的使用次数权重 × 风险权重 × 来源权重 */
|
||||
priority: number
|
||||
/** 为什么排在这里(中文,服务端写好的) */
|
||||
reasons: string[]
|
||||
/** 所在表被运行用过的次数 */
|
||||
table_usage: number
|
||||
/** 所在表的目录版本:成批审阅时逐表带上 */
|
||||
version: number
|
||||
}
|
||||
|
||||
/** GET /catalog/review-queue */
|
||||
export interface CatalogReviewQueue {
|
||||
total: number
|
||||
items: CatalogReviewItem[]
|
||||
}
|
||||
|
||||
/** POST /catalog/review/batch 的一项 */
|
||||
export interface CatalogReviewBatchItem {
|
||||
table_name: string
|
||||
path: string
|
||||
action: CatalogReviewAction
|
||||
if_version: number
|
||||
}
|
||||
|
||||
/** 成批审阅逐条的结果:一张表冲突不影响别的表;成功的写明这张表写入后的版本 */
|
||||
export interface CatalogReviewBatchResult {
|
||||
table_name: string
|
||||
path: string
|
||||
ok: boolean
|
||||
version: number | null
|
||||
error: string | null
|
||||
code: string | null
|
||||
}
|
||||
|
||||
/** 用过这张表的一次运行(新的在前) */
|
||||
export interface CatalogUsageRun {
|
||||
run_id: string
|
||||
workflow_id: string | null
|
||||
workflow_name: string | null
|
||||
run_class: 'formal' | 'exploratory' | null
|
||||
status: string
|
||||
started_at: string | null
|
||||
}
|
||||
|
||||
/** GET /catalog/{table}/usage */
|
||||
export interface CatalogTableUsage {
|
||||
total: number
|
||||
runs: CatalogUsageRun[]
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /catalog/review-progress:常用的前 top_n 张表、全部表、影响数字的项各审阅到哪儿。
|
||||
* 「常用表」只算被运行用过的:tables 可能比 top_n 少,没有运行时是 0。三行的进度都按「已确认 ÷(已确认 + 待确认)」算
|
||||
* (review.ts 的 confirmProgress):已驳回的、有确证的(外键约束)不用再确认,不计入
|
||||
*/
|
||||
export interface CatalogReviewProgress {
|
||||
top_n: number
|
||||
/** items_proposed、items_verified:接口约定里没有写明,服务端给了才用(没有待确认数时只写已确认几项) */
|
||||
top: { tables: number; items_total: number; items_confirmed: number; items_proposed?: number; items_verified?: number }
|
||||
all: { items_total: number; items_confirmed: number; items_proposed: number; items_rejected: number; items_verified?: number }
|
||||
facts: { proposed: number; confirmed: number }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 导出导入(/api/datasources/{id}/catalog/export、import/preview、import,后端 data/catalog_transfer.py):
|
||||
// 本机整理好的目录带到另一个实例。先预览(只读),确认后逐表写入。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 导出的文件:含全部状态的项 */
|
||||
export interface CatalogExportFile {
|
||||
format: 'agentlab-catalog'
|
||||
version: 1
|
||||
exported_at: string
|
||||
source: { name: string; kind: string }
|
||||
guidance: string | null
|
||||
tables: Record<string, { notes: CatalogNotes; version: number }>
|
||||
}
|
||||
|
||||
/** 一处冲突:这里已确认、已驳回的项和文件中的不一样,以这里为准 */
|
||||
export interface CatalogImportConflict {
|
||||
path: string
|
||||
label: string
|
||||
file_value: unknown
|
||||
target_value: unknown
|
||||
why: string
|
||||
}
|
||||
|
||||
/** 预览、导入报告里的一张表 */
|
||||
export interface CatalogImportTable {
|
||||
/** 文件中的表名 */
|
||||
table_name: string
|
||||
/** 对上的这个数据源里的表名(名字不一样时才有意思);对不上为 null */
|
||||
matched_as: string | null
|
||||
add: number
|
||||
update: number
|
||||
keep: number
|
||||
conflicts: CatalogImportConflict[]
|
||||
skipped: { path: string; why: string }[]
|
||||
/** 导入后这张表的目录版本(只有导入的报告有) */
|
||||
version?: number | null
|
||||
/** 这张表没导入的原因(逐表写入,一张表失败不影响别的)。接口约定里没有写明,服务端给了才显示 */
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export interface CatalogImportTotals {
|
||||
tables: number
|
||||
add: number
|
||||
update: number
|
||||
keep: number
|
||||
conflicts: number
|
||||
skipped: number
|
||||
}
|
||||
|
||||
/** POST /catalog/import/preview、/catalog/import */
|
||||
export interface CatalogImportReport {
|
||||
tables: CatalogImportTable[]
|
||||
/** 文件中有、这个数据源里对不上的表 */
|
||||
unmatched_tables: string[]
|
||||
totals: CatalogImportTotals
|
||||
/** 起草要求:写入文件中的(这里还没有)、保留这里的、不导入 */
|
||||
guidance: { file: string | null; target: string | null; action: 'keep' | 'replace' | 'none' }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 表助手(POST /api/datasources/{id}/catalog/{table}/assistant,后端 data/catalog_assistant.py):对话完善一张表的
|
||||
// 目录。回答是事件流,每个事件一行 data: <JSON>。对话不存服务端,每一轮都把前面的对话和还没保存的修改带上。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 提案里的一项:和助手流里的 catalog_patch 一样,多一个给人看的位置 */
|
||||
export interface CatalogAssistantChange extends CatalogPatchChange {
|
||||
label?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件流里的一个事件。服务端等模型时另发 SSE 注释行(「: keep-alive」,不是事件),读流时跳过。
|
||||
* query 的 row_count 是取回的行数;truncated(接口约定里没有写明,服务端给了才用)为真时后面还有行,取回的只是前一截
|
||||
*/
|
||||
export type CatalogAssistantEvent =
|
||||
| { type: 'text'; delta: string }
|
||||
| { type: 'query'; sql: string; columns: string[]; rows: unknown[][]; row_count: number; truncated?: boolean; error: string | null }
|
||||
| { type: 'proposal'; source_id: string; table: string; version: number; changes: CatalogAssistantChange[] }
|
||||
| { type: 'done'; usage: { input_tokens: number; output_tokens: number }; queries: number }
|
||||
| { type: 'error'; message: string; code?: string | null }
|
||||
|
||||
/** 交给表助手的一轮请求 */
|
||||
export interface CatalogAssistantRequest {
|
||||
messages: { role: 'user' | 'assistant'; content: string }[]
|
||||
/** 界面上还没保存的修改:最多 200 项(服务端同一个上限,多了整个请求 422),多出来的只交最近的 200 项 */
|
||||
pending: CatalogPatchSubmit[]
|
||||
}
|
||||
|
||||
/** GET /datasources/{id}/catalog/{table}/impact */
|
||||
export interface CatalogImpact {
|
||||
table: string
|
||||
|
||||
@@ -81,6 +81,7 @@ const ORDER = [
|
||||
'manage', // 数据、工具、知识、设置
|
||||
'versions', // 上传表格的版本:回滚、清单、移除一期、清除原件
|
||||
'catalog', // 数据目录:表清单、起草、单项审阅、批量确认、编辑即确认、409
|
||||
'catalog-loop', // 数据目录闭环:表助手、审阅队列、导出导入、防错事实的说明
|
||||
// 复核套件与导出
|
||||
'schedules', // 定时任务
|
||||
'compare', // 运行对比
|
||||
@@ -97,7 +98,7 @@ const ORDER = [
|
||||
const ESTIMATE = {
|
||||
challenge: 30,
|
||||
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
|
||||
evidence: 91, versions: 60, catalog: 140, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
|
||||
evidence: 91, versions: 60, catalog: 150, 'catalog-loop': 60, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
|
||||
// 占位,换成真检查时按实测改
|
||||
schedules: 60, compare: 45, verify: 71, inquiry: 60, bundle: 16, whatif: 33, 'report-export': 30,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+130
-8
@@ -436,9 +436,12 @@ function newJob(state, src, { tables, use_model, note = null, retry_of = null })
|
||||
const tasks = tables.map((name) => {
|
||||
const t = state.tables[src].find((x) => x.name === name && !x.missing)
|
||||
const cols = t?.columns?.length ?? 0
|
||||
const partsTotal = t && use_model ? (cols > PART_COLUMNS ? 1 + Math.ceil(cols / PART_COLUMNS) : 1) : null
|
||||
return {
|
||||
table_name: name, status: t ? 'pending' : 'skipped', columns: cols,
|
||||
parts_total: t && use_model ? (cols > PART_COLUMNS ? 1 + Math.ceil(cols / PART_COLUMNS) : 1) : null, parts_done: 0,
|
||||
parts_total: partsTotal, parts_done: 0,
|
||||
// 逐段写进目录:已写进的段数;段被缩小、拆半后实际的块数和已结束的块数(不用模型时为 null)
|
||||
parts_written: 0, chunks_total: partsTotal, chunks_done: partsTotal == null ? null : 0,
|
||||
added: 0, updated: 0, removed: 0, version: t?.version ?? 0, model_items: null, blank_columns: 0, blank_sample: [],
|
||||
error: t ? null : `表结构里没有 ${name},请先重新探查结构`, model_error: null,
|
||||
started_at: null, finished_at: t ? null : isoNow(),
|
||||
@@ -507,7 +510,13 @@ function finishJob(job, status) {
|
||||
*/
|
||||
function stopJob(job, status = 'cancelled', error = null) {
|
||||
for (const t of job.tasks) {
|
||||
if (t.status === 'pending' || t.status === 'running') Object.assign(t, { status: 'cancelled', error, finished_at: isoNow() })
|
||||
if (t.status !== 'pending' && t.status !== 'running') continue
|
||||
// 写过段的表照旧记已停止,原因写一句已写进目录几段、剩下几段没起草(服务端停止、中断时的说法:catalog_jobs 的
|
||||
// STOPPED_WRITTEN、INTERRUPTED_WRITTEN)
|
||||
const written = t.parts_written ?? 0
|
||||
const modelError = written > 0 && t.parts_total
|
||||
? `${status === 'interrupted' ? '已中断' : '已停止'}:已写进目录 ${written} 段,剩下 ${t.parts_total - written} 段没起草` : t.model_error
|
||||
Object.assign(t, { status: 'cancelled', error, model_error: modelError, finished_at: isoNow() })
|
||||
}
|
||||
if (error) job.error = error
|
||||
delete job.stopAsked
|
||||
@@ -524,15 +533,19 @@ function failJob(job, error) {
|
||||
}
|
||||
|
||||
function lastDraftOf(state, src, name) {
|
||||
for (const job of [...state.jobs].reverse()) {
|
||||
if (job.source_id !== src) continue
|
||||
const jobs = [...state.jobs].reverse().filter((j) => j.source_id === src)
|
||||
const fixture = state.tables[src]?.find((x) => x.name === name)?.lastDraft ?? null
|
||||
// 最近一次这张表结束于完成、部分完成的起草记下的留空列数(服务端 last_drafts 的 blank_columns_from)
|
||||
const ended = jobs.map((j) => j.tasks.find((x) => x.table_name === name)).find((t) => t && (t.status === 'ok' || t.status === 'partial'))
|
||||
const from = ended ? ended.blank_columns : fixture?.blank_columns_from ?? null
|
||||
for (const job of jobs) {
|
||||
const t = job.tasks.find((x) => x.table_name === name)
|
||||
if (t) {
|
||||
return { status: t.status, job_id: job.id, finished_at: t.finished_at, error: t.error, model_error: t.model_error,
|
||||
blank_columns: t.blank_columns }
|
||||
blank_columns: t.blank_columns, blank_columns_from: from, parts_written: t.parts_written ?? 0 }
|
||||
}
|
||||
}
|
||||
return state.tables[src]?.find((x) => x.name === name)?.lastDraft ?? null
|
||||
return fixture
|
||||
}
|
||||
|
||||
const jobOf = (state, key) => {
|
||||
@@ -1202,8 +1215,10 @@ await section('数据目录 · 起草:已有任务(409)、服务重启中
|
||||
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('重试'),
|
||||
// 重试不是续跑:服务端把这些表整张重新分段起草(不跳过已写进目录的段),不能说成「接着起草」剩下的部分
|
||||
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-interrupted]')).includes('已写进目录的内容不会丢') && !(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-status]')) === '已停止'
|
||||
@@ -1544,6 +1559,113 @@ await section('数据目录 · 起草:模型拿不准、留空的列', async (
|
||||
await close()
|
||||
})
|
||||
|
||||
await section('数据目录 · 起草:逐段写进目录、块进度与停止后的留空提示', async () => {
|
||||
const state = freshState()
|
||||
// 上一次起草写完了这两张表,各留了几列空(之后停止、又没有新写入时,界面接着用这次记下的留空列数提示)
|
||||
const old = newJob(state, S1, { tables: ['store_sales', 'members'], use_model: true })
|
||||
settle(state, old, 'store_sales', { added: 10, model_items: 40, blank_columns: 6, blank_sample: BLANK_SAMPLE })
|
||||
settle(state, old, 'members', { added: 2, model_items: 3, blank_columns: 3, blank_sample: ['member_no', 'joined_on'] })
|
||||
finishJob(old)
|
||||
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
|
||||
await page.locator('[data-catalog-row]').first().waitFor()
|
||||
check('开始起草(门店销售、会员 2 张,用模型)', await startDraft(page, { select: ['store_sales', 'members'], model: true }))
|
||||
const job = state.jobs.at(-1)
|
||||
const task = (n) => draftBox(page).locator(`[data-draft-task="${n}"]`)
|
||||
const text = (n, sel) => task(n).locator(sel).first().innerText({ timeout: 500 }).then((t) => t.replace(/\s+/g, ' ').trim()).catch(() => '')
|
||||
|
||||
// 宽表第 1 段写完就写进目录:逐表写「已写进目录 1 段」;没拆过的段不写块进度(段进度已经说清了)
|
||||
settle(state, job, 'store_sales', { status: 'running', parts_done: 1, parts_written: 1, chunks_total: 3, chunks_done: 1 })
|
||||
check('宽表写完一段:逐表写「已写进目录 1 段」,段进度「1 / 3 段」,没拆过不写块进度',
|
||||
await until(async () => (await text('store_sales', '[data-draft-task-written]')) === '已写进目录 1 段')
|
||||
&& (await text('store_sales', '[data-draft-task-parts]')) === '1 / 3 段' && await task('store_sales').locator('[data-draft-task-chunks]').count() === 0,
|
||||
`${await text('store_sales', '[data-draft-task-written]')} · ${await text('store_sales', '[data-draft-task-parts]')}`)
|
||||
check('……「已写进目录」悬停写明停止、服务重启时保留', ((await task('store_sales').locator('[data-draft-task-written]').getAttribute('title')) ?? '').includes('停止或服务重启时已写进的段保留'))
|
||||
// 额度用完、超时后段被拆半:块比段多,写「已结束几块 / 共几块」,悬停说明为什么变多
|
||||
settle(state, job, 'store_sales', { status: 'running', parts_done: 1, parts_written: 1, chunks_total: 5, chunks_done: 2 })
|
||||
const chunks = task('store_sales').locator('[data-draft-task-chunks]')
|
||||
check('段被拆小:写块进度「2 / 5 块」,悬停说明实际分成 5 块', await until(async () => (await text('store_sales', '[data-draft-task-chunks]')) === '2 / 5 块')
|
||||
&& ((await chunks.getAttribute('title')) ?? '').includes('实际分成 5 块'), await text('store_sales', '[data-draft-task-chunks]'))
|
||||
settle(state, job, 'store_sales', { status: 'running', parts_done: 2, parts_written: 2, chunks_total: 5, chunks_done: 4 })
|
||||
check('……接着写:「已写进目录 2 段」「2 / 3 段」「4 / 5 块」', await until(async () => (await text('store_sales', '[data-draft-task-written]')) === '已写进目录 2 段')
|
||||
&& (await text('store_sales', '[data-draft-task-parts]')) === '2 / 3 段' && (await text('store_sales', '[data-draft-task-chunks]')) === '4 / 5 块')
|
||||
check('……还没开始的表不写段进度、块进度、已写进目录', await task('members').locator('[data-draft-task-parts], [data-draft-task-chunks], [data-draft-task-written]').count() === 0)
|
||||
await shot(page, 'catalog-draft-chunks')
|
||||
|
||||
// 停止:已写进的段保留。说法照服务端:写过段的表记已停止,原因写已写进几段、剩下几段没起草
|
||||
await page.locator('[data-draft-stop]').click()
|
||||
check('点「停止」:写明正在停止、已写进目录的段保留', await until(async () => (await draftText(page, '[data-draft-title]')).includes('已写进目录的段保留')),
|
||||
await draftText(page, '[data-draft-title]'))
|
||||
stopJob(job)
|
||||
check('停下来:宽表记已停止,原因写「已写进目录 2 段,剩下 1 段没起草」', await until(async () => (await draftPhase(page)) === 'done')
|
||||
&& (await text('store_sales', '[data-draft-task-status]')) === '已停止'
|
||||
&& (await text('store_sales', '[data-draft-task-reason]')).includes('已写进目录 2 段,剩下 1 段没起草'),
|
||||
`${await text('store_sales', '[data-draft-task-status]')} · ${await text('store_sales', '[data-draft-task-reason]')}`)
|
||||
check('……服务端的原因已经写了,界面不再重复一行「已写进目录」', await task('store_sales').locator('[data-draft-task-written]').count() === 0)
|
||||
const cancelled = await draftText(page, '[data-draft-cancelled]')
|
||||
// 重试会把这张宽表整张重新起草(服务端不跳过已写进目录的段),不能说成「接着起草剩下的段」
|
||||
check('……汇总写明已写进的段保留:其中 1 张表已写进目录 2 段,只重试这些表时会重新起草整张表', cancelled.includes('每写完一段就写进目录')
|
||||
&& cancelled.includes('其中 1 张表已写进目录 2 段') && cancelled.includes('只重试这些表时会重新起草整张表,已写进的不会丢')
|
||||
&& !cancelled.includes('接着起草'), cancelled)
|
||||
check('……可以只重试这 2 张', (await page.locator('[data-draft-retry]').innerText().catch(() => '')).includes('(2 张)'))
|
||||
await page.locator('[data-draft-close]').click()
|
||||
|
||||
// 老服务端停下来时原因里没写:界面自己补一句。部分完成的表段进度是「都结束了」,看不出写进了几段:也写一句
|
||||
const legacy = newJob(state, S1, { tables: ['store_sales', 'members'], use_model: true })
|
||||
settle(state, legacy, 'members', { status: 'partial', parts_total: 2, parts_done: 2, parts_written: 1, model_error: CALL_ERR })
|
||||
settle(state, legacy, 'store_sales', { status: 'running', parts_done: 1, parts_written: 1 })
|
||||
stopJob(legacy)
|
||||
legacy.tasks[0].model_error = null
|
||||
await page.locator('[data-catalog-draft]').first().click()
|
||||
await draftBox(page).waitFor()
|
||||
await until(async () => (await draftBox(page).locator('[data-draft-last-open]').count()) === 1)
|
||||
await draftBox(page).locator('[data-draft-last-open]').click()
|
||||
check('服务端的原因里没写已写进几段:界面补「已写进目录 1 段,剩下 2 段没起草」', await until(async () => (await text('store_sales', '[data-draft-task-written]')) === '已写进目录 1 段,剩下 2 段没起草'),
|
||||
await text('store_sales', '[data-draft-task-written]'))
|
||||
check('……部分完成的表写「已写进目录 1 段」,原因照旧', (await text('members', '[data-draft-task-written]')) === '已写进目录 1 段'
|
||||
&& (await text('members', '[data-draft-task-reason]')).includes(CALL_ERR), await text('members', '[data-draft-task-written]'))
|
||||
await page.locator('[data-draft-close]').click()
|
||||
state.jobs.pop()
|
||||
|
||||
// 服务重启中断:写过段的宽表原因写「已中断:已写进目录 1 段,剩下 2 段没起草」(服务端中断时的说法),界面不再补一行;
|
||||
// 提示写明重试会重新起草整张表,不说接着起草
|
||||
const crashed = newJob(state, S1, { tables: ['store_sales'], use_model: true })
|
||||
settle(state, crashed, 'store_sales', { status: 'running', parts_done: 1, parts_written: 1 })
|
||||
stopJob(crashed, 'interrupted', '服务重启,起草中断')
|
||||
await page.locator('[data-catalog-draft]').first().click()
|
||||
await draftBox(page).waitFor()
|
||||
await until(async () => (await draftBox(page).locator('[data-draft-last-open]').count()) === 1)
|
||||
await draftBox(page).locator('[data-draft-last-open]').click()
|
||||
check('服务重启中断:宽表的原因写「已中断:已写进目录 1 段,剩下 2 段没起草」,不再补一行「已写进目录」',
|
||||
await until(async () => (await text('store_sales', '[data-draft-task-reason]')) === '已中断:已写进目录 1 段,剩下 2 段没起草')
|
||||
&& await task('store_sales').locator('[data-draft-task-written]').count() === 0, await text('store_sales', '[data-draft-task-reason]'))
|
||||
check('……提示写明只重试这些表会重新起草整张表,已写进目录的内容不会丢', (await draftText(page, '[data-draft-interrupted]')).includes('会重新起草整张表,已写进目录的内容不会丢'),
|
||||
await draftText(page, '[data-draft-interrupted]'))
|
||||
await page.locator('[data-draft-close]').click()
|
||||
state.jobs.pop()
|
||||
|
||||
// 清单:会员表这次还没开始就停了(没有新写入):接着用上一次写完时记下的留空列数提示;门店销售这次写进了 2 段,不再拿旧数提示
|
||||
const rowBlank = (n) => page.locator(`[data-catalog-row="${n}"] [data-row-blank]`)
|
||||
await goto(page, `/data/catalog/${S1}`)
|
||||
check('清单:停止、没有新写入的表接着提示「上次起草有 3 列模型拿不准、留空了」', await until(async () => (await rowBlank('members').innerText({ timeout: 500 }).catch(() => '')).includes('上次起草有 3 列模型拿不准、留空了')),
|
||||
await rowBlank('members').innerText({ timeout: 500 }).catch(() => ''))
|
||||
check('……这次写进了段的宽表不拿旧的留空列数提示', await rowBlank('store_sales').count() === 0)
|
||||
await page.locator('[data-catalog-row="members"] [data-catalog-open-row]').click()
|
||||
const tip = page.locator('[data-catalog-last-draft]')
|
||||
check('表详情:顶部照旧提示 3 列留空,写明是再之前那次起草记下的(最近一次已停止)',
|
||||
await until(async () => (await tip.innerText({ timeout: 500 }).catch(() => '')).includes('上次起草有 3 列模型拿不准、留空了'))
|
||||
&& (await tip.getAttribute('data-catalog-last-draft')) === 'cancelled'
|
||||
&& (await tip.locator('[data-catalog-last-draft-earlier]').innerText().catch(() => '')).includes('最近一次起草已停止'),
|
||||
await tip.innerText({ timeout: 500 }).catch(() => ''))
|
||||
check('……给「重新起草这张表」「补充起草要求」,旁边是「和助手一起完善」', await tip.locator('[data-catalog-redraft]').count() === 1
|
||||
&& await tip.locator('[data-catalog-draft-guidance]').count() === 1 && await tip.locator('[data-catalog-assistant-open="notice"]').count() === 1)
|
||||
await cleanCopy(page, '逐段写进目录的提示')
|
||||
check('只发过起草任务相关的写请求', writes(sent, /./).every((s) => /catalog\/(draft-jobs|guidance)/.test(s.key)),
|
||||
JSON.stringify(writes(sent, /./).map((s) => s.key)))
|
||||
check('没有原生对话框', natives.length === 0, natives.join(' | '))
|
||||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||||
await close()
|
||||
})
|
||||
|
||||
await section('数据目录 · 起草:表结构里已经没有的表', async () => {
|
||||
const state = freshState()
|
||||
const tOf = (n) => state.tables[S1].find((t) => t.name === n)
|
||||
|
||||
Reference in New Issue
Block a user