mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:02:00 +00:00
8059cd5147
连接层 - 所有 SQLite 数据源关闭双引号字符串兼容(DQS),写错的双引号列名直接报错;设置失败就拒绝连接,启动时自检 - 连接缓存按数据源加版本快照分键、按源整体失效、最多 32 个;版本快照以 immutable 只读打开,建连接前核对文件哈希,并在借出连接时核对文件指纹,被改过就拒绝查询 - 结构探查遇到取不了列的视图时逐个对象重取,只跳过坏的那个并注明原因 解析(新 xlsx_scan + tabular) - 先流式扫描真实边界和版式(不信 dimension),再按边界读值,不整表进内存;按 Excel 自身上限设硬上限,并发解析最多两个 - 隐藏和深度隐藏的工作表不导入;稀疏区域、无缓存值的公式、同名工作表、DTD 拒收 - 去掉空行和两侧空列;列名表名统一规范化(新 names 模块)并保留原表头,撞名不再互相覆盖 - 前导零、超长数字按文本;千分位按数字;日期不带 T;带单位的数字文本写进回执 - 数字列混入非数字、交叉表和多块结构必须由用户决定(422),可选存空值或按原样导入(未规整);STRICT 建表,整份在一个事务里写完 版本底座(新 table_versions、raw_store) - 每次上传写新库文件、落盘、0444,事务里写构建、导入记录、快照后才切指针;失败时旧版完整可查 - 原件按哈希存档,不提供下载,可以清除;上传源不许改连接字段、不再重新探查;手工 SQLite 源不许指向上传目录和应用自己的数据文件(按文件身份判断) - 启动时清理半成品与孤儿文件、给老上传补初始版本;被运行引用的版本不回收,发起运行与回收互斥 - 上传接口的解析移到线程池 运行固定数据版本 - 发起时把图里(含子工作流)用到的上传源的当前快照记进运行记录和 run.started,续跑、审批恢复沿用;数据源工具按固定的快照生成描述、表结构并查询 - 固定的版本没了、源被删除后同名重建,都明确报错,不改用当前版本或同名的新源;查询快照记 data_version 证据层 - 新报告记 entity_syntax: 2,[[t:明细]]、[[c:明细.金额]] 和 SQL 里不加引号的中文表名都认得,名字按 SQLite 的口径查找;老报告按原规则复核,结果不变 - 写作规则改为中文表名、字段名一律用 [[t:]] / [[c:]];未规整的表在工具描述、写作目录和裁判摘录里标出 前端 - 上传弹窗接上导入前的决定(混合列、交叉表)和导入回执(跳过的隐藏表、类型转换、警告、原表头对照、未规整标记),上传前告知原件会保存 - 数据源卡片按 origin 认上传的表格,显示当前版本;上传的表格不再给「探查结构」
2056 lines
97 KiB
TypeScript
2056 lines
97 KiB
TypeScript
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import {
|
||
AlertTriangle, ChevronRight, Database, EyeOff, FileSpreadsheet, Info, KeyRound, Lock, Plug, Plus, RefreshCw,
|
||
Search, Table2, Upload, X,
|
||
} from 'lucide-react'
|
||
import clsx from 'clsx'
|
||
import { ApiError, api, uploadDecision } from '../api/client'
|
||
import type { IntrospectPreview, TableSchema, UploadProgress } from '../api/client'
|
||
import type { CurrentSnapshot, UploadDecision, UploadMixedColumn, UploadResult } from '../types'
|
||
import { useCatalog, useOnReconnect } from '../store/catalog'
|
||
import {
|
||
confirmDialog, DeleteButton, EmptyState, ErrorState, Field, HealthPill, IconButton, Modal, promptDialog,
|
||
SectionBar, Skeleton, Spinner, toast, useRadioGroup, useTicker,
|
||
} from '../components/ui'
|
||
import { humanizeError } from '../lib/errors'
|
||
import {
|
||
formatBytes, formatDateTime, formatDuration, formatNumber, formatRelative, parseServerTime, shortLabel,
|
||
} from '../lib/format'
|
||
import { checkHealth, forgetHealth, healthFromServer, setHealth, useHealth } from '../lib/health'
|
||
import type { HealthRecord } from '../lib/health'
|
||
import { workflowList, workflowsMentioning } from '../lib/mentions'
|
||
import { RAW_STATE_LABEL, UPLOAD_CONVERSION_LABEL, UPLOAD_SHAPE_LABEL, UPLOAD_TEXT } from '../lib/terms'
|
||
import { useRunClock } from '../run/useRunClock'
|
||
|
||
// ===========================================================================
|
||
// 数据源
|
||
// ===========================================================================
|
||
|
||
/**
|
||
* 数据源管理。/data 页的两个标签(数据库 / 表格)共用这一个组件,view 只决定
|
||
* 显示哪一类——切标签不卸载,列表和卡片上的展开状态都还在。
|
||
*
|
||
* 这里的每一项配置都会影响两件事:Copilot 编排时看得见什么,以及 agent 运行时
|
||
* 能查什么。所以表单上的说明写的是"这项填错会怎样",而不是字段的字面意思——
|
||
* Oracle 少填一个 schema,探查结果就是空的,而报错信息只会说 ORA-00942。
|
||
*/
|
||
export type DataView = 'databases' | 'tables'
|
||
|
||
/**
|
||
* 上传的表格:认后端给的来源标记 origin,不按库文件路径猜。上传库现在按版本存放
|
||
* (uploads/tables/<源 id>/builds/<构建 id>.db),路径每传一次就变;而手工登记的 SQLite
|
||
* 即使指向别处同名的目录也不是上传的表格
|
||
*/
|
||
export const isUploadedTable = (row: any): boolean => row?.origin === 'upload'
|
||
|
||
/**
|
||
* 迁移补建的初始版本(升级前就传上来的表):没有原件、没有记下文件名。它的快照时间是迁移那一刻
|
||
* (升级后服务启动时),不是导入时间,界面上不能当导入时间写;原来的同步时间(schema_synced_at)
|
||
* 迁移时原样保留,照常写「结构同步于」
|
||
*/
|
||
const isLegacySnapshot = (snap: CurrentSnapshot | null | undefined): boolean =>
|
||
!!snap && snap.raw_state === 'absent' && !snap.file_name
|
||
|
||
const NAME_RE = /^[a-z][a-z0-9_]{0,40}$/
|
||
|
||
/** 数据源 options 里的键:证据面板展示查询原始行时,这些列一律写「已遮罩」 */
|
||
const MASK_KEY = 'mask_columns'
|
||
|
||
/**
|
||
* 增、删、改、传之后让全局的数据源目录跟上:检查器挑工具、问数据的范围、助手的数据源
|
||
* 提示都读它(store/catalog 的 datasources),不重拉的话刚接入的库在那几处看不见
|
||
*/
|
||
const syncCatalog = () => void useCatalog.getState().reload('datasources')
|
||
|
||
export function DataSourcesTab({ view = 'databases' }: { view?: DataView }) {
|
||
const navigate = useNavigate()
|
||
const [rows, setRows] = useState<any[] | null>(null)
|
||
const [kinds, setKinds] = useState<any[]>([])
|
||
const [loadError, setLoadError] = useState<unknown>(null)
|
||
const [editing, setEditing] = useState<any | null>(null)
|
||
const [uploading, setUploading] = useState<{ name?: string } | null>(null)
|
||
const [kick, setKick] = useState<{ id: string; seq: number } | null>(null)
|
||
const loadSeq = useRef(0)
|
||
const hasRows = useRef(false)
|
||
|
||
// 首次加载才画骨架;之后的刷新保留列表、不卸载——展开的表清单、滚动位置、
|
||
// 卡片上的忙碌态都长在卡片上,以前整页换成 Spinner,这些全丢,还跳回顶部
|
||
const load = async () => {
|
||
const seq = ++loadSeq.current
|
||
try {
|
||
const [list, meta] = await Promise.all([api.datasources.list(), api.datasources.kinds()])
|
||
if (seq !== loadSeq.current) return
|
||
hasRows.current = true
|
||
setRows(list)
|
||
setKinds(meta.kinds ?? [])
|
||
setLoadError(null)
|
||
} catch (e) {
|
||
if (seq !== loadSeq.current) return
|
||
if (hasRows.current) toast.error(e)
|
||
else setLoadError(e)
|
||
}
|
||
}
|
||
useEffect(() => { void load() }, [])
|
||
useOnReconnect(load)
|
||
|
||
const upsert = (row: any) => setRows((rs) => {
|
||
if (!rs) return [row]
|
||
return rs.some((r) => r.id === row.id)
|
||
? rs.map((r) => (r.id === row.id ? row : r))
|
||
: [...rs, row].sort((a, b) => a.name.localeCompare(b.name))
|
||
})
|
||
const drop = (id: string) => {
|
||
setRows((rs) => rs && rs.filter((r) => r.id !== id))
|
||
syncCatalog()
|
||
}
|
||
|
||
const tables = view === 'tables'
|
||
const shown = rows?.filter((r) => isUploadedTable(r) === tables) ?? []
|
||
const kindOf = (k: string) => kinds.find((x) => x.value === k)
|
||
|
||
return (
|
||
<div>
|
||
{tables ? (
|
||
<SectionBar
|
||
title="表格"
|
||
hint="上传 Excel / CSV 文件,每个工作表会转为一张可用 SQL 查询的表,数字可计算、可溯源。同名文件重新上传会直接替换数据,工具名不变。"
|
||
>
|
||
<button className="btn btn-primary btn-sm" onClick={() => setUploading({})}>
|
||
<Upload size={12} /> 上传表格
|
||
</button>
|
||
</SectionBar>
|
||
) : (
|
||
<SectionBar title="数据库" hint="接入后,助手编排时可读取这些数据库的结构,Agent 运行时可直接查询。卡片右侧显示上次测试连接的结果和时间。">
|
||
<button className="btn btn-primary btn-sm"
|
||
onClick={() => setEditing({ kind: 'mysql', readonly: true, enabled: true, options: {} })}>
|
||
<Plus size={12} /> 接入数据库
|
||
</button>
|
||
</SectionBar>
|
||
)}
|
||
|
||
{rows === null ? (
|
||
loadError
|
||
? <ErrorState error={loadError} onRetry={() => void load()} />
|
||
: <Skeleton rows={3} height={72} gap={8} />
|
||
) : !shown.length ? (
|
||
tables ? (
|
||
<EmptyState
|
||
icon={<FileSpreadsheet size={22} />}
|
||
title="还没有上传表格"
|
||
body="支持 Excel(.xlsx)和 CSV / TSV。上传后可在「问数据」中直接提问,无需编写 SQL。"
|
||
action={<button className="btn btn-primary btn-sm" onClick={() => setUploading({})}><Upload size={12} /> 上传表格</button>}
|
||
/>
|
||
) : (
|
||
<EmptyState
|
||
icon={<Database size={22} />}
|
||
title="还没有接入数据库"
|
||
body="支持 MySQL、PostgreSQL、Oracle、SQLite。如只有 Excel 文件,可在「表格」标签页上传。"
|
||
action={
|
||
<div className="flex gap-2">
|
||
<button className="btn btn-primary btn-sm"
|
||
onClick={() => setEditing({ kind: 'mysql', readonly: true, enabled: true, options: {} })}>
|
||
<Plus size={12} /> 接入数据库
|
||
</button>
|
||
<button className="btn btn-sm" onClick={() => navigate('/data/tables')}>
|
||
<FileSpreadsheet size={12} /> 上传表格
|
||
</button>
|
||
</div>
|
||
}
|
||
/>
|
||
)
|
||
) : (
|
||
<div className="space-y-2.5">
|
||
{shown.map((row) => (
|
||
<SourceCard
|
||
key={row.id}
|
||
row={row}
|
||
meta={kindOf(row.kind)}
|
||
// 卡片上换 schema、探查结构改的也是这个源:检查器、问数据读的目录要跟上
|
||
onChange={(r) => { upsert(r); syncCatalog() }}
|
||
onRemoved={drop}
|
||
onEdit={() => setEditing(row)}
|
||
onReupload={() => setUploading({ name: row.name })}
|
||
kick={kick && kick.id === row.id ? kick.seq : 0}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{editing && (
|
||
<SourceEditor
|
||
source={editing}
|
||
kinds={kinds}
|
||
onClose={() => setEditing(null)}
|
||
onSaved={(row, tested, reconnected) => {
|
||
const isNew = !editing.id
|
||
setEditing(null)
|
||
upsert(row)
|
||
syncCatalog()
|
||
// 本机记着的那次测连接说的是改之前的库:换了地址、账号或连接参数就不作数了
|
||
// (后端同样清掉了自己记的那份,除非表单里刚测过这一份)
|
||
if (tested) setHealth(`datasource:${row.id}`, tested)
|
||
else if (reconnected) forgetHealth(`datasource:${row.id}`)
|
||
if (isNew) {
|
||
toast.ok(`已接入「${row.name}」。下一步:探查结构,助手将据此编写 SQL`, {
|
||
action: { label: '探查结构', onClick: () => setKick({ id: row.id, seq: Date.now() }) },
|
||
})
|
||
} else {
|
||
toast.ok(`已保存「${row.name}」`)
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{uploading && (
|
||
<TableUploader
|
||
initialName={uploading.name}
|
||
// 手工登记的 SQLite 不在前端拦:还没迁移的早期上传(数据文件缺失、迁移失败)也是这种源,
|
||
// 服务端按它是否在上传目录里决定能不能同名替换,前端不知道上传目录在哪
|
||
taken={(rows ?? []).filter((r) => !isUploadedTable(r) && r.kind !== 'sqlite').map((r) => r.name)}
|
||
sqliteTaken={(rows ?? []).filter((r) => !isUploadedTable(r) && r.kind === 'sqlite').map((r) => r.name)}
|
||
onClose={() => setUploading(null)}
|
||
onImported={(row) => {
|
||
upsert(row)
|
||
syncCatalog()
|
||
if (!tables) navigate('/data/tables', { replace: true })
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 结构同步超过这么久就提示可能过期:库表会变,Copilot 照旧表写 SQL 只会报错 */
|
||
const STALE_SCHEMA_MS = 7 * 24 * 3600_000
|
||
|
||
function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, kick }: {
|
||
row: any; meta?: any
|
||
onChange: (row: any) => void
|
||
onRemoved: (id: string) => void
|
||
onEdit: () => void
|
||
onReupload: () => void
|
||
/** 非 0 时自动点一次「探查结构」(新建后 toast 上的按钮) */
|
||
kick: number
|
||
}) {
|
||
const healthKey = `datasource:${row.id}`
|
||
// 后端记着上次测的结果:换了浏览器、清了缓存也还在。本机刚测过的更新就用本机的
|
||
const { record, checkingSince } = useHealth(healthKey, healthFromServer(row))
|
||
const workflows = useCatalog((s) => s.workflows)
|
||
const [busy, setBusy] = useState('')
|
||
const uploaded = isUploadedTable(row)
|
||
// 传上来的表通常只有一两张,直接摊开;库动辄几十上百个对象,默认收着
|
||
const [open, setOpen] = useState(() => uploaded && row.table_count > 0 && row.table_count <= 3)
|
||
const [masking, setMasking] = useState(false)
|
||
const masks = maskList(row.options?.[MASK_KEY])
|
||
const clock = useRunClock(!!busy)
|
||
const busySince = useRef(0)
|
||
useTicker(60_000)
|
||
const now = Date.now()
|
||
|
||
const test = () => checkHealth(healthKey, async () => {
|
||
const r = await api.datasources.test(row.id)
|
||
return { ok: !!r.ok, ms: r.elapsed_ms ?? null, error: r.error, hint: r.hint, detail: r.detail }
|
||
})
|
||
|
||
const configured: string = row.options?.schema ?? ''
|
||
const configuredLabel = configured || '默认 schema'
|
||
// 缓存是按哪个 schema 探的(null:老缓存没记,或者还没探过)。和配置对不上时,
|
||
// 助手写 SQL 用的是另一个 schema 的表——改了配置没重探、或者重探失败都会这样
|
||
const cachedSchema: string | null = row.cached_schema ?? null
|
||
// 上传的表格只有一个库,结构随版本冻结,谈不上「和配置的 schema 对不上」
|
||
const schemaDrift = !uploaded && cachedSchema != null && cachedSchema !== configured
|
||
|
||
const introspect = async () => {
|
||
if (busy) return
|
||
busySince.current = Date.now()
|
||
setBusy('introspect')
|
||
try {
|
||
const next = await api.datasources.introspect(row.id)
|
||
onChange(next)
|
||
if (next.table_count) {
|
||
toast.ok(`「${row.name}」探查到 ${formatNumber(next.table_count)} 个对象`)
|
||
} else if (!next.schema_error) {
|
||
// 连得上、也没报错,就是这个 schema 下真的没有对象。探查失败的红字
|
||
// 卡片自己会显示,不再叠一条 toast
|
||
toast.warn('连接成功,但该 schema 下没有对象,请检查 schema 名称是否正确')
|
||
}
|
||
} catch (e) {
|
||
toast.error(e)
|
||
} finally {
|
||
setBusy('')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 换个 schema 看看:只看不存(dry_run),缓存和配置都不动。探到对象再问要不要
|
||
* 写进配置;要,就改配置并按新配置真探一次。
|
||
*
|
||
* 以前后端没有「只看」,一探结果就落进缓存,选「不改」还得马上再探一次换回去,
|
||
* 换回去失败时助手看到的就是另一个 schema 的表
|
||
*/
|
||
const probeOther = async (schema: string) => {
|
||
if (busy) return
|
||
busySince.current = Date.now()
|
||
setBusy(`schema:${schema}`)
|
||
let preview: IntrospectPreview
|
||
try {
|
||
preview = await api.datasources.previewSchema(row.id, schema)
|
||
} catch (e) {
|
||
toast.error(e)
|
||
return
|
||
} finally {
|
||
setBusy('')
|
||
}
|
||
if (preview.schema_error) {
|
||
toast.warn(`「${schema}」探查失败:${preview.schema_error}`, { detail: '仅预览,未修改配置和缓存' })
|
||
return
|
||
}
|
||
if (!preview.table_count) {
|
||
toast.info(`「${schema}」下没有对象。仅预览,未修改配置和缓存`)
|
||
return
|
||
}
|
||
const total = Math.max(preview.table_count, preview.total ?? 0)
|
||
const sample = preview.tables.slice(0, 6).map((t) => t.slice(t.lastIndexOf('.') + 1))
|
||
const ok = await confirmDialog({
|
||
title: `把 schema 改成「${schema}」?`,
|
||
body: `「${schema}」下有 ${formatNumber(total)} 个对象${sample.length ? `,例如 ${sample.join('、')}${total > sample.length ? ' 等' : ''}` : ''}。`,
|
||
consequences: [
|
||
`改:写入该数据源的配置并重新探查,之后助手编写 SQL 时均使用 ${schema}`,
|
||
`不改:不做任何变更。刚才仅为预览,助手读取的仍是「${configuredLabel}」的结构`,
|
||
],
|
||
confirmLabel: `改成 ${schema}`,
|
||
cancelLabel: '不改',
|
||
})
|
||
if (!ok) return
|
||
busySince.current = Date.now()
|
||
setBusy('introspect')
|
||
try {
|
||
// options 整组替换:Oracle 的 service_name / sid 这些要原样带上
|
||
onChange(await api.datasources.update(row.id, { options: { ...(row.options ?? {}), schema } }))
|
||
} catch (e) {
|
||
const h = humanizeError(e)
|
||
toast.error(`修改失败(${h.title}):配置仍为「${configuredLabel}」,缓存未变更`, { detail: h.raw })
|
||
setBusy('')
|
||
return
|
||
}
|
||
try {
|
||
const next = await api.datasources.introspect(row.id)
|
||
onChange(next)
|
||
toast.ok(`已将「${row.name}」的 schema 改为 ${schema},探查到 ${formatNumber(next.table_count ?? 0)} 个对象`)
|
||
} catch (e) {
|
||
// 配置已经改了、缓存还是旧的:卡片上那行「结构和配置对不上」会一直提醒
|
||
const h = humanizeError(e)
|
||
toast.error(`schema 已改为 ${schema},但重新探查失败(${h.title}):助手读取的仍是原来的结构。请再次点击「探查结构」`, { detail: h.raw })
|
||
} finally {
|
||
setBusy('')
|
||
}
|
||
}
|
||
|
||
useEffect(() => { if (kick) void introspect() }, [kick])
|
||
|
||
// 只看不存,好好的库也可以随手看看别的 schema;单文件的 SQLite 和传上来的表没有 schema 可换
|
||
const synced = parseServerTime(row.schema_synced_at)?.getTime() ?? 0
|
||
const canProbeOther = !uploaded && row.kind !== 'sqlite'
|
||
// 候选里去掉配置里现有的那个:点它等于 dry_run 自己,探到了还问「把 schema 改成它?」
|
||
const otherSchemas: string[] = (row.available_schemas ?? []).filter((s: string) => s !== configured).slice(0, 12)
|
||
const introspectOther = async () => {
|
||
const others = otherSchemas.slice(0, 8)
|
||
const schema = await promptDialog({
|
||
title: '预览哪个 schema?',
|
||
body: `仅预览,不保存:不修改缓存和配置;探查到对象后再确认是否写入配置。${others.length ? `该服务器上还有:${others.join('、')}` : ''}`,
|
||
label: 'schema',
|
||
placeholder: others[0] ?? (row.kind === 'postgres' ? 'public' : row.kind === 'oracle' ? 'ANALYTICS' : row.database || ''),
|
||
confirmLabel: '预览',
|
||
validate: (v) => (!v ? '请填写 schema 名称' : v === configured ? `「${v}」即当前配置的 schema,请直接点击「探查结构」` : null),
|
||
})
|
||
if (schema) await probeOther(schema)
|
||
}
|
||
|
||
const remove = async () => {
|
||
const using = workflowsMentioning(workflows, row.tools ?? [])
|
||
const ok = await confirmDialog({
|
||
title: `删除数据源「${row.name}」?`,
|
||
danger: true,
|
||
consequences: [
|
||
row.tools?.length ? `工具 ${row.tools.join('、')} 将一并删除` : '',
|
||
using.length
|
||
? `${workflowList(using)}使用了这些工具,运行到相应步骤时会失败`
|
||
: '目前没有工作流直接引用该数据源的工具',
|
||
'助手和「问数据」将无法再访问该数据库',
|
||
uploaded ? '已上传的表将无法再查询,如需使用须重新上传' : '连接信息(含密码)一并删除,不可恢复',
|
||
].filter(Boolean),
|
||
requireText: row.name,
|
||
confirmLabel: '删除数据源',
|
||
})
|
||
if (!ok) return
|
||
try {
|
||
await api.datasources.remove(row.id)
|
||
forgetHealth(healthKey)
|
||
onRemoved(row.id)
|
||
toast.ok(`已删除数据源「${row.name}」`)
|
||
} catch (e) {
|
||
toast.error(e)
|
||
}
|
||
}
|
||
|
||
// 数量变了(刚探查完)才播一次入场,首次渲染不播
|
||
const lastCount = useRef(row.table_count)
|
||
const countChanged = lastCount.current !== row.table_count
|
||
useEffect(() => { lastCount.current = row.table_count }, [row.table_count])
|
||
|
||
// 上传的表格结构随版本冻结,不会「过期」:要更新就重新上传
|
||
const staleSchema = !uploaded && !!synced && now - synced > STALE_SCHEMA_MS
|
||
const failed = record && !record.ok && !checkingSince
|
||
|
||
return (
|
||
<article className="rounded-lg border bg-panel" data-source={row.name}>
|
||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 px-3 pt-2.5">
|
||
{uploaded
|
||
? <FileSpreadsheet size={14} className={row.enabled ? 'text-dim' : 'text-faint'} aria-hidden />
|
||
: <Database size={14} className={row.enabled ? 'text-dim' : 'text-faint'} aria-hidden />}
|
||
<span className="mono text-sm font-medium">{row.name}</span>
|
||
{!uploaded && <span className="chip">{shortLabel(meta?.label) || row.kind}</span>}
|
||
{row.readonly
|
||
? <span className="chip" title="仅允许 SELECT,模型生成的 UPDATE / DELETE 会被拦截"><Lock size={10} aria-hidden /> 只读</span>
|
||
: <span className="chip" style={{ color: 'var(--warn)', borderColor: 'color-mix(in srgb, var(--warn) 45%, transparent)' }}
|
||
title="模型生成的 UPDATE / DELETE 会被实际执行">
|
||
<AlertTriangle size={10} aria-hidden /> 可写
|
||
</span>}
|
||
{!row.enabled && <span className="chip" title="助手和 Agent 均无法访问该数据源">已停用</span>}
|
||
<span className="flex-1" />
|
||
<HealthPill record={record} checkingSince={checkingSince} />
|
||
<div className="flex items-center gap-1">
|
||
<button className="btn btn-sm" disabled={!!checkingSince} onClick={() => void test()}>
|
||
<Plug size={11} aria-hidden /> 测试连接
|
||
</button>
|
||
{/* 上传的表格不探查:结构在导入时随版本冻结,服务端也不接受(要更新就重新上传) */}
|
||
{!uploaded && (
|
||
<button className="btn btn-sm tnum" disabled={!!busy} onClick={() => void introspect()}
|
||
title="读取表结构并缓存,助手据此编写 SQL">
|
||
{busy === 'introspect'
|
||
? <><Spinner size={11} /> 探查中 {formatDuration(clock - busySince.current)}</>
|
||
: <><RefreshCw size={11} aria-hidden /> 探查结构</>}
|
||
</button>
|
||
)}
|
||
{uploaded
|
||
? <button className="btn btn-sm btn-ghost" onClick={onReupload} title={UPLOAD_TEXT.reuploadHint}>{UPLOAD_TEXT.reupload}</button>
|
||
: <button className="btn btn-sm btn-ghost" onClick={onEdit}>编辑</button>}
|
||
<DeleteButton label={`删除数据源 ${row.name}`} onClick={() => void remove()} />
|
||
</div>
|
||
</div>
|
||
|
||
{row.description && <p className="px-3 pt-1 text-xs leading-relaxed text-dim">{row.description}</p>}
|
||
|
||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 pb-2.5 pt-1.5 text-2xs text-faint">
|
||
<Address row={row} meta={meta} uploaded={uploaded} />
|
||
{row.options?.schema && <span className="chip">schema {row.options.schema}</span>}
|
||
{masks.length > 0 && (
|
||
<span className="chip" data-mask-columns={masks.join(',')} title={`在证据面板中显示为「已遮罩」:${masks.join('、')}`}>
|
||
<EyeOff size={10} aria-hidden /> 遮罩 {formatNumber(masks.length)} 列
|
||
</span>
|
||
)}
|
||
{uploaded && (
|
||
// 传上来的表没有编辑框(只能重传),遮罩列在这里改
|
||
<button type="button" className="text-2xs underline decoration-dotted underline-offset-2 hover:text-dim"
|
||
onClick={() => setMasking(true)} data-edit-mask="">
|
||
{masks.length ? '修改遮罩列' : '设置遮罩列'}
|
||
</button>
|
||
)}
|
||
{!!row.tools?.length && <span className="mono">{row.tools.join(' · ')}</span>}
|
||
</div>
|
||
{masking && (
|
||
<MaskColumnsDialog row={row} onClose={() => setMasking(false)}
|
||
onSaved={(next) => { setMasking(false); onChange(next); syncCatalog() }} />
|
||
)}
|
||
|
||
{failed && (
|
||
<div className="px-3 pb-3">
|
||
<ErrorState compact error={record} onRetry={() => void test()} />
|
||
</div>
|
||
)}
|
||
|
||
{row.schema_error && (
|
||
<div
|
||
role="alert"
|
||
className="mx-3 mb-3 rounded-lg border px-3 py-2 text-xs leading-relaxed"
|
||
style={{
|
||
borderColor: 'color-mix(in srgb, var(--err) 35%, var(--border))',
|
||
background: 'color-mix(in srgb, var(--err) 6%, transparent)',
|
||
}}
|
||
>
|
||
<div className="font-medium text-[var(--err)]">上次探查失败</div>
|
||
<div className="mt-0.5 break-all text-dim">{row.schema_error}</div>
|
||
{!uploaded && !!otherSchemas.length && (
|
||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||
<span className="text-faint">
|
||
当前为「{row.options?.schema || row.database || '默认'}」。该服务器上还有以下 schema,点击可预览(不保存):
|
||
</span>
|
||
{otherSchemas.map((s: string) => (
|
||
<button key={s} className="chip hover:border-[var(--accent)] hover:text-fg" disabled={!!busy}
|
||
onClick={() => void probeOther(s)}>
|
||
{busy === `schema:${s}` ? <Spinner size={10} /> : <Search size={10} aria-hidden />}
|
||
预览 {s}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{schemaDrift && (
|
||
<div
|
||
className="mx-3 mb-3 flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg border px-3 py-2 text-xs leading-relaxed"
|
||
style={{
|
||
borderColor: 'color-mix(in srgb, var(--warn) 45%, var(--border))',
|
||
background: 'color-mix(in srgb, var(--warn) 7%, transparent)',
|
||
}}
|
||
data-schema-drift
|
||
>
|
||
<AlertTriangle size={12} className="shrink-0 text-[var(--warn)]" aria-hidden />
|
||
<span className="min-w-0 flex-1">
|
||
<span className="text-[var(--warn)]">
|
||
助手读取的结构按「{cachedSchema || '默认 schema'}」探查,配置中为「{configuredLabel}」
|
||
</span>
|
||
<span className="text-dim">:助手编写 SQL 时使用的表可能不在配置的 schema 中。</span>
|
||
</span>
|
||
<button className="btn btn-xs" disabled={!!busy} onClick={() => void introspect()}>
|
||
<RefreshCw size={11} aria-hidden /> 按配置重新探查
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-2 border-t px-3 py-1.5 text-2xs">
|
||
{row.table_count ? (
|
||
<button
|
||
className="-ml-1 flex items-center gap-1.5 rounded px-1 py-0.5 text-dim hover:bg-hover hover:text-fg"
|
||
aria-expanded={open}
|
||
onClick={() => setOpen((v) => !v)}
|
||
>
|
||
<ChevronRight size={12} className={clsx('transition-transform', open && 'rotate-90')} aria-hidden />
|
||
<Table2 size={11} aria-hidden />
|
||
<span key={row.table_count} className={clsx('tnum', countChanged && 'fade-up')}>
|
||
{formatNumber(row.table_count)}
|
||
</span>
|
||
个对象
|
||
</button>
|
||
) : row.schema_error ? (
|
||
// "还没探查"和"探查失败了"是两回事:前者点一下按钮就行,后者点了
|
||
// 也没用。以前两者显示成同一句,没有一处说的是真话
|
||
// 红字上面那块已经说了,这里只说后果,不再叠一道红
|
||
<span className="text-faint">结构不可用:助手无法获取该数据库的表清单</span>
|
||
) : uploaded ? (
|
||
// 上传的表格没有「探查结构」可点,只能重新上传
|
||
<span className="text-[var(--warn)]">{UPLOAD_TEXT.noSchema}</span>
|
||
) : (
|
||
<span className="text-[var(--warn)]">尚未探查结构 · 助手无法获取表清单</span>
|
||
)}
|
||
{canProbeOther && (
|
||
<button className="tnum inline-flex items-center gap-1 rounded px-1 py-0.5 text-faint hover:bg-hover hover:text-fg disabled:opacity-50"
|
||
disabled={!!busy} onClick={() => void introspectOther()} data-probe-schema
|
||
title="指定 schema 预览其中的对象:不保存,不修改缓存和配置;探查到对象后再决定是否写入配置">
|
||
{busy.startsWith('schema:')
|
||
? <><Spinner size={10} /> 正在预览「{busy.slice(7)}」 {formatDuration(clock - busySince.current)}</>
|
||
: <><Search size={10} aria-hidden /> 预览其他 schema…</>}
|
||
</button>
|
||
)}
|
||
<span className="flex-1" />
|
||
{/* 上传的表格在地址那一行写当前版本和导入时间,这里不再重复一遍「结构同步于」。
|
||
迁移补建的初始版本没有导入时间可写,留着原来的同步时间 */}
|
||
{synced > 0 && !(uploaded && row.current_snapshot && !isLegacySnapshot(row.current_snapshot)) && (
|
||
<span className={clsx('tnum', staleSchema ? 'text-[var(--warn)]' : 'text-faint')}
|
||
title={`${formatDateTime(row.schema_synced_at)} 同步${staleSchema ? '。库表可能已变更,请点击「探查结构」重新同步' : ''}`}>
|
||
{staleSchema && <AlertTriangle size={10} className="mr-1 inline" aria-hidden />}
|
||
结构同步于 {formatRelative(row.schema_synced_at)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{open && row.table_count > 0 && <SchemaBrowser row={row} />}
|
||
</article>
|
||
)
|
||
}
|
||
|
||
/** 连接地址。端口为空时不留尾巴冒号,淡色写上默认端口 */
|
||
function Address({ row, meta, uploaded }: { row: any; meta?: any; uploaded: boolean }) {
|
||
if (uploaded) {
|
||
const snap: CurrentSnapshot | null = row.current_snapshot ?? null
|
||
return (
|
||
<>
|
||
<span>上传的表格{row.table_count ? ` · ${row.table_count} 张表` : ''}</span>
|
||
{snap && <UploadVersion snap={snap} />}
|
||
</>
|
||
)
|
||
}
|
||
if (row.kind === 'sqlite') return <span className="mono break-all">{row.database}</span>
|
||
const target = row.kind === 'oracle'
|
||
? (row.options?.service_name ? `/${row.options.service_name}` : row.options?.sid ? `:${row.options.sid}` : '')
|
||
: row.database ? `/${row.database}` : ''
|
||
return (
|
||
<span className="mono break-all">
|
||
{row.username ? `${row.username}@` : ''}{row.host ?? ''}
|
||
{row.port
|
||
? `:${row.port}`
|
||
: meta?.default_port ? <span className="opacity-60" title="未填写端口,使用默认端口">:{meta.default_port}</span> : null}
|
||
{target}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 上传表格当前启用的是哪一版:哪个文件、什么时候导入的。库文件的路径按版本变(而且不是用户
|
||
* 传的那个文件),写出来没有意义;同名重传之后,这一行变了才说明新版本真的启用了
|
||
*/
|
||
function UploadVersion({ snap }: { snap: CurrentSnapshot }) {
|
||
const legacy = isLegacySnapshot(snap)
|
||
// 迁移补建的版本:created_at 是迁移的时间,写成「刚刚导入」是假话
|
||
const at = legacy ? null : parseServerTime(snap.created_at)
|
||
const rawNote = snap.raw_state !== 'kept' ? RAW_STATE_LABEL[snap.raw_state] : ''
|
||
return (
|
||
<span className="inline-flex min-w-0 flex-wrap items-baseline gap-x-1" data-current-version={snap.id}
|
||
title={legacy ? UPLOAD_TEXT.legacyTitle : at ? UPLOAD_TEXT.importedTitle(formatDateTime(snap.created_at)) : undefined}>
|
||
<span>{UPLOAD_TEXT.currentVersion}</span>
|
||
<span className="mono break-all text-dim">{snap.file_name || UPLOAD_TEXT.legacyFile}</span>
|
||
{at && <span className="tnum">· {UPLOAD_TEXT.importedAt(formatRelative(snap.created_at))}</span>}
|
||
{rawNote && <span>· {rawNote}</span>}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/** 一次最多画这么多行:上千个对象的库全画出来会卡,过滤一下就够用了 */
|
||
const SCHEMA_ROWS = 300
|
||
|
||
type ColumnsState = TableSchema | { error: unknown } | 'loading'
|
||
|
||
/**
|
||
* 结构浏览器:助手写 SQL 靠的就是这份结构,用户得能方便地核对「它看到了
|
||
* 什么」。按 schema 分组,一行一张表,点开懒加载列(名称 / 类型 / 说明)。
|
||
*
|
||
* 列信息取后端给的结构化字段(columns / kind / comment / found)。以前解析给
|
||
* 模型看的那段 detail 文本,列注释里带两个空格或换行就会错位
|
||
*/
|
||
function SchemaBrowser({ row }: { row: any }) {
|
||
const [tables, setTables] = useState<string[] | null>(null)
|
||
const [error, setError] = useState<unknown>(null)
|
||
const [q, setQ] = useState('')
|
||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
|
||
const [details, setDetails] = useState<Record<string, ColumnsState>>({})
|
||
|
||
// 重新探查后表清单和列都可能变了:重取清单、清掉列缓存,但展开着的那几张
|
||
// 保持展开(下面会按需重取)
|
||
useEffect(() => {
|
||
let live = true
|
||
setDetails({})
|
||
api.datasources.schema(row.id).then(
|
||
(s) => { if (live) { setTables(s.tables ?? []); setError(null) } },
|
||
(e) => { if (live) setError(e) },
|
||
)
|
||
return () => { live = false }
|
||
// 上传的表格换了版本(同名重传)也要重取:结构跟着当前版本走
|
||
}, [row.id, row.schema_synced_at, row.table_count, row.current_snapshot?.id])
|
||
|
||
useEffect(() => {
|
||
for (const t of expanded) {
|
||
if (details[t]) continue
|
||
setDetails((d) => ({ ...d, [t]: 'loading' }))
|
||
api.datasources.tableSchema(row.id, t).then(
|
||
(s) => setDetails((d) => ({ ...d, [t]: s })),
|
||
(e) => setDetails((d) => ({ ...d, [t]: { error: e } })),
|
||
)
|
||
}
|
||
}, [expanded, details, row.id])
|
||
|
||
const filtered = useMemo(() => {
|
||
const needle = q.trim().toLowerCase()
|
||
return (tables ?? []).filter((t) => !needle || t.toLowerCase().includes(needle))
|
||
}, [tables, q])
|
||
const groups = useMemo(() => {
|
||
const m = new Map<string, string[]>()
|
||
for (const t of filtered.slice(0, SCHEMA_ROWS)) {
|
||
const i = t.lastIndexOf('.')
|
||
const g = i > 0 ? t.slice(0, i) : ''
|
||
m.set(g, [...(m.get(g) ?? []), t])
|
||
}
|
||
return [...m.entries()]
|
||
}, [filtered])
|
||
|
||
const toggle = (t: string) => setExpanded((s) => {
|
||
const next = new Set(s)
|
||
if (next.has(t)) next.delete(t)
|
||
else next.add(t)
|
||
return next
|
||
})
|
||
|
||
if (error) return <div className="border-t p-3"><ErrorState compact error={error} /></div>
|
||
if (!tables) return <div className="border-t p-3"><Skeleton rows={4} height={10} gap={8} /></div>
|
||
|
||
return (
|
||
<div className="border-t" data-schema-browser>
|
||
{tables.length > 8 && (
|
||
<div className="flex items-center gap-2 border-b px-3 py-1.5">
|
||
<Search size={12} className="text-faint" aria-hidden />
|
||
<input
|
||
className="min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-faint"
|
||
placeholder={`在 ${tables.length} 个对象中查找…`}
|
||
aria-label="过滤表名"
|
||
value={q}
|
||
onChange={(e) => setQ(e.target.value)}
|
||
/>
|
||
{q && <span className="tnum text-2xs text-faint">{filtered.length} 个匹配</span>}
|
||
</div>
|
||
)}
|
||
{/* 不留上内边距:sticky 的分组头贴着滚动区的内边距停,留了就会有一条缝,下面的行从缝里漏出来 */}
|
||
<div className="max-h-[420px] overflow-y-auto px-2 pb-1.5">
|
||
{!filtered.length && <div className="px-1 py-2 text-xs text-faint">没有匹配「{q}」的对象</div>}
|
||
{groups.map(([group, list]) => (
|
||
<div key={group || '_'} className="mb-1">
|
||
{(groups.length > 1 || group) && (
|
||
<div className="sticky top-0 z-[1] -mx-2 flex items-center gap-1.5 border-b border-[var(--hairline)] bg-panel px-3 py-1 text-2xs font-medium text-faint">
|
||
{group || '默认 schema'} <span className="tnum opacity-70">{list.length}</span>
|
||
</div>
|
||
)}
|
||
{list.map((t) => {
|
||
const isOpen = expanded.has(t)
|
||
const d = details[t]
|
||
const loaded = d && typeof d === 'object' && 'table' in d ? d : null
|
||
return (
|
||
<div key={t}>
|
||
<button
|
||
className="flex w-full items-center gap-1.5 rounded px-1 py-[3px] text-left hover:bg-hover"
|
||
aria-expanded={isOpen}
|
||
onClick={() => toggle(t)}
|
||
>
|
||
<ChevronRight size={11} className={clsx('shrink-0 text-faint transition-transform', isOpen && 'rotate-90')} aria-hidden />
|
||
<span className="mono truncate text-xs">{group ? t.slice(group.length + 1) : t}</span>
|
||
{loaded?.kind === 'view' && <span className="text-2xs text-faint">视图</span>}
|
||
{!!loaded?.columns?.length && (
|
||
<span className="tnum text-2xs text-faint">{loaded.columns.length} 列</span>
|
||
)}
|
||
</button>
|
||
{isOpen && <ColumnList detail={d} uploaded={isUploadedTable(row)} />}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
))}
|
||
{filtered.length > SCHEMA_ROWS && (
|
||
<div className="px-1 py-1.5 text-2xs text-faint">
|
||
另有 {formatNumber(filtered.length - SCHEMA_ROWS)} 个未列出,请输入关键字缩小范围
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ColumnList({ detail, uploaded }: { detail?: ColumnsState; uploaded: boolean }) {
|
||
if (!detail || detail === 'loading') {
|
||
return <div className="mb-1 ml-5 py-1"><Skeleton rows={3} height={9} gap={6} /></div>
|
||
}
|
||
if ('error' in detail) return <div className="mb-1.5 ml-5"><ErrorState compact error={detail.error} /></div>
|
||
if (detail.found === false) {
|
||
// 清单里有、缓存里却找不到:清单和列是两次请求取的,中间重新探查过。
|
||
// 只认明确的 false:老后端的响应里根本没有 found,得往下走到原文显示
|
||
return (
|
||
<div className="mb-1.5 ml-5 text-2xs text-faint">
|
||
{uploaded
|
||
// 上传的表格没有「探查结构」:结构跟着版本走,清单和列之间多半是换了版本
|
||
? '当前版本中已没有这张表,可能刚重新上传过。请收起后再展开。'
|
||
: '缓存中已没有这张表的结构,可能刚重新探查过。请收起后再展开,或点击「探查结构」。'}
|
||
</div>
|
||
)
|
||
}
|
||
// 老后端只给 detail 文本:原样显示,不去解析
|
||
if (!detail.columns) {
|
||
return <div className="mono mb-1.5 ml-5 whitespace-pre-wrap text-2xs text-dim">{detail.detail || '没有列信息'}</div>
|
||
}
|
||
const view = detail.kind === 'view'
|
||
return (
|
||
<div className="mb-1.5 ml-5 mt-0.5 overflow-hidden rounded-md border bg-bg">
|
||
{(detail.comment || view) && (
|
||
<div className="flex gap-2 border-b px-2 py-1 text-2xs text-dim">
|
||
{view && <span className="chip">视图</span>}
|
||
{detail.comment && <span className="whitespace-pre-line">{detail.comment}</span>}
|
||
</div>
|
||
)}
|
||
{!detail.columns.length ? (
|
||
<div className="px-2 py-1.5 text-2xs text-faint">探查时未获取到列信息</div>
|
||
) : (
|
||
<table className="w-full text-2xs">
|
||
<thead className="text-faint">
|
||
<tr className="border-b">
|
||
<th className="w-[38%] px-2 py-1 text-left font-medium">列</th>
|
||
<th className="w-[24%] px-2 py-1 text-left font-medium">类型</th>
|
||
<th className="px-2 py-1 text-left font-medium">说明</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{detail.columns.map((c) => (
|
||
<tr key={c.name} className="border-b border-[var(--hairline)] last:border-0">
|
||
<td className="mono px-2 py-[3px]">
|
||
{c.pk && <KeyRound size={9} className="mr-1 inline text-[var(--accent)]" aria-label="主键" />}
|
||
{c.name}
|
||
</td>
|
||
<td className="mono px-2 py-[3px] text-faint">{c.type}</td>
|
||
<td className="px-2 py-[3px] text-dim">
|
||
{c.not_null && <span className="mr-1.5 text-faint">非空</span>}
|
||
{c.comment && <span className="whitespace-pre-line">{c.comment}</span>}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 接入 / 编辑数据库
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface SourceForm {
|
||
name: string
|
||
kind: string
|
||
host: string
|
||
port: number | null
|
||
database: string
|
||
username: string
|
||
password: string
|
||
description: string
|
||
readonly: boolean
|
||
enabled: boolean
|
||
schema: string
|
||
oracleMode: 'service_name' | 'sid'
|
||
serviceName: string
|
||
sid: string
|
||
/** options.mask_columns:证据面板展示查询原始行时遮掉的列 */
|
||
maskColumns: string[]
|
||
/** options 里除 schema / service_name / sid / mask_columns 之外的键,全部摊开可编辑 */
|
||
advanced: [string, string][]
|
||
}
|
||
|
||
const OWN_OPTION_KEYS = new Set(['schema', 'service_name', 'sid', MASK_KEY])
|
||
|
||
function toForm(source: any): SourceForm {
|
||
const o = source.options ?? {}
|
||
const oracle = source.kind === 'oracle'
|
||
return {
|
||
name: source.name ?? '',
|
||
kind: source.kind ?? 'mysql',
|
||
host: source.host ?? '',
|
||
port: source.port ?? null,
|
||
database: oracle ? '' : (source.database ?? ''),
|
||
username: source.username ?? '',
|
||
password: '',
|
||
description: source.description ?? '',
|
||
readonly: source.readonly ?? true,
|
||
enabled: source.enabled ?? true,
|
||
schema: o.schema ?? '',
|
||
oracleMode: o.sid && !o.service_name ? 'sid' : 'service_name',
|
||
// 老数据把服务名存在 database 上:回显它,保存时后端会挪进 options
|
||
serviceName: o.service_name ?? (oracle ? source.database ?? '' : ''),
|
||
sid: o.sid ?? '',
|
||
maskColumns: maskList(o[MASK_KEY]),
|
||
advanced: Object.entries(o)
|
||
.filter(([k]) => !OWN_OPTION_KEYS.has(k))
|
||
.map(([k, v]) => [k, String(v ?? '')]),
|
||
}
|
||
}
|
||
|
||
function toBody(form: SourceForm, isNew: boolean, wasOracle = true): any {
|
||
const options: Record<string, string | string[]> = {}
|
||
for (const [k, v] of form.advanced) if (k.trim() && v.trim()) options[k.trim()] = v.trim()
|
||
if (form.schema.trim()) options.schema = form.schema.trim()
|
||
if (form.maskColumns.length) options[MASK_KEY] = form.maskColumns
|
||
const oracle = form.kind === 'oracle'
|
||
if (oracle) {
|
||
// 只写选中的那一个,另一个不带:两处都有值时引擎听 service_name,
|
||
// 留着旧的 sid 只会在下次切换时暗中说了算
|
||
if (form.oracleMode === 'sid') { if (form.sid.trim()) options.sid = form.sid.trim() }
|
||
else if (form.serviceName.trim()) options.service_name = form.serviceName.trim()
|
||
}
|
||
const body: any = {
|
||
kind: form.kind,
|
||
host: form.kind === 'sqlite' ? null : form.host.trim() || null,
|
||
port: form.kind === 'sqlite' ? null : form.port,
|
||
username: form.kind === 'sqlite' ? null : form.username.trim() || null,
|
||
options,
|
||
readonly: form.readonly,
|
||
description: form.description,
|
||
enabled: form.enabled,
|
||
}
|
||
// Oracle 不写 database:服务名只存一处(options),写了反而可能压住它。
|
||
// 从别的类型改成 Oracle 时要清掉:原来那个库名会被后端当成 service_name
|
||
if (!oracle) body.database = form.database.trim() || null
|
||
else if (isNew || !wasOracle) body.database = null
|
||
// 不传 password 表示不动;只有真填了才提交
|
||
if (form.password) body.password = form.password
|
||
if (isNew) body.name = form.name.trim()
|
||
return body
|
||
}
|
||
|
||
/**
|
||
* 422 的中文 detail 说的是哪一项。眼下只有查询时限按字段拒(后端 query_timeout_problem),
|
||
* 认不出的交回 toast
|
||
*/
|
||
function fieldOfRejection(message: string): string | null {
|
||
if (/查询时限/.test(message)) return 'query_timeout_s'
|
||
return null
|
||
}
|
||
|
||
const FIELD_LABEL: Record<string, string> = {
|
||
host: '主机', database: '数据库', username: '用户名',
|
||
}
|
||
|
||
/**
|
||
* 决定连到哪个库的那几项(提交体里的)。schema 不在内:它只影响探查哪一片,不影响
|
||
* 连不连得上,后端判断测连接结果还作不作数时同样不看它;遮罩列只管面板上怎么显示,也不在内
|
||
*/
|
||
function connectionOf(body: any): string {
|
||
const { schema: _schema, [MASK_KEY]: _mask, ...options } = body.options ?? {}
|
||
return JSON.stringify([
|
||
body.kind, body.host ?? null, body.port ?? null, body.database ?? null, body.username ?? null,
|
||
options, !!body.password,
|
||
])
|
||
}
|
||
|
||
function SourceEditor({ source, kinds, onClose, onSaved }: {
|
||
source: any; kinds: any[]; onClose: () => void
|
||
/** reconnected:连接配置改过了,之前测的结果不再代表它 */
|
||
onSaved: (row: any, tested?: HealthRecord, reconnected?: boolean) => void
|
||
}) {
|
||
const isNew = !source.id
|
||
const [initial] = useState(() => toForm(source))
|
||
const [form, setForm] = useState<SourceForm>(initial)
|
||
const [saving, setSaving] = useState(false)
|
||
const [test, setTest] = useState<{ since?: number; result?: HealthRecord; sig?: string }>({})
|
||
// 后端按字段拒掉的保存(422,detail 是一句中文):写在那一项下面,不只弹 toast——
|
||
// toast 盖在弹窗上面,几秒就没了,人还得猜是哪一项
|
||
const [fieldError, setFieldError] = useState<{ key: string; message: string } | null>(null)
|
||
// 高级连接参数开没开,由人(和报错)决定,不跟着「填了几项」走:删空唯一一项的那一下
|
||
// 要是跟着收起,正在改的框被藏起来,焦点掉到 body,接着敲的字全落空
|
||
const [advOpen, setAdvOpen] = useState(() => initial.advanced.some(([, v]) => v))
|
||
const resultRef = useRef<HTMLDivElement>(null)
|
||
const meta = kinds.find((k) => k.value === form.kind)
|
||
const set = (patch: Partial<SourceForm>) => setForm((f) => ({ ...f, ...patch }))
|
||
|
||
const oracle = form.kind === 'oracle'
|
||
const sqlite = form.kind === 'sqlite'
|
||
const needs: string[] = meta?.needs ?? []
|
||
const nameError = isNew && form.name && !NAME_RE.test(form.name)
|
||
? '须以小写字母开头,只能包含小写字母、数字和下划线(将用作工具名 db_query__<标识>)'
|
||
: null
|
||
|
||
const missing: string[] = []
|
||
if (isNew && !form.name) missing.push('标识')
|
||
// 密码不拦:后端允许不带密码(trust 认证、本机 root 免密),拦了的话这种库
|
||
// 既建不了、也改不了——连改一句说明都得先编一个密码,而编的密码又会把连接弄坏
|
||
const noPassword = needs.includes('password') && !form.password && !source.has_password
|
||
for (const n of needs) {
|
||
if (n === 'password') continue
|
||
if (n === 'database') {
|
||
if (!form.database.trim()) missing.push(sqlite ? '数据库文件路径' : '数据库')
|
||
} else if (!String((form as any)[n] ?? '').trim()) {
|
||
missing.push(FIELD_LABEL[n] ?? n)
|
||
}
|
||
}
|
||
if (oracle) {
|
||
if (form.oracleMode === 'sid' ? !form.sid.trim() : !form.serviceName.trim()) {
|
||
missing.push(form.oracleMode === 'sid' ? 'SID' : 'service_name')
|
||
}
|
||
}
|
||
const required = (key: string) => needs.includes(key)
|
||
const blocked = missing.length > 0 || !!nameError
|
||
const body = toBody(form, isNew, source.kind === 'oracle')
|
||
const sig = JSON.stringify({ ...body, password: form.password })
|
||
const dirty = JSON.stringify(form) !== JSON.stringify(initial)
|
||
const testFresh = test.result && test.sig === sig
|
||
|
||
const runTest = async () => {
|
||
const at = Date.now()
|
||
setTest({ since: at })
|
||
try {
|
||
const r = await api.datasources.testConfig({
|
||
...body, id: source.id, name: form.name || source.name || undefined,
|
||
})
|
||
setTest({ sig, result: { ok: !!r.ok, ms: r.elapsed_ms ?? null, at: Date.now(), error: r.error, hint: r.hint, detail: r.detail } })
|
||
if (!r.ok) requestAnimationFrame(() => resultRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }))
|
||
} catch (e) {
|
||
setTest({})
|
||
toast.error(e)
|
||
}
|
||
}
|
||
|
||
const save = async () => {
|
||
if (blocked) return
|
||
setSaving(true)
|
||
setFieldError(null)
|
||
try {
|
||
const row = isNew
|
||
? await api.datasources.create(body)
|
||
: await api.datasources.update(source.id, body)
|
||
const before = connectionOf(toBody(initial, isNew, source.kind === 'oracle'))
|
||
onSaved(row, testFresh ? test.result : undefined, connectionOf(body) !== before)
|
||
} catch (e) {
|
||
const rejected = e instanceof ApiError && e.status === 422 ? e.message : ''
|
||
const key = rejected ? fieldOfRejection(rejected) : null
|
||
if (key) {
|
||
setFieldError({ key, message: rejected })
|
||
setAdvOpen(true)
|
||
requestAnimationFrame(() => document.getElementById(`ds-adv-${key}`)?.focus())
|
||
} else {
|
||
toast.error(e)
|
||
}
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const advancedDefs: any[] = (meta?.advanced ?? []).filter((a: any) => !OWN_OPTION_KEYS.has(a.key))
|
||
const knownAdvanced = new Set(advancedDefs.map((a) => a.key))
|
||
const advValue = (k: string) => form.advanced.find(([key]) => key === k)?.[1] ?? ''
|
||
const setAdv = (k: string, v: string) => {
|
||
if (fieldError?.key === k) setFieldError(null)
|
||
setForm((f) => {
|
||
const rest = f.advanced.filter(([key]) => key !== k)
|
||
return { ...f, advanced: v ? [...rest, [k, v]] : rest }
|
||
})
|
||
}
|
||
const extras = form.advanced.map((kv, i) => [kv, i] as const).filter(([[k]]) => !knownAdvanced.has(k))
|
||
const advancedCount = form.advanced.filter(([, v]) => v).length
|
||
|
||
// 高级连接参数:SQLite 也有(查询时限),以前只在网络库的分支里画,SQLite 源配不了
|
||
const advanced = (
|
||
<details className="rounded-lg border" open={advOpen} onToggle={(e) => setAdvOpen(e.currentTarget.open)}>
|
||
<summary className="cursor-pointer select-none px-2.5 py-1.5 text-xs text-dim hover:text-fg">
|
||
高级连接参数{advancedCount ? `(${advancedCount})` : ''}
|
||
<span className="ml-1.5 text-2xs text-faint">
|
||
{sqlite ? '如查询时限等,一般无需填写' : '对应连接串中的 options,一般无需填写'}
|
||
</span>
|
||
</summary>
|
||
<div className="space-y-2.5 border-t px-2.5 py-2.5">
|
||
{advancedDefs.map((a) => {
|
||
const err = fieldError && fieldError.key === a.key ? fieldError.message : undefined
|
||
return (
|
||
<Field key={a.key} htmlFor={`ds-adv-${a.key}`} label={<>{a.label} <span className="mono text-faint">{a.key}</span></>}
|
||
hint={a.help} error={err}>
|
||
<input id={`ds-adv-${a.key}`} className="field mono" value={advValue(a.key)} placeholder={a.placeholder ?? ''}
|
||
aria-invalid={err ? true : undefined}
|
||
aria-describedby={err ? `ds-adv-${a.key}-error` : a.help ? `ds-adv-${a.key}-hint` : undefined}
|
||
style={err ? { borderColor: 'var(--err)' } : undefined}
|
||
onChange={(e) => setAdv(a.key, e.target.value)} />
|
||
</Field>
|
||
)
|
||
})}
|
||
{extras.map(([[k, v], i]) => (
|
||
<div key={i} className="flex items-center gap-2">
|
||
<input className="field mono w-40" value={k} placeholder="参数名" aria-label="参数名"
|
||
onChange={(e) => setForm((f) => ({ ...f, advanced: f.advanced.map((kv, j) => (j === i ? [e.target.value, kv[1]] : kv)) }))} />
|
||
<input className="field mono flex-1" value={v} placeholder="值" aria-label={`参数 ${k || '(未命名)'} 的值`}
|
||
onChange={(e) => setForm((f) => ({ ...f, advanced: f.advanced.map((kv, j) => (j === i ? [kv[0], e.target.value] : kv)) }))} />
|
||
<IconButton label={`移除参数 ${k || '(未命名)'}`} icon={<X size={12} />}
|
||
onClick={() => setForm((f) => ({ ...f, advanced: f.advanced.filter((_, j) => j !== i) }))} />
|
||
</div>
|
||
))}
|
||
{!sqlite && (
|
||
<button className="btn btn-sm btn-ghost" onClick={() => setForm((f) => ({ ...f, advanced: [...f.advanced, ['', '']] }))}>
|
||
<Plus size={11} aria-hidden /> 添加参数
|
||
</button>
|
||
)}
|
||
</div>
|
||
</details>
|
||
)
|
||
|
||
return (
|
||
<Modal
|
||
open
|
||
onClose={onClose}
|
||
dirty={dirty}
|
||
width={620}
|
||
title={isNew ? '接入数据库' : `编辑「${source.name}」`}
|
||
footer={
|
||
<>
|
||
<div className="mr-auto flex min-w-0 items-center">
|
||
{(test.since || test.result) && (
|
||
<HealthPill
|
||
record={test.result}
|
||
checkingSince={test.since}
|
||
stale={!!test.result && !testFresh}
|
||
labels={{ ok: '连接成功', fail: '连接失败' }}
|
||
/>
|
||
)}
|
||
</div>
|
||
<button className="btn" onClick={onClose}>取消</button>
|
||
<button className="btn" disabled={!!test.since || blocked} onClick={() => void runTest()}
|
||
title={blocked ? `缺少必填项:${missing.join('、') || '标识格式'}` : '使用当前配置测试连接,不保存'}>
|
||
{test.since ? <Spinner size={11} /> : <Plug size={12} aria-hidden />} 测试连接
|
||
</button>
|
||
<button className="btn btn-primary" disabled={saving || blocked} onClick={() => void save()}
|
||
title={blocked ? `缺少必填项:${missing.join('、') || '标识格式'}` : undefined}>
|
||
{saving ? <Spinner size={11} /> : null} 保存
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="标识" required={isNew} error={nameError}
|
||
hint={isNew ? `将用作工具名 db_query__${form.name || 'xxx'};创建后不可修改` : '标识已用于工具名,不可修改'}>
|
||
{(p) => (
|
||
<input {...p} className="field mono" value={form.name} disabled={!isNew} placeholder="sales"
|
||
autoComplete="off" spellCheck={false}
|
||
style={nameError ? { borderColor: 'var(--err)' } : undefined}
|
||
onChange={(e) => set({ name: e.target.value })} />
|
||
)}
|
||
</Field>
|
||
<Field label="类型">
|
||
{(p) => (
|
||
<select {...p} className="field" value={form.kind}
|
||
onChange={(e) => {
|
||
const next = kinds.find((x) => x.value === e.target.value)
|
||
// 端口没填、或者还是上一种的默认端口,就换成这一种的默认端口
|
||
const keep = form.port != null && form.port !== meta?.default_port
|
||
set({ kind: e.target.value, port: keep ? form.port : next?.default_port ?? null })
|
||
}}>
|
||
{kinds.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
|
||
</select>
|
||
)}
|
||
</Field>
|
||
</div>
|
||
|
||
{meta?.hint && (
|
||
<div className="flex gap-2 rounded-lg border px-2.5 py-2 text-xs leading-relaxed text-dim"
|
||
style={{ borderColor: 'color-mix(in srgb, var(--accent) 30%, var(--border))', background: 'var(--accent-soft)' }}>
|
||
<Info size={13} className="mt-0.5 shrink-0 text-[var(--accent)]" aria-hidden />
|
||
<span>{meta.hint}</span>
|
||
</div>
|
||
)}
|
||
|
||
{sqlite ? (
|
||
<>
|
||
<Field label="数据库文件路径" required={required('database')} hint="绝对路径;~ 不会被展开">
|
||
{(p) => (
|
||
<input {...p} className="field mono" value={form.database} placeholder="/绝对/路径/data.db"
|
||
onChange={(e) => set({ database: e.target.value })} />
|
||
)}
|
||
</Field>
|
||
{advancedDefs.length > 0 && advanced}
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="grid grid-cols-[1fr_110px] gap-3">
|
||
<Field label="主机" required={required('host')}>
|
||
{(p) => (
|
||
<input {...p} className="field mono" value={form.host} placeholder="10.0.0.12 或 db.example.com"
|
||
onChange={(e) => set({ host: e.target.value })} />
|
||
)}
|
||
</Field>
|
||
<Field label="端口" hint={meta?.default_port ? `留空则使用 ${meta.default_port}` : undefined}>
|
||
{(p) => (
|
||
<input {...p} className="field mono" type="number" value={form.port ?? ''}
|
||
placeholder={String(meta?.default_port ?? '')}
|
||
onChange={(e) => set({ port: e.target.value ? Number(e.target.value) : null })} />
|
||
)}
|
||
</Field>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{oracle ? (
|
||
<OracleTarget form={form} set={set} />
|
||
) : (
|
||
<Field label="数据库" required={required('database')}>
|
||
{(p) => (
|
||
<input {...p} className="field mono" value={form.database}
|
||
onChange={(e) => set({ database: e.target.value })} />
|
||
)}
|
||
</Field>
|
||
)}
|
||
<Field label="schema" hint="只读账号名下通常没有对象,数据位于其他 schema 中">
|
||
{(p) => (
|
||
<input {...p} className="field mono" value={form.schema}
|
||
placeholder={oracle ? '如 ANALYTICS' : form.kind === 'postgres' ? '留空则使用 public' : '留空则使用默认值'}
|
||
onChange={(e) => set({ schema: e.target.value })} />
|
||
)}
|
||
</Field>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="用户名" required={required('username')}>
|
||
{(p) => (
|
||
<input {...p} className="field mono" value={form.username} autoComplete="off"
|
||
onChange={(e) => set({ username: e.target.value })} />
|
||
)}
|
||
</Field>
|
||
<Field label="密码" hint={noPassword ? '未填写密码:仅适用于免密登录的数据库' : undefined}>
|
||
{(p) => (
|
||
<input {...p} className="field mono" type="password" autoComplete="new-password"
|
||
placeholder={source.has_password ? '已保存,留空则不改' : ''}
|
||
value={form.password}
|
||
onChange={(e) => set({ password: e.target.value })} />
|
||
)}
|
||
</Field>
|
||
</div>
|
||
|
||
{advanced}
|
||
</>
|
||
)}
|
||
|
||
<Field label="说明" hint="供助手判断应查询哪个数据库,请写清其中包含的数据">
|
||
{(p) => (
|
||
<input {...p} className="field" value={form.description} placeholder="销售库:订单、客户、产品"
|
||
onChange={(e) => set({ description: e.target.value })} />
|
||
)}
|
||
</Field>
|
||
|
||
<MaskColumnsField sourceId={source.id} synced={!!source.table_count} value={form.maskColumns}
|
||
onChange={(maskColumns) => set({ maskColumns })} />
|
||
|
||
<label className="flex cursor-pointer items-start gap-2 rounded-lg border p-2.5"
|
||
style={form.readonly ? undefined : { borderColor: 'var(--warn)', background: 'color-mix(in srgb, var(--warn) 7%, transparent)' }}>
|
||
<input type="checkbox" className="mt-0.5" checked={form.readonly}
|
||
onChange={(e) => set({ readonly: e.target.checked })} />
|
||
<span className="text-xs">
|
||
只读
|
||
<span className="ml-1.5 text-2xs leading-relaxed text-faint">
|
||
强烈建议保持勾选。此处的 SQL 由模型生成,取消勾选后 UPDATE / DELETE
|
||
会被实际执行(DROP / TRUNCATE 在任何情况下都不允许)
|
||
</span>
|
||
</span>
|
||
</label>
|
||
|
||
<label className="flex cursor-pointer items-center gap-2 text-xs">
|
||
<input type="checkbox" checked={form.enabled} onChange={(e) => set({ enabled: e.target.checked })} />
|
||
启用<span className="text-2xs text-faint">停用后助手和 Agent 均无法访问该数据源</span>
|
||
</label>
|
||
|
||
{missing.length > 0 && (
|
||
<p className="text-2xs text-faint">缺少必填项:{missing.join('、')}</p>
|
||
)}
|
||
|
||
{test.result && !test.result.ok && (
|
||
<div ref={resultRef}>
|
||
<ErrorState compact error={test.result} onRetry={blocked ? undefined : () => void runTest()} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Oracle 的连接目标:service_name 和 SID 二选一,读写 options 里对应的那个键。
|
||
*
|
||
* 以前这个框绑在 database 上,而后端把服务名存在 options.service_name——编辑时
|
||
* 框是空的,用户以为配置丢了去补填,新值又被 options 里的旧值悄悄压住。
|
||
*/
|
||
const ORACLE_MODES = ['service_name', 'sid'] as const
|
||
|
||
function OracleTarget({ form, set }: { form: SourceForm; set: (patch: Partial<SourceForm>) => void }) {
|
||
const sid = form.oracleMode === 'sid'
|
||
const radio = useRadioGroup(ORACLE_MODES, form.oracleMode, (m) => set({ oracleMode: m }))
|
||
return (
|
||
<div className="min-w-0">
|
||
<div className="mb-1 flex items-center gap-2">
|
||
<label className="label !mb-0" htmlFor="ds-oracle-target">
|
||
{sid ? 'SID' : 'service_name'}<span className="ml-0.5 text-[var(--err)]" aria-hidden>*</span>
|
||
</label>
|
||
<span role="radiogroup" aria-label="Oracle 连接方式:service_name 或 SID" className="ml-auto inline-flex rounded-md border p-px">
|
||
{ORACLE_MODES.map((m) => (
|
||
<button key={m} type="button" {...radio(m)}
|
||
className={clsx('rounded px-1.5 text-2xs leading-4', form.oracleMode === m ? 'bg-accent-soft text-fg' : 'text-faint hover:text-dim')}
|
||
onClick={() => set({ oracleMode: m })}>
|
||
{m === 'sid' ? 'SID' : 'service_name'}
|
||
</button>
|
||
))}
|
||
</span>
|
||
</div>
|
||
<input
|
||
id="ds-oracle-target"
|
||
className="field mono"
|
||
aria-describedby="ds-oracle-target-hint"
|
||
value={sid ? form.sid : form.serviceName}
|
||
placeholder={sid ? 'ORCL' : 'ORCLPDB1'}
|
||
onChange={(e) => set(sid ? { sid: e.target.value } : { serviceName: e.target.value })}
|
||
/>
|
||
<div id="ds-oracle-target-hint" className="mt-1 text-2xs leading-relaxed text-faint">
|
||
{sid ? '仅提供 SID 的旧版数据库使用此项;保存时只保存 SID' : '通常填写此项;保存时只保存 service_name'}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 遮罩列(options.mask_columns)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** options.mask_columns → 列名列表。列表和「a, b」这样的文字都认(后端 masked_columns 同一个认法),按大小写不敏感去重 */
|
||
function maskList(raw: unknown): string[] {
|
||
const items = typeof raw === 'string' ? raw.split(/[,,、\s]+/) : Array.isArray(raw) ? raw : []
|
||
const seen = new Set<string>()
|
||
const out: string[] = []
|
||
for (const item of items) {
|
||
const name = typeof item === 'string' || typeof item === 'number' ? String(item).trim() : ''
|
||
if (!name || seen.has(name.toLowerCase())) continue
|
||
seen.add(name.toLowerCase())
|
||
out.push(name)
|
||
}
|
||
return out
|
||
}
|
||
|
||
/** 挑列时最多读几张表的列:库动辄上百张表,一张一个请求,挑列用不着全读 */
|
||
const MASK_SCAN_TABLES = 40
|
||
|
||
/**
|
||
* 从已探查的结构里收列名(列名 → 出现在哪几张表)。第一次聚焦输入框才取,同一个数据源取一次
|
||
*/
|
||
function useSchemaColumns(sourceId: string | undefined, active: boolean) {
|
||
const [state, setState] = useState<{ status: 'idle' | 'loading' | 'ok' | 'error'; columns: Map<string, string[]>; tables: number; scanned: number }>(
|
||
{ status: 'idle', columns: new Map(), tables: 0, scanned: 0 })
|
||
// 取过就不再取(状态不进依赖:置成 loading 那一下会让 effect 重跑,把正在取的那次当成过期的丢掉)
|
||
const started = useRef<string | null>(null)
|
||
useEffect(() => {
|
||
if (!active || !sourceId || started.current === sourceId) return
|
||
started.current = sourceId
|
||
let alive = true
|
||
setState((s) => ({ ...s, status: 'loading' }))
|
||
void (async () => {
|
||
try {
|
||
const list: string[] = (await api.datasources.schema(sourceId))?.tables ?? []
|
||
const pick = list.slice(0, MASK_SCAN_TABLES)
|
||
const columns = new Map<string, string[]>()
|
||
for (let i = 0; i < pick.length; i += 6) {
|
||
const batch = await Promise.all(pick.slice(i, i + 6).map((t) => api.datasources.tableSchema(sourceId, t).catch(() => null)))
|
||
batch.forEach((detail, j) => {
|
||
for (const c of detail?.columns ?? []) {
|
||
const name = String(c?.name ?? '').trim()
|
||
if (!name) continue
|
||
columns.set(name, [...(columns.get(name) ?? []), pick[i + j]])
|
||
}
|
||
})
|
||
}
|
||
if (alive) setState({ status: 'ok', columns, tables: list.length, scanned: pick.length })
|
||
} catch {
|
||
if (alive) setState((s) => ({ ...s, status: 'error' }))
|
||
}
|
||
})()
|
||
return () => { alive = false; started.current = null }
|
||
}, [active, sourceId])
|
||
return state
|
||
}
|
||
|
||
/**
|
||
* 遮罩的列:列名一个个加成标签,可以从已探查的结构里挑,也可以直接输。
|
||
* 用户拍板写明:在有身份体系之前,遮罩只减少暴露,不是安全边界
|
||
*/
|
||
function MaskColumnsInput({ id, sourceId, synced, value, onChange }: {
|
||
id: string; sourceId?: string; synced: boolean; value: string[]; onChange: (v: string[]) => void
|
||
}) {
|
||
const [draft, setDraft] = useState('')
|
||
const [touched, setTouched] = useState(false)
|
||
const known = useSchemaColumns(sourceId, touched && synced)
|
||
const add = (text: string) => {
|
||
const next = maskList([...value, ...maskList(text)])
|
||
if (next.length !== value.length) onChange(next)
|
||
setDraft('')
|
||
}
|
||
const has = new Set(value.map((v) => v.toLowerCase()))
|
||
const options = [...known.columns.entries()].filter(([c]) => !has.has(c.toLowerCase()))
|
||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||
const state = !sourceId ? '保存并探查结构后可从列中选择;当前可直接输入列名'
|
||
: !synced ? '尚未探查结构:请直接输入列名'
|
||
: known.status === 'loading' ? '正在读取已探查的结构…'
|
||
: known.status === 'error' ? '已探查的结构获取失败:请直接输入列名'
|
||
: known.status === 'ok'
|
||
? `从已探查的 ${formatNumber(known.scanned)} 张表中选择${known.tables > known.scanned ? `(共 ${formatNumber(known.tables)} 张,仅读取了前 ${formatNumber(known.scanned)} 张,其余请直接输入列名)` : ''},或直接输入列名`
|
||
: '点击输入框可从已探查的列中选择'
|
||
return (
|
||
<div data-mask-input="">
|
||
{value.length > 0 && (
|
||
<div className="mb-1.5 flex flex-wrap gap-1" aria-label="已遮罩的列">
|
||
{value.map((c) => (
|
||
<span key={c} className="chip mono" style={{ color: 'var(--text)', borderColor: 'var(--border-strong)' }} data-mask-chip={c}>
|
||
<EyeOff size={10} aria-hidden /> {c}
|
||
<button type="button" className="ml-0.5 hover:opacity-60" aria-label={`不再遮罩 ${c}`}
|
||
onClick={() => onChange(value.filter((v) => v !== c))}>
|
||
<X size={9} aria-hidden />
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="flex gap-1.5">
|
||
<input id={id} className="field mono min-w-0 flex-1" list={`${id}-list`} value={draft} autoComplete="off" spellCheck={false}
|
||
placeholder="phone, email" aria-describedby={`${id}-hint`}
|
||
onFocus={() => setTouched(true)}
|
||
onChange={(e) => {
|
||
const v = e.target.value
|
||
// 从候选里点中一项(浏览器报 insertReplacementText,不是逐字敲的)、或者敲了逗号:直接加成标签。
|
||
// 逐字敲到恰好等于某个列名(敲 id_card 途中的 id)不算点中
|
||
const kind = (e.nativeEvent as InputEvent).inputType
|
||
const picked = (!kind || kind === 'insertReplacementText') && known.columns.has(v)
|
||
if (/[,,、]/.test(v) || picked) add(v)
|
||
else setDraft(v)
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && !e.nativeEvent.isComposing && draft.trim()) { e.preventDefault(); add(draft) }
|
||
if (e.key === 'Backspace' && !draft && value.length) onChange(value.slice(0, -1))
|
||
}} />
|
||
<button type="button" className="btn btn-sm" disabled={!draft.trim()} onClick={() => add(draft)}>添加</button>
|
||
</div>
|
||
<datalist id={`${id}-list`}>
|
||
{options.slice(0, 300).map(([c, tables]) => (
|
||
<option key={c} value={c}>{tables.slice(0, 3).join('、')}{tables.length > 3 ? ` 等 ${tables.length} 张表` : ''}</option>
|
||
))}
|
||
</datalist>
|
||
<div id={`${id}-hint`} className="mt-1 space-y-0.5 text-2xs leading-relaxed text-faint">
|
||
<div>证据面板展示查询结果的原始行时,这些列(不区分大小写)一律显示为「已遮罩」。{state}</div>
|
||
<div style={{ color: 'var(--st-waiting)' }} data-mask-boundary="">
|
||
遮罩只减少暴露,不是安全边界:完整快照仍可通过工件获取,在 SQL 中为列设置别名也可绕过
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MaskColumnsField(props: { sourceId?: string; synced: boolean; value: string[]; onChange: (v: string[]) => void }) {
|
||
return (
|
||
<div data-field="mask_columns">
|
||
<label className="label" htmlFor="ds-mask-columns">遮罩的列</label>
|
||
<MaskColumnsInput id="ds-mask-columns" {...props} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 传上来的表没有编辑框:卡片上单独改遮罩列,options 其余的键原样带上 */
|
||
function MaskColumnsDialog({ row, onClose, onSaved }: { row: any; onClose: () => void; onSaved: (row: any) => void }) {
|
||
const [initial] = useState(() => maskList(row.options?.[MASK_KEY]))
|
||
const [value, setValue] = useState(initial)
|
||
const [saving, setSaving] = useState(false)
|
||
const dirty = value.join('\u0000') !== initial.join('\u0000')
|
||
const save = async () => {
|
||
setSaving(true)
|
||
try {
|
||
const { [MASK_KEY]: _old, ...rest } = row.options ?? {}
|
||
onSaved(await api.datasources.update(row.id, { options: value.length ? { ...rest, [MASK_KEY]: value } : rest }))
|
||
toast.ok(value.length ? `「${row.name}」遮罩 ${value.length} 列` : `「${row.name}」不再遮罩任何列`)
|
||
} catch (e) {
|
||
toast.error(e)
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
return (
|
||
<Modal open onClose={onClose} dirty={dirty} width={520} title={`「${row.name}」的遮罩列`}
|
||
footer={<>
|
||
<button className="btn" onClick={onClose}>取消</button>
|
||
<button className="btn btn-primary" disabled={saving || !dirty} onClick={() => void save()}>
|
||
{saving ? <Spinner size={11} /> : null} 保存
|
||
</button>
|
||
</>}>
|
||
<MaskColumnsField sourceId={row.id} synced={!!row.table_count} value={value} onChange={setValue} />
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 传表格
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** 列名看起来像一行数据:纯数字、日期、Unnamed / 空、重复 */
|
||
function suspiciousColumns(cols: { name: string }[]): Set<string> {
|
||
const out = new Set<string>()
|
||
const seen = new Map<string, number>()
|
||
for (const c of cols) seen.set(c.name.toLowerCase(), (seen.get(c.name.toLowerCase()) ?? 0) + 1)
|
||
for (const c of cols) {
|
||
const n = c.name.trim()
|
||
if (!n
|
||
|| /^-?\d+(\.\d+)?%?$/.test(n)
|
||
|| /^\d{4}[-/.年]\d{1,2}([-/.月]\d{1,2}日?)?/.test(n)
|
||
|| /^(unnamed|column|col|field)[\s_:]*\d*$/i.test(n)
|
||
|| (seen.get(n.toLowerCase()) ?? 0) > 1) out.add(c.name)
|
||
}
|
||
return out
|
||
}
|
||
|
||
const isAbort = (e: unknown) => e instanceof DOMException && e.name === 'AbortError'
|
||
|
||
/**
|
||
* 上传的真实进度:字节发了多少(XHR 的上传进度),发完之后是后端在处理,那段
|
||
* 没有进度可报——只写「处理中」和已等了多久,不把条拉满冒充完成。浏览器算不出
|
||
* 总字节时只写已发多少,不画百分比。
|
||
*
|
||
* 知识库的上传占位行也用它
|
||
*/
|
||
export function UploadMeter({ progress, startedAt, sentAt, processing, now }: {
|
||
progress: UploadProgress | null
|
||
/** 开始发的时刻;还没轮到它时不传 */
|
||
startedAt?: number
|
||
/** 字节发完的时刻 */
|
||
sentAt?: number
|
||
/** 发完之后后端在干什么,比如「正在读表、推断列类型」 */
|
||
processing: string
|
||
now: number
|
||
}) {
|
||
if (!startedAt) return <span className="text-faint">排队中,等待前面的文件上传完成</span>
|
||
if (progress?.sent) {
|
||
return (
|
||
<span className="inline-flex items-center gap-1.5 text-dim" data-upload-phase="processing">
|
||
<Spinner size={10} /> 上传完成,{processing} · <span className="tnum">{formatDuration(now - (sentAt ?? now))}</span>
|
||
</span>
|
||
)
|
||
}
|
||
// 浏览器还没报过进度(刚开始,或者中间有层拦着不报):不写「已传 0 B」,只写在传
|
||
const loaded = progress?.loaded ?? null
|
||
const total = progress?.total ?? null
|
||
const pct = total && loaded != null ? Math.min(1, loaded / total) : null
|
||
return (
|
||
<span className="block" data-upload-phase="sending">
|
||
<span className="tnum flex flex-wrap gap-x-1.5 text-dim">
|
||
<span>上传中{loaded != null && <> · 已传 {formatBytes(loaded)}{total ? ` / ${formatBytes(total)}` : ''}</>}</span>
|
||
{pct != null && <span>· {Math.round(pct * 100)}%</span>}
|
||
<span className="text-faint">· {formatDuration(now - startedAt)}</span>
|
||
</span>
|
||
{pct != null && (
|
||
<span className="mt-1 block h-1 overflow-hidden rounded-full bg-hover" role="progressbar"
|
||
aria-label="上传进度" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(pct * 100)}>
|
||
{/* 用 scaleX 推进而不是改 width:只动 transform,不触发重排 */}
|
||
<span className="block h-full origin-left rounded-full bg-[var(--st-running)] transition-transform duration-200"
|
||
style={{ transform: `scaleX(${pct})` }} />
|
||
</span>
|
||
)}
|
||
</span>
|
||
)
|
||
}
|
||
/** 用户对解析器问题的回答,随上传一起发:mixed 数字列混入非数字怎么办,rawMode 按原样导入(未规整) */
|
||
interface UploadChoices { mixed: 'reject' | 'null'; rawMode: boolean }
|
||
const NO_CHOICES: UploadChoices = { mixed: 'reject', rawMode: false }
|
||
|
||
const WARN_BOX = {
|
||
borderColor: 'color-mix(in srgb, var(--warn) 45%, var(--border))',
|
||
background: 'color-mix(in srgb, var(--warn) 8%, transparent)',
|
||
}
|
||
const WARN_CHIP = {
|
||
color: 'var(--warn)', borderColor: 'var(--warn)', background: 'color-mix(in srgb, var(--warn) 10%, transparent)',
|
||
}
|
||
|
||
/** 这次上传已经选好的处理方式。重传时写在表单上,免得人以为又是从头导入 */
|
||
function ChosenLine({ choices }: { choices: UploadChoices }) {
|
||
const items = [
|
||
choices.rawMode ? UPLOAD_TEXT.chosenRaw : '',
|
||
choices.mixed === 'null' ? UPLOAD_TEXT.chosenMixed : '',
|
||
].filter(Boolean)
|
||
if (!items.length) return null
|
||
return <p className="text-2xs text-faint" data-upload-choices>{UPLOAD_TEXT.chosen}:{items.join(';')}</p>
|
||
}
|
||
|
||
/** 「1,234」「N/A」这样的示例,最多三个 */
|
||
const quoteList = (values: unknown[], max = 3) => values.slice(0, max).map((v) => `「${String(v)}」`).join('')
|
||
|
||
/**
|
||
* 解析器退回、要用户先拍板时的那一页的正文:为什么不能直接导入。可以怎么选、选了会怎样写在
|
||
* 底栏的按钮上和按钮旁边(TableUploader 里,和这一页共用同一个弹窗,切换时不重新挂载)。
|
||
*
|
||
* 只有后端给的两种(数字列混入非数字、交叉表或多块结构)。选「取消」回到表单:文件、名字、
|
||
* 表头行号都还在——表头行号设错是这两种问题最常见的来由,改一下行号可能就不用选了
|
||
*/
|
||
function UploadDecisionBody({ decision, choices, headerRow, sheetNoun }: {
|
||
decision: UploadDecision
|
||
choices: UploadChoices
|
||
headerRow: number
|
||
/** 「工作表」;CSV 没有工作表,是「文件」 */
|
||
sheetNoun: string
|
||
}) {
|
||
if (decision.kind === 'mixed') {
|
||
return (
|
||
<div className="space-y-3" data-upload-decision="mixed">
|
||
<p className="text-xs leading-relaxed text-dim">{UPLOAD_TEXT.mixedLead}</p>
|
||
<MixedColumnList columns={decision.details.columns} sheetNoun={sheetNoun} />
|
||
<ChosenLine choices={choices} />
|
||
</div>
|
||
)
|
||
}
|
||
const preview = decision.details.mixed
|
||
return (
|
||
<div className="space-y-3" data-upload-decision="shape">
|
||
<p className="text-xs leading-relaxed text-dim">{UPLOAD_TEXT.shapeLead}</p>
|
||
<ul className="space-y-2">
|
||
{decision.details.reasons.map((r, i) => (
|
||
<li key={i} className="rounded-lg border bg-bg p-2.5 text-xs" data-shape-reason={r.kind}>
|
||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||
{UPLOAD_SHAPE_LABEL[r.kind] && <span className="font-medium">{UPLOAD_SHAPE_LABEL[r.kind]}</span>}
|
||
{r.sheet && <span className="text-faint">{sheetNoun}「{r.sheet}」</span>}
|
||
</div>
|
||
<p className="mt-0.5 leading-relaxed text-dim">{r.message}</p>
|
||
{r.cells.length > 0 && (
|
||
<div className="mt-1.5 flex flex-wrap items-center gap-1" data-shape-cells>
|
||
{r.cells.slice(0, 8).map((cell) => <span key={cell} className="chip mono">{cell}</span>)}
|
||
{r.cells.length > 8 && (
|
||
<span className="text-2xs text-faint">{UPLOAD_TEXT.moreCells(formatNumber(r.cells.length))}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
{/* 选按原样导入以后还会因为这些列再问一次:第一次就列出来,一次看全要拍板的事 */}
|
||
{!!preview?.length && (
|
||
<div className="space-y-2" data-shape-mixed>
|
||
<p className="text-xs leading-relaxed text-dim">{UPLOAD_TEXT.shapeMixedLead}</p>
|
||
<MixedColumnList columns={preview} sheetNoun={sheetNoun} />
|
||
{decision.details.mixed_complete === false && (
|
||
<p className="text-2xs text-faint">{UPLOAD_TEXT.shapeMixedPartial}</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
<p className="text-xs leading-relaxed text-dim" data-shape-recipe>{UPLOAD_TEXT.shapeRecipe}</p>
|
||
<p className="text-2xs text-faint">{UPLOAD_TEXT.shapeHeader(headerRow)}</p>
|
||
<ChosenLine choices={choices} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 混合列清单:列名(原表头)、所在工作表、数字和非数字各多少、非数字的取值 */
|
||
function MixedColumnList({ columns, sheetNoun }: { columns: UploadMixedColumn[]; sheetNoun: string }) {
|
||
return (
|
||
<ul className="space-y-2">
|
||
{columns.map((c, i) => (
|
||
<li key={`${c.table}.${c.column}.${i}`} className="rounded-lg border bg-bg p-2.5 text-xs"
|
||
data-mixed-column={c.column}>
|
||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||
<span className="font-medium">{c.header || c.column}</span>
|
||
{!!c.header && c.header !== c.column && <span className="mono text-faint">{c.column}</span>}
|
||
<span className="text-faint">{sheetNoun}「{c.sheet}」</span>
|
||
<span className="tnum text-faint">
|
||
{UPLOAD_TEXT.mixedCounts(formatNumber(c.numeric), formatNumber(c.nonnumeric))}
|
||
</span>
|
||
</div>
|
||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||
{c.values.map((v, j) => (
|
||
<span key={j} className="chip" data-mixed-value={String(v.value)}>
|
||
<span className="mono">「{String(v.value)}」</span>
|
||
<span className="tnum text-faint">{formatNumber(v.count)} 个</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)
|
||
}
|
||
|
||
/** 一张导入的表:表名、行列数、区域、未规整标记、去掉的空行空列,以及原表头 → 列名 */
|
||
function UploadedTableCard({ table: t, flagged, sheetNoun }: {
|
||
table: UploadResult['tables'][number]
|
||
flagged: Set<string>
|
||
/** 「工作表」;CSV 没有工作表,是「文件」 */
|
||
sheetNoun: string
|
||
}) {
|
||
const trimmed = t.columns_trimmed ?? []
|
||
const housekeeping = [
|
||
t.blank_rows_skipped ? UPLOAD_TEXT.blankRows(formatNumber(t.blank_rows_skipped)) : '',
|
||
trimmed.length ? UPLOAD_TEXT.trimmedCols(trimmed.join('、')) : '',
|
||
].filter(Boolean)
|
||
return (
|
||
<div className="rounded-lg border bg-bg p-2.5" data-upload-table={t.name}>
|
||
<div className="mb-1.5 flex flex-wrap items-baseline gap-x-2 text-xs">
|
||
<span className="mono font-medium">{t.name}</span>
|
||
{t.sheet !== t.name && <span className="text-faint">{sheetNoun}「{t.sheet}」</span>}
|
||
<span className="tnum text-faint">{formatNumber(t.rows)} 行 · {t.columns.length} 列</span>
|
||
{t.region && <span className="mono text-faint" title={UPLOAD_TEXT.region}>{t.region}</span>}
|
||
{t.unshaped && (
|
||
<span className="chip" style={WARN_CHIP} title={UPLOAD_TEXT.unshapedHint} data-unshaped>
|
||
<AlertTriangle size={10} aria-hidden /> {UPLOAD_TEXT.unshaped}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{housekeeping.length > 0 && (
|
||
<p className="mb-1.5 text-2xs text-faint" data-upload-housekeeping>{housekeeping.join(';')}</p>
|
||
)}
|
||
<div className="flex flex-wrap gap-1">
|
||
{t.columns.map((c, j) => {
|
||
const header = c.header ?? c.name
|
||
const bad = flagged.has(header)
|
||
// 原表头和 SQL 列名不同(括号、空格换成下划线,数字开头加前缀,重名加序号)时两个都写:
|
||
// 用户认的是原表头,助手写 SQL 用的是列名
|
||
const mapped = c.header != null && c.header !== c.name
|
||
return (
|
||
<span key={`${c.name}-${j}`} className="chip" data-suspicious={bad || undefined}
|
||
data-header={mapped ? header : undefined}
|
||
style={bad ? WARN_CHIP : undefined}
|
||
title={bad ? '该列的表头看起来像一行数据' : mapped ? `原表头「${header}」,SQL 中的列名为 ${c.name}` : undefined}>
|
||
{mapped && (
|
||
<>
|
||
<span className="text-faint">{header || UPLOAD_TEXT.emptyHeader}</span>
|
||
<span className="text-faint" aria-hidden>→</span>
|
||
</>
|
||
)}
|
||
<span className="mono">{c.name || '(空)'}</span>
|
||
<span className="mono text-faint">{c.type}</span>
|
||
</span>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 回执里「导入时的处理」:跳过的工作表、类型转换、警告。一样都没有就不画 */
|
||
function UploadNotes({ result }: { result: UploadResult }) {
|
||
const skipped = result.skipped_sheets ?? []
|
||
const hidden = skipped.filter((s) => s.reason === 'hidden')
|
||
const empty = skipped.filter((s) => s.reason !== 'hidden')
|
||
const conversions = result.conversions ?? []
|
||
const warnings = result.warnings ?? []
|
||
if (!hidden.length && !empty.length && !conversions.length && !warnings.length) return null
|
||
const names = (list: typeof skipped) => list
|
||
.map((s) => `「${s.sheet}」${s.state === 'veryHidden' ? UPLOAD_TEXT.veryHidden : ''}`).join('、')
|
||
return (
|
||
<div className="space-y-2 rounded-lg border bg-bg p-2.5 text-xs leading-relaxed" data-upload-notes>
|
||
<div className="text-2xs font-medium text-dim">{UPLOAD_TEXT.notes}</div>
|
||
{warnings.length > 0 && (
|
||
<ul className="space-y-1" data-upload-warnings>
|
||
{warnings.map((w, i) => (
|
||
<li key={i} className="flex gap-1.5">
|
||
<AlertTriangle size={12} className="mt-0.5 shrink-0 text-[var(--warn)]" aria-hidden />
|
||
<span>{w}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
{conversions.length > 0 && (
|
||
<ul className="space-y-1" data-upload-conversions>
|
||
{conversions.map((c, i) => (
|
||
<li key={i} className="flex gap-1.5" data-conversion={c.kind}>
|
||
<Info size={12} className="mt-0.5 shrink-0 text-faint" aria-hidden />
|
||
<span>
|
||
<span className="mono">{c.table}.{c.column}</span>:{UPLOAD_CONVERSION_LABEL[c.kind] ?? '已按列类型转换'},
|
||
{UPLOAD_TEXT.conversionCount(formatNumber(c.count))}
|
||
{(c.examples ?? []).length > 0 && UPLOAD_TEXT.examples(quoteList(c.examples))}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
{hidden.length > 0 && (
|
||
<p className="flex gap-1.5" data-skipped-hidden>
|
||
<EyeOff size={12} className="mt-0.5 shrink-0 text-faint" aria-hidden />
|
||
<span>{UPLOAD_TEXT.skippedHidden(formatNumber(hidden.length), names(hidden))}</span>
|
||
</p>
|
||
)}
|
||
{empty.length > 0 && (
|
||
<p className="flex gap-1.5 text-dim" data-skipped-empty>
|
||
<Info size={12} className="mt-0.5 shrink-0 text-faint" aria-hidden />
|
||
<span>{UPLOAD_TEXT.skippedEmpty(names(empty))}</span>
|
||
</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 传一个 Excel / CSV,变成可以用 SQL 查的表。
|
||
*
|
||
* 为什么不传进知识库:那条路只能把表格切块检索,数字就成了模型从片段里
|
||
* "读"出来的。而这个项目的地基是"所有算术下沉到 SQL 或口径卡"——表格必须
|
||
* 变成表,数字才是算出来的、才追溯得到是哪条查询。
|
||
*
|
||
* 导入完弹窗不关,停在结果上:推断出的列名和类型要当场给人看。表头行取错了
|
||
* (比如文件前两行是标题),列名会变成一行数据——不给看的话,这个错要等到
|
||
* 有人发现汇总一直少一行才暴露。以前导入成功的同一帧弹窗就被卸载了,这段
|
||
* 回显从来没被人看到过。
|
||
*
|
||
* 解析器不再替用户做取舍:数字列混进了「N/A」、表格是日期横排的交叉表,服务端回 422 请用户
|
||
* 选(UploadDecisionBody),选完带着答案重传。答案跟着这一份文件和表头行号走,换文件、
|
||
* 改行号就清掉,重新问
|
||
*/
|
||
function TableUploader({ initialName, taken, sqliteTaken, onClose, onImported }: {
|
||
initialName?: string
|
||
/** 已经被数据库占用的名字(传表格只能就地替换表格,不能顶掉数据库) */
|
||
taken: string[]
|
||
/**
|
||
* 手工登记的 SQLite 源的名字:可能是还没迁移的早期上传,服务端允许同名替换,也可能是真正的手工库,
|
||
* 服务端回 409。前端分不出来,只提示、不拦
|
||
*/
|
||
sqliteTaken: string[]
|
||
onClose: () => void
|
||
onImported: (row: any) => void
|
||
}) {
|
||
const [file, setFile] = useState<File | null>(null)
|
||
const [name, setName] = useState(initialName ?? '')
|
||
const [description, setDescription] = useState('')
|
||
const [headerRow, setHeaderRow] = useState(1)
|
||
const [busy, setBusy] = useState(false)
|
||
const [progress, setProgress] = useState<{ p: UploadProgress; sentAt?: number } | null>(null)
|
||
const [dragging, setDragging] = useState(false)
|
||
const [result, setResult] = useState<UploadResult | null>(null)
|
||
const [choices, setChoices] = useState<UploadChoices>(NO_CHOICES)
|
||
const [decision, setDecision] = useState<UploadDecision | null>(null)
|
||
/** 服务端以重名拒收(409)时那句原话,挂在当时那个名字上:改了名字就不再显示 */
|
||
const [rejected, setRejected] = useState<{ name: string; message: string } | null>(null)
|
||
const headerRef = useRef<HTMLInputElement>(null)
|
||
const nameRef = useRef<HTMLInputElement>(null)
|
||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||
const consequenceId = useId()
|
||
const clock = useRunClock(busy)
|
||
const busySince = useRef(0)
|
||
const upload = useRef<{ abort: () => void; sent: boolean } | null>(null)
|
||
const sent = !!progress?.p.sent
|
||
// CSV / TSV 没有工作表,一个文件就是一张表:退回的原因、回执里说「文件「…」」(后端按扩展名判断,这里同一个规则)
|
||
const sheetNoun = /\.(csv|tsv)$/i.test(file?.name ?? '') ? '文件' : '工作表'
|
||
|
||
// 表单、选择页、回执共用一个弹窗(不重新挂载,不重播入场),焦点得自己挪:选择页落在最稳妥的「取消」上
|
||
useEffect(() => {
|
||
if (!decision) return
|
||
const frame = requestAnimationFrame(() => cancelRef.current?.focus({ preventScroll: true }))
|
||
return () => cancelAnimationFrame(frame)
|
||
}, [decision])
|
||
|
||
// 弹窗关掉时字节还没发完,就不传了:后端收不全,什么都不会建。已经发完的让它
|
||
// 做完——后端已经在建表,这时断开只会让人以为没传上
|
||
useEffect(() => () => { if (upload.current && !upload.current.sent) upload.current.abort() }, [])
|
||
|
||
const pick = (f: File | null) => {
|
||
setFile(f)
|
||
setResult(null)
|
||
setDecision(null)
|
||
setChoices(NO_CHOICES)
|
||
// 拿文件名当默认数据源名。它会变成工具名的一部分,所以只留 ASCII;
|
||
// 中文文件名清洗完可能什么都不剩,那就让用户自己填
|
||
if (f && !name) {
|
||
const stem = f.name.replace(/\.[^.]+$/, '')
|
||
const slug = stem.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '')
|
||
setName(/^[a-z]/.test(slug) ? slug.slice(0, 40) : '')
|
||
}
|
||
}
|
||
|
||
const nameError = name && !NAME_RE.test(name)
|
||
? '须以小写字母开头,只能包含小写字母、数字和下划线'
|
||
: taken.includes(name) ? `「${name}」已被其他数据库用作标识,请更换名称`
|
||
: rejected && rejected.name === name ? rejected.message : null
|
||
|
||
const submit = async (next: UploadChoices = choices) => {
|
||
if (!file || !name || nameError) return
|
||
const ctl = new AbortController()
|
||
const handle = { abort: () => ctl.abort(), sent: false }
|
||
upload.current = handle
|
||
busySince.current = Date.now()
|
||
setChoices(next)
|
||
setDecision(null)
|
||
setProgress(null)
|
||
setBusy(true)
|
||
try {
|
||
const out = await api.datasources.uploadTable(file, {
|
||
name, description, header_row: headerRow, mixed: next.mixed, raw_mode: next.rawMode,
|
||
}, {
|
||
signal: ctl.signal,
|
||
onProgress: (p) => {
|
||
if (p.sent) handle.sent = true
|
||
setProgress((cur) => ({ p, sentAt: cur?.sentAt ?? (p.sent ? Date.now() : undefined) }))
|
||
},
|
||
})
|
||
setResult(out)
|
||
onImported(out.source)
|
||
} catch (e) {
|
||
const asked = uploadDecision(e)
|
||
if (isAbort(e)) toast.info(`上传已取消,「${name}」未创建或修改`)
|
||
else if (asked) setDecision(asked)
|
||
else if (e instanceof ApiError && e.status === 409) {
|
||
// 只有重名会回 409(同名的是手工登记的库):原话写在名字下面,焦点给名字
|
||
setRejected({ name, message: e.message })
|
||
requestAnimationFrame(() => { nameRef.current?.focus(); nameRef.current?.select() })
|
||
} else toast.error(e)
|
||
} finally {
|
||
if (upload.current === handle) upload.current = null
|
||
setBusy(false)
|
||
setProgress(null)
|
||
}
|
||
}
|
||
|
||
const changeHeaderRow = (n: number) => {
|
||
setHeaderRow(n)
|
||
// 换了表头行,解析出来的就是另一张表:上次的回答不作数
|
||
setChoices(NO_CHOICES)
|
||
}
|
||
|
||
const retry = () => {
|
||
// 回到表单改行号:文件、名字都留着,同名重传会就地替换刚才那份
|
||
setResult(null)
|
||
changeHeaderRow(headerRow + 1)
|
||
requestAnimationFrame(() => { headerRef.current?.focus(); headerRef.current?.select() })
|
||
}
|
||
|
||
if (decision) {
|
||
const back = () => {
|
||
// 不导入,回到表单:焦点给表头行号(最常见的改法),不留在原地——那里换成了表单的「取消」,
|
||
// 再按一下回车就把整个弹窗关了
|
||
setDecision(null)
|
||
requestAnimationFrame(() => headerRef.current?.focus({ preventScroll: true }))
|
||
}
|
||
const cancel = <button ref={cancelRef} className="btn" onClick={back} title={UPLOAD_TEXT.cancelHint}>取消</button>
|
||
return (
|
||
<Modal
|
||
open
|
||
onClose={onClose}
|
||
// 文件、名字、表头行号和已选的处理方式都还在:Esc、点遮罩、点 × 先问一句,和表单一样。
|
||
// 底部的「取消」是回到表单,不关弹窗
|
||
dirty={!!file}
|
||
width={640}
|
||
title={decision.kind === 'mixed' ? UPLOAD_TEXT.mixedTitle : UPLOAD_TEXT.shapeTitle}
|
||
footer={decision.kind === 'mixed' ? (
|
||
<>
|
||
{cancel}
|
||
<button className="btn btn-primary" onClick={() => void submit({ ...choices, mixed: 'null' })}>
|
||
{UPLOAD_TEXT.mixedAccept}
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
{/* 后果紧挨着那个按钮写:这一项不是「修好了」,是带着问题导入 */}
|
||
<span id={consequenceId} className="mr-auto min-w-0 flex-1 self-center text-2xs leading-snug text-[var(--warn)]"
|
||
data-raw-consequence>
|
||
{UPLOAD_TEXT.rawConsequence}
|
||
</span>
|
||
{cancel}
|
||
<button className="btn" aria-describedby={consequenceId} style={{ color: 'var(--warn)' }}
|
||
onClick={() => void submit({ ...choices, rawMode: true })}>
|
||
<AlertTriangle size={12} aria-hidden /> {UPLOAD_TEXT.rawAccept}
|
||
</button>
|
||
</>
|
||
)}
|
||
>
|
||
<UploadDecisionBody decision={decision} choices={choices} headerRow={headerRow} sheetNoun={sheetNoun} />
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
if (result) {
|
||
// 看原表头像不像数据:SQL 列名清洗过(「2026-01」成了 c_2026_01),拿它判断就看不出来了
|
||
const flagged = result.tables.map((t) => suspiciousColumns(t.columns.map((c) => ({ name: c.header ?? c.name }))))
|
||
const anyFlagged = flagged.some((s) => s.size > 0)
|
||
const anyMapped = result.tables.some((t) => t.columns.some((c) => c.header != null && c.header !== c.name))
|
||
return (
|
||
<Modal
|
||
open
|
||
onClose={onClose}
|
||
width={640}
|
||
title={result.replaced ? `已替换「${name}」里的数据` : `已创建数据源「${name}」`}
|
||
footer={
|
||
<>
|
||
<button className="btn" onClick={retry}>表头有误 · 修改行号后重新上传</button>
|
||
<button className="btn btn-primary" onClick={onClose} data-autofocus>
|
||
列名无误 · 完成
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="space-y-3" data-upload-result>
|
||
<p className="text-xs leading-relaxed text-dim">
|
||
共 {result.tables.length} 张表,表头取自第 {headerRow} 行。请核对列名和类型:如果列名显示为一行数据,
|
||
说明表头行号设置有误。{anyMapped && UPLOAD_TEXT.headerMapped}
|
||
</p>
|
||
{anyFlagged && (
|
||
<div role="alert" className="flex gap-2 rounded-lg border px-2.5 py-2 text-xs leading-relaxed" style={WARN_BOX}>
|
||
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-[var(--warn)]" aria-hidden />
|
||
<span>
|
||
标黄的列,表头看起来像数据(纯数字、日期、空值或重复值)。表头可能不在第 {headerRow} 行,
|
||
可尝试改为第 {headerRow + 1} 行后重新上传。
|
||
</span>
|
||
</div>
|
||
)}
|
||
{result.tables.map((t, i) => (
|
||
<UploadedTableCard key={t.name} table={t} flagged={flagged[i]} sheetNoun={sheetNoun} />
|
||
))}
|
||
<UploadNotes result={result} />
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Modal
|
||
open
|
||
onClose={onClose}
|
||
dirty={!!file && !busy}
|
||
title="上传表格"
|
||
footer={
|
||
<>
|
||
{busy && !sent ? (
|
||
<button className="btn" onClick={() => upload.current?.abort()}>取消上传</button>
|
||
) : (
|
||
<button className="btn" onClick={onClose} disabled={busy}
|
||
title={busy ? '文件已上传完成,正在建表,请稍候' : undefined}>取消</button>
|
||
)}
|
||
<button className="btn btn-primary tnum" onClick={() => void submit()}
|
||
disabled={busy || !file || !name || !!nameError}
|
||
title={!file ? '请先选择文件' : !name ? '请填写数据源名' : undefined}>
|
||
{busy ? <><Spinner size={11} /> 导入中 {formatDuration(clock - busySince.current)}</> : <><Upload size={12} aria-hidden /> 导入</>}
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="space-y-3">
|
||
<label
|
||
className={clsx(
|
||
'flex flex-col items-center justify-center gap-1.5 rounded-lg border border-dashed px-4 py-5 text-center transition-colors',
|
||
busy ? 'opacity-60' : 'cursor-pointer',
|
||
dragging ? 'border-[var(--accent)] bg-accent-soft' : !busy && 'hover:border-[var(--border-strong)] hover:bg-hover',
|
||
)}
|
||
onDragOver={(e) => { e.preventDefault(); if (!busy) setDragging(true) }}
|
||
onDragLeave={() => setDragging(false)}
|
||
onDrop={(e) => { e.preventDefault(); setDragging(false); if (!busy) pick(e.dataTransfer.files?.[0] ?? null) }}
|
||
>
|
||
<FileSpreadsheet size={20} className={file ? 'text-[var(--accent)]' : 'text-faint'} aria-hidden />
|
||
{file ? (
|
||
<span className="text-xs"><span className="mono">{file.name}</span> <span className="tnum text-faint">· {formatBytes(file.size)}</span></span>
|
||
) : (
|
||
<span className="text-xs text-dim">拖入文件,或点击选择</span>
|
||
)}
|
||
<span className="text-2xs text-faint">支持 Excel(.xlsx)和 CSV / TSV,每个工作表会转为一张表。不支持 .xls 格式,请先另存为 .xlsx</span>
|
||
<input type="file" className="sr-only" accept=".xlsx,.xlsm,.csv,.tsv" aria-label="选择表格文件" disabled={busy}
|
||
onChange={(e) => pick(e.target.files?.[0] ?? null)} />
|
||
</label>
|
||
|
||
{/* 上传之前说清:原件整份留在服务端,隐藏工作表虽然不导入也在里面 */}
|
||
<p className="flex gap-1.5 text-2xs leading-relaxed text-faint" data-raw-notice>
|
||
<Info size={11} className="mt-0.5 shrink-0" aria-hidden />
|
||
<span>{UPLOAD_TEXT.rawNotice}</span>
|
||
</p>
|
||
|
||
<ChosenLine choices={choices} />
|
||
|
||
{busy && (
|
||
<div className="rounded-lg border bg-bg px-3 py-2 text-2xs" data-upload-progress>
|
||
<UploadMeter progress={progress?.p ?? null} startedAt={busySince.current} sentAt={progress?.sentAt}
|
||
processing="正在读表、推断列类型" now={clock} />
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-[1fr_120px] gap-3">
|
||
<Field label="数据源名" required error={nameError}
|
||
hint={sqliteTaken.includes(name)
|
||
? <span className="text-[var(--warn)]" data-name-sqlite>{UPLOAD_TEXT.nameSqliteTaken}</span>
|
||
: `将用作工具名的一部分(db_query__${name || 'sales'});同名重新上传会直接替换`}>
|
||
{(p) => (
|
||
<input {...p} ref={nameRef} className="field mono" value={name} placeholder="sales" autoComplete="off" spellCheck={false}
|
||
style={nameError ? { borderColor: 'var(--err)' } : undefined}
|
||
onChange={(e) => setName(e.target.value)} />
|
||
)}
|
||
</Field>
|
||
<Field label="表头在第几行">
|
||
{(p) => (
|
||
<input {...p} ref={headerRef} className="field tnum" type="number" min={1} value={headerRow}
|
||
onChange={(e) => changeHeaderRow(Math.max(1, Number(e.target.value) || 1))} />
|
||
)}
|
||
</Field>
|
||
</div>
|
||
|
||
<Field label="说明(供助手参考)">
|
||
{(p) => (
|
||
<input {...p} className="field" value={description} placeholder="2026 年各月销售明细"
|
||
onChange={(e) => setDescription(e.target.value)} />
|
||
)}
|
||
</Field>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|