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,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 数据集的「表数据」分区:分页查看该数据集物理表里的数据行。
|
|
3
|
+
*
|
|
4
|
+
* 只读组件,不发任何写请求。取数走 `api.ts` 的 `listDatasetRows`,物理表与
|
|
5
|
+
* SQL 都不出现在前端:只带 datasetId / scope 与分页参数。列顺序与列集合由
|
|
6
|
+
* 服务端从元数据登记的业务列给出,`_row_id` 只当行键与行号展示。
|
|
7
|
+
*
|
|
8
|
+
* 翻页是「整页重取」而非增量累加:每页 20–100 行,页码由服务端按
|
|
9
|
+
* `COUNT(*)` 夹紧,删除尾部行后停留在越界页码会自动落到末页。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
13
|
+
import type { ReactElement } from 'react'
|
|
14
|
+
import {
|
|
15
|
+
ADMIN_ROW_ID_COLUMN,
|
|
16
|
+
ADMIN_ROW_MAX_PAGE_SIZE,
|
|
17
|
+
ADMIN_ROW_PAGE_SIZE,
|
|
18
|
+
type DatasetRowsResult,
|
|
19
|
+
} from '../../admin-contract'
|
|
20
|
+
import { listDatasetRows } from './api'
|
|
21
|
+
import { c, s } from './styles'
|
|
22
|
+
|
|
23
|
+
/** 每页条数可选项:默认 20,最大取契约上限。 */
|
|
24
|
+
const PAGE_SIZE_OPTIONS = [...new Set([ADMIN_ROW_PAGE_SIZE, 50, ADMIN_ROW_MAX_PAGE_SIZE])].sort((a, b) => a - b)
|
|
25
|
+
|
|
26
|
+
/** 单个单元格的展示字符上限(超长省略,完整值放 title)。 */
|
|
27
|
+
const CELL_MAX_LENGTH = 80
|
|
28
|
+
|
|
29
|
+
function cellText(value: unknown): string {
|
|
30
|
+
if (typeof value === 'string') return value
|
|
31
|
+
if (typeof value === 'object' && value !== null) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.stringify(value)
|
|
34
|
+
} catch {
|
|
35
|
+
return '[object]'
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return String(value)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function Cell(props: { value: unknown }): ReactElement {
|
|
42
|
+
const { value } = props
|
|
43
|
+
if (value === null || value === undefined) {
|
|
44
|
+
return <span style={{ color: c.fg2 }}>—</span>
|
|
45
|
+
}
|
|
46
|
+
const text = cellText(value)
|
|
47
|
+
return <span title={text}>{text.length > CELL_MAX_LENGTH ? `${text.slice(0, CELL_MAX_LENGTH)}…` : text}</span>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RowsPanelProps {
|
|
51
|
+
datasetId: string
|
|
52
|
+
scopeKey: string
|
|
53
|
+
/** 元数据登记的行数,只用于与真实行数比对后给出提示。 */
|
|
54
|
+
recordedRows: number
|
|
55
|
+
/** 数据集状态:非就绪时给出提示,但不阻断查看。 */
|
|
56
|
+
status: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function RowsPanel(props: RowsPanelProps): ReactElement {
|
|
60
|
+
const { datasetId, scopeKey, recordedRows, status } = props
|
|
61
|
+
const [data, setData] = useState<DatasetRowsResult | null>(null)
|
|
62
|
+
const [page, setPage] = useState(1)
|
|
63
|
+
const [pageSize, setPageSize] = useState(ADMIN_ROW_PAGE_SIZE)
|
|
64
|
+
const [loading, setLoading] = useState(false)
|
|
65
|
+
const [error, setError] = useState<string | null>(null)
|
|
66
|
+
const requestId = useRef(0)
|
|
67
|
+
|
|
68
|
+
const load = useCallback(async (target: number, size: number): Promise<void> => {
|
|
69
|
+
const id = requestId.current + 1
|
|
70
|
+
requestId.current = id
|
|
71
|
+
setLoading(true)
|
|
72
|
+
setError(null)
|
|
73
|
+
try {
|
|
74
|
+
const result = await listDatasetRows(datasetId, scopeKey, target, size)
|
|
75
|
+
if (id !== requestId.current) return
|
|
76
|
+
setData(result)
|
|
77
|
+
// 服务端会把页码夹到分页边界内,以它返回的为准。
|
|
78
|
+
setPage(result.page)
|
|
79
|
+
} catch (failure) {
|
|
80
|
+
if (id !== requestId.current) return
|
|
81
|
+
setError(failure instanceof Error ? failure.message : '加载数据失败')
|
|
82
|
+
} finally {
|
|
83
|
+
if (id === requestId.current) setLoading(false)
|
|
84
|
+
}
|
|
85
|
+
}, [datasetId, scopeKey])
|
|
86
|
+
|
|
87
|
+
// 换数据集(外层用 key 重挂载)或改每页条数:回到第一页重取。
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
setData(null)
|
|
90
|
+
void load(1, pageSize)
|
|
91
|
+
}, [load, pageSize])
|
|
92
|
+
|
|
93
|
+
const total = data?.total ?? 0
|
|
94
|
+
const totalPages = data?.totalPages ?? 1
|
|
95
|
+
const columns = data?.columns ?? []
|
|
96
|
+
const rows = data?.rows ?? []
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div style={s.panel}>
|
|
100
|
+
<div style={{ ...s.head, display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}>
|
|
101
|
+
<strong style={{ fontSize: 13 }}>表数据</strong>
|
|
102
|
+
<span style={s.muted}>{loading ? '加载中…' : `共 ${total} 行`}</span>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
{status !== 'ready' ? <div style={{ ...s.muted, marginBottom: 8 }}>数据集当前状态为 {status},数据可能不完整。</div> : null}
|
|
106
|
+
{error !== null ? <div style={{ ...s.banner, marginTop: 0 }}><span>{error}</span></div> : null}
|
|
107
|
+
{recordedRows !== total && error === null && data !== null
|
|
108
|
+
? <div style={{ ...s.muted, marginBottom: 8 }}>元数据登记 {recordedRows} 行,实际 {total} 行。</div>
|
|
109
|
+
: null}
|
|
110
|
+
|
|
111
|
+
{columns.length > 0
|
|
112
|
+
? (
|
|
113
|
+
<div style={{ ...s.scroll, maxHeight: 320 }}>
|
|
114
|
+
<table style={s.table}>
|
|
115
|
+
<thead>
|
|
116
|
+
<tr>
|
|
117
|
+
<th style={s.th}>#</th>
|
|
118
|
+
{columns.map(column => <th key={column} style={s.th} title={column}>{column}</th>)}
|
|
119
|
+
</tr>
|
|
120
|
+
</thead>
|
|
121
|
+
<tbody>
|
|
122
|
+
{rows.map(row => (
|
|
123
|
+
<tr key={String(row[ADMIN_ROW_ID_COLUMN] ?? '')}>
|
|
124
|
+
<td style={{ ...s.td, color: c.fg2, maxWidth: 60 }}>{String(row[ADMIN_ROW_ID_COLUMN] ?? '')}</td>
|
|
125
|
+
{columns.map(column => (
|
|
126
|
+
<td key={column} style={s.td}><Cell value={row[column]} /></td>
|
|
127
|
+
))}
|
|
128
|
+
</tr>
|
|
129
|
+
))}
|
|
130
|
+
{rows.length === 0 && !loading
|
|
131
|
+
? (
|
|
132
|
+
<tr>
|
|
133
|
+
<td style={{ ...s.td, color: c.fg2, padding: '16px 8px', textAlign: 'center' }} colSpan={columns.length + 1}>
|
|
134
|
+
{total === 0 ? '该数据集还没有数据行' : '这一页没有数据'}
|
|
135
|
+
</td>
|
|
136
|
+
</tr>
|
|
137
|
+
)
|
|
138
|
+
: null}
|
|
139
|
+
</tbody>
|
|
140
|
+
</table>
|
|
141
|
+
</div>
|
|
142
|
+
)
|
|
143
|
+
: loading
|
|
144
|
+
? <div style={s.muted}>加载中…</div>
|
|
145
|
+
: error === null
|
|
146
|
+
? <div style={s.muted}>该数据集没有已登记的列,无法展示数据。</div>
|
|
147
|
+
: null}
|
|
148
|
+
|
|
149
|
+
<div style={{ ...s.bar, display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
style={{ ...s.button, ...(page <= 1 || loading ? s.disabledButton : null) }}
|
|
153
|
+
disabled={page <= 1 || loading}
|
|
154
|
+
onClick={() => void load(1, pageSize)}
|
|
155
|
+
>« 首页</button>
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
style={{ ...s.button, ...(page <= 1 || loading ? s.disabledButton : null) }}
|
|
159
|
+
disabled={page <= 1 || loading}
|
|
160
|
+
onClick={() => void load(page - 1, pageSize)}
|
|
161
|
+
>上一页</button>
|
|
162
|
+
<span>第 {page} / {totalPages} 页</span>
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
style={{ ...s.button, ...(page >= totalPages || loading ? s.disabledButton : null) }}
|
|
166
|
+
disabled={page >= totalPages || loading}
|
|
167
|
+
onClick={() => void load(page + 1, pageSize)}
|
|
168
|
+
>下一页</button>
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
style={{ ...s.button, ...(page >= totalPages || loading ? s.disabledButton : null) }}
|
|
172
|
+
disabled={page >= totalPages || loading}
|
|
173
|
+
onClick={() => void load(totalPages, pageSize)}
|
|
174
|
+
>末页 »</button>
|
|
175
|
+
<label style={{ ...s.muted, display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
176
|
+
每页
|
|
177
|
+
<select
|
|
178
|
+
style={s.select}
|
|
179
|
+
value={pageSize}
|
|
180
|
+
disabled={loading}
|
|
181
|
+
onChange={event => setPageSize(Number(event.target.value))}
|
|
182
|
+
>
|
|
183
|
+
{PAGE_SIZE_OPTIONS.map(size => <option key={size} value={size}>{size}</option>)}
|
|
184
|
+
</select>
|
|
185
|
+
行
|
|
186
|
+
</label>
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
style={{ ...s.button, ...(loading ? s.disabledButton : null) }}
|
|
190
|
+
disabled={loading}
|
|
191
|
+
onClick={() => void load(page, pageSize)}
|
|
192
|
+
>刷新</button>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设置页「数据集」根组件:顶部页签条 + 两个同级面板。
|
|
3
|
+
*
|
|
4
|
+
* - 「数据集」:跨工作区聚合浏览 / 新建 / 编辑 / 查看表数据(既有能力,见 `DatasetsPanel`);
|
|
5
|
+
* - 「数据源」:登记与管理远程数据库连接,浏览远端表并导入成数据集(见 `DataSourcesPanel`)。
|
|
6
|
+
*
|
|
7
|
+
* 两个面板各自持有状态(搜索、分页、选中项),切换页签不互相重置。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useState } from 'react'
|
|
11
|
+
import type { ReactElement } from 'react'
|
|
12
|
+
import { DatasetsPanel } from './DatasetsPanel'
|
|
13
|
+
import { DataSourcesPanel } from './DataSourcesPanel'
|
|
14
|
+
import { s } from './styles'
|
|
15
|
+
|
|
16
|
+
type Tab = 'datasets' | 'sources'
|
|
17
|
+
|
|
18
|
+
const TABS: { key: Tab; label: string }[] = [
|
|
19
|
+
{ key: 'datasets', label: '数据集' },
|
|
20
|
+
{ key: 'sources', label: '数据源' },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
export function DatasetSettingsSection(): ReactElement {
|
|
24
|
+
const [tab, setTab] = useState<Tab>('datasets')
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div style={s.page}>
|
|
28
|
+
<div style={s.tabs}>
|
|
29
|
+
{TABS.map(entry => (
|
|
30
|
+
<button
|
|
31
|
+
key={entry.key}
|
|
32
|
+
type="button"
|
|
33
|
+
style={{ ...s.tab, ...(tab === entry.key ? s.activeTab : null) }}
|
|
34
|
+
onClick={() => setTab(entry.key)}
|
|
35
|
+
>{entry.label}</button>
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
{tab === 'datasets' ? <DatasetsPanel /> : <DataSourcesPanel />}
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 远端表浏览与导入:选 schema(PostgreSQL)→ 过滤 / 点选一张表 → 选目标工作区并导入。
|
|
3
|
+
*
|
|
4
|
+
* 只读浏览(GET /sources/:id/tables),导入是写操作(POST /sources/:id/import)。
|
|
5
|
+
* 导入完成后产出的是普通数据集,之后一律回到「数据集」页签用既有能力操作。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
9
|
+
import type { ReactElement } from 'react'
|
|
10
|
+
import {
|
|
11
|
+
ADMIN_COLUMN_TYPE_LABELS,
|
|
12
|
+
type DataSourceView,
|
|
13
|
+
type ImportSourceTableResult,
|
|
14
|
+
type ScopeView,
|
|
15
|
+
type SourceTableView,
|
|
16
|
+
} from '../../admin-contract'
|
|
17
|
+
import { importSourceTable, listSourceTables } from './api'
|
|
18
|
+
import { c, s } from './styles'
|
|
19
|
+
|
|
20
|
+
export interface SourceTablesPanelProps {
|
|
21
|
+
source: DataSourceView
|
|
22
|
+
scopes: ScopeView[]
|
|
23
|
+
onError: (error: Error) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatRows(count: number): string {
|
|
27
|
+
if (count < 0) return '未知'
|
|
28
|
+
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`
|
|
29
|
+
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`
|
|
30
|
+
return String(count)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function SourceTablesPanel(props: SourceTablesPanelProps): ReactElement {
|
|
34
|
+
const { source, scopes, onError } = props
|
|
35
|
+
const [schemas, setSchemas] = useState<string[]>([])
|
|
36
|
+
const [schema, setSchema] = useState('')
|
|
37
|
+
const [keyword, setKeyword] = useState('')
|
|
38
|
+
const [tables, setTables] = useState<SourceTableView[]>([])
|
|
39
|
+
const [loading, setLoading] = useState(false)
|
|
40
|
+
const [selected, setSelected] = useState<string | null>(null)
|
|
41
|
+
const [scopeKey, setScopeKey] = useState('')
|
|
42
|
+
const [name, setName] = useState('')
|
|
43
|
+
const [importing, setImporting] = useState(false)
|
|
44
|
+
const [result, setResult] = useState<ImportSourceTableResult | null>(null)
|
|
45
|
+
const requestId = useRef(0)
|
|
46
|
+
|
|
47
|
+
const load = useCallback(async (schemaArg: string, q: string): Promise<void> => {
|
|
48
|
+
const id = requestId.current + 1
|
|
49
|
+
requestId.current = id
|
|
50
|
+
setLoading(true)
|
|
51
|
+
try {
|
|
52
|
+
const loaded = await listSourceTables(source.id, schemaArg, q)
|
|
53
|
+
if (id !== requestId.current) return
|
|
54
|
+
setSchemas(loaded.schemas)
|
|
55
|
+
setTables(loaded.tables)
|
|
56
|
+
setSelected(null)
|
|
57
|
+
} catch (failure) {
|
|
58
|
+
if (id !== requestId.current) return
|
|
59
|
+
onError(failure instanceof Error ? failure : new Error('加载远端表失败'))
|
|
60
|
+
} finally {
|
|
61
|
+
if (id === requestId.current) setLoading(false)
|
|
62
|
+
}
|
|
63
|
+
}, [source.id, onError])
|
|
64
|
+
|
|
65
|
+
// 切换数据源或 schema 立即重载;关键字走 300ms 防抖。
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const timer = setTimeout(() => void load(schema, keyword), keyword.trim().length > 0 ? 300 : 0)
|
|
68
|
+
return () => clearTimeout(timer)
|
|
69
|
+
}, [load, schema, keyword])
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (scopeKey.length === 0 && scopes.length > 0) setScopeKey(scopes[0].scopeKey)
|
|
73
|
+
}, [scopes, scopeKey])
|
|
74
|
+
|
|
75
|
+
const current = tables.find(table => table.tableName === selected) ?? null
|
|
76
|
+
|
|
77
|
+
async function handleImport(): Promise<void> {
|
|
78
|
+
if (selected === null || scopeKey.length === 0 || importing) return
|
|
79
|
+
setImporting(true)
|
|
80
|
+
setResult(null)
|
|
81
|
+
try {
|
|
82
|
+
const imported = await importSourceTable(source.id, {
|
|
83
|
+
scopeKey,
|
|
84
|
+
tableName: selected,
|
|
85
|
+
schemaName: schema.trim().length > 0 ? schema.trim() : null,
|
|
86
|
+
name: name.trim().length > 0 ? name.trim() : null,
|
|
87
|
+
})
|
|
88
|
+
setResult(imported)
|
|
89
|
+
} catch (failure) {
|
|
90
|
+
onError(failure instanceof Error ? failure : new Error('导入失败'))
|
|
91
|
+
} finally {
|
|
92
|
+
setImporting(false)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div style={{ ...s.panel, background: 'transparent', borderTop: `1px solid ${c.border}`, borderRadius: 0 }}>
|
|
98
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
|
99
|
+
<strong style={{ fontSize: 13 }}>远端表</strong>
|
|
100
|
+
<span style={s.muted}>{loading ? '加载中…' : `共 ${tables.length} 张`}</span>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div style={{ display: 'flex', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
|
104
|
+
<select
|
|
105
|
+
style={s.select}
|
|
106
|
+
value={schema}
|
|
107
|
+
disabled={loading || schemas.length <= 1}
|
|
108
|
+
onChange={e => setSchema(e.target.value)}
|
|
109
|
+
>
|
|
110
|
+
{schemas.length === 0
|
|
111
|
+
? <option value="">(默认)</option>
|
|
112
|
+
: schemas.map(item => <option key={item} value={item}>{item}</option>)}
|
|
113
|
+
</select>
|
|
114
|
+
<input
|
|
115
|
+
style={{ ...s.search, flex: 1, minWidth: 140 }}
|
|
116
|
+
placeholder="按表名过滤…"
|
|
117
|
+
value={keyword}
|
|
118
|
+
onChange={e => setKeyword(e.target.value)}
|
|
119
|
+
/>
|
|
120
|
+
<button type="button" style={s.button} disabled={loading} onClick={() => void load(schema, keyword)}>刷新</button>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div style={{ ...s.scroll, maxHeight: 240 }}>
|
|
124
|
+
<table style={s.table}>
|
|
125
|
+
<thead>
|
|
126
|
+
<tr>
|
|
127
|
+
<th style={s.th}>表名</th>
|
|
128
|
+
<th style={s.th}>行数</th>
|
|
129
|
+
<th style={s.th}>列数</th>
|
|
130
|
+
<th style={s.th}>主键</th>
|
|
131
|
+
</tr>
|
|
132
|
+
</thead>
|
|
133
|
+
<tbody>
|
|
134
|
+
{tables.length === 0 && !loading
|
|
135
|
+
? (
|
|
136
|
+
<tr>
|
|
137
|
+
<td style={{ ...s.td, color: c.fg2, textAlign: 'center', padding: '16px 8px' }} colSpan={4}>
|
|
138
|
+
没有匹配的表
|
|
139
|
+
</td>
|
|
140
|
+
</tr>
|
|
141
|
+
)
|
|
142
|
+
: tables.map(table => (
|
|
143
|
+
<tr
|
|
144
|
+
key={table.tableName}
|
|
145
|
+
style={{ ...s.row, ...(table.tableName === selected ? s.selectedRow : null) }}
|
|
146
|
+
onClick={() => setSelected(table.tableName === selected ? null : table.tableName)}
|
|
147
|
+
>
|
|
148
|
+
<td style={s.td} title={table.tableName}>{table.tableName}</td>
|
|
149
|
+
<td style={s.td}>{formatRows(table.rowCount)}</td>
|
|
150
|
+
<td style={s.td}>{table.columns.length}</td>
|
|
151
|
+
<td style={s.td}>{table.primaryKey ?? '—'}</td>
|
|
152
|
+
</tr>
|
|
153
|
+
))}
|
|
154
|
+
</tbody>
|
|
155
|
+
</table>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{current !== null
|
|
159
|
+
? (
|
|
160
|
+
<div style={{ marginTop: 10 }}>
|
|
161
|
+
<div style={s.label}>{current.tableName} 的列结构({current.columns.length})</div>
|
|
162
|
+
<div style={{ ...s.scroll, maxHeight: 180 }}>
|
|
163
|
+
<table style={s.table}>
|
|
164
|
+
<thead>
|
|
165
|
+
<tr>
|
|
166
|
+
<th style={s.th}>列名</th>
|
|
167
|
+
<th style={s.th}>类型</th>
|
|
168
|
+
<th style={s.th}>可空</th>
|
|
169
|
+
<th style={s.th}>注释</th>
|
|
170
|
+
</tr>
|
|
171
|
+
</thead>
|
|
172
|
+
<tbody>
|
|
173
|
+
{current.columns.map(column => (
|
|
174
|
+
<tr key={column.name}>
|
|
175
|
+
<td style={s.td} title={column.name}>{column.name}</td>
|
|
176
|
+
<td style={s.td}>{ADMIN_COLUMN_TYPE_LABELS[column.type] ?? column.type}</td>
|
|
177
|
+
<td style={s.td}>{column.nullable ? '是' : '否'}</td>
|
|
178
|
+
<td style={{ ...s.td, color: c.fg2 }} title={column.description ?? ''}>{column.description ?? '—'}</td>
|
|
179
|
+
</tr>
|
|
180
|
+
))}
|
|
181
|
+
</tbody>
|
|
182
|
+
</table>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
)
|
|
186
|
+
: null}
|
|
187
|
+
|
|
188
|
+
<div style={{ ...s.bar, marginTop: 12 }}>
|
|
189
|
+
<select
|
|
190
|
+
style={s.select}
|
|
191
|
+
value={scopeKey}
|
|
192
|
+
disabled={importing || selected === null}
|
|
193
|
+
onChange={e => setScopeKey(e.target.value)}
|
|
194
|
+
>
|
|
195
|
+
{scopes.length === 0
|
|
196
|
+
? <option value="">(暂无可用工作区)</option>
|
|
197
|
+
: scopes.map(scope => <option key={scope.scopeKey} value={scope.scopeKey}>{scope.scopeKey}</option>)}
|
|
198
|
+
</select>
|
|
199
|
+
<input
|
|
200
|
+
style={{ ...s.search, flex: 1, minWidth: 120 }}
|
|
201
|
+
placeholder="数据集名称(默认取表名)"
|
|
202
|
+
value={name}
|
|
203
|
+
disabled={importing}
|
|
204
|
+
onChange={e => setName(e.target.value)}
|
|
205
|
+
/>
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
style={{ ...s.primaryButton, ...(selected === null || scopeKey.length === 0 || importing ? s.disabledButton : null) }}
|
|
209
|
+
disabled={selected === null || scopeKey.length === 0 || importing}
|
|
210
|
+
onClick={() => void handleImport()}
|
|
211
|
+
>{importing ? '导入中…' : '导入到工作区'}</button>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{result !== null
|
|
215
|
+
? (
|
|
216
|
+
<div style={{ ...s.banner, marginTop: 8, marginBottom: 0, borderColor: c.ok, color: c.ok }}>
|
|
217
|
+
<span>
|
|
218
|
+
已导入 {result.name}(datasetId: {result.datasetId}):{result.rowCount} 行 / {result.columnCount} 列
|
|
219
|
+
{result.status === 'running' ? '(后台导入中,完成后会通知)' : ''}
|
|
220
|
+
</span>
|
|
221
|
+
</div>
|
|
222
|
+
)
|
|
223
|
+
: null}
|
|
224
|
+
</div>
|
|
225
|
+
)
|
|
226
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设置页管理接口的浏览器侧取数封装。
|
|
3
|
+
*
|
|
4
|
+
* 全部走同源 `fetch`,前缀来自 `admin-contract` 的 `ADMIN_API_BASE`(与主机侧同一真源,
|
|
5
|
+
* 避免客户端硬编码漂移)。错误统一收口成 `AdminApiError`,携带服务端的中文 message,
|
|
6
|
+
* 由组件层直接展示。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
ADMIN_API_BASE,
|
|
11
|
+
type ConnectionTestView,
|
|
12
|
+
type CreateDataSourceRequest,
|
|
13
|
+
type CreateDatasetRequest,
|
|
14
|
+
type DataSourceView,
|
|
15
|
+
type DatasetAdminView,
|
|
16
|
+
type DatasetDetailView,
|
|
17
|
+
type DatasetRowsResult,
|
|
18
|
+
type ImportSourceTableRequest,
|
|
19
|
+
type ImportSourceTableResult,
|
|
20
|
+
type ListDatasetsResult,
|
|
21
|
+
type ListSourceTablesResult,
|
|
22
|
+
type PatchDataSourceRequest,
|
|
23
|
+
type PatchDatasetRequest,
|
|
24
|
+
type ScopeView,
|
|
25
|
+
type TestDataSourceRequest,
|
|
26
|
+
} from '../../admin-contract'
|
|
27
|
+
|
|
28
|
+
/** 服务端脱敏后的错误(含中文 message 与机器码)。 */
|
|
29
|
+
export class AdminApiError extends Error {
|
|
30
|
+
constructor(
|
|
31
|
+
public readonly status: number,
|
|
32
|
+
public readonly code: string,
|
|
33
|
+
message: string,
|
|
34
|
+
) {
|
|
35
|
+
super(message)
|
|
36
|
+
this.name = 'AdminApiError'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
41
|
+
const response = await fetch(`${ADMIN_API_BASE}${path}`, {
|
|
42
|
+
...init,
|
|
43
|
+
credentials: 'same-origin',
|
|
44
|
+
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) },
|
|
45
|
+
})
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
let code = 'UNKNOWN'
|
|
48
|
+
let message = `请求失败(HTTP ${response.status})`
|
|
49
|
+
try {
|
|
50
|
+
const body = await response.json() as { error?: { code?: string; message?: string } }
|
|
51
|
+
if (body.error) {
|
|
52
|
+
code = body.error.code ?? code
|
|
53
|
+
message = body.error.message ?? message
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// 响应体非 JSON:保留默认文案。
|
|
57
|
+
}
|
|
58
|
+
throw new AdminApiError(response.status, code, message)
|
|
59
|
+
}
|
|
60
|
+
return (await response.json()) as T
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function listScopes(): Promise<{ scopes: ScopeView[] }> {
|
|
64
|
+
return request('/scopes')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function listDatasets(query: string, page: number, pageSize: number): Promise<ListDatasetsResult> {
|
|
68
|
+
const params = new URLSearchParams()
|
|
69
|
+
if (query.trim().length > 0) params.set('q', query)
|
|
70
|
+
params.set('page', String(page))
|
|
71
|
+
params.set('pageSize', String(pageSize))
|
|
72
|
+
return request(`/datasets?${params.toString()}`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getDataset(id: string, scope: string): Promise<DatasetDetailView> {
|
|
76
|
+
return request(`/datasets/${encodeURIComponent(id)}?scope=${encodeURIComponent(scope)}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 分页读取数据集的物理表数据(只读)。 */
|
|
80
|
+
export function listDatasetRows(
|
|
81
|
+
id: string,
|
|
82
|
+
scope: string,
|
|
83
|
+
page: number,
|
|
84
|
+
pageSize: number,
|
|
85
|
+
): Promise<DatasetRowsResult> {
|
|
86
|
+
const params = new URLSearchParams()
|
|
87
|
+
params.set('scope', scope)
|
|
88
|
+
params.set('page', String(page))
|
|
89
|
+
params.set('pageSize', String(pageSize))
|
|
90
|
+
return request(`/datasets/${encodeURIComponent(id)}/rows?${params.toString()}`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createDataset(body: CreateDatasetRequest): Promise<DatasetDetailView> {
|
|
94
|
+
return request('/datasets', { method: 'POST', body: JSON.stringify(body) })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function patchDataset(
|
|
98
|
+
id: string,
|
|
99
|
+
scope: string,
|
|
100
|
+
body: PatchDatasetRequest,
|
|
101
|
+
): Promise<DatasetDetailView> {
|
|
102
|
+
return request(`/datasets/${encodeURIComponent(id)}?scope=${encodeURIComponent(scope)}`, {
|
|
103
|
+
method: 'PATCH',
|
|
104
|
+
body: JSON.stringify(body),
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function deleteDataset(
|
|
109
|
+
id: string,
|
|
110
|
+
scope: string,
|
|
111
|
+
): Promise<{ id: string; name: string; dropped: boolean }> {
|
|
112
|
+
return request(`/datasets/${encodeURIComponent(id)}?scope=${encodeURIComponent(scope)}`, {
|
|
113
|
+
method: 'DELETE',
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── 数据源 ───────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
export function listDataSources(): Promise<{ sources: DataSourceView[] }> {
|
|
120
|
+
return request('/sources')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function getDataSource(id: string): Promise<DataSourceView> {
|
|
124
|
+
return request(`/sources/${encodeURIComponent(id)}`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createDataSource(body: CreateDataSourceRequest): Promise<DataSourceView> {
|
|
128
|
+
return request('/sources', { method: 'POST', body: JSON.stringify(body) })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function patchDataSource(id: string, body: PatchDataSourceRequest): Promise<DataSourceView> {
|
|
132
|
+
return request(`/sources/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(body) })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function deleteDataSource(id: string): Promise<{ deleted: boolean }> {
|
|
136
|
+
return request(`/sources/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 测已登记的数据源(给 source)或未保存的草稿(给完整参数)。 */
|
|
140
|
+
export function testDataSource(body: TestDataSourceRequest): Promise<ConnectionTestView> {
|
|
141
|
+
return request('/sources/test', { method: 'POST', body: JSON.stringify(body) })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function listSourceTables(id: string, schema?: string, query?: string): Promise<ListSourceTablesResult> {
|
|
145
|
+
const params = new URLSearchParams()
|
|
146
|
+
if (schema !== undefined && schema.trim().length > 0) params.set('schema', schema)
|
|
147
|
+
if (query !== undefined && query.trim().length > 0) params.set('q', query)
|
|
148
|
+
const suffix = params.toString().length > 0 ? `?${params.toString()}` : ''
|
|
149
|
+
return request(`/sources/${encodeURIComponent(id)}/tables${suffix}`)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function importSourceTable(id: string, body: ImportSourceTableRequest): Promise<ImportSourceTableResult> {
|
|
153
|
+
return request(`/sources/${encodeURIComponent(id)}/import`, { method: 'POST', body: JSON.stringify(body) })
|
|
154
|
+
}
|