Excel 导入改造期 1:止血与版本底座——写错的列名不再静默得 0,导入不再悄悄读错,每次上传生成不可变的版本,运行固定发起时的数据版本

连接层
- 所有 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 认上传的表格,显示当前版本;上传的表格不再给「探查结构」
This commit is contained in:
ThearchyHelios
2026-10-01 13:42:04 +08:00
parent c837cdbfa3
commit 8059cd5147
38 changed files with 12465 additions and 535 deletions
+65 -13
View File
@@ -1,8 +1,8 @@
import type {
Approval, AutofixResult, Conversation, ConversationDetail, ConversationTurn, CustomTool, DataSource, EvidenceGraph,
EvidenceAudit, EvidenceJudgeResult, EvidenceSegmentDetail, GraphSpec, KbDocument, MemoryItem, Provider, PublishCheck, PublishLevel, ReviewResult, Run,
RunEvent, RunStatus, Skill, ToolChange, ToolInfo, ToolTrust, UpgradeResult, ValidationIssue, VarIssue, Variable, Workflow,
WorkflowVersion,
RunEvent, RunStatus, Skill, ToolChange, ToolInfo, ToolTrust, UpgradeResult, UploadDecision, UploadMixedColumn, UploadResult,
UploadShapeReason, ValidationIssue, VarIssue, Variable, Workflow, WorkflowVersion,
} from '../types'
import { localActor } from '../lib/actor'
import { VALIDATION_TITLE, describeValidation } from '../lib/validation'
@@ -30,11 +30,16 @@ export class ApiError extends Error {
* detail 是给人看的话,随时可能改写;要按错误种类分支的认这个
*/
code?: string
/**
* 后端要用户先拍板时随错误一起给的 {kind, details}(比如上传表格的 422:数字列混入非数字、
* 交叉表)。原样保留,按接口各自校验后再用(上传表格见 uploadDecision)
*/
decision?: unknown
constructor(
public status: number,
message: string,
opts?: { kind?: ApiErrorKind; detail?: unknown; raw?: string; timeoutMs?: number; code?: string },
opts?: { kind?: ApiErrorKind; detail?: unknown; raw?: string; timeoutMs?: number; code?: string; decision?: unknown },
) {
super(message)
this.name = 'ApiError'
@@ -43,6 +48,7 @@ export class ApiError extends Error {
this.raw = opts?.raw
this.timeoutMs = opts?.timeoutMs
this.code = opts?.code
this.decision = opts?.decision
}
}
@@ -167,6 +173,48 @@ const codeOf = (body: unknown): string | undefined => {
return typeof code === 'string' && code ? code : undefined
}
/** 后端 {detail, decision} 里要用户拍板的内容:是个对象就原样带上,没有就是 undefined */
const decisionOf = (body: unknown): unknown => {
const decision = body && typeof body === 'object' ? (body as { decision?: unknown }).decision : undefined
return decision && typeof decision === 'object' ? decision : undefined
}
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
/**
* 上传表格被退回、要用户先选怎么处理(422 带 decision)时,取出那份决定;别的错误返回 null。
*
* 只认界面会画的两种(mixed、shape),而且逐项校验形状:后端以后多一种决定、或者字段对不上,
* 宁可当普通报错弹出那句话(detail 本身就是给人看的),也不画一个空的选择框让人照着点
*/
export function uploadDecision(e: unknown): UploadDecision | null {
if (!(e instanceof ApiError) || e.status !== 422 || !isObj(e.decision)) return null
const { kind, details } = e.decision as { kind?: unknown; details?: unknown }
if (!isObj(details)) return null
if (kind === 'mixed' && mixedColumns(details.columns)) {
return { kind, details: { columns: details.columns as unknown as UploadMixedColumn[] } }
}
if (kind === 'shape' && Array.isArray(details.reasons) && details.reasons.length
&& details.reasons.every((r) => isObj(r) && typeof r.message === 'string')) {
// 预告的混合列只是告知:形状对不上就不画,结构问题照常问
const mixed = mixedColumns(details.mixed) ? details.mixed as unknown as UploadMixedColumn[] : undefined
return {
kind,
details: {
reasons: (details.reasons as Record<string, unknown>[]).map((r) => ({
...r, cells: Array.isArray(r.cells) ? r.cells.map(String) : [],
}) as unknown as UploadShapeReason),
...(mixed ? { mixed, mixed_complete: details.mixed_complete !== false } : {}),
},
}
}
return null
}
/** 混合列清单的形状对不对:非空数组,每项有列名和取值数组 */
const mixedColumns = (v: unknown): boolean => Array.isArray(v) && v.length > 0
&& v.every((c) => isObj(c) && typeof c.column === 'string' && Array.isArray(c.values))
/** 非 2xx 的响应 → ApiError,顺带报告连接状态。fetch 和 XHR 两条路共用 */
function failure(status: number, statusText: string, text: string, path?: string): ApiError {
let body: any
@@ -184,7 +232,7 @@ function failure(status: number, statusText: string, text: string, path?: string
const detail = body?.detail ?? body
return new ApiError(status, describeDetail(detail, path), {
detail, raw: typeof body?.raw === 'string' ? body.raw : `${status} ${text.slice(0, 2000)}`,
code: codeOf(body),
code: codeOf(body), decision: decisionOf(body),
})
}
const message = status >= 500
@@ -472,21 +520,25 @@ export const api = {
test: (id: string) => post<ConnectionTest>(`/datasources/${id}/test`, {}),
/** 测一份还没保存的配置,只测连接不落库。编辑时带上 id,后端可以沿用已存的密码 */
testConfig: (body: any) => post<ConnectionTest>('/datasources/test', body),
/** 上传 Excel / CSV,变成一个可以用 SQL 查的数据源。同名就地替换。onProgress 报字节进度 */
uploadTable: (file: File, body: { name: string; description?: string; header_row?: number }, opts?: UploadOptions) => {
/**
* 上传 Excel / CSV,变成一个可以用 SQL 查的数据源。同名发布新版本替换。onProgress 报字节进度。
*
* 解析器要用户先拍板时回 422,错误上带着 decision(用 uploadDecision 取);用户选完,带上
* 答案重传:mixed='null' 把数字列里的非数字存为空值,raw_mode=true 按原样导入(未规整)
*/
uploadTable: (file: File, body: {
name: string; description?: string; header_row?: number; mixed?: 'reject' | 'null'; raw_mode?: boolean
}, opts?: UploadOptions) => {
const form = new FormData()
form.append('file', file)
form.append('name', body.name)
form.append('description', body.description ?? '')
form.append('header_row', String(body.header_row ?? 1))
type Out = {
source: any; replaced: boolean
tables: { name: string; sheet: string; rows: number
columns: { name: string; type: string }[] }[]
}
form.append('mixed', body.mixed ?? 'reject')
form.append('raw_mode', body.raw_mode ? 'true' : 'false')
return opts?.onProgress
? upload<Out>('/datasources/upload', form, opts)
: request<Out>('/datasources/upload', { method: 'POST', body: form, signal: opts?.signal })
? upload<UploadResult>('/datasources/upload', form, opts)
: request<UploadResult>('/datasources/upload', { method: 'POST', body: form, signal: opts?.signal })
},
introspect: (id: string, schema?: string) =>
post<any>(`/datasources/${id}/introspect${schema ? `?schema=${encodeURIComponent(schema)}` : ''}`, {}),
+81
View File
@@ -820,3 +820,84 @@ export const AGENT_GUARD_TEXT = {
unlimited: '不限:费用仅受最大步数和上下文长度约束',
unlimitedShort: '不限',
} as const
/**
* 上传表格:数据页「表格」标签的上传弹窗、导入回执和卡片。种类的说法和后端 data/tabular.py 的
* CONVERSION_KINDS、SHAPE_KINDS 对得上;后端以后多出来的种类,界面上只显示它给的那句话
*/
export const UPLOAD_TEXT = {
/** 上传前的告知:原件按内容存档、不提供下载,可以清除 */
rawNotice: '原始文件将完整保存在服务端(包括不导入的隐藏工作表),用于核对和追溯,之后可以清除。',
/** 表单和决定页上:这次上传已经选好的处理方式 */
chosen: '本次上传已选择',
chosenMixed: '数字列中的非数字值存为空值',
chosenRaw: '按原样导入(未规整)',
cancelHint: '不导入,返回上传表单',
// 数字列混入非数字
mixedTitle: '部分数字列中混有非数字的值',
mixedLead: '以下各列以数字为主,但混有非数字的值。可以把这些值存为空值、整列按数字导入,空值不参与求和、平均等计算;'
+ '也可以取消,在 Excel 中修正后重新上传。',
mixedAccept: '把这些值存为空值,按数字导入',
mixedCounts: (numeric: string, other: string) => `数字 ${numeric} 个,非数字 ${other} 个`,
// 交叉表、多块结构
shapeTitle: '该表格不是一行一条记录的明细表',
shapeLead: '检测到以下结构,默认不导入:',
shapeRecipe: '这类表格需要按配方导入(指定表头、分段和合计行的处理方式),该功能将在后续版本提供。',
shapeHeader: (row: number) => `如果表头不在第 ${row} 行,请取消后修改表头行号,再重新上传。`,
/** 结构问题之外,预告的混合列:选按原样导入以后还要再选一次 */
shapeMixedLead: '另外,以下各列以数字为主,但混有非数字的值。选择按原样导入后,还需要选择这些值的处理方式:',
shapeMixedPartial: '以上按表格前面的部分统计,其余部分可能还有。',
rawAccept: '按原样导入(未规整)',
rawConsequence: '按原样导入后,同一列混有不同口径的行,不能直接对列求和',
moreCells: (n: string) => `等 ${n} 处`,
// 导入回执
headerMapped: '箭头左侧为原表头,右侧为 SQL 中使用的列名。',
emptyHeader: '(空表头)',
unshaped: '未规整',
unshapedHint: '按原样导入、未经规整:同一列里混有不同口径的行,不能直接对列求和',
region: '导入区域:表头行到最后一行数据',
blankRows: (n: string) => `已跳过 ${n} 个空行`,
trimmedCols: (cols: string) => `已去掉两侧整列为空的列 ${cols}`,
notes: '导入时的处理',
skippedHidden: (n: string, names: string) => `已跳过 ${n} 个隐藏工作表:${names}。隐藏工作表不导入,原始文件中仍保留。`,
skippedEmpty: (names: string) => `已跳过没有内容的工作表:${names}。`,
veryHidden: '(深度隐藏)',
conversionCount: (n: string) => `共 ${n} 个`,
/** list 是一串「」括起来的示例,紧跟在「如」后面 */
examples: (list: string) => `(如${list})`,
// 卡片
currentVersion: '当前版本',
legacyFile: '早期上传的文件',
/** 迁移补建的初始版本:快照的创建时间是迁移那一刻,不是导入时间,不写出来 */
legacyTitle: '早期上传的版本,未记录原始文件名和导入时间',
importedAt: (when: string) => `${when}${/\d$/.test(when) ? ' ' : ''}导入`,
importedTitle: (when: string) => `当前版本于 ${when} 导入`,
/** 名字和一个手工登记的 SQLite 源重名:可能是还没迁移的早期上传,能不能替换由服务端判断 */
nameSqliteTaken: '已有同名的 SQLite 数据源:早期上传的表格可同名替换,否则将被拒绝',
noSchema: '未读取到表结构 · 请重新上传',
reupload: '重新上传',
reuploadHint: '同名重新上传:发布新版本替换其中的数据,工具名不变',
} as const
/** 导入回执里的类型转换(tabular.CONVERSION_KINDS) */
export const UPLOAD_CONVERSION_LABEL: Record<string, string> = {
thousands_separator: '千分位写法的文本已按数字保存',
nonnumeric_to_null: '非数字的值已存为空值',
kept_as_text: '含前导零或超长数字,整列按文本保存',
}
/** 不是规整明细表的几种理由(tabular.SHAPE_KINDS) */
export const UPLOAD_SHAPE_LABEL: Record<string, string> = {
date_header: '表头是横排的日期',
date_row: '表内有横排的日期行',
section_title: '表内有分段标题行',
table_totals: '表内有表格对象的汇总行',
formula_above: '表内有汇总上方各行的公式',
}
/** 上传表格的原件状态(kept 不单独标:上传前已告知会保存) */
export const RAW_STATE_LABEL: Record<string, string> = {
kept: '原件已保存',
purged: '原件已清除',
absent: '未保存原件',
}
+419 -57
View File
@@ -1,12 +1,13 @@
import { useEffect, useMemo, useRef, useState } from 'react'
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 } from '../api/client'
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,
@@ -19,6 +20,7 @@ import {
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'
// ===========================================================================
@@ -35,9 +37,20 @@ import { useRunClock } from '../run/useRunClock'
*/
export type DataView = 'databases' | 'tables'
/** 传上来的表格落在数据目录的 uploads/tables/<name>.db。后端没给来源标记,按路径认 */
export const isUploadedTable = (row: any): boolean =>
row?.kind === 'sqlite' && /[\\/]uploads[\\/]tables[\\/][^\\/]+\.db$/.test(row?.database ?? '')
/**
* 上传的表格:认后端给的来源标记 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}$/
@@ -192,7 +205,10 @@ export function DataSourcesTab({ view = 'databases' }: { view?: DataView }) {
{uploading && (
<TableUploader
initialName={uploading.name}
taken={(rows ?? []).filter((r) => !isUploadedTable(r)).map((r) => r.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)
@@ -242,7 +258,8 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, kick }
// 缓存是按哪个 schema 探的(null:老缓存没记,或者还没探过)。和配置对不上时,
// 助手写 SQL 用的是另一个 schema 的表——改了配置没重探、或者重探失败都会这样
const cachedSchema: string | null = row.cached_schema ?? null
const schemaDrift = cachedSchema != null && cachedSchema !== configured
// 上传的表格只有一个库,结构随版本冻结,谈不上「和配置的 schema 对不上」
const schemaDrift = !uploaded && cachedSchema != null && cachedSchema !== configured
const introspect = async () => {
if (busy) return
@@ -382,7 +399,8 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, kick }
const countChanged = lastCount.current !== row.table_count
useEffect(() => { lastCount.current = row.table_count }, [row.table_count])
const staleSchema = !!synced && now - synced > STALE_SCHEMA_MS
// 上传的表格结构随版本冻结,不会「过期」:要更新就重新上传
const staleSchema = !uploaded && !!synced && now - synced > STALE_SCHEMA_MS
const failed = record && !record.ok && !checkingSince
return (
@@ -406,14 +424,17 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, kick }
<button className="btn btn-sm" disabled={!!checkingSince} onClick={() => void test()}>
<Plug size={11} aria-hidden /> 测试连接
</button>
<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 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="同名重新上传:直接替换其中的数据,工具名不变">重新上传</button>
? <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>
@@ -460,7 +481,7 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, kick }
>
<div className="font-medium text-[var(--err)]">上次探查失败</div>
<div className="mt-0.5 break-all text-dim">{row.schema_error}</div>
{!!otherSchemas.length && (
{!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,点击可预览(不保存):
@@ -518,6 +539,9 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, kick }
// 也没用。以前两者显示成同一句,没有一处说的是真话
// 红字上面那块已经说了,这里只说后果,不再叠一道红
<span className="text-faint">结构不可用:助手无法获取该数据库的表清单</span>
) : uploaded ? (
// 上传的表格没有「探查结构」可点,只能重新上传
<span className="text-[var(--warn)]">{UPLOAD_TEXT.noSchema}</span>
) : (
<span className="text-[var(--warn)]">尚未探查结构 · 助手无法获取表清单</span>
)}
@@ -531,7 +555,9 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, kick }
</button>
)}
<span className="flex-1" />
{synced > 0 && (
{/* 上传的表格在地址那一行写当前版本和导入时间,这里不再重复一遍「结构同步于」。
迁移补建的初始版本没有导入时间可写,留着原来的同步时间 */}
{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 />}
@@ -548,7 +574,13 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, kick }
/** 连接地址。端口为空时不留尾巴冒号,淡色写上默认端口 */
function Address({ row, meta, uploaded }: { row: any; meta?: any; uploaded: boolean }) {
if (uploaded) {
return <span>上传的表格{row.table_count ? ` · ${row.table_count} 张表` : ''}</span>
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'
@@ -565,6 +597,26 @@ function Address({ row, meta, uploaded }: { row: any; meta?: any; uploaded: bool
)
}
/**
* 上传表格当前启用的是哪一版:哪个文件、什么时候导入的。库文件的路径按版本变(而且不是用户
* 传的那个文件),写出来没有意义;同名重传之后,这一行变了才说明新版本真的启用了
*/
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
@@ -594,7 +646,8 @@ function SchemaBrowser({ row }: { row: any }) {
(e) => { if (live) setError(e) },
)
return () => { live = false }
}, [row.id, row.schema_synced_at, row.table_count])
// 上传的表格换了版本(同名重传)也要重取:结构跟着当前版本走
}, [row.id, row.schema_synced_at, row.table_count, row.current_snapshot?.id])
useEffect(() => {
for (const t of expanded) {
@@ -674,7 +727,7 @@ function SchemaBrowser({ row }: { row: any }) {
<span className="tnum text-2xs text-faint">{loaded.columns.length} 列</span>
)}
</button>
{isOpen && <ColumnList detail={d} />}
{isOpen && <ColumnList detail={d} uploaded={isUploadedTable(row)} />}
</div>
)
})}
@@ -690,7 +743,7 @@ function SchemaBrowser({ row }: { row: any }) {
)
}
function ColumnList({ detail }: { detail?: ColumnsState }) {
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>
}
@@ -700,7 +753,10 @@ function ColumnList({ detail }: { detail?: ColumnsState }) {
// 只认明确的 false:老后端的响应里根本没有 found,得往下走到原文显示
return (
<div className="mb-1.5 ml-5 text-2xs text-faint">
缓存中已没有这张表的结构,可能刚重新探查过。请收起后再展开,或点击「探查结构」。
{uploaded
// 上传的表格没有「探查结构」:结构跟着版本走,清单和列之间多半是换了版本
? '当前版本中已没有这张表,可能刚重新上传过。请收起后再展开。'
: '缓存中已没有这张表的结构,可能刚重新探查过。请收起后再展开,或点击「探查结构」。'}
</div>
)
}
@@ -1464,6 +1520,231 @@ export function UploadMeter({ progress, startedAt, sentAt, processing, now }: {
</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 查的表。
@@ -1476,11 +1757,20 @@ export function UploadMeter({ progress, startedAt, sentAt, processing, now }: {
* (比如文件前两行是标题),列名会变成一行数据——不给看的话,这个错要等到
* 有人发现汇总一直少一行才暴露。以前导入成功的同一帧弹窗就被卸载了,这段
* 回显从来没被人看到过。
*
* 解析器不再替用户做取舍:数字列混进了「N/A」、表格是日期横排的交叉表,服务端回 422 请用户
* 选(UploadDecisionBody),选完带着答案重传。答案跟着这一份文件和表头行号走,换文件、
* 改行号就清掉,重新问
*/
function TableUploader({ initialName, taken, onClose, onImported }: {
function TableUploader({ initialName, taken, sqliteTaken, onClose, onImported }: {
initialName?: string
/** 已经被数据库占用的名字(传表格只能就地替换表格,不能顶掉数据库) */
taken: string[]
/**
* 手工登记的 SQLite 源的名字:可能是还没迁移的早期上传,服务端允许同名替换,也可能是真正的手工库,
* 服务端回 409。前端分不出来,只提示、不拦
*/
sqliteTaken: string[]
onClose: () => void
onImported: (row: any) => void
}) {
@@ -1491,12 +1781,28 @@ function TableUploader({ initialName, taken, onClose, onImported }: {
const [busy, setBusy] = useState(false)
const [progress, setProgress] = useState<{ p: UploadProgress; sentAt?: number } | null>(null)
const [dragging, setDragging] = useState(false)
const [result, setResult] = useState<Awaited<ReturnType<typeof api.datasources.uploadTable>> | null>(null)
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])
// 弹窗关掉时字节还没发完,就不传了:后端收不全,什么都不会建。已经发完的让它
// 做完——后端已经在建表,这时断开只会让人以为没传上
@@ -1505,6 +1811,8 @@ function TableUploader({ initialName, taken, onClose, onImported }: {
const pick = (f: File | null) => {
setFile(f)
setResult(null)
setDecision(null)
setChoices(NO_CHOICES)
// 拿文件名当默认数据源名。它会变成工具名的一部分,所以只留 ASCII;
// 中文文件名清洗完可能什么都不剩,那就让用户自己填
if (f && !name) {
@@ -1516,18 +1824,23 @@ function TableUploader({ initialName, taken, onClose, onImported }: {
const nameError = name && !NAME_RE.test(name)
? '须以小写字母开头,只能包含小写字母、数字和下划线'
: taken.includes(name) ? `「${name}」已被其他数据库用作标识,请更换名称` : null
: taken.includes(name) ? `「${name}」已被其他数据库用作标识,请更换名称`
: rejected && rejected.name === name ? rejected.message : null
const submit = async () => {
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 }, {
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
@@ -1537,8 +1850,14 @@ function TableUploader({ initialName, taken, onClose, onImported }: {
setResult(out)
onImported(out.source)
} catch (e) {
const asked = uploadDecision(e)
if (isAbort(e)) toast.info(`上传已取消,「${name}」未创建或修改`)
else toast.error(e)
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)
@@ -1546,16 +1865,68 @@ function TableUploader({ initialName, taken, onClose, onImported }: {
}
}
const changeHeaderRow = (n: number) => {
setHeaderRow(n)
// 换了表头行,解析出来的就是另一张表:上次的回答不作数
setChoices(NO_CHOICES)
}
const retry = () => {
// 回到表单改行号:文件、名字都留着,同名重传会就地替换刚才那份
setResult(null)
setHeaderRow((n) => n + 1)
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) {
const flagged = result.tables.map((t) => suspiciousColumns(t.columns))
// 看原表头像不像数据: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
@@ -1574,40 +1945,21 @@ function TableUploader({ initialName, taken, onClose, onImported }: {
<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={{ borderColor: 'color-mix(in srgb, var(--warn) 45%, var(--border))', background: 'color-mix(in srgb, var(--warn) 8%, transparent)' }}>
<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} 行,
可尝试改为第 {headerRow + 1} 行后重新上传。
</span>
</div>
)}
{result.tables.map((t, i) => (
<div key={t.name} className="rounded-lg border bg-bg p-2.5">
<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">工作表「{t.sheet}」</span>}
<span className="tnum text-faint">{formatNumber(t.rows)} 行 · {t.columns.length} 列</span>
</div>
<div className="flex flex-wrap gap-1">
{t.columns.map((c, j) => {
const bad = flagged[i].has(c.name)
return (
<span key={`${c.name}-${j}`} className="chip" data-suspicious={bad || undefined}
style={bad ? { color: 'var(--warn)', borderColor: 'var(--warn)', background: 'color-mix(in srgb, var(--warn) 10%, transparent)' } : undefined}
title={bad ? '该列名看起来像一行数据' : undefined}>
<span className="mono">{c.name || '(空)'}</span>
<span className="mono text-faint">{c.type}</span>
</span>
)
})}
</div>
</div>
<UploadedTableCard key={t.name} table={t} flagged={flagged[i]} sheetNoun={sheetNoun} />
))}
<UploadNotes result={result} />
</div>
</Modal>
)
@@ -1657,6 +2009,14 @@ function TableUploader({ initialName, taken, onClose, onImported }: {
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}
@@ -1666,9 +2026,11 @@ function TableUploader({ initialName, taken, onClose, onImported }: {
<div className="grid grid-cols-[1fr_120px] gap-3">
<Field label="数据源名" required error={nameError}
hint={`将用作工具名的一部分(db_query__${name || 'sales'});同名重新上传会直接替换`}>
hint={sqliteTaken.includes(name)
? <span className="text-[var(--warn)]" data-name-sqlite>{UPLOAD_TEXT.nameSqliteTaken}</span>
: `将用作工具名的一部分(db_query__${name || 'sales'});同名重新上传会直接替换`}>
{(p) => (
<input {...p} className="field mono" value={name} placeholder="sales" autoComplete="off" spellCheck={false}
<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)} />
)}
@@ -1676,7 +2038,7 @@ function TableUploader({ initialName, taken, onClose, onImported }: {
<Field label="表头在第几行">
{(p) => (
<input {...p} ref={headerRef} className="field tnum" type="number" min={1} value={headerRow}
onChange={(e) => setHeaderRow(Math.max(1, Number(e.target.value) || 1))} />
onChange={(e) => changeHeaderRow(Math.max(1, Number(e.target.value) || 1))} />
)}
</Field>
</div>
+102
View File
@@ -227,6 +227,108 @@ export interface DataSource extends ServerHealth {
tools?: string[]
/** 缓存按哪个 schema 探的:'' 默认 schema,null 没有缓存 */
cached_schema?: string | null
/**
* upload:上传的表格(连接信息由系统维护,不能手改、不能重新探查,更新数据靠同名重新上传);
* manual:手工登记的连接。卡片按它分到「表格」「数据库」两个标签,不再按库文件路径猜
*/
origin?: 'upload' | 'manual' | (string & {})
/** 上传表格当前启用的版本。手工登记的源恒为 null */
current_snapshot?: CurrentSnapshot | null
}
/** 上传表格当前启用的版本(DataSource.current_snapshot) */
export interface CurrentSnapshot {
id: string
/**
* 这一版导入(发布)的时间。迁移补建的初始版本(raw_state 为 absent、没有文件名)例外:
* 这里是迁移那一刻(升级后服务启动的时间),不是导入时间
*/
created_at: string | null
/** 上传时的文件名。迁移前上传的老版本没有记录,是空串 */
file_name: string
/** 原件:kept 保存在服务端;purged 已清除;absent 没有保存(迁移前上传的老版本) */
raw_state: 'kept' | 'purged' | 'absent' | (string & {})
}
/**
* 上传表格时解析器要用户先拍板的事(POST /api/datasources/upload 回 422,body 是
* {detail, decision})。mixed:数字列混入非数字;shape:交叉表、多块结构
*/
export type UploadDecision =
| { kind: 'mixed'; details: { columns: UploadMixedColumn[] } }
| {
kind: 'shape'
details: {
reasons: UploadShapeReason[]
/** 预告:选了按原样导入之后还会问的混合列(没有就是 undefined) */
mixed?: UploadMixedColumn[]
/** 预告是不是看完了整张表;为假时之后的混合列可能比这里多 */
mixed_complete?: boolean
}
}
/** 一列以数字为主、混有非数字的值。values 是非数字的取值和个数(最多五种) */
export interface UploadMixedColumn {
sheet: string
table: string
/** SQL 列名 */
column: string
/** 原表头 */
header: string
numeric: number
nonnumeric: number
values: { value: string; count: number }[]
}
/** 这张表不是一行一条记录的一条理由。cells 是 A1 坐标或区域,message 是给人看的一句话(已含坐标) */
export interface UploadShapeReason {
sheet: string
/** date_header / date_row / section_title / table_totals / formula_above */
kind: string
cells: string[]
message: string
}
/** 导入回执里的一张表 */
export interface UploadedTable {
name: string
/** 原工作表名(CSV 是文件名) */
sheet: string
rows: number
/** name 是 SQL 列名,header 是原表头(表头格为空时是空串) */
columns: { name: string; type: string; header?: string | null }[]
/** 导入区域(A1,表头行到最后一行数据) */
region?: string | null
/** 按原样导入、未经规整 */
unshaped?: boolean
blank_rows_skipped?: number
/** 去掉的左右两侧整列为空的列(列字母) */
columns_trimmed?: string[]
}
/** 导入时做过的类型转换:kind 是 thousands_separator / nonnumeric_to_null / kept_as_text */
export interface UploadConversion {
table: string
column: string
kind: string
count: number
examples: unknown[]
}
/** POST /api/datasources/upload 成功时的回执 */
export interface UploadResult {
source: DataSource
/** 同名就地更新(新版本替换了旧版本),不是新建 */
replaced: boolean
import_id?: string
snapshot_id?: string
/** 同一份文件、同样的选项以前导入过,沿用了那次的库 */
build_reused?: boolean
/** reason:hidden 隐藏工作表不导入;empty 没有内容。state:visible / hidden / veryHidden */
skipped_sheets?: { sheet: string; state: string; reason: string }[]
conversions?: UploadConversion[]
warnings?: string[]
tables: UploadedTable[]
}
/** 自定义工具(GET /api/custom-tools 的一行;POST / PATCH 的返回同形) */