mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:31:58 +00:00
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:
+65
-13
@@ -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)}` : ''}`, {}),
|
||||
|
||||
@@ -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: '未保存原件',
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 的返回同形) */
|
||||
|
||||
Reference in New Issue
Block a user