dsh-lh-data 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +332 -0
- package/cordis.patch.yml +7 -0
- package/lib/admin-contract.d.ts +274 -0
- package/lib/admin-http.d.ts +31 -0
- package/lib/admin-validate.d.ts +87 -0
- package/lib/admin.d.ts +60 -0
- package/lib/client.js +2969 -0
- package/lib/client.js.map +1 -0
- package/lib/datasource/columns.d.ts +13 -0
- package/lib/datasource/connection.d.ts +33 -0
- package/lib/datasource/connector/base.d.ts +21 -0
- package/lib/datasource/connector/mysql.d.ts +23 -0
- package/lib/datasource/connector/postgresql.d.ts +23 -0
- package/lib/datasource/crypto.d.ts +13 -0
- package/lib/datasource/driver.d.ts +39 -0
- package/lib/datasource/errors.d.ts +25 -0
- package/lib/datasource/importer.d.ts +38 -0
- package/lib/datasource/source-sql.d.ts +11 -0
- package/lib/datasource/source-store.d.ts +28 -0
- package/lib/datasource/types.d.ts +119 -0
- package/lib/db.d.ts +91 -0
- package/lib/http-common.d.ts +31 -0
- package/lib/http.d.ts +13 -0
- package/lib/index.d.ts +50 -0
- package/lib/index.js +5735 -0
- package/lib/parse.d.ts +41 -0
- package/lib/preview.d.ts +75 -0
- package/lib/render.d.ts +100 -0
- package/lib/scope-registry.d.ts +47 -0
- package/lib/scope.d.ts +56 -0
- package/lib/sql.d.ts +133 -0
- package/lib/store.d.ts +174 -0
- package/lib/table.d.ts +38 -0
- package/lib/tooling.d.ts +267 -0
- package/lib/tools/datasource.d.ts +16 -0
- package/lib/tools/import.d.ts +21 -0
- package/lib/tools/read.d.ts +77 -0
- package/lib/tools/registry.d.ts +12 -0
- package/lib/tools/write.d.ts +37 -0
- package/lib/view.d.ts +138 -0
- package/package.json +61 -0
- package/src/admin-contract.ts +351 -0
- package/src/admin-http.ts +254 -0
- package/src/admin-validate.ts +393 -0
- package/src/admin.ts +558 -0
- package/src/client/index.ts +403 -0
- package/src/client/settings/CreateForm.tsx +186 -0
- package/src/client/settings/DataSourceForm.tsx +278 -0
- package/src/client/settings/DataSourcesPanel.tsx +301 -0
- package/src/client/settings/DatasetEditor.tsx +245 -0
- package/src/client/settings/DatasetTable.tsx +110 -0
- package/src/client/settings/DatasetsPanel.tsx +207 -0
- package/src/client/settings/RowsPanel.tsx +196 -0
- package/src/client/settings/Section.tsx +41 -0
- package/src/client/settings/SourceTablesPanel.tsx +226 -0
- package/src/client/settings/api.ts +154 -0
- package/src/client/settings/styles.ts +125 -0
- package/src/datasource/columns.ts +56 -0
- package/src/datasource/connection.ts +124 -0
- package/src/datasource/connector/base.ts +54 -0
- package/src/datasource/connector/mysql.ts +187 -0
- package/src/datasource/connector/postgresql.ts +212 -0
- package/src/datasource/crypto.ts +61 -0
- package/src/datasource/driver.ts +108 -0
- package/src/datasource/errors.ts +69 -0
- package/src/datasource/importer.ts +262 -0
- package/src/datasource/index.ts +62 -0
- package/src/datasource/source-sql.ts +64 -0
- package/src/datasource/source-store.ts +152 -0
- package/src/datasource/types.ts +133 -0
- package/src/db.ts +277 -0
- package/src/http-common.ts +91 -0
- package/src/http.ts +130 -0
- package/src/index.ts +486 -0
- package/src/parse.ts +213 -0
- package/src/preview.ts +198 -0
- package/src/render.ts +294 -0
- package/src/scope-registry.ts +111 -0
- package/src/scope.ts +159 -0
- package/src/sql.ts +491 -0
- package/src/store.ts +412 -0
- package/src/table.ts +160 -0
- package/src/tooling.ts +551 -0
- package/src/tools/datasource.ts +378 -0
- package/src/tools/import.ts +282 -0
- package/src/tools/read.ts +536 -0
- package/src/tools/registry.ts +56 -0
- package/src/tools/write.ts +241 -0
- package/src/view.ts +371 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器半身:`dataset_query` 的结果表格卡片 —— 设计文档 §7。
|
|
3
|
+
*
|
|
4
|
+
* 它接管 `tool.call.toolview` 上 key = `dataset_query` 的渲染位,按工具结果里的
|
|
5
|
+
* `meta`(`output.presentationMeta` 产出,模型不可见)自动拉取并展示完整结果:
|
|
6
|
+
* 翻页、排序、过期提示,全部只带 `viewId`,不接触任何 SQL 或物理表名。
|
|
7
|
+
*
|
|
8
|
+
* 构建契约(与 dsh 的 client bundle preset 一致):
|
|
9
|
+
* - 产物是 CJS 工厂,由 `window.__ModuleLoader__.load({ id, factory })` 注册;
|
|
10
|
+
* - `react` 是平台 seed 词,运行时由模块表提供,打包时保持 external;
|
|
11
|
+
* - 只能用基线能力(React + cordis slots),不请求任何额外工作区包。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
15
|
+
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
16
|
+
import { ADMIN_SECTION_ID, ADMIN_SECTION_LABEL, ADMIN_SECTION_ORDER } from '../admin-contract'
|
|
17
|
+
import { DatasetSettingsSection } from './settings/Section'
|
|
18
|
+
|
|
19
|
+
/** 本插件注册的工具名,也是 `tool.call.toolview` 的 keyed 派发键。 */
|
|
20
|
+
const TOOL_KEY = 'dataset_query'
|
|
21
|
+
const TOOLVIEW_SLOT = 'tool.call.toolview'
|
|
22
|
+
|
|
23
|
+
// ── 平台面(只声明本组件实际用到的部分) ──────────────────────────────────
|
|
24
|
+
|
|
25
|
+
interface SlotRegistry {
|
|
26
|
+
inject(slot: string, setup: () => () => void): void
|
|
27
|
+
// spec 是开放对象:toolview 用 key,settings.section 用 id/order/label 等。
|
|
28
|
+
register(spec: { name: string; [key: string]: unknown }, component: unknown): () => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ToolCallViewProps {
|
|
32
|
+
toolName: string
|
|
33
|
+
/** 冻结中的调用或已落地的结果节点。 */
|
|
34
|
+
block: {
|
|
35
|
+
meta?: unknown
|
|
36
|
+
content?: { type: string; text?: string }[]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── 视图元数据契约(与 view.ts 的 ViewDescriptor 对齐) ───────────────────
|
|
41
|
+
|
|
42
|
+
interface ViewColumn { name: string; type: string }
|
|
43
|
+
|
|
44
|
+
interface DatasetViewMeta {
|
|
45
|
+
kind: string
|
|
46
|
+
viewId: string
|
|
47
|
+
endpoint: string
|
|
48
|
+
datasetId: string
|
|
49
|
+
name: string
|
|
50
|
+
columns: ViewColumn[]
|
|
51
|
+
totalRows: number
|
|
52
|
+
pageSize: number
|
|
53
|
+
maxPageSize: number
|
|
54
|
+
stable: boolean
|
|
55
|
+
sortable: string[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface PagePayload {
|
|
59
|
+
columns: string[]
|
|
60
|
+
rows: Record<string, unknown>[]
|
|
61
|
+
page: number
|
|
62
|
+
pageSize: number
|
|
63
|
+
totalRows: number
|
|
64
|
+
totalPages: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function asViewMeta(value: unknown): DatasetViewMeta | undefined {
|
|
68
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
69
|
+
const meta = value as Partial<DatasetViewMeta>
|
|
70
|
+
if (meta.kind !== 'dataset-view') return undefined
|
|
71
|
+
if (typeof meta.viewId !== 'string' || typeof meta.endpoint !== 'string') return undefined
|
|
72
|
+
return meta as DatasetViewMeta
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── 样式(内联,避免引入 CSS 管线) ────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
const styles = {
|
|
78
|
+
wrap: { margin: '8px 0', fontSize: 12, color: 'var(--dsw-fg-secondary, #666)' },
|
|
79
|
+
head: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 },
|
|
80
|
+
title: { fontWeight: 600, color: 'var(--dsw-fg, inherit)' },
|
|
81
|
+
scroll: { maxHeight: 420, overflow: 'auto', border: '1px solid var(--dsw-border, #e5e5e5)', borderRadius: 6 },
|
|
82
|
+
table: { borderCollapse: 'collapse', width: '100%', fontSize: 12 },
|
|
83
|
+
th: {
|
|
84
|
+
position: 'sticky', top: 0, background: 'var(--dsw-bg-subtle, #fafafa)',
|
|
85
|
+
textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid var(--dsw-border, #e5e5e5)',
|
|
86
|
+
whiteSpace: 'nowrap',
|
|
87
|
+
} as const,
|
|
88
|
+
thButton: { background: 'none', border: 'none', cursor: 'pointer', font: 'inherit', padding: 0, color: 'inherit' },
|
|
89
|
+
td: { padding: '4px 8px', borderBottom: '1px solid var(--dsw-border, #eee)', maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } as const,
|
|
90
|
+
bar: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 6, flexWrap: 'wrap' },
|
|
91
|
+
button: { border: '1px solid var(--dsw-border, #ddd)', background: 'transparent', borderRadius: 4, padding: '2px 8px', cursor: 'pointer', fontSize: 12 },
|
|
92
|
+
muted: { opacity: 0.7 },
|
|
93
|
+
error: { color: 'var(--dsw-danger, #c0392b)' },
|
|
94
|
+
warn: { background: '#FEF3C7', color: '#92400E', padding: '6px 10px', borderRadius: 6, fontSize: 12, marginBottom: 6 },
|
|
95
|
+
} as const
|
|
96
|
+
|
|
97
|
+
function formatCell(value: unknown): string {
|
|
98
|
+
if (value === null || value === undefined) return ''
|
|
99
|
+
if (typeof value === 'object') {
|
|
100
|
+
try {
|
|
101
|
+
return JSON.stringify(value)
|
|
102
|
+
} catch {
|
|
103
|
+
return '[object]'
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return String(value)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── CSV 导出(纯字符串构造,不引入任何依赖) ─────────────────────────────
|
|
110
|
+
|
|
111
|
+
/** 命中即需要加引号转义的字符:逗号、双引号、回车、换行。 */
|
|
112
|
+
const CSV_NEEDS_QUOTE = /[",\r\n]/
|
|
113
|
+
/** Excel 公式注入的高危前缀;只对文本值生效,负数等数值原样导出。 */
|
|
114
|
+
const CSV_RISKY_PREFIX = /^[=+\-@\t\r]/
|
|
115
|
+
|
|
116
|
+
function toCsvCell(value: unknown): string {
|
|
117
|
+
let text = formatCell(value)
|
|
118
|
+
if (text.length === 0) return ''
|
|
119
|
+
// 前置单引号:Excel 会把它当公式执行,内容本身不变。
|
|
120
|
+
if (typeof value === 'string' && CSV_RISKY_PREFIX.test(text)) text = `'${text}`
|
|
121
|
+
if (CSV_NEEDS_QUOTE.test(text)) text = `"${text.replace(/"/g, '""')}"`
|
|
122
|
+
return text
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function toCsvRow(cells: unknown[]): string {
|
|
126
|
+
return cells.map(cell => toCsvCell(cell)).join(',')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 表头 + 数据行;行分隔用 `\r\n` 且末行收尾,Excel / Numbers 都不会串列。 */
|
|
130
|
+
function toCsv(columns: string[], rows: Record<string, unknown>[]): string {
|
|
131
|
+
const lines = [toCsvRow(columns)]
|
|
132
|
+
for (const row of rows) lines.push(toCsvRow(columns.map(column => row[column])))
|
|
133
|
+
return `${lines.join('\r\n')}\r\n`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 只拼数据行:导出逐页追加时用(表头由 `toCsv` 的首页分片带出)。 */
|
|
137
|
+
function toCsvRows(columns: string[], rows: Record<string, unknown>[]): string {
|
|
138
|
+
if (rows.length === 0) return ''
|
|
139
|
+
return `${rows.map(row => toCsvRow(columns.map(column => row[column]))).join('\r\n')}\r\n`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 文件名 `名称-YYYYMMDD-HHmmss.csv`,剔除文件系统不接受与控制字符。 */
|
|
143
|
+
function buildCsvFileName(name: string, now: Date = new Date()): string {
|
|
144
|
+
const pad = (value: number): string => String(value).padStart(2, '0')
|
|
145
|
+
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
|
|
146
|
+
+ `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
|
147
|
+
const safe = name.replace(/[\\/:*?"<>|\r\n\t]/g, '_').trim()
|
|
148
|
+
return `${safe.length > 0 ? safe : 'dataset'}-${stamp}.csv`
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Blob + 临时 `<a download>` 触发下载;前置 BOM 让 Excel 认成 UTF-8。 */
|
|
152
|
+
function downloadCsv(filename: string, text: string): void {
|
|
153
|
+
const url = URL.createObjectURL(new Blob([`\uFEFF${text}`], { type: 'text/csv;charset=utf-8' }))
|
|
154
|
+
const link = document.createElement('a')
|
|
155
|
+
link.href = url
|
|
156
|
+
link.download = filename
|
|
157
|
+
link.style.display = 'none'
|
|
158
|
+
document.body.appendChild(link)
|
|
159
|
+
link.click()
|
|
160
|
+
link.remove()
|
|
161
|
+
// 立刻回收会让部分浏览器来不及取流,延后一拍释放。
|
|
162
|
+
setTimeout(() => URL.revokeObjectURL(url), 0)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── 卡片 ──────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
interface CardState {
|
|
168
|
+
status: 'loading' | 'ready' | 'error'
|
|
169
|
+
payload?: PagePayload
|
|
170
|
+
message?: string
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 导出进度与结果(与分页取数状态分开,互不覆盖)。 */
|
|
174
|
+
interface ExportState {
|
|
175
|
+
running: boolean
|
|
176
|
+
progress?: { done: number; total: number }
|
|
177
|
+
error?: string
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function DatasetViewCard(props: ToolCallViewProps): React.ReactElement | null {
|
|
181
|
+
const meta = useMemo(() => asViewMeta(props.block?.meta), [props.block])
|
|
182
|
+
const [page, setPage] = useState(1)
|
|
183
|
+
const [sort, setSort] = useState<{ column: string; order: 'asc' | 'desc' } | undefined>(undefined)
|
|
184
|
+
const [state, setState] = useState<CardState>({ status: 'loading' })
|
|
185
|
+
const requestId = useRef(0)
|
|
186
|
+
// 导出走独立通道:翻页不打断它,只有卸载与排序变更才中止。
|
|
187
|
+
const exportAbort = useRef<AbortController | undefined>(undefined)
|
|
188
|
+
const [exportState, setExportState] = useState<ExportState>({ running: false })
|
|
189
|
+
|
|
190
|
+
useEffect(() => () => exportAbort.current?.abort(), [])
|
|
191
|
+
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
if (meta === undefined) return
|
|
194
|
+
const controller = new AbortController()
|
|
195
|
+
const id = requestId.current + 1
|
|
196
|
+
requestId.current = id
|
|
197
|
+
setState(previous => previous.status === 'ready' ? { ...previous, status: 'loading' } : previous)
|
|
198
|
+
|
|
199
|
+
const url = new URL(meta.endpoint, window.location.origin)
|
|
200
|
+
url.searchParams.set('page', String(page))
|
|
201
|
+
url.searchParams.set('pageSize', String(meta.pageSize))
|
|
202
|
+
if (sort !== undefined) {
|
|
203
|
+
url.searchParams.set('sort', sort.column)
|
|
204
|
+
url.searchParams.set('order', sort.order)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
void (async () => {
|
|
208
|
+
try {
|
|
209
|
+
const response = await fetch(url.toString(), { signal: controller.signal, credentials: 'same-origin' })
|
|
210
|
+
if (!response.ok) {
|
|
211
|
+
const body = await response.json().catch(() => null) as { error?: { code?: string } } | null
|
|
212
|
+
if (id === requestId.current) {
|
|
213
|
+
// 出错时保留上一页 payload,表格不清空,仅顶部显示软提示。
|
|
214
|
+
setState(previous => ({
|
|
215
|
+
status: 'error',
|
|
216
|
+
payload: previous.payload,
|
|
217
|
+
message: body?.error?.code === 'VIEW_NOT_FOUND' || response.status === 404
|
|
218
|
+
? '数据接口不可用(请检查视图路由前缀配置或插件是否已启用视图)'
|
|
219
|
+
: `取数失败(HTTP ${response.status})`,
|
|
220
|
+
}))
|
|
221
|
+
}
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
const payload = await response.json() as PagePayload
|
|
225
|
+
if (id === requestId.current) setState({ status: 'ready', payload })
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (controller.signal.aborted) return
|
|
228
|
+
if (id === requestId.current) setState({ status: 'error', message: `取数失败:${String(error)}` })
|
|
229
|
+
}
|
|
230
|
+
})()
|
|
231
|
+
|
|
232
|
+
return () => controller.abort()
|
|
233
|
+
}, [meta, page, sort])
|
|
234
|
+
|
|
235
|
+
const toggleSort = useCallback((column: string) => {
|
|
236
|
+
if (meta === undefined || !meta.sortable.includes(column)) return
|
|
237
|
+
// 顺序变了,正在导出的那份行序就作废。
|
|
238
|
+
if (exportAbort.current !== undefined) {
|
|
239
|
+
exportAbort.current.abort()
|
|
240
|
+
exportAbort.current = undefined
|
|
241
|
+
setExportState({ running: false })
|
|
242
|
+
}
|
|
243
|
+
setPage(1)
|
|
244
|
+
setSort(previous => previous?.column === column
|
|
245
|
+
? { column, order: previous.order === 'asc' ? 'desc' : 'asc' }
|
|
246
|
+
: { column, order: 'asc' })
|
|
247
|
+
}, [meta])
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 导出全部可浏览行为 CSV:把 `pageSize` 顶到 `maxPageSize`,从第 1 页顺序翻到
|
|
251
|
+
* 末页,不受当前页码/每页行数限制。每页转成字符串分片后即丢掉行对象,
|
|
252
|
+
* 避免几万行对象同时常驻;最后拼一次 Blob 触发下载。
|
|
253
|
+
*/
|
|
254
|
+
const exportCsv = useCallback(() => {
|
|
255
|
+
if (meta === undefined) return
|
|
256
|
+
exportAbort.current?.abort()
|
|
257
|
+
const controller = new AbortController()
|
|
258
|
+
exportAbort.current = controller
|
|
259
|
+
setExportState({ running: true, progress: { done: 0, total: meta.totalRows } })
|
|
260
|
+
|
|
261
|
+
void (async () => {
|
|
262
|
+
try {
|
|
263
|
+
const base = new URL(meta.endpoint, window.location.origin)
|
|
264
|
+
base.searchParams.set('pageSize', String(Math.max(1, Math.floor(meta.maxPageSize > 0 ? meta.maxPageSize : meta.pageSize))))
|
|
265
|
+
if (sort !== undefined) {
|
|
266
|
+
base.searchParams.set('sort', sort.column)
|
|
267
|
+
base.searchParams.set('order', sort.order)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const chunks: string[] = []
|
|
271
|
+
let exportColumns = meta.columns.map(column => column.name)
|
|
272
|
+
let totalRows = meta.totalRows
|
|
273
|
+
let totalPages = 1
|
|
274
|
+
let done = 0
|
|
275
|
+
|
|
276
|
+
for (let index = 1; index <= totalPages; index += 1) {
|
|
277
|
+
if (controller.signal.aborted) return
|
|
278
|
+
const url = new URL(base.toString())
|
|
279
|
+
url.searchParams.set('page', String(index))
|
|
280
|
+
const response = await fetch(url.toString(), { signal: controller.signal, credentials: 'same-origin' })
|
|
281
|
+
if (!response.ok) {
|
|
282
|
+
const body = await response.json().catch(() => null) as { error?: { code?: string } } | null
|
|
283
|
+
if (controller.signal.aborted) return
|
|
284
|
+
throw new Error(body?.error?.code === 'VIEW_NOT_FOUND' || response.status === 404
|
|
285
|
+
? '数据接口不可用(请检查视图路由前缀配置或插件是否已启用视图)'
|
|
286
|
+
: `导出失败(HTTP ${response.status})`)
|
|
287
|
+
}
|
|
288
|
+
const current = await response.json() as PagePayload
|
|
289
|
+
if (controller.signal.aborted) return
|
|
290
|
+
if (index === 1) {
|
|
291
|
+
// 列集合与总页数只认首屏,避免中途数据变化导致串列。
|
|
292
|
+
exportColumns = current.columns.length > 0 ? current.columns : exportColumns
|
|
293
|
+
totalRows = current.totalRows
|
|
294
|
+
totalPages = Math.max(1, current.totalPages)
|
|
295
|
+
chunks.push(toCsv(exportColumns, current.rows))
|
|
296
|
+
} else {
|
|
297
|
+
chunks.push(toCsvRows(exportColumns, current.rows))
|
|
298
|
+
}
|
|
299
|
+
done += current.rows.length
|
|
300
|
+
setExportState({ running: true, progress: { done, total: totalRows } })
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
downloadCsv(buildCsvFileName(meta.name), chunks.join(''))
|
|
304
|
+
if (exportAbort.current === controller) exportAbort.current = undefined
|
|
305
|
+
setExportState({ running: false })
|
|
306
|
+
} catch (error) {
|
|
307
|
+
if (controller.signal.aborted) return
|
|
308
|
+
if (exportAbort.current === controller) exportAbort.current = undefined
|
|
309
|
+
setExportState({ running: false, error: error instanceof Error ? error.message : `导出失败:${String(error)}` })
|
|
310
|
+
}
|
|
311
|
+
})()
|
|
312
|
+
}, [meta, sort])
|
|
313
|
+
|
|
314
|
+
// 无视图(小结果集 / 主机未启用视图):退化为纯文本,不发起任何请求。
|
|
315
|
+
if (meta === undefined) {
|
|
316
|
+
const text = (props.block?.content ?? [])
|
|
317
|
+
.filter(block => block.type === 'text')
|
|
318
|
+
.map(block => block.text ?? '')
|
|
319
|
+
.join('\n')
|
|
320
|
+
return React.createElement('div', { style: styles.wrap }, React.createElement('pre', { style: { margin: 0, whiteSpace: 'pre-wrap' } }, text))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const payload = state.payload
|
|
324
|
+
const columns = payload?.columns ?? meta.columns.map(column => column.name)
|
|
325
|
+
const rows = payload?.rows ?? []
|
|
326
|
+
const totalPages = payload?.totalPages ?? Math.max(1, Math.ceil(meta.totalRows / meta.pageSize))
|
|
327
|
+
const busy = state.status === 'loading' && payload === undefined
|
|
328
|
+
// 服务端把 page 夹到分页边界内,首屏拿到 totalPages 后即可反推可浏览的行数上限。
|
|
329
|
+
const servableRows = payload === undefined ? meta.totalRows : payload.totalPages * payload.pageSize
|
|
330
|
+
|
|
331
|
+
const head = React.createElement('tr', null, ...columns.map(column => React.createElement(
|
|
332
|
+
'th',
|
|
333
|
+
{ key: column, style: styles.th },
|
|
334
|
+
meta.sortable.includes(column)
|
|
335
|
+
? React.createElement('button', {
|
|
336
|
+
type: 'button',
|
|
337
|
+
style: styles.thButton,
|
|
338
|
+
onClick: () => toggleSort(column),
|
|
339
|
+
title: '按该列排序',
|
|
340
|
+
}, `${column}${sort?.column === column ? (sort.order === 'asc' ? ' ↑' : ' ↓') : ''}`)
|
|
341
|
+
: column,
|
|
342
|
+
)))
|
|
343
|
+
|
|
344
|
+
const body = rows.map((row, index) => React.createElement('tr', { key: String(row._row_id ?? index) },
|
|
345
|
+
...columns.map(column => React.createElement('td', { key: column, style: styles.td, title: formatCell(row[column]) }, formatCell(row[column])))))
|
|
346
|
+
|
|
347
|
+
return React.createElement('div', { style: styles.wrap },
|
|
348
|
+
React.createElement('div', { style: styles.warn }, '数据可能已发生变化,以最新查询结果为准'),
|
|
349
|
+
React.createElement('div', { style: styles.head },
|
|
350
|
+
React.createElement('span', { style: styles.title }, `数据集 ${meta.name}`),
|
|
351
|
+
React.createElement('span', null, `共 ${meta.totalRows} 行 · ${meta.columns.length} 列`),
|
|
352
|
+
servableRows < meta.totalRows
|
|
353
|
+
? React.createElement('span', { style: styles.muted }, `(仅可浏览前 ${servableRows} 行)`)
|
|
354
|
+
: null,
|
|
355
|
+
state.status === 'error' ? React.createElement('span', { style: styles.error }, state.message ?? '') : null,
|
|
356
|
+
busy ? React.createElement('span', { style: styles.muted }, '加载中…') : null,
|
|
357
|
+
),
|
|
358
|
+
React.createElement('div', { style: styles.scroll },
|
|
359
|
+
React.createElement('table', { style: styles.table },
|
|
360
|
+
React.createElement('thead', null, head),
|
|
361
|
+
React.createElement('tbody', null, ...body))),
|
|
362
|
+
React.createElement('div', { style: styles.bar },
|
|
363
|
+
React.createElement('button', { type: 'button', style: styles.button, disabled: page <= 1, onClick: () => setPage(1) }, '« 首页'),
|
|
364
|
+
React.createElement('button', { type: 'button', style: styles.button, disabled: page <= 1, onClick: () => setPage(current => Math.max(1, current - 1)) }, '上一页'),
|
|
365
|
+
React.createElement('span', null, `第 ${payload?.page ?? page} / ${totalPages} 页`),
|
|
366
|
+
React.createElement('button', {
|
|
367
|
+
type: 'button',
|
|
368
|
+
style: styles.button,
|
|
369
|
+
disabled: !meta.stable || page >= totalPages,
|
|
370
|
+
onClick: () => setPage(current => Math.min(totalPages, current + 1)),
|
|
371
|
+
}, '下一页'),
|
|
372
|
+
!meta.stable ? React.createElement('span', { style: styles.muted }, '(该查询未声明稳定排序,仅展示首页)') : null,
|
|
373
|
+
React.createElement('button', {
|
|
374
|
+
type: 'button',
|
|
375
|
+
style: styles.button,
|
|
376
|
+
disabled: exportState.running || state.status === 'error',
|
|
377
|
+
title: `按当前排序导出全部可浏览行(${servableRows} 行)为 CSV`,
|
|
378
|
+
onClick: exportCsv,
|
|
379
|
+
}, exportState.running
|
|
380
|
+
? `导出中…(${exportState.progress?.done ?? 0} / ${exportState.progress?.total ?? servableRows} 行)`
|
|
381
|
+
: '导出 CSV'),
|
|
382
|
+
exportState.error === undefined
|
|
383
|
+
? null
|
|
384
|
+
: React.createElement('span', { style: styles.error }, exportState.error),
|
|
385
|
+
))
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── cordis 插件(浏览器半身) ─────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
export const name = 'dsh-lh-data'
|
|
391
|
+
export const inject = ['slots']
|
|
392
|
+
|
|
393
|
+
export function apply(ctx: ClientContext): void {
|
|
394
|
+
const slots = (ctx as unknown as { slots?: SlotRegistry }).slots
|
|
395
|
+
if (slots === undefined) return
|
|
396
|
+
slots.inject(TOOLVIEW_SLOT, () => slots.register({ name: TOOLVIEW_SLOT, key: TOOL_KEY }, DatasetViewCard))
|
|
397
|
+
// 设置面板新增「数据集」菜单(外部插件可自由注册该 list slot)。
|
|
398
|
+
slots.inject('settings.section', () =>
|
|
399
|
+
slots.register(
|
|
400
|
+
{ name: 'settings.section', id: ADMIN_SECTION_ID, order: ADMIN_SECTION_ORDER, label: ADMIN_SECTION_LABEL },
|
|
401
|
+
DatasetSettingsSection,
|
|
402
|
+
))
|
|
403
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 新建空数据集表单:选工作区 + 填名称/描述/来源 + 逐行定义列。
|
|
3
|
+
* 列结构提交后即冻结(设置页不支持改列)。提交走 `createDataset`。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState } from 'react'
|
|
7
|
+
import type { ReactElement } from 'react'
|
|
8
|
+
import {
|
|
9
|
+
ADMIN_COLUMN_TYPES,
|
|
10
|
+
ADMIN_COLUMN_TYPE_LABELS,
|
|
11
|
+
ADMIN_MAX_COLUMNS,
|
|
12
|
+
type DatasetColumnSpec,
|
|
13
|
+
type ScopeView,
|
|
14
|
+
} from '../../admin-contract'
|
|
15
|
+
import { createDataset } from './api'
|
|
16
|
+
import { c, s } from './styles'
|
|
17
|
+
|
|
18
|
+
export interface CreateFormProps {
|
|
19
|
+
scopes: ScopeView[]
|
|
20
|
+
onCreated: (detail: unknown) => void
|
|
21
|
+
onCancel: () => void
|
|
22
|
+
onError: (error: Error) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ColumnDraft {
|
|
26
|
+
name: string
|
|
27
|
+
type: string
|
|
28
|
+
nullable: boolean
|
|
29
|
+
description: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const EMPTY_COLUMN: ColumnDraft = { name: '', type: 'text', nullable: false, description: '' }
|
|
33
|
+
|
|
34
|
+
export function CreateForm(props: CreateFormProps): ReactElement {
|
|
35
|
+
const { scopes, onCreated, onCancel, onError } = props
|
|
36
|
+
const [scopeKey, setScopeKey] = useState(scopes[0]?.scopeKey ?? '')
|
|
37
|
+
const [name, setName] = useState('')
|
|
38
|
+
const [description, setDescription] = useState('')
|
|
39
|
+
const [sourcePath, setSourcePath] = useState('')
|
|
40
|
+
const [columns, setColumns] = useState<ColumnDraft[]>([{ ...EMPTY_COLUMN }])
|
|
41
|
+
const [submitting, setSubmitting] = useState(false)
|
|
42
|
+
|
|
43
|
+
const canSubmit =
|
|
44
|
+
scopeKey.length > 0 &&
|
|
45
|
+
name.trim().length > 0 &&
|
|
46
|
+
columns.length > 0 &&
|
|
47
|
+
columns.length <= ADMIN_MAX_COLUMNS &&
|
|
48
|
+
columns.every(column => column.name.trim().length > 0)
|
|
49
|
+
|
|
50
|
+
function updateColumn(index: number, patch: Partial<ColumnDraft>): void {
|
|
51
|
+
setColumns(previous => previous.map((column, i) => (i === index ? { ...column, ...patch } : column)))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function handleSubmit(): Promise<void> {
|
|
55
|
+
if (!canSubmit || submitting) return
|
|
56
|
+
const body = {
|
|
57
|
+
scopeKey,
|
|
58
|
+
name: name.trim(),
|
|
59
|
+
description: description.trim().length > 0 ? description.trim() : undefined,
|
|
60
|
+
sourcePath: sourcePath.trim().length > 0 ? sourcePath.trim() : null,
|
|
61
|
+
columns: columns.map<{ name: string; type: DatasetColumnSpec['type']; nullable: boolean; description?: string }>(column => ({
|
|
62
|
+
name: column.name.trim(),
|
|
63
|
+
type: column.type as DatasetColumnSpec['type'],
|
|
64
|
+
nullable: column.nullable,
|
|
65
|
+
description: column.description.trim().length > 0 ? column.description.trim() : undefined,
|
|
66
|
+
})),
|
|
67
|
+
}
|
|
68
|
+
setSubmitting(true)
|
|
69
|
+
try {
|
|
70
|
+
const created = await createDataset(body)
|
|
71
|
+
onCreated(created)
|
|
72
|
+
} catch (error) {
|
|
73
|
+
onError(error instanceof Error ? error : new Error('新建失败'))
|
|
74
|
+
} finally {
|
|
75
|
+
setSubmitting(false)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div style={s.panel}>
|
|
81
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}>
|
|
82
|
+
<strong style={{ fontSize: 13 }}>新建数据集</strong>
|
|
83
|
+
<span style={s.muted}>空表,建好后用 agent 工具灌入数据</span>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div style={s.grid2}>
|
|
87
|
+
<div style={s.field}>
|
|
88
|
+
<label style={s.label}>工作区</label>
|
|
89
|
+
<select style={s.input} value={scopeKey} disabled={submitting} onChange={e => setScopeKey(e.target.value)}>
|
|
90
|
+
{scopes.length === 0
|
|
91
|
+
? <option value="">(暂无可用工作区)</option>
|
|
92
|
+
: scopes.map(scope => <option key={scope.scopeKey} value={scope.scopeKey}>{scope.scopeKey}</option>)}
|
|
93
|
+
</select>
|
|
94
|
+
</div>
|
|
95
|
+
<div style={s.field}>
|
|
96
|
+
<label style={s.label}>名称</label>
|
|
97
|
+
<input style={s.input} value={name} disabled={submitting} onChange={e => setName(e.target.value)} />
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
<div style={s.grid2}>
|
|
101
|
+
<div style={s.field}>
|
|
102
|
+
<label style={s.label}>描述</label>
|
|
103
|
+
<input style={s.input} value={description} disabled={submitting} onChange={e => setDescription(e.target.value)} />
|
|
104
|
+
</div>
|
|
105
|
+
<div style={s.field}>
|
|
106
|
+
<label style={s.label}>来源路径(可选)</label>
|
|
107
|
+
<input style={s.input} value={sourcePath} disabled={submitting} onChange={e => setSourcePath(e.target.value)} />
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div style={s.label}>列定义({columns.length} / {ADMIN_MAX_COLUMNS})</div>
|
|
112
|
+
<div style={{ ...s.scroll, maxHeight: 220, marginBottom: 10 }}>
|
|
113
|
+
<table style={s.table}>
|
|
114
|
+
<thead>
|
|
115
|
+
<tr>
|
|
116
|
+
<th style={s.th}>列名</th>
|
|
117
|
+
<th style={s.th}>类型</th>
|
|
118
|
+
<th style={s.th}>可空</th>
|
|
119
|
+
<th style={s.th}>说明</th>
|
|
120
|
+
<th style={s.th} />
|
|
121
|
+
</tr>
|
|
122
|
+
</thead>
|
|
123
|
+
<tbody>
|
|
124
|
+
{columns.map((column, index) => (
|
|
125
|
+
<tr key={index}>
|
|
126
|
+
<td style={s.td}>
|
|
127
|
+
<input
|
|
128
|
+
style={{ ...s.input, border: column.name.trim().length === 0 ? `1px solid ${c.danger}` : s.input.border }}
|
|
129
|
+
value={column.name}
|
|
130
|
+
disabled={submitting}
|
|
131
|
+
onChange={e => updateColumn(index, { name: e.target.value })}
|
|
132
|
+
/>
|
|
133
|
+
</td>
|
|
134
|
+
<td style={s.td}>
|
|
135
|
+
<select
|
|
136
|
+
style={s.input}
|
|
137
|
+
value={column.type}
|
|
138
|
+
disabled={submitting}
|
|
139
|
+
onChange={e => updateColumn(index, { type: e.target.value })}
|
|
140
|
+
>
|
|
141
|
+
{ADMIN_COLUMN_TYPES.map(type => (
|
|
142
|
+
<option key={type} value={type}>{ADMIN_COLUMN_TYPE_LABELS[type]}</option>
|
|
143
|
+
))}
|
|
144
|
+
</select>
|
|
145
|
+
</td>
|
|
146
|
+
<td style={s.td}>
|
|
147
|
+
<input
|
|
148
|
+
type="checkbox"
|
|
149
|
+
checked={column.nullable}
|
|
150
|
+
disabled={submitting}
|
|
151
|
+
onChange={e => updateColumn(index, { nullable: e.target.checked })}
|
|
152
|
+
/>
|
|
153
|
+
</td>
|
|
154
|
+
<td style={s.td}>
|
|
155
|
+
<input style={s.input} value={column.description} disabled={submitting} onChange={e => updateColumn(index, { description: e.target.value })} />
|
|
156
|
+
</td>
|
|
157
|
+
<td style={s.td}>
|
|
158
|
+
<span
|
|
159
|
+
style={{ ...s.link, ...(columns.length <= 1 || submitting ? s.disabledButton : null) }}
|
|
160
|
+
onClick={() => { if (columns.length > 1 && !submitting) setColumns(previous => previous.filter((_, i) => i !== index)) }}
|
|
161
|
+
>删除</span>
|
|
162
|
+
</td>
|
|
163
|
+
</tr>
|
|
164
|
+
))}
|
|
165
|
+
</tbody>
|
|
166
|
+
</table>
|
|
167
|
+
</div>
|
|
168
|
+
<button
|
|
169
|
+
type="button"
|
|
170
|
+
style={{ ...s.button, ...(columns.length >= ADMIN_MAX_COLUMNS || submitting ? s.disabledButton : null) }}
|
|
171
|
+
disabled={columns.length >= ADMIN_MAX_COLUMNS || submitting}
|
|
172
|
+
onClick={() => setColumns(previous => [...previous, { ...EMPTY_COLUMN }])}
|
|
173
|
+
>添加一列</button>
|
|
174
|
+
|
|
175
|
+
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
style={{ ...s.primaryButton, ...(!canSubmit || submitting ? s.disabledButton : null) }}
|
|
179
|
+
disabled={!canSubmit || submitting}
|
|
180
|
+
onClick={() => void handleSubmit()}
|
|
181
|
+
>{submitting ? '提交中…' : '提交'}</button>
|
|
182
|
+
<button type="button" style={s.button} disabled={submitting} onClick={onCancel}>取消</button>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
)
|
|
186
|
+
}
|