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,245 @@
1
+ /**
2
+ * 数据集详情与编辑面板。
3
+ *
4
+ * 可改:名称 / 描述 / 来源路径,以及列结构里的**说明与样例**(纯元数据)。
5
+ * 不可改:列名 / 类型 / 可空性——它们对应物理表 DDL,由导入时推断确定。
6
+ * 删除走二次确认。写操作受 `writable` 控制(只读模式或接口关闭时置灰)。
7
+ */
8
+
9
+ import { useState } from 'react'
10
+ import type { ReactElement } from 'react'
11
+ import {
12
+ ADMIN_COLUMN_TYPE_LABELS,
13
+ ADMIN_MAX_SAMPLE_VALUES,
14
+ type DatasetColumnPatch,
15
+ type DatasetDetailView,
16
+ type PatchDatasetRequest,
17
+ } from '../../admin-contract'
18
+ import { patchDataset, deleteDataset } from './api'
19
+ import { c, s } from './styles'
20
+
21
+ /** 样例分隔符:展示用全角顿号,输入同时接受半角与全角逗号。 */
22
+ const SAMPLE_SEPARATORS = /[、,,]/
23
+ /** 空值的展示记号,与 `sampleToText` 互逆。 */
24
+ const NULL_MARK = '∅'
25
+
26
+ interface ColumnDraft {
27
+ description: string
28
+ sampleText: string
29
+ }
30
+
31
+ function sampleToText(sample: unknown[]): string {
32
+ if (!Array.isArray(sample)) return ''
33
+ return sample
34
+ .slice(0, ADMIN_MAX_SAMPLE_VALUES)
35
+ .map(value => (value === null ? NULL_MARK : String(value)))
36
+ .join('、')
37
+ }
38
+
39
+ function textToSample(text: string): (string | null)[] {
40
+ return text
41
+ .split(SAMPLE_SEPARATORS)
42
+ .map(value => value.trim())
43
+ .filter(value => value.length > 0)
44
+ .map(value => (value === NULL_MARK ? null : value))
45
+ }
46
+
47
+ function initialDrafts(columns: DatasetDetailView['columns']): Record<string, ColumnDraft> {
48
+ const drafts: Record<string, ColumnDraft> = {}
49
+ for (const column of columns) {
50
+ drafts[column.sanitizedName] = {
51
+ description: column.description ?? '',
52
+ sampleText: sampleToText(column.sample),
53
+ }
54
+ }
55
+ return drafts
56
+ }
57
+
58
+ export interface DatasetEditorProps {
59
+ detail: DatasetDetailView
60
+ writable: boolean
61
+ onChanged: (detail: DatasetDetailView) => void
62
+ onDeleted: (id: string) => void
63
+ onError: (error: Error) => void
64
+ }
65
+
66
+ export function DatasetEditor(props: DatasetEditorProps): ReactElement {
67
+ const { detail, writable, onChanged, onDeleted, onError } = props
68
+ const [name, setName] = useState(detail.name)
69
+ const [description, setDescription] = useState(detail.description ?? '')
70
+ const [sourcePath, setSourcePath] = useState(detail.sourcePath ?? '')
71
+ const [columnDrafts, setColumnDrafts] = useState<Record<string, ColumnDraft>>(() => initialDrafts(detail.columns))
72
+ const [saving, setSaving] = useState(false)
73
+ const [deleting, setDeleting] = useState(false)
74
+ const [confirming, setConfirming] = useState(false)
75
+
76
+ function updateColumnDraft(key: string, patch: Partial<ColumnDraft>): void {
77
+ setColumnDrafts(previous => ({ ...previous, [key]: { ...previous[key], ...patch } }))
78
+ }
79
+
80
+ const columnDirty = detail.columns.some(column => {
81
+ const draft = columnDrafts[column.sanitizedName]
82
+ if (draft === undefined) return false
83
+ return draft.description !== (column.description ?? '') || draft.sampleText !== sampleToText(column.sample)
84
+ })
85
+ const dirty =
86
+ name !== detail.name ||
87
+ description !== (detail.description ?? '') ||
88
+ sourcePath !== (detail.sourcePath ?? '') ||
89
+ columnDirty
90
+
91
+ /** 只提交改动的列;未改动的列连同样例一并原样保留,不参与往返。 */
92
+ function buildColumnPatches(): DatasetColumnPatch[] {
93
+ const patches: DatasetColumnPatch[] = []
94
+ for (const column of detail.columns) {
95
+ const draft = columnDrafts[column.sanitizedName]
96
+ if (draft === undefined) continue
97
+ const patch: DatasetColumnPatch = { sanitizedName: column.sanitizedName }
98
+ let changed = false
99
+ if (draft.description !== (column.description ?? '')) {
100
+ patch.description = draft.description
101
+ changed = true
102
+ }
103
+ if (draft.sampleText !== sampleToText(column.sample)) {
104
+ patch.sample = textToSample(draft.sampleText)
105
+ changed = true
106
+ }
107
+ if (changed) patches.push(patch)
108
+ }
109
+ return patches
110
+ }
111
+
112
+ async function handleSave(): Promise<void> {
113
+ if (!dirty || saving) return
114
+ const columnPatches = buildColumnPatches()
115
+ const body: PatchDatasetRequest = { name, description, sourcePath }
116
+ if (columnPatches.length > 0) body.columns = columnPatches
117
+ setSaving(true)
118
+ try {
119
+ const updated = await patchDataset(detail.id, detail.scopeKey, body)
120
+ onChanged(updated)
121
+ } catch (error) {
122
+ onError(error instanceof Error ? error : new Error('保存失败'))
123
+ } finally {
124
+ setSaving(false)
125
+ }
126
+ }
127
+
128
+ async function handleDelete(): Promise<void> {
129
+ if (deleting) return
130
+ setDeleting(true)
131
+ try {
132
+ await deleteDataset(detail.id, detail.scopeKey)
133
+ onDeleted(detail.id)
134
+ } catch (error) {
135
+ onError(error instanceof Error ? error : new Error('删除失败'))
136
+ setConfirming(false)
137
+ } finally {
138
+ setDeleting(false)
139
+ }
140
+ }
141
+
142
+ const disabled = !writable || saving
143
+
144
+ return (
145
+ <div style={s.panel}>
146
+ <div style={{ ...s.head, display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}>
147
+ <strong style={{ fontSize: 13 }}>数据集详情</strong>
148
+ <span style={s.muted}>工作区:{detail.scopeKey}</span>
149
+ </div>
150
+
151
+ <div style={s.grid2}>
152
+ <div style={s.field}>
153
+ <label style={s.label}>名称</label>
154
+ <input style={s.input} value={name} disabled={disabled} onChange={e => setName(e.target.value)} />
155
+ </div>
156
+ <div style={s.field}>
157
+ <label style={s.label}>来源路径</label>
158
+ <input style={s.input} value={sourcePath} disabled={disabled} onChange={e => setSourcePath(e.target.value)} />
159
+ </div>
160
+ </div>
161
+ <div style={s.field}>
162
+ <label style={s.label}>描述</label>
163
+ <textarea style={s.textarea} value={description} disabled={disabled} onChange={e => setDescription(e.target.value)} />
164
+ </div>
165
+
166
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
167
+ <button
168
+ type="button"
169
+ style={{ ...s.primaryButton, ...(disabled || !dirty ? s.disabledButton : null) }}
170
+ disabled={disabled || !dirty}
171
+ onClick={() => void handleSave()}
172
+ >{saving ? '保存中…' : '保存修改'}</button>
173
+
174
+ {!confirming
175
+ ? (
176
+ <button
177
+ type="button"
178
+ style={{ ...s.dangerButton, ...(!writable ? s.disabledButton : null) }}
179
+ disabled={!writable}
180
+ onClick={() => setConfirming(true)}
181
+ >删除数据集</button>
182
+ )
183
+ : (
184
+ <>
185
+ <span style={{ color: c.danger }}>确认删除「{detail.name}」及其全部数据?</span>
186
+ <button
187
+ type="button"
188
+ style={{ ...s.dangerButton, ...(deleting ? s.disabledButton : null) }}
189
+ disabled={deleting}
190
+ onClick={() => void handleDelete()}
191
+ >{deleting ? '删除中…' : '确认删除'}</button>
192
+ <button type="button" style={s.button} disabled={deleting} onClick={() => setConfirming(false)}>取消</button>
193
+ </>
194
+ )}
195
+ {!writable ? <span style={s.muted}>(只读模式或接口已关闭,不可修改)</span> : null}
196
+ </div>
197
+
198
+ <div style={{ marginTop: 14 }}>
199
+ <div style={{ ...s.label, marginBottom: 6 }}>
200
+ 列结构 · 共 {detail.columns.length} 列 (列名 / 类型 / 可空性由导入时推断,不可修改;说明与样例可编辑)
201
+ </div>
202
+ <div style={{ ...s.scroll, maxHeight: 240 }}>
203
+ <table style={s.table}>
204
+ <thead>
205
+ <tr>
206
+ <th style={s.th}>列名</th>
207
+ <th style={s.th}>类型</th>
208
+ <th style={s.th}>可空</th>
209
+ <th style={s.th}>说明</th>
210
+ <th style={s.th}>样例</th>
211
+ </tr>
212
+ </thead>
213
+ <tbody>
214
+ {detail.columns.map(column => (
215
+ <tr key={column.sanitizedName}>
216
+ <td style={s.td} title={column.name}>{column.name}</td>
217
+ <td style={s.td}>{ADMIN_COLUMN_TYPE_LABELS[column.type] ?? column.type}</td>
218
+ <td style={s.td}>{column.nullable ? '是' : '否'}</td>
219
+ <td style={{ ...s.td, overflow: 'visible' }}>
220
+ <input
221
+ style={s.input}
222
+ value={columnDrafts[column.sanitizedName]?.description ?? ''}
223
+ disabled={disabled}
224
+ placeholder="补充说明,便于 agent 理解该列"
225
+ onChange={event => updateColumnDraft(column.sanitizedName, { description: event.target.value })}
226
+ />
227
+ </td>
228
+ <td style={{ ...s.td, overflow: 'visible' }}>
229
+ <input
230
+ style={s.input}
231
+ value={columnDrafts[column.sanitizedName]?.sampleText ?? ''}
232
+ disabled={disabled}
233
+ placeholder="多个值用 、 分隔"
234
+ onChange={event => updateColumnDraft(column.sanitizedName, { sampleText: event.target.value })}
235
+ />
236
+ </td>
237
+ </tr>
238
+ ))}
239
+ </tbody>
240
+ </table>
241
+ </div>
242
+ </div>
243
+ </div>
244
+ )
245
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * 跨工作区聚合的数据集列表表格。列:名称 / 工作区 / 行数 / 列数 / 状态 / 来源 / 更新时间。
3
+ * 列表与详情/编辑是上下分区的:本组件只负责表格与分页,选中回调上抛给 Section。
4
+ */
5
+
6
+ import type { ReactElement } from 'react'
7
+ import type { DatasetAdminView } from '../../admin-contract'
8
+ import { c, s, STATUS_COLOR } from './styles'
9
+
10
+ const STATUS_TEXT: Record<string, string> = { ready: '就绪', importing: '导入中', failed: '失败' }
11
+
12
+ export function fmtAgo(ts: number): string {
13
+ if (!Number.isFinite(ts) || ts <= 0) return '—'
14
+ const diff = Date.now() - ts
15
+ if (diff < 60_000) return '刚刚'
16
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`
17
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`
18
+ return `${Math.floor(diff / 86_400_000)} 天前`
19
+ }
20
+
21
+ function ellipsis(value: string | null, max = 32): string {
22
+ if (value === null) return '—'
23
+ return value.length > max ? `${value.slice(0, max)}…` : value
24
+ }
25
+
26
+ export interface DatasetTableProps {
27
+ items: DatasetAdminView[]
28
+ page: number
29
+ pageSize: number
30
+ total: number
31
+ selectedId: string | null
32
+ loading: boolean
33
+ onSelect: (item: DatasetAdminView) => void
34
+ onPageChange: (page: number) => void
35
+ }
36
+
37
+ export function DatasetTable(props: DatasetTableProps): ReactElement {
38
+ const { items, page, pageSize, total, selectedId, loading, onSelect, onPageChange } = props
39
+ const totalPages = Math.max(1, Math.ceil(total / pageSize))
40
+
41
+ const rows = items.length === 0 && !loading
42
+ ? (
43
+ <tr>
44
+ <td style={{ ...s.td, color: c.fg2, padding: '18px 8px', textAlign: 'center' }} colSpan={7}>
45
+ 还没有数据集,用 agent 的 dataset_import 导入,或直接点击「新建数据集」
46
+ </td>
47
+ </tr>
48
+ )
49
+ : items.map(item => (
50
+ <tr
51
+ key={`${item.scopeKey}:${item.id}`}
52
+ style={{ ...s.row, ...(item.id === selectedId ? s.selectedRow : null) }}
53
+ onClick={() => onSelect(item)}
54
+ >
55
+ <td style={s.td} title={item.name}>{ellipsis(item.name, 40)}</td>
56
+ <td style={s.td} title={item.scopeKey}>{ellipsis(item.scopeKey, 36)}</td>
57
+ <td style={s.td}>{item.rowCount}</td>
58
+ <td style={s.td}>{item.columnCount}</td>
59
+ <td style={s.td}>
60
+ <span style={{ ...s.dot, background: STATUS_COLOR[item.status] ?? c.fg2 }} />
61
+ {STATUS_TEXT[item.status] ?? item.status}
62
+ </td>
63
+ <td style={s.td} title={item.sourcePath ?? ''}>{ellipsis(item.sourcePath, 40)}</td>
64
+ <td style={{ ...s.td, color: c.fg2 }}>{fmtAgo(item.updatedAt)}</td>
65
+ </tr>
66
+ ))
67
+
68
+ return (
69
+ <div>
70
+ <div style={s.scroll}>
71
+ <table style={s.table}>
72
+ <thead>
73
+ <tr>
74
+ <th style={s.th}>名称</th>
75
+ <th style={s.th}>工作区</th>
76
+ <th style={s.th}>行数</th>
77
+ <th style={s.th}>列数</th>
78
+ <th style={s.th}>状态</th>
79
+ <th style={s.th}>来源</th>
80
+ <th style={s.th}>更新时间</th>
81
+ </tr>
82
+ </thead>
83
+ <tbody>{rows}</tbody>
84
+ </table>
85
+ </div>
86
+ <div style={{ ...s.bar, display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
87
+ <button
88
+ type="button"
89
+ style={{ ...s.button, ...(page <= 1 ? s.disabledButton : null) }}
90
+ disabled={page <= 1}
91
+ onClick={() => onPageChange(1)}
92
+ >« 首页</button>
93
+ <button
94
+ type="button"
95
+ style={{ ...s.button, ...(page <= 1 ? s.disabledButton : null) }}
96
+ disabled={page <= 1}
97
+ onClick={() => onPageChange(page - 1)}
98
+ >上一页</button>
99
+ <span>第 {Math.min(page, totalPages)} / {totalPages} 页 · 共 {total} 个</span>
100
+ <button
101
+ type="button"
102
+ style={{ ...s.button, ...(page >= totalPages ? s.disabledButton : null) }}
103
+ disabled={page >= totalPages}
104
+ onClick={() => onPageChange(page + 1)}
105
+ >下一页</button>
106
+ {loading ? <span style={s.muted}>加载中…</span> : null}
107
+ </div>
108
+ </div>
109
+ )
110
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * 「数据集」页签:顶部工具条(搜索 / 新建 / 刷新)+ 聚合列表表格 + 详情/编辑或新建面板 +
3
+ * 表数据分页面板。所有数据来自 `api.ts`,跨工作区聚合与表数据读取由主机侧完成。
4
+ *
5
+ * 状态以「列表 / 详情 / 新建」分区呈现:表格常驻,选中行在下方展开详情与
6
+ * 只读的表数据分页,「新建」时详情区替换为新建表单(此时没有表数据可看)。
7
+ * 写操作的只读/关闭判定由错误码驱动。
8
+ */
9
+
10
+ import { useCallback, useEffect, useRef, useState } from 'react'
11
+ import type { ReactElement } from 'react'
12
+ import {
13
+ ADMIN_PAGE_SIZE,
14
+ type DatasetAdminView,
15
+ type DatasetDetailView,
16
+ type ScopeView,
17
+ } from '../../admin-contract'
18
+ import { AdminApiError, getDataset, listDatasets, listScopes } from './api'
19
+ import { DatasetTable } from './DatasetTable'
20
+ import { DatasetEditor } from './DatasetEditor'
21
+ import { RowsPanel } from './RowsPanel'
22
+ import { CreateForm } from './CreateForm'
23
+ import { s } from './styles'
24
+
25
+ type Mode = 'browse' | 'create'
26
+
27
+ export function DatasetsPanel(): ReactElement {
28
+ const [scopes, setScopes] = useState<ScopeView[]>([])
29
+ const [items, setItems] = useState<DatasetAdminView[]>([])
30
+ const [total, setTotal] = useState(0)
31
+ const [query, setQuery] = useState('')
32
+ const [page, setPage] = useState(1)
33
+ const [mode, setMode] = useState<Mode>('browse')
34
+ const [selectedId, setSelectedId] = useState<string | null>(null)
35
+ const [detail, setDetail] = useState<DatasetDetailView | null>(null)
36
+ const [loadingList, setLoadingList] = useState(false)
37
+ const [loadingDetail, setLoadingDetail] = useState(false)
38
+ const [writable, setWritable] = useState(true)
39
+ const [error, setError] = useState<string | null>(null)
40
+ const requestId = useRef(0)
41
+
42
+ const loadScopes = useCallback(async (): Promise<void> => {
43
+ try {
44
+ const result = await listScopes()
45
+ setScopes(result.scopes)
46
+ } catch (failure) {
47
+ // 路由未挂载等同无可用工作区;列表加载会给出更具体的错误。
48
+ if (failure instanceof AdminApiError && (failure.code === 'READ_ONLY' || failure.code === 'ADMIN_DISABLED')) {
49
+ setWritable(false)
50
+ }
51
+ }
52
+ }, [])
53
+
54
+ const loadList = useCallback(async (q: string, p: number): Promise<void> => {
55
+ const id = requestId.current + 1
56
+ requestId.current = id
57
+ setLoadingList(true)
58
+ try {
59
+ const result = await listDatasets(q, p, ADMIN_PAGE_SIZE)
60
+ if (id !== requestId.current) return
61
+ setItems(result.items)
62
+ setTotal(result.total)
63
+ const warning = result.warnings.map(w => `${w.scopeKey}: ${w.message}`).join(';')
64
+ setError(warning.length > 0 ? warning : null)
65
+ } catch (failure) {
66
+ if (id !== requestId.current) return
67
+ setError(failure instanceof Error ? failure.message : '加载失败')
68
+ } finally {
69
+ if (id === requestId.current) setLoadingList(false)
70
+ }
71
+ }, [])
72
+
73
+ // 初次挂载:工作区列表 + 首屏列表。
74
+ useEffect(() => {
75
+ void loadScopes()
76
+ void loadList('', 1)
77
+ }, [loadScopes, loadList])
78
+
79
+ // 关键字防抖:停止输入 300ms 后回到第一页重新拉取。
80
+ useEffect(() => {
81
+ const timer = setTimeout(() => {
82
+ setPage(1)
83
+ void loadList(query, 1)
84
+ }, 300)
85
+ return () => clearTimeout(timer)
86
+ }, [query, loadList])
87
+
88
+ const openDetail = useCallback(async (item: DatasetAdminView): Promise<void> => {
89
+ setSelectedId(item.id)
90
+ setMode('browse')
91
+ setDetail(null)
92
+ setLoadingDetail(true)
93
+ try {
94
+ const fetched = await getDataset(item.id, item.scopeKey)
95
+ setDetail(fetched)
96
+ } catch (failure) {
97
+ setError(failure instanceof Error ? failure.message : '加载详情失败')
98
+ } finally {
99
+ setLoadingDetail(false)
100
+ }
101
+ }, [])
102
+
103
+ const handleWriteError = useCallback((failure: Error): void => {
104
+ setError(failure.message)
105
+ if (failure instanceof AdminApiError && (failure.code === 'READ_ONLY' || failure.code === 'ADMIN_DISABLED')) {
106
+ setWritable(false)
107
+ }
108
+ }, [])
109
+
110
+ const handleChanged = useCallback((updated: DatasetDetailView): void => {
111
+ setDetail(updated)
112
+ void loadList(query, page)
113
+ }, [query, page, loadList])
114
+
115
+ const handleDeleted = useCallback(async (id: string): Promise<void> => {
116
+ setSelectedId(null)
117
+ setDetail(null)
118
+ await loadScopes()
119
+ await loadList(query, page)
120
+ void id
121
+ }, [query, page, loadList, loadScopes])
122
+
123
+ const handleCreated = useCallback(async (created: unknown): Promise<void> => {
124
+ setMode('browse')
125
+ await loadScopes()
126
+ await loadList(query, page)
127
+ const record = created as DatasetDetailView
128
+ setSelectedId(record.id)
129
+ setDetail(record)
130
+ }, [query, page, loadList, loadScopes])
131
+
132
+ return (
133
+ <div>
134
+ <div style={s.toolbar}>
135
+ <input
136
+ style={s.search}
137
+ placeholder="按名称 / 来源 / 工作区搜索…"
138
+ value={query}
139
+ onChange={e => setQuery(e.target.value)}
140
+ />
141
+ <button
142
+ type="button"
143
+ style={{ ...s.primaryButton, ...(!writable ? s.disabledButton : null) }}
144
+ disabled={!writable}
145
+ onClick={() => setMode('create')}
146
+ title={writable ? '新建空数据集' : '只读模式或接口已关闭'}
147
+ >+ 新建数据集</button>
148
+ <button type="button" style={s.button} disabled={loadingList} onClick={() => void loadList(query, page)}>刷新</button>
149
+ </div>
150
+
151
+ {error !== null
152
+ ? (
153
+ <div style={s.banner}>
154
+ <span>{error}</span>
155
+ <span style={{ ...s.link, marginLeft: 'auto' }} onClick={() => setError(null)}>忽略</span>
156
+ </div>
157
+ )
158
+ : null}
159
+
160
+ <DatasetTable
161
+ items={items}
162
+ page={page}
163
+ pageSize={ADMIN_PAGE_SIZE}
164
+ total={total}
165
+ selectedId={selectedId}
166
+ loading={loadingList}
167
+ onSelect={item => void openDetail(item)}
168
+ onPageChange={p => { setPage(p); void loadList(query, p) }}
169
+ />
170
+
171
+ {mode === 'create'
172
+ ? (
173
+ <CreateForm
174
+ scopes={scopes}
175
+ onCreated={detail => void handleCreated(detail)}
176
+ onCancel={() => setMode('browse')}
177
+ onError={handleWriteError}
178
+ />
179
+ )
180
+ : detail !== null
181
+ ? (
182
+ <>
183
+ <DatasetEditor
184
+ detail={detail}
185
+ writable={writable}
186
+ onChanged={handleChanged}
187
+ onDeleted={id => void handleDeleted(id)}
188
+ onError={handleWriteError}
189
+ />
190
+ {/* key 让切换数据集时重置分页状态,避免残留上一张表的页码。 */}
191
+ <RowsPanel
192
+ key={`${detail.scopeKey}:${detail.id}`}
193
+ datasetId={detail.id}
194
+ scopeKey={detail.scopeKey}
195
+ recordedRows={detail.rowCount}
196
+ status={detail.status}
197
+ />
198
+ </>
199
+ )
200
+ : (
201
+ <div style={{ ...s.muted, marginTop: 12 }}>
202
+ {loadingDetail ? '加载详情中…' : '选择一个数据集查看详情,或点击「新建数据集」创建空表。'}
203
+ </div>
204
+ )}
205
+ </div>
206
+ )
207
+ }