@zhin.js/console 1.0.47 → 1.0.49

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.
@@ -0,0 +1,708 @@
1
+ import { useState, useEffect, useCallback, useMemo } from 'react'
2
+ import { useDatabase } from '@zhin.js/client'
3
+ import type { DatabaseType, TableInfo, SelectResult, KvEntry } from '@zhin.js/client'
4
+ import {
5
+ Database, Table2, Plus, Trash2, Pencil, RefreshCw, Loader2, AlertCircle,
6
+ CheckCircle, Search, ChevronLeft, ChevronRight, X, Save, Key
7
+ } from 'lucide-react'
8
+ import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card'
9
+ import { Button } from '../components/ui/button'
10
+ import { Alert, AlertDescription } from '../components/ui/alert'
11
+ import { Badge } from '../components/ui/badge'
12
+ import { ScrollArea } from '../components/ui/scroll-area'
13
+ import {
14
+ Dialog,
15
+ DialogContent,
16
+ DialogHeader,
17
+ DialogTitle,
18
+ DialogFooter,
19
+ DialogClose,
20
+ } from '../components/ui/dialog'
21
+ import { Input } from '../components/ui/input'
22
+
23
+ // ── 通用 JSON 编辑器 ───────────────────────────────────────────────
24
+
25
+ function JsonField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
26
+ return (
27
+ <div className="space-y-1">
28
+ <label className="text-xs font-medium text-muted-foreground">{label}</label>
29
+ <Input value={value} onChange={(e: any) => onChange(e.target.value)} className="font-mono text-xs" />
30
+ </div>
31
+ )
32
+ }
33
+
34
+ // ── 关系型数据库表视图 ─────────────────────────────────────────────
35
+
36
+ function RelatedTableView({
37
+ tableName,
38
+ tableInfo,
39
+ select,
40
+ insert,
41
+ update,
42
+ remove,
43
+ }: {
44
+ tableName: string
45
+ tableInfo?: TableInfo
46
+ select: (table: string, page?: number, pageSize?: number, where?: any) => Promise<SelectResult>
47
+ insert: (table: string, row: any) => Promise<any>
48
+ update: (table: string, row: any, where: any) => Promise<any>
49
+ remove: (table: string, where: any) => Promise<any>
50
+ }) {
51
+ const [data, setData] = useState<SelectResult | null>(null)
52
+ const [loading, setLoading] = useState(false)
53
+ const [page, setPage] = useState(1)
54
+ const [msg, setMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
55
+ const [editRow, setEditRow] = useState<any>(null)
56
+ const [addRow, setAddRow] = useState(false)
57
+ const [formData, setFormData] = useState<Record<string, string>>({})
58
+ const pageSize = 50
59
+
60
+ const load = useCallback(async () => {
61
+ setLoading(true)
62
+ setMsg(null)
63
+ try {
64
+ const result = await select(tableName, page, pageSize)
65
+ setData(result)
66
+ } catch (err) {
67
+ setMsg({ type: 'error', text: (err as Error).message })
68
+ } finally {
69
+ setLoading(false)
70
+ }
71
+ }, [tableName, page, select])
72
+
73
+ useEffect(() => { load() }, [load])
74
+
75
+ const columns = useMemo(() => {
76
+ if (tableInfo?.columns) return Object.keys(tableInfo.columns)
77
+ if (data?.rows?.length) return Object.keys(data.rows[0])
78
+ return []
79
+ }, [tableInfo, data])
80
+
81
+ const primaryKey = useMemo(() => {
82
+ if (!tableInfo?.columns) return columns[0] || 'id'
83
+ const pk = Object.entries(tableInfo.columns).find(([, col]: [string, any]) => col.primary)
84
+ return pk ? pk[0] : columns[0] || 'id'
85
+ }, [tableInfo, columns])
86
+
87
+ const handleSave = async (isNew: boolean) => {
88
+ try {
89
+ const row: any = {}
90
+ for (const col of columns) {
91
+ const val = formData[col]
92
+ if (val === undefined || val === '') continue
93
+ try { row[col] = JSON.parse(val) } catch { row[col] = val }
94
+ }
95
+ if (isNew) {
96
+ await insert(tableName, row)
97
+ } else {
98
+ const where: any = { [primaryKey]: editRow[primaryKey] }
99
+ await update(tableName, row, where)
100
+ }
101
+ setMsg({ type: 'success', text: isNew ? '添加成功' : '更新成功' })
102
+ setEditRow(null)
103
+ setAddRow(false)
104
+ setFormData({})
105
+ setTimeout(() => setMsg(null), 2000)
106
+ await load()
107
+ } catch (err) {
108
+ setMsg({ type: 'error', text: (err as Error).message })
109
+ }
110
+ }
111
+
112
+ const handleDelete = async (row: any) => {
113
+ if (!confirm('确定要删除这条记录吗?')) return
114
+ try {
115
+ await remove(tableName, { [primaryKey]: row[primaryKey] })
116
+ setMsg({ type: 'success', text: '删除成功' })
117
+ setTimeout(() => setMsg(null), 2000)
118
+ await load()
119
+ } catch (err) {
120
+ setMsg({ type: 'error', text: (err as Error).message })
121
+ }
122
+ }
123
+
124
+ const openEdit = (row: any) => {
125
+ setEditRow(row)
126
+ setAddRow(false)
127
+ const fd: Record<string, string> = {}
128
+ for (const col of columns) {
129
+ fd[col] = typeof row[col] === 'object' ? JSON.stringify(row[col]) : String(row[col] ?? '')
130
+ }
131
+ setFormData(fd)
132
+ }
133
+
134
+ const openAdd = () => {
135
+ setEditRow(null)
136
+ setAddRow(true)
137
+ const fd: Record<string, string> = {}
138
+ for (const col of columns) fd[col] = ''
139
+ setFormData(fd)
140
+ }
141
+
142
+ const totalPages = data ? Math.ceil(data.total / pageSize) : 0
143
+
144
+ return (
145
+ <div className="space-y-3">
146
+ {msg && (
147
+ <Alert variant={msg.type === 'error' ? 'destructive' : 'success'} className="py-2">
148
+ {msg.type === 'error' ? <AlertCircle className="h-4 w-4" /> : <CheckCircle className="h-4 w-4" />}
149
+ <AlertDescription>{msg.text}</AlertDescription>
150
+ </Alert>
151
+ )}
152
+
153
+ <div className="flex items-center gap-2">
154
+ <Button size="sm" variant="outline" onClick={load} disabled={loading}>
155
+ <RefreshCw className={`w-3.5 h-3.5 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
156
+ </Button>
157
+ <Button size="sm" onClick={openAdd}><Plus className="w-3.5 h-3.5 mr-1" />添加</Button>
158
+ <span className="text-xs text-muted-foreground ml-auto">
159
+ 共 {data?.total ?? 0} 条 · 第 {page}/{totalPages || 1} 页
160
+ </span>
161
+ </div>
162
+
163
+ {/* 数据表格 */}
164
+ <ScrollArea className="border rounded-md">
165
+ <div className="overflow-x-auto">
166
+ <table className="w-full text-sm">
167
+ <thead>
168
+ <tr className="border-b bg-muted/50">
169
+ {columns.map((col: string) => (
170
+ <th key={col} className="px-3 py-2 text-left font-medium text-muted-foreground whitespace-nowrap">{col}</th>
171
+ ))}
172
+ <th className="px-3 py-2 text-right font-medium text-muted-foreground w-20">操作</th>
173
+ </tr>
174
+ </thead>
175
+ <tbody>
176
+ {loading && !data ? (
177
+ <tr><td colSpan={columns.length + 1} className="text-center py-8"><Loader2 className="w-4 h-4 animate-spin inline-block" /></td></tr>
178
+ ) : !data?.rows?.length ? (
179
+ <tr><td colSpan={columns.length + 1} className="text-center py-8 text-muted-foreground">暂无数据</td></tr>
180
+ ) : data.rows.map((row: any, i: number) => (
181
+ <tr key={i} className="border-b hover:bg-muted/30 transition-colors">
182
+ {columns.map((col: string) => (
183
+ <td key={col} className="px-3 py-1.5 font-mono text-xs max-w-[300px] truncate">
184
+ {typeof row[col] === 'object' ? JSON.stringify(row[col]) : String(row[col] ?? '')}
185
+ </td>
186
+ ))}
187
+ <td className="px-3 py-1.5 text-right space-x-1 whitespace-nowrap">
188
+ <Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => openEdit(row)}><Pencil className="w-3 h-3" /></Button>
189
+ <Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-destructive" onClick={() => handleDelete(row)}><Trash2 className="w-3 h-3" /></Button>
190
+ </td>
191
+ </tr>
192
+ ))}
193
+ </tbody>
194
+ </table>
195
+ </div>
196
+ </ScrollArea>
197
+
198
+ {/* 分页 */}
199
+ {totalPages > 1 && (
200
+ <div className="flex items-center justify-center gap-2">
201
+ <Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p: number) => p - 1)}><ChevronLeft className="w-4 h-4" /></Button>
202
+ <span className="text-xs text-muted-foreground">{page} / {totalPages}</span>
203
+ <Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => setPage((p: number) => p + 1)}><ChevronRight className="w-4 h-4" /></Button>
204
+ </div>
205
+ )}
206
+
207
+ {/* 编辑/新增弹窗 */}
208
+ <Dialog open={editRow !== null || addRow} onOpenChange={(open) => { if (!open) { setEditRow(null); setAddRow(false) } }}>
209
+ <DialogContent className="max-w-md max-h-[80vh] overflow-y-auto">
210
+ <DialogHeader>
211
+ <DialogTitle>{addRow ? '添加记录' : '编辑记录'}</DialogTitle>
212
+ </DialogHeader>
213
+ <div className="space-y-3 py-2">
214
+ {columns.map((col: string) => (
215
+ <JsonField
216
+ key={col}
217
+ label={col}
218
+ value={formData[col] ?? ''}
219
+ onChange={(v: string) => setFormData((prev: Record<string, string>) => ({ ...prev, [col]: v }))}
220
+ />
221
+ ))}
222
+ </div>
223
+ <DialogFooter>
224
+ <DialogClose asChild><Button variant="outline" size="sm">取消</Button></DialogClose>
225
+ <Button size="sm" onClick={() => handleSave(addRow)}><Save className="w-3.5 h-3.5 mr-1" />保存</Button>
226
+ </DialogFooter>
227
+ </DialogContent>
228
+ </Dialog>
229
+ </div>
230
+ )
231
+ }
232
+
233
+ // ── 文档型数据库集合视图 ───────────────────────────────────────────
234
+
235
+ function DocumentCollectionView({
236
+ tableName,
237
+ select,
238
+ insert,
239
+ update,
240
+ remove,
241
+ }: {
242
+ tableName: string
243
+ select: (table: string, page?: number, pageSize?: number, where?: any) => Promise<SelectResult>
244
+ insert: (table: string, row: any) => Promise<any>
245
+ update: (table: string, row: any, where: any) => Promise<any>
246
+ remove: (table: string, where: any) => Promise<any>
247
+ }) {
248
+ const [data, setData] = useState<SelectResult | null>(null)
249
+ const [loading, setLoading] = useState(false)
250
+ const [page, setPage] = useState(1)
251
+ const [msg, setMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
252
+ const [editDoc, setEditDoc] = useState<any>(null)
253
+ const [addDoc, setAddDoc] = useState(false)
254
+ const [jsonText, setJsonText] = useState('')
255
+ const pageSize = 20
256
+
257
+ const load = useCallback(async () => {
258
+ setLoading(true)
259
+ setMsg(null)
260
+ try {
261
+ const result = await select(tableName, page, pageSize)
262
+ setData(result)
263
+ } catch (err) {
264
+ setMsg({ type: 'error', text: (err as Error).message })
265
+ } finally {
266
+ setLoading(false)
267
+ }
268
+ }, [tableName, page, select])
269
+
270
+ useEffect(() => { load() }, [load])
271
+
272
+ const handleSave = async (isNew: boolean) => {
273
+ try {
274
+ const doc = JSON.parse(jsonText)
275
+ if (isNew) {
276
+ await insert(tableName, doc)
277
+ } else {
278
+ const { _id, ...rest } = doc
279
+ await update(tableName, rest, { _id: editDoc._id })
280
+ }
281
+ setMsg({ type: 'success', text: isNew ? '添加成功' : '更新成功' })
282
+ setEditDoc(null)
283
+ setAddDoc(false)
284
+ setJsonText('')
285
+ setTimeout(() => setMsg(null), 2000)
286
+ await load()
287
+ } catch (err) {
288
+ setMsg({ type: 'error', text: (err as Error).message })
289
+ }
290
+ }
291
+
292
+ const handleDelete = async (doc: any) => {
293
+ if (!confirm('确定要删除这条文档吗?')) return
294
+ try {
295
+ await remove(tableName, { _id: doc._id })
296
+ setMsg({ type: 'success', text: '删除成功' })
297
+ setTimeout(() => setMsg(null), 2000)
298
+ await load()
299
+ } catch (err) {
300
+ setMsg({ type: 'error', text: (err as Error).message })
301
+ }
302
+ }
303
+
304
+ const totalPages = data ? Math.ceil(data.total / pageSize) : 0
305
+
306
+ return (
307
+ <div className="space-y-3">
308
+ {msg && (
309
+ <Alert variant={msg.type === 'error' ? 'destructive' : 'success'} className="py-2">
310
+ {msg.type === 'error' ? <AlertCircle className="h-4 w-4" /> : <CheckCircle className="h-4 w-4" />}
311
+ <AlertDescription>{msg.text}</AlertDescription>
312
+ </Alert>
313
+ )}
314
+
315
+ <div className="flex items-center gap-2">
316
+ <Button size="sm" variant="outline" onClick={load} disabled={loading}>
317
+ <RefreshCw className={`w-3.5 h-3.5 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
318
+ </Button>
319
+ <Button size="sm" onClick={() => { setAddDoc(true); setJsonText('{\n \n}') }}>
320
+ <Plus className="w-3.5 h-3.5 mr-1" />添加文档
321
+ </Button>
322
+ <span className="text-xs text-muted-foreground ml-auto">共 {data?.total ?? 0} 条 · 第 {page}/{totalPages || 1} 页</span>
323
+ </div>
324
+
325
+ {/* 文档列表 */}
326
+ <div className="space-y-2">
327
+ {loading && !data ? (
328
+ <div className="text-center py-8"><Loader2 className="w-4 h-4 animate-spin inline-block" /></div>
329
+ ) : !data?.rows?.length ? (
330
+ <p className="text-center py-8 text-muted-foreground text-sm">暂无文档</p>
331
+ ) : data.rows.map((doc: any, i: number) => (
332
+ <Card key={doc._id || i} className="overflow-hidden">
333
+ <CardContent className="p-3">
334
+ <div className="flex items-start justify-between gap-2">
335
+ <pre className="text-xs font-mono flex-1 overflow-x-auto whitespace-pre-wrap break-all">{JSON.stringify(doc, null, 2)}</pre>
336
+ <div className="flex flex-col gap-1 shrink-0">
337
+ <Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => {
338
+ setEditDoc(doc)
339
+ setAddDoc(false)
340
+ setJsonText(JSON.stringify(doc, null, 2))
341
+ }}><Pencil className="w-3 h-3" /></Button>
342
+ <Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-destructive" onClick={() => handleDelete(doc)}><Trash2 className="w-3 h-3" /></Button>
343
+ </div>
344
+ </div>
345
+ </CardContent>
346
+ </Card>
347
+ ))}
348
+ </div>
349
+
350
+ {totalPages > 1 && (
351
+ <div className="flex items-center justify-center gap-2">
352
+ <Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p: number) => p - 1)}><ChevronLeft className="w-4 h-4" /></Button>
353
+ <span className="text-xs text-muted-foreground">{page} / {totalPages}</span>
354
+ <Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => setPage((p: number) => p + 1)}><ChevronRight className="w-4 h-4" /></Button>
355
+ </div>
356
+ )}
357
+
358
+ {/* 编辑/新增弹窗 */}
359
+ <Dialog open={editDoc !== null || addDoc} onOpenChange={(open) => { if (!open) { setEditDoc(null); setAddDoc(false) } }}>
360
+ <DialogContent className="max-w-lg">
361
+ <DialogHeader><DialogTitle>{addDoc ? '添加文档' : '编辑文档'}</DialogTitle></DialogHeader>
362
+ <textarea
363
+ value={jsonText}
364
+ onChange={(e: any) => setJsonText(e.target.value)}
365
+ className="w-full h-60 font-mono text-xs p-3 border rounded-md resize-none bg-background"
366
+ spellCheck={false}
367
+ />
368
+ <DialogFooter>
369
+ <DialogClose asChild><Button variant="outline" size="sm">取消</Button></DialogClose>
370
+ <Button size="sm" onClick={() => handleSave(addDoc)}><Save className="w-3.5 h-3.5 mr-1" />保存</Button>
371
+ </DialogFooter>
372
+ </DialogContent>
373
+ </Dialog>
374
+ </div>
375
+ )
376
+ }
377
+
378
+ // ── KV 数据库桶视图 ────────────────────────────────────────────────
379
+
380
+ function KvBucketView({
381
+ tableName,
382
+ kvGet,
383
+ kvSet,
384
+ kvDelete,
385
+ kvEntries,
386
+ }: {
387
+ tableName: string
388
+ kvGet: (table: string, key: string) => Promise<{ key: string; value: any }>
389
+ kvSet: (table: string, key: string, value: any, ttl?: number) => Promise<any>
390
+ kvDelete: (table: string, key: string) => Promise<any>
391
+ kvEntries: (table: string) => Promise<{ entries: KvEntry[] }>
392
+ }) {
393
+ const [entries, setEntries] = useState<KvEntry[]>([])
394
+ const [loading, setLoading] = useState(false)
395
+ const [msg, setMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
396
+ const [editEntry, setEditEntry] = useState<KvEntry | null>(null)
397
+ const [addEntry, setAddEntry] = useState(false)
398
+ const [keyInput, setKeyInput] = useState('')
399
+ const [valueInput, setValueInput] = useState('')
400
+
401
+ const load = useCallback(async () => {
402
+ setLoading(true)
403
+ setMsg(null)
404
+ try {
405
+ const result = await kvEntries(tableName)
406
+ setEntries(result.entries)
407
+ } catch (err) {
408
+ setMsg({ type: 'error', text: (err as Error).message })
409
+ } finally {
410
+ setLoading(false)
411
+ }
412
+ }, [tableName, kvEntries])
413
+
414
+ useEffect(() => { load() }, [load])
415
+
416
+ const handleSave = async (isNew: boolean) => {
417
+ try {
418
+ let val: any
419
+ try { val = JSON.parse(valueInput) } catch { val = valueInput }
420
+ await kvSet(tableName, keyInput, val)
421
+ setMsg({ type: 'success', text: isNew ? '添加成功' : '更新成功' })
422
+ setEditEntry(null)
423
+ setAddEntry(false)
424
+ setKeyInput('')
425
+ setValueInput('')
426
+ setTimeout(() => setMsg(null), 2000)
427
+ await load()
428
+ } catch (err) {
429
+ setMsg({ type: 'error', text: (err as Error).message })
430
+ }
431
+ }
432
+
433
+ const handleDelete = async (key: string) => {
434
+ if (!confirm(`确定要删除键 "${key}" 吗?`)) return
435
+ try {
436
+ await kvDelete(tableName, key)
437
+ setMsg({ type: 'success', text: '删除成功' })
438
+ setTimeout(() => setMsg(null), 2000)
439
+ await load()
440
+ } catch (err) {
441
+ setMsg({ type: 'error', text: (err as Error).message })
442
+ }
443
+ }
444
+
445
+ return (
446
+ <div className="space-y-3">
447
+ {msg && (
448
+ <Alert variant={msg.type === 'error' ? 'destructive' : 'success'} className="py-2">
449
+ {msg.type === 'error' ? <AlertCircle className="h-4 w-4" /> : <CheckCircle className="h-4 w-4" />}
450
+ <AlertDescription>{msg.text}</AlertDescription>
451
+ </Alert>
452
+ )}
453
+
454
+ <div className="flex items-center gap-2">
455
+ <Button size="sm" variant="outline" onClick={load} disabled={loading}>
456
+ <RefreshCw className={`w-3.5 h-3.5 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
457
+ </Button>
458
+ <Button size="sm" onClick={() => { setAddEntry(true); setKeyInput(''); setValueInput('') }}>
459
+ <Plus className="w-3.5 h-3.5 mr-1" />添加键值
460
+ </Button>
461
+ <span className="text-xs text-muted-foreground ml-auto">共 {entries.length} 个键</span>
462
+ </div>
463
+
464
+ {/* KV 列表 */}
465
+ <ScrollArea className="border rounded-md">
466
+ <table className="w-full text-sm">
467
+ <thead>
468
+ <tr className="border-b bg-muted/50">
469
+ <th className="px-3 py-2 text-left font-medium text-muted-foreground w-1/3">Key</th>
470
+ <th className="px-3 py-2 text-left font-medium text-muted-foreground">Value</th>
471
+ <th className="px-3 py-2 text-right font-medium text-muted-foreground w-20">操作</th>
472
+ </tr>
473
+ </thead>
474
+ <tbody>
475
+ {loading && !entries.length ? (
476
+ <tr><td colSpan={3} className="text-center py-8"><Loader2 className="w-4 h-4 animate-spin inline-block" /></td></tr>
477
+ ) : !entries.length ? (
478
+ <tr><td colSpan={3} className="text-center py-8 text-muted-foreground">暂无数据</td></tr>
479
+ ) : entries.map((entry) => (
480
+ <tr key={entry.key} className="border-b hover:bg-muted/30 transition-colors">
481
+ <td className="px-3 py-1.5 font-mono text-xs"><Key className="w-3 h-3 inline-block mr-1 text-muted-foreground" />{entry.key}</td>
482
+ <td className="px-3 py-1.5 font-mono text-xs max-w-[400px] truncate">
483
+ {typeof entry.value === 'object' ? JSON.stringify(entry.value) : String(entry.value ?? '')}
484
+ </td>
485
+ <td className="px-3 py-1.5 text-right space-x-1 whitespace-nowrap">
486
+ <Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => {
487
+ setEditEntry(entry)
488
+ setAddEntry(false)
489
+ setKeyInput(entry.key)
490
+ setValueInput(typeof entry.value === 'object' ? JSON.stringify(entry.value, null, 2) : String(entry.value ?? ''))
491
+ }}><Pencil className="w-3 h-3" /></Button>
492
+ <Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-destructive" onClick={() => handleDelete(entry.key)}><Trash2 className="w-3 h-3" /></Button>
493
+ </td>
494
+ </tr>
495
+ ))}
496
+ </tbody>
497
+ </table>
498
+ </ScrollArea>
499
+
500
+ {/* 编辑/新增弹窗 */}
501
+ <Dialog open={editEntry !== null || addEntry} onOpenChange={(open) => { if (!open) { setEditEntry(null); setAddEntry(false) } }}>
502
+ <DialogContent className="max-w-md">
503
+ <DialogHeader><DialogTitle>{addEntry ? '添加键值' : '编辑键值'}</DialogTitle></DialogHeader>
504
+ <div className="space-y-3 py-2">
505
+ <div className="space-y-1">
506
+ <label className="text-xs font-medium text-muted-foreground">Key</label>
507
+ <Input value={keyInput} onChange={(e: any) => setKeyInput(e.target.value)} className="font-mono text-xs" disabled={!addEntry} />
508
+ </div>
509
+ <div className="space-y-1">
510
+ <label className="text-xs font-medium text-muted-foreground">Value (JSON 或纯文本)</label>
511
+ <textarea
512
+ value={valueInput}
513
+ onChange={(e: any) => setValueInput(e.target.value)}
514
+ className="w-full h-32 font-mono text-xs p-3 border rounded-md resize-none bg-background"
515
+ spellCheck={false}
516
+ />
517
+ </div>
518
+ </div>
519
+ <DialogFooter>
520
+ <DialogClose asChild><Button variant="outline" size="sm">取消</Button></DialogClose>
521
+ <Button size="sm" onClick={() => handleSave(addEntry)}><Save className="w-3.5 h-3.5 mr-1" />保存</Button>
522
+ </DialogFooter>
523
+ </DialogContent>
524
+ </Dialog>
525
+ </div>
526
+ )
527
+ }
528
+
529
+ // ── 数据库类型标签文本 ─────────────────────────────────────────────
530
+
531
+ const DB_TYPE_LABELS: Record<DatabaseType, string> = {
532
+ related: '关系型',
533
+ document: '文档型',
534
+ keyvalue: '键值型',
535
+ }
536
+
537
+ const DIALECT_LABELS: Record<string, string> = {
538
+ sqlite: 'SQLite',
539
+ mysql: 'MySQL',
540
+ pg: 'PostgreSQL',
541
+ memory: 'Memory',
542
+ mongodb: 'MongoDB',
543
+ redis: 'Redis',
544
+ }
545
+
546
+ // ── 主页面组件 ──────────────────────────────────────────────────────
547
+
548
+ export default function DatabasePage() {
549
+ const {
550
+ info, tables, loading, error,
551
+ loadInfo, loadTables, dropTable,
552
+ select, insert, update, remove,
553
+ kvGet, kvSet, kvDelete, kvEntries,
554
+ } = useDatabase()
555
+
556
+ const [selectedTable, setSelectedTable] = useState<string | null>(null)
557
+ const selectedTableInfo = useMemo(() => tables.find((t: TableInfo) => t.name === selectedTable), [tables, selectedTable])
558
+ const dbType = info?.type ?? 'related'
559
+
560
+ return (
561
+ <div className="space-y-4">
562
+ {/* 标题栏 */}
563
+ <div className="flex items-center justify-between">
564
+ <div>
565
+ <h1 className="text-2xl font-bold tracking-tight">数据库管理</h1>
566
+ <p className="text-sm text-muted-foreground mt-1">
567
+ 浏览和管理{DB_TYPE_LABELS[dbType]}数据库中的数据
568
+ </p>
569
+ </div>
570
+ <div className="flex items-center gap-2">
571
+ {info && (
572
+ <Badge variant="secondary">
573
+ {DIALECT_LABELS[info.dialect] || info.dialect} · {DB_TYPE_LABELS[info.type]}
574
+ </Badge>
575
+ )}
576
+ <Button variant="outline" size="sm" onClick={() => { loadInfo().catch(() => {}); loadTables().catch(() => {}) }} disabled={loading}>
577
+ <RefreshCw className={`w-4 h-4 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
578
+ </Button>
579
+ </div>
580
+ </div>
581
+
582
+ {error && (
583
+ <Alert variant="destructive" className="py-2">
584
+ <AlertCircle className="h-4 w-4" />
585
+ <AlertDescription>{error}</AlertDescription>
586
+ </Alert>
587
+ )}
588
+
589
+ {/* 主体区域 */}
590
+ <Card className="overflow-hidden">
591
+ <CardContent className="p-0">
592
+ <div className="flex" style={{ minHeight: '500px' }}>
593
+ {/* 左侧表/集合/桶列表 */}
594
+ <div className="w-56 border-r flex flex-col shrink-0">
595
+ <div className="px-3 py-2 border-b bg-muted/30">
596
+ <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
597
+ {dbType === 'related' ? '数据表' : dbType === 'document' ? '集合' : '桶'}
598
+ </span>
599
+ </div>
600
+ <ScrollArea className="flex-1">
601
+ <div className="py-1">
602
+ {loading && !tables.length ? (
603
+ <div className="flex items-center justify-center py-8">
604
+ <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
605
+ </div>
606
+ ) : !tables.length ? (
607
+ <p className="text-sm text-muted-foreground text-center py-8">暂无数据</p>
608
+ ) : tables.map((t: TableInfo) => (
609
+ <button
610
+ key={t.name}
611
+ className={`
612
+ group w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left
613
+ hover:bg-accent transition-colors rounded-sm
614
+ ${selectedTable === t.name ? 'bg-accent text-accent-foreground font-medium' : ''}
615
+ `}
616
+ onClick={() => setSelectedTable(t.name)}
617
+ >
618
+ {dbType === 'keyvalue' ? <Key className="w-3.5 h-3.5 text-muted-foreground shrink-0" /> : <Table2 className="w-3.5 h-3.5 text-muted-foreground shrink-0" />}
619
+ <span className="truncate flex-1">{t.name}</span>
620
+ {t.columns && <Badge variant="secondary" className="ml-auto text-[10px] px-1 py-0">{Object.keys(t.columns).length}</Badge>}
621
+ <Button
622
+ size="sm" variant="ghost"
623
+ className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-destructive shrink-0"
624
+ onClick={(e: any) => {
625
+ e.stopPropagation()
626
+ const label = dbType === 'related' ? '表' : dbType === 'document' ? '集合' : '桶'
627
+ if (!confirm(`确定要删除${label} "${t.name}" 吗?此操作不可撤销!`)) return
628
+ dropTable(t.name).then(() => {
629
+ if (selectedTable === t.name) setSelectedTable(null)
630
+ }).catch(() => {})
631
+ }}
632
+ >
633
+ <Trash2 className="w-3 h-3" />
634
+ </Button>
635
+ </button>
636
+ ))}
637
+ </div>
638
+ </ScrollArea>
639
+ </div>
640
+
641
+ {/* 右侧内容 */}
642
+ <div className="flex-1 min-w-0 p-4">
643
+ {selectedTable ? (
644
+ <>
645
+ <div className="flex items-center gap-2 mb-4">
646
+ <Table2 className="w-5 h-5 text-muted-foreground" />
647
+ <h2 className="text-lg font-semibold">{selectedTable}</h2>
648
+ {selectedTableInfo?.columns && (
649
+ <div className="flex gap-1 ml-2 flex-wrap">
650
+ {(Object.entries(selectedTableInfo.columns) as [string, { type: string; primary?: boolean }][]).slice(0, 8).map(([col, def]) => (
651
+ <Badge key={col} variant="outline" className="text-[10px] px-1.5 py-0">
652
+ {col}{def.primary ? ' 🔑' : ''}: {def.type}
653
+ </Badge>
654
+ ))}
655
+ {Object.keys(selectedTableInfo.columns).length > 8 && (
656
+ <Badge variant="outline" className="text-[10px] px-1.5 py-0">+{Object.keys(selectedTableInfo.columns).length - 8}</Badge>
657
+ )}
658
+ </div>
659
+ )}
660
+ </div>
661
+
662
+ {dbType === 'keyvalue' ? (
663
+ <KvBucketView
664
+ key={selectedTable}
665
+ tableName={selectedTable}
666
+ kvGet={kvGet}
667
+ kvSet={kvSet}
668
+ kvDelete={kvDelete}
669
+ kvEntries={kvEntries}
670
+ />
671
+ ) : dbType === 'document' ? (
672
+ <DocumentCollectionView
673
+ key={selectedTable}
674
+ tableName={selectedTable}
675
+ select={select}
676
+ insert={insert}
677
+ update={update}
678
+ remove={remove}
679
+ />
680
+ ) : (
681
+ <RelatedTableView
682
+ key={selectedTable}
683
+ tableName={selectedTable}
684
+ tableInfo={selectedTableInfo}
685
+ select={select}
686
+ insert={insert}
687
+ update={update}
688
+ remove={remove}
689
+ />
690
+ )}
691
+ </>
692
+ ) : (
693
+ <div className="flex items-center justify-center h-full text-muted-foreground">
694
+ <div className="text-center">
695
+ <Database className="w-12 h-12 mx-auto mb-3 opacity-30" />
696
+ <p className="text-sm">
697
+ 在左侧选择{dbType === 'related' ? '一个表' : dbType === 'document' ? '一个集合' : '一个桶'}开始管理
698
+ </p>
699
+ </div>
700
+ </div>
701
+ )}
702
+ </div>
703
+ </div>
704
+ </CardContent>
705
+ </Card>
706
+ </div>
707
+ )
708
+ }