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,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
|
+
}
|