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,278 @@
1
+ /**
2
+ * 数据源新建 / 编辑表单。
3
+ *
4
+ * 两步提交:**先测连、后保存**。连接参数(类型 / 主机 / 端口 / 库 / 用户 / 密码)任一改动
5
+ * 都会把测试结果打回「未测试」,未测通时保存按钮禁用;服务端在保存时也会再测一次,
6
+ * 避免前端被绕过。编辑态下密码留空表示「保留原密码」。
7
+ */
8
+
9
+ import { useState } from 'react'
10
+ import type { ReactElement } from 'react'
11
+ import {
12
+ ADMIN_SOURCE_DEFAULT_PORTS,
13
+ ADMIN_SOURCE_TYPES,
14
+ ADMIN_SOURCE_TYPE_LABELS,
15
+ type ConnectionTestView,
16
+ type DataSourceView,
17
+ type TestDataSourceRequest,
18
+ } from '../../admin-contract'
19
+ import { createDataSource, patchDataSource, testDataSource } from './api'
20
+ import { c, s } from './styles'
21
+
22
+ export interface DataSourceFormProps {
23
+ /** 传入即编辑态;null / undefined 为新建。 */
24
+ initial?: DataSourceView | null
25
+ onSaved: (view: DataSourceView) => void
26
+ onCancel: () => void
27
+ onError: (error: Error) => void
28
+ }
29
+
30
+ type TestState = 'idle' | 'testing' | 'ok' | 'fail'
31
+
32
+ /** 连接参数的具体形态:测连、新建、编辑三处共用同一份草稿。 */
33
+ interface ConnectionFields {
34
+ type: string
35
+ host: string
36
+ port: number
37
+ database: string
38
+ username: string
39
+ password: string
40
+ sslMode: string | null
41
+ poolMax: number | null
42
+ }
43
+
44
+ export function DataSourceForm(props: DataSourceFormProps): ReactElement {
45
+ const { initial, onSaved, onCancel, onError } = props
46
+ const editing = initial !== null && initial !== undefined
47
+ const defaultType = initial?.type ?? 'mysql'
48
+
49
+ const [name, setName] = useState(initial?.name ?? '')
50
+ const [type, setType] = useState(defaultType)
51
+ const [host, setHost] = useState(initial?.host ?? '127.0.0.1')
52
+ const [port, setPort] = useState(String(initial?.port ?? ADMIN_SOURCE_DEFAULT_PORTS[defaultType]))
53
+ const [database, setDatabase] = useState(initial?.database ?? '')
54
+ const [username, setUsername] = useState(initial?.username ?? '')
55
+ const [password, setPassword] = useState('')
56
+ const [sslMode, setSslMode] = useState(initial?.sslMode ?? '')
57
+ const [poolMax, setPoolMax] = useState(initial?.poolMax === null ? '' : String(initial?.poolMax ?? ''))
58
+ const [description, setDescription] = useState(initial?.description ?? '')
59
+ const [testState, setTestState] = useState<TestState>('idle')
60
+ const [testResult, setTestResult] = useState<ConnectionTestView | null>(null)
61
+ const [saving, setSaving] = useState(false)
62
+
63
+ const requiredFilled =
64
+ name.trim().length > 0 && host.trim().length > 0 && database.trim().length > 0 && username.trim().length > 0
65
+
66
+ /** 连接参数变了,之前的测试结论就作废。 */
67
+ function changeConnection(next: () => void): void {
68
+ next()
69
+ setTestState('idle')
70
+ setTestResult(null)
71
+ }
72
+
73
+ function draft(): ConnectionFields {
74
+ const parsedPort = Number.parseInt(port, 10)
75
+ const parsedPool = Number.parseInt(poolMax, 10)
76
+ return {
77
+ type,
78
+ host: host.trim(),
79
+ port: Number.isFinite(parsedPort) ? parsedPort : ADMIN_SOURCE_DEFAULT_PORTS[type],
80
+ database: database.trim(),
81
+ username: username.trim(),
82
+ password,
83
+ sslMode: sslMode.trim().length > 0 ? sslMode.trim() : null,
84
+ poolMax: Number.isFinite(parsedPool) ? parsedPool : null,
85
+ }
86
+ }
87
+
88
+ async function handleTest(): Promise<void> {
89
+ if (!requiredFilled || testState === 'testing') return
90
+ setTestState('testing')
91
+ try {
92
+ const result = await testDataSource(draft())
93
+ setTestResult(result)
94
+ setTestState(result.success ? 'ok' : 'fail')
95
+ } catch (error) {
96
+ setTestState('fail')
97
+ onError(error instanceof Error ? error : new Error('测试连接失败'))
98
+ }
99
+ }
100
+
101
+ async function handleSave(): Promise<void> {
102
+ if (testState !== 'ok' || saving) return
103
+ setSaving(true)
104
+ try {
105
+ const connection = draft()
106
+ const saved = editing
107
+ ? await patchDataSource(initial.id, {
108
+ name: name.trim(),
109
+ ...connection,
110
+ // 密码留空 = 保留原密码;填了才替换。
111
+ ...(password.length > 0 ? {} : { password: undefined }),
112
+ description: description.trim().length > 0 ? description.trim() : null,
113
+ test: true,
114
+ })
115
+ : await createDataSource({
116
+ ...connection,
117
+ name: name.trim(),
118
+ description: description.trim().length > 0 ? description.trim() : null,
119
+ test: true,
120
+ })
121
+ onSaved(saved)
122
+ } catch (error) {
123
+ onError(error instanceof Error ? error : new Error('保存失败'))
124
+ } finally {
125
+ setSaving(false)
126
+ }
127
+ }
128
+
129
+ const testHint = testState === 'ok'
130
+ ? `连接成功(${testResult?.latency ?? 0}ms${testResult?.version === null ? '' : ` · ${testResult?.version}`})`
131
+ : testState === 'fail'
132
+ ? (testResult?.error ?? '连接失败')
133
+ : '保存前请先测试连接'
134
+
135
+ return (
136
+ <div style={s.panel}>
137
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}>
138
+ <strong style={{ fontSize: 13 }}>{editing ? '编辑数据源' : '新建数据源'}</strong>
139
+ <span style={s.muted}>
140
+ {editing && initial.hasPassword ? '已保存过密码,留空即保留' : '密码加密存储,不会回显'}
141
+ </span>
142
+ </div>
143
+
144
+ <div style={s.grid2}>
145
+ <div style={s.field}>
146
+ <label style={s.label}>名称</label>
147
+ <input style={s.input} value={name} disabled={saving} onChange={e => setName(e.target.value)} />
148
+ </div>
149
+ <div style={s.field}>
150
+ <label style={s.label}>类型</label>
151
+ <select
152
+ style={s.input}
153
+ value={type}
154
+ disabled={saving}
155
+ onChange={e => changeConnection(() => {
156
+ setType(e.target.value)
157
+ setPort(String(ADMIN_SOURCE_DEFAULT_PORTS[e.target.value]))
158
+ })}
159
+ >
160
+ {ADMIN_SOURCE_TYPES.map(item => (
161
+ <option key={item} value={item}>{ADMIN_SOURCE_TYPE_LABELS[item]}</option>
162
+ ))}
163
+ </select>
164
+ </div>
165
+ </div>
166
+
167
+ <div style={s.grid2}>
168
+ <div style={s.field}>
169
+ <label style={s.label}>主机</label>
170
+ <input
171
+ style={s.input}
172
+ value={host}
173
+ disabled={saving}
174
+ onChange={e => changeConnection(() => setHost(e.target.value))}
175
+ />
176
+ </div>
177
+ <div style={s.field}>
178
+ <label style={s.label}>端口</label>
179
+ <input
180
+ style={s.input}
181
+ value={port}
182
+ inputMode="numeric"
183
+ disabled={saving}
184
+ onChange={e => changeConnection(() => setPort(e.target.value))}
185
+ />
186
+ </div>
187
+ </div>
188
+
189
+ <div style={s.grid2}>
190
+ <div style={s.field}>
191
+ <label style={s.label}>数据库名</label>
192
+ <input
193
+ style={s.input}
194
+ value={database}
195
+ disabled={saving}
196
+ onChange={e => changeConnection(() => setDatabase(e.target.value))}
197
+ />
198
+ </div>
199
+ <div style={s.field}>
200
+ <label style={s.label}>用户名</label>
201
+ <input
202
+ style={s.input}
203
+ value={username}
204
+ disabled={saving}
205
+ onChange={e => changeConnection(() => setUsername(e.target.value))}
206
+ />
207
+ </div>
208
+ </div>
209
+
210
+ <div style={s.grid2}>
211
+ <div style={s.field}>
212
+ <label style={s.label}>{editing ? '重设密码(留空保留)' : '密码'}</label>
213
+ <input
214
+ style={s.input}
215
+ type="password"
216
+ value={password}
217
+ disabled={saving}
218
+ onChange={e => changeConnection(() => setPassword(e.target.value))}
219
+ />
220
+ </div>
221
+ <div style={s.field}>
222
+ <label style={s.label}>SSL 模式(可选)</label>
223
+ <input
224
+ style={s.input}
225
+ value={sslMode}
226
+ disabled={saving}
227
+ placeholder={type === 'mysql' ? 'disabled / preferred / required' : 'disable / require / verify-full'}
228
+ onChange={e => changeConnection(() => setSslMode(e.target.value))}
229
+ />
230
+ </div>
231
+ </div>
232
+
233
+ <div style={s.grid2}>
234
+ <div style={s.field}>
235
+ <label style={s.label}>连接池上限(可选)</label>
236
+ <input
237
+ style={s.input}
238
+ value={poolMax}
239
+ inputMode="numeric"
240
+ disabled={saving}
241
+ onChange={e => changeConnection(() => setPoolMax(e.target.value))}
242
+ />
243
+ </div>
244
+ <div style={s.field}>
245
+ <label style={s.label}>描述(可选)</label>
246
+ <input style={s.input} value={description} disabled={saving} onChange={e => setDescription(e.target.value)} />
247
+ </div>
248
+ </div>
249
+
250
+ <div
251
+ style={{
252
+ ...s.banner,
253
+ marginBottom: 10,
254
+ borderColor: testState === 'ok' ? c.ok : testState === 'fail' ? c.danger : c.border,
255
+ color: testState === 'ok' ? c.ok : testState === 'fail' ? c.danger : c.fg2,
256
+ }}
257
+ >
258
+ <span>{testHint}</span>
259
+ </div>
260
+
261
+ <div style={{ display: 'flex', gap: 8 }}>
262
+ <button
263
+ type="button"
264
+ style={{ ...s.button, ...(!requiredFilled || testState === 'testing' ? s.disabledButton : null) }}
265
+ disabled={!requiredFilled || testState === 'testing'}
266
+ onClick={() => void handleTest()}
267
+ >{testState === 'testing' ? '测试中…' : '测试连接'}</button>
268
+ <button
269
+ type="button"
270
+ style={{ ...s.primaryButton, ...(testState !== 'ok' || saving ? s.disabledButton : null) }}
271
+ disabled={testState !== 'ok' || saving}
272
+ onClick={() => void handleSave()}
273
+ >{saving ? '保存中…' : '保存'}</button>
274
+ <button type="button" style={s.button} disabled={saving} onClick={onCancel}>取消</button>
275
+ </div>
276
+ </div>
277
+ )
278
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * 「数据源」页签:列表(名称 / 类型 / 地址 / 库名 / 连通状态 / 最近检测)+ 工具条
3
+ * (搜索 / 新建 / 刷新)+ 选中项的详情面板。
4
+ *
5
+ * 详情面板上部位只读连接摘要(密码位只显示「已设置 · 不可查看」),下部位
6
+ * `SourceTablesPanel`:浏览远端表并把选中的表导入到某个工作区。
7
+ *
8
+ * 写操作(新建 / 编辑 / 删除 / 导入)在只读模式或接口关闭时整体置灰 —— 判定由服务端
9
+ * 返回的 `READ_ONLY` / `ADMIN_DISABLED` / `SOURCE_DISABLED` 错误码驱动,不靠前端猜。
10
+ */
11
+
12
+ import { useCallback, useEffect, useState } from 'react'
13
+ import type { ReactElement } from 'react'
14
+ import {
15
+ ADMIN_SOURCE_TYPE_LABELS,
16
+ type DataSourceView,
17
+ type ScopeView,
18
+ } from '../../admin-contract'
19
+ import { AdminApiError, deleteDataSource, listDataSources, listScopes, testDataSource } from './api'
20
+ import { DataSourceForm } from './DataSourceForm'
21
+ import { SourceTablesPanel } from './SourceTablesPanel'
22
+ import { fmtAgo } from './DatasetTable'
23
+ import { c, s, SOURCE_STATUS_COLOR, SOURCE_STATUS_TEXT, TYPE_BADGE } from './styles'
24
+
25
+ type Mode = 'browse' | 'create' | 'edit'
26
+
27
+ const READ_ONLY_CODES = ['READ_ONLY', 'ADMIN_DISABLED', 'SOURCE_DISABLED']
28
+
29
+ export function DataSourcesPanel(): ReactElement {
30
+ const [items, setItems] = useState<DataSourceView[]>([])
31
+ const [scopes, setScopes] = useState<ScopeView[]>([])
32
+ const [query, setQuery] = useState('')
33
+ const [selectedId, setSelectedId] = useState<string | null>(null)
34
+ const [mode, setMode] = useState<Mode>('browse')
35
+ const [loading, setLoading] = useState(false)
36
+ const [writable, setWritable] = useState(true)
37
+ const [error, setError] = useState<string | null>(null)
38
+ const [notice, setNotice] = useState<string | null>(null)
39
+ const [testingId, setTestingId] = useState<string | null>(null)
40
+ const [confirmId, setConfirmId] = useState<string | null>(null)
41
+
42
+ const loadScopes = useCallback(async (): Promise<void> => {
43
+ try {
44
+ setScopes((await listScopes()).scopes)
45
+ } catch {
46
+ // 工作区列表拿不到不阻断数据源浏览;导入时再提示。
47
+ }
48
+ }, [])
49
+
50
+ const loadList = useCallback(async (): Promise<void> => {
51
+ setLoading(true)
52
+ try {
53
+ setItems((await listDataSources()).sources)
54
+ setError(null)
55
+ } catch (failure) {
56
+ setError(failure instanceof Error ? failure.message : '加载数据源失败')
57
+ if (failure instanceof AdminApiError && READ_ONLY_CODES.includes(failure.code)) setWritable(false)
58
+ } finally {
59
+ setLoading(false)
60
+ }
61
+ }, [])
62
+
63
+ useEffect(() => {
64
+ void loadScopes()
65
+ void loadList()
66
+ }, [loadScopes, loadList])
67
+
68
+ const selected = items.find(item => item.id === selectedId) ?? null
69
+ const needle = query.trim().toLowerCase()
70
+ const visible = needle.length === 0
71
+ ? items
72
+ : items.filter(item => (
73
+ item.name.toLowerCase().includes(needle)
74
+ || item.type.toLowerCase().includes(needle)
75
+ || item.host.toLowerCase().includes(needle)
76
+ || item.database.toLowerCase().includes(needle)
77
+ ))
78
+
79
+ async function handleTest(id: string): Promise<void> {
80
+ if (testingId !== null) return
81
+ setTestingId(id)
82
+ setNotice(null)
83
+ try {
84
+ const result = await testDataSource({ source: id })
85
+ setNotice(result.success
86
+ ? `连接成功(${result.latency}ms${result.version === null ? '' : ` · ${result.version}`})`
87
+ : `连接失败:${result.error ?? '未知错误'}`)
88
+ await loadList()
89
+ } catch (failure) {
90
+ setError(failure instanceof Error ? failure.message : '测试连接失败')
91
+ } finally {
92
+ setTestingId(null)
93
+ }
94
+ }
95
+
96
+ async function handleDelete(id: string): Promise<void> {
97
+ setConfirmId(null)
98
+ try {
99
+ await deleteDataSource(id)
100
+ if (selectedId === id) {
101
+ setSelectedId(null)
102
+ setMode('browse')
103
+ }
104
+ await loadList()
105
+ } catch (failure) {
106
+ setError(failure instanceof Error ? failure.message : '删除失败')
107
+ }
108
+ }
109
+
110
+ async function handleSaved(view: DataSourceView): Promise<void> {
111
+ setMode('browse')
112
+ setNotice(`已保存数据源「${view.name}」`)
113
+ await loadList()
114
+ setSelectedId(view.id)
115
+ }
116
+
117
+ function handleError(failure: Error): void {
118
+ setError(failure.message)
119
+ if (failure instanceof AdminApiError && READ_ONLY_CODES.includes(failure.code)) setWritable(false)
120
+ }
121
+
122
+ return (
123
+ <div>
124
+ <div style={s.toolbar}>
125
+ <input
126
+ style={s.search}
127
+ placeholder="按名称 / 类型 / 主机 / 库名搜索…"
128
+ value={query}
129
+ onChange={e => setQuery(e.target.value)}
130
+ />
131
+ <button
132
+ type="button"
133
+ style={{ ...s.primaryButton, ...(!writable ? s.disabledButton : null) }}
134
+ disabled={!writable}
135
+ onClick={() => { setMode('create'); setSelectedId(null) }}
136
+ title={writable ? '新建数据源' : '只读模式、接口已关闭或数据源功能未启用'}
137
+ >+ 新建数据源</button>
138
+ <button type="button" style={s.button} disabled={loading} onClick={() => void loadList()}>刷新</button>
139
+ </div>
140
+
141
+ {error !== null
142
+ ? (
143
+ <div style={s.banner}>
144
+ <span>{error}</span>
145
+ <span style={{ ...s.link, marginLeft: 'auto' }} onClick={() => setError(null)}>忽略</span>
146
+ </div>
147
+ )
148
+ : null}
149
+
150
+ {notice !== null
151
+ ? (
152
+ <div style={{ ...s.banner, borderColor: c.ok, color: c.ok }}>
153
+ <span>{notice}</span>
154
+ <span style={{ ...s.link, marginLeft: 'auto' }} onClick={() => setNotice(null)}>忽略</span>
155
+ </div>
156
+ )
157
+ : null}
158
+
159
+ {confirmId !== null
160
+ ? (
161
+ <div style={s.banner}>
162
+ <span>确认删除这个数据源?该操作不可恢复。</span>
163
+ <button type="button" style={{ ...s.dangerButton, marginLeft: 'auto' }} onClick={() => void handleDelete(confirmId)}>删除</button>
164
+ <button type="button" style={s.button} onClick={() => setConfirmId(null)}>取消</button>
165
+ </div>
166
+ )
167
+ : null}
168
+
169
+ <div style={s.scroll}>
170
+ <table style={s.table}>
171
+ <thead>
172
+ <tr>
173
+ <th style={s.th}>名称</th>
174
+ <th style={s.th}>类型</th>
175
+ <th style={s.th}>地址</th>
176
+ <th style={s.th}>库名</th>
177
+ <th style={s.th}>状态</th>
178
+ <th style={s.th}>最近检测</th>
179
+ <th style={s.th}>操作</th>
180
+ </tr>
181
+ </thead>
182
+ <tbody>
183
+ {visible.length === 0 && !loading
184
+ ? (
185
+ <tr>
186
+ <td style={{ ...s.td, color: c.fg2, textAlign: 'center', padding: '18px 8px' }} colSpan={7}>
187
+ 还没有数据源,点击「新建数据源」登记一个 MySQL / PostgreSQL 连接
188
+ </td>
189
+ </tr>
190
+ )
191
+ : visible.map(item => (
192
+ <tr
193
+ key={item.id}
194
+ style={{ ...s.row, ...(item.id === selectedId ? s.selectedRow : null) }}
195
+ onClick={() => { setSelectedId(item.id); setMode('browse') }}
196
+ >
197
+ <td style={s.td} title={item.description ?? ''}>{item.name}</td>
198
+ <td style={s.td}>
199
+ <span
200
+ style={{
201
+ display: 'inline-block', padding: '0 6px', borderRadius: 4, fontSize: 11,
202
+ color: TYPE_BADGE[item.type] ?? c.fg2, border: `1px solid ${TYPE_BADGE[item.type] ?? c.border}`,
203
+ }}
204
+ >{ADMIN_SOURCE_TYPE_LABELS[item.type] ?? item.type}</span>
205
+ </td>
206
+ <td style={s.td} title={`${item.host}:${item.port}`}>{item.host}:{item.port}</td>
207
+ <td style={s.td}>{item.database}</td>
208
+ <td style={s.td}>
209
+ <span style={{ ...s.dot, background: SOURCE_STATUS_COLOR[item.status] ?? c.fg2 }} />
210
+ {SOURCE_STATUS_TEXT[item.status] ?? item.status}
211
+ </td>
212
+ <td style={{ ...s.td, color: c.fg2 }}>
213
+ {item.lastCheckedAt === null ? '—' : fmtAgo(item.lastCheckedAt)}
214
+ </td>
215
+ <td style={{ ...s.td, whiteSpace: 'nowrap' }}>
216
+ <span
217
+ style={{ ...s.link, marginRight: 8, ...(testingId !== null ? s.disabledButton : null) }}
218
+ onClick={event => {
219
+ event.stopPropagation()
220
+ if (testingId === null) void handleTest(item.id)
221
+ }}
222
+ >{testingId === item.id ? '测试中…' : '测试'}</span>
223
+ <span
224
+ style={{ ...s.link, marginRight: 8, ...(!writable ? s.disabledButton : null) }}
225
+ onClick={event => {
226
+ event.stopPropagation()
227
+ if (!writable) return
228
+ setSelectedId(item.id)
229
+ setMode('edit')
230
+ }}
231
+ >编辑</span>
232
+ <span
233
+ style={{ ...s.link, color: c.danger, ...(!writable ? s.disabledButton : null) }}
234
+ onClick={event => {
235
+ event.stopPropagation()
236
+ if (writable) setConfirmId(item.id)
237
+ }}
238
+ >删除</span>
239
+ </td>
240
+ </tr>
241
+ ))}
242
+ </tbody>
243
+ </table>
244
+ </div>
245
+
246
+ {mode === 'create'
247
+ ? (
248
+ <DataSourceForm
249
+ onSaved={view => void handleSaved(view)}
250
+ onCancel={() => setMode('browse')}
251
+ onError={handleError}
252
+ />
253
+ )
254
+ : mode === 'edit' && selected !== null
255
+ ? (
256
+ <DataSourceForm
257
+ initial={selected}
258
+ onSaved={view => void handleSaved(view)}
259
+ onCancel={() => setMode('browse')}
260
+ onError={handleError}
261
+ />
262
+ )
263
+ : selected !== null
264
+ ? (
265
+ <>
266
+ <div style={s.panel}>
267
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
268
+ <strong style={{ fontSize: 13 }}>{selected.name}</strong>
269
+ <span style={s.muted}>
270
+ {ADMIN_SOURCE_TYPE_LABELS[selected.type] ?? selected.type} · 最近更新 {fmtAgo(selected.updatedAt)}
271
+ </span>
272
+ </div>
273
+ <div style={s.grid2}>
274
+ <div><span style={s.muted}>地址:</span>{selected.host}:{selected.port}</div>
275
+ <div><span style={s.muted}>库名:</span>{selected.database}</div>
276
+ <div><span style={s.muted}>用户名:</span>{selected.username}</div>
277
+ <div>
278
+ <span style={s.muted}>密码:</span>
279
+ {selected.hasPassword ? '已设置 · 不可查看' : '未设置'}
280
+ </div>
281
+ <div><span style={s.muted}>SSL:</span>{selected.sslMode ?? '—'}</div>
282
+ <div><span style={s.muted}>连接池上限:</span>{selected.poolMax ?? '—'}</div>
283
+ </div>
284
+ {selected.description !== null
285
+ ? <div style={{ ...s.muted, marginTop: 6 }}>{selected.description}</div>
286
+ : null}
287
+ {selected.lastError !== null
288
+ ? <div style={{ ...s.muted, marginTop: 6, color: c.danger }}>最近错误:{selected.lastError}</div>
289
+ : null}
290
+ </div>
291
+ <SourceTablesPanel source={selected} scopes={scopes} onError={handleError} />
292
+ </>
293
+ )
294
+ : (
295
+ <div style={{ ...s.muted, marginTop: 12 }}>
296
+ {loading ? '加载中…' : '选择一个数据源查看详情与远端表,或点击「新建数据源」。'}
297
+ </div>
298
+ )}
299
+ </div>
300
+ )
301
+ }