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