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.
Files changed (89) hide show
  1. package/README.md +332 -0
  2. package/cordis.patch.yml +7 -0
  3. package/lib/admin-contract.d.ts +274 -0
  4. package/lib/admin-http.d.ts +31 -0
  5. package/lib/admin-validate.d.ts +87 -0
  6. package/lib/admin.d.ts +60 -0
  7. package/lib/client.js +2969 -0
  8. package/lib/client.js.map +1 -0
  9. package/lib/datasource/columns.d.ts +13 -0
  10. package/lib/datasource/connection.d.ts +33 -0
  11. package/lib/datasource/connector/base.d.ts +21 -0
  12. package/lib/datasource/connector/mysql.d.ts +23 -0
  13. package/lib/datasource/connector/postgresql.d.ts +23 -0
  14. package/lib/datasource/crypto.d.ts +13 -0
  15. package/lib/datasource/driver.d.ts +39 -0
  16. package/lib/datasource/errors.d.ts +25 -0
  17. package/lib/datasource/importer.d.ts +38 -0
  18. package/lib/datasource/source-sql.d.ts +11 -0
  19. package/lib/datasource/source-store.d.ts +28 -0
  20. package/lib/datasource/types.d.ts +119 -0
  21. package/lib/db.d.ts +91 -0
  22. package/lib/http-common.d.ts +31 -0
  23. package/lib/http.d.ts +13 -0
  24. package/lib/index.d.ts +50 -0
  25. package/lib/index.js +5735 -0
  26. package/lib/parse.d.ts +41 -0
  27. package/lib/preview.d.ts +75 -0
  28. package/lib/render.d.ts +100 -0
  29. package/lib/scope-registry.d.ts +47 -0
  30. package/lib/scope.d.ts +56 -0
  31. package/lib/sql.d.ts +133 -0
  32. package/lib/store.d.ts +174 -0
  33. package/lib/table.d.ts +38 -0
  34. package/lib/tooling.d.ts +267 -0
  35. package/lib/tools/datasource.d.ts +16 -0
  36. package/lib/tools/import.d.ts +21 -0
  37. package/lib/tools/read.d.ts +77 -0
  38. package/lib/tools/registry.d.ts +12 -0
  39. package/lib/tools/write.d.ts +37 -0
  40. package/lib/view.d.ts +138 -0
  41. package/package.json +61 -0
  42. package/src/admin-contract.ts +351 -0
  43. package/src/admin-http.ts +254 -0
  44. package/src/admin-validate.ts +393 -0
  45. package/src/admin.ts +558 -0
  46. package/src/client/index.ts +403 -0
  47. package/src/client/settings/CreateForm.tsx +186 -0
  48. package/src/client/settings/DataSourceForm.tsx +278 -0
  49. package/src/client/settings/DataSourcesPanel.tsx +301 -0
  50. package/src/client/settings/DatasetEditor.tsx +245 -0
  51. package/src/client/settings/DatasetTable.tsx +110 -0
  52. package/src/client/settings/DatasetsPanel.tsx +207 -0
  53. package/src/client/settings/RowsPanel.tsx +196 -0
  54. package/src/client/settings/Section.tsx +41 -0
  55. package/src/client/settings/SourceTablesPanel.tsx +226 -0
  56. package/src/client/settings/api.ts +154 -0
  57. package/src/client/settings/styles.ts +125 -0
  58. package/src/datasource/columns.ts +56 -0
  59. package/src/datasource/connection.ts +124 -0
  60. package/src/datasource/connector/base.ts +54 -0
  61. package/src/datasource/connector/mysql.ts +187 -0
  62. package/src/datasource/connector/postgresql.ts +212 -0
  63. package/src/datasource/crypto.ts +61 -0
  64. package/src/datasource/driver.ts +108 -0
  65. package/src/datasource/errors.ts +69 -0
  66. package/src/datasource/importer.ts +262 -0
  67. package/src/datasource/index.ts +62 -0
  68. package/src/datasource/source-sql.ts +64 -0
  69. package/src/datasource/source-store.ts +152 -0
  70. package/src/datasource/types.ts +133 -0
  71. package/src/db.ts +277 -0
  72. package/src/http-common.ts +91 -0
  73. package/src/http.ts +130 -0
  74. package/src/index.ts +486 -0
  75. package/src/parse.ts +213 -0
  76. package/src/preview.ts +198 -0
  77. package/src/render.ts +294 -0
  78. package/src/scope-registry.ts +111 -0
  79. package/src/scope.ts +159 -0
  80. package/src/sql.ts +491 -0
  81. package/src/store.ts +412 -0
  82. package/src/table.ts +160 -0
  83. package/src/tooling.ts +551 -0
  84. package/src/tools/datasource.ts +378 -0
  85. package/src/tools/import.ts +282 -0
  86. package/src/tools/read.ts +536 -0
  87. package/src/tools/registry.ts +56 -0
  88. package/src/tools/write.ts +241 -0
  89. 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
+ }