@zhin.js/console 1.0.51 → 1.0.52
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/CHANGELOG.md +13 -0
- package/README.md +22 -0
- package/browser.tsconfig.json +19 -0
- package/client/src/components/PageHeader.tsx +26 -0
- package/client/src/components/ui/accordion.tsx +2 -1
- package/client/src/components/ui/badge.tsx +1 -3
- package/client/src/components/ui/scroll-area.tsx +5 -2
- package/client/src/components/ui/select.tsx +7 -3
- package/client/src/components/ui/separator.tsx +5 -2
- package/client/src/components/ui/tabs.tsx +4 -2
- package/client/src/layouts/dashboard.tsx +223 -121
- package/client/src/main.tsx +34 -34
- package/client/src/pages/bot-detail/MessageBody.tsx +110 -0
- package/client/src/pages/bot-detail/date-utils.ts +8 -0
- package/client/src/pages/bot-detail/index.tsx +798 -0
- package/client/src/pages/bot-detail/types.ts +92 -0
- package/client/src/pages/bot-detail/useBotConsole.tsx +600 -0
- package/client/src/pages/bots.tsx +111 -73
- package/client/src/pages/database/constants.ts +16 -0
- package/client/src/pages/database/database-page.tsx +170 -0
- package/client/src/pages/database/document-collection-view.tsx +155 -0
- package/client/src/pages/database/index.tsx +1 -0
- package/client/src/pages/database/json-field.tsx +11 -0
- package/client/src/pages/database/kv-bucket-view.tsx +169 -0
- package/client/src/pages/database/related-table-view.tsx +221 -0
- package/client/src/pages/env.tsx +38 -28
- package/client/src/pages/files/code-editor.tsx +85 -0
- package/client/src/pages/files/editor-constants.ts +9 -0
- package/client/src/pages/files/file-editor.tsx +133 -0
- package/client/src/pages/files/file-icons.tsx +25 -0
- package/client/src/pages/files/files-page.tsx +92 -0
- package/client/src/pages/files/hljs-global.d.ts +10 -0
- package/client/src/pages/files/index.tsx +1 -0
- package/client/src/pages/files/language.ts +18 -0
- package/client/src/pages/files/tree-node.tsx +69 -0
- package/client/src/pages/files/use-hljs-theme.ts +23 -0
- package/client/src/pages/logs.tsx +77 -22
- package/client/src/style.css +144 -0
- package/client/src/utils/parseComposerContent.ts +57 -0
- package/client/tailwind.config.js +1 -0
- package/client/tsconfig.json +3 -1
- package/dist/assets/index-COKXlFo2.js +124 -0
- package/dist/assets/style-kkLO-vsa.css +3 -0
- package/dist/client.js +482 -464
- package/dist/index.html +2 -2
- package/dist/style.css +1 -1
- package/lib/index.js +1010 -81
- package/lib/transform.js +16 -2
- package/lib/websocket.js +845 -28
- package/node.tsconfig.json +18 -0
- package/package.json +13 -15
- package/src/bin.ts +24 -0
- package/src/bot-db-models.ts +74 -0
- package/src/bot-hub.ts +240 -0
- package/src/bot-persistence.ts +270 -0
- package/src/build.ts +90 -0
- package/src/dev.ts +107 -0
- package/src/index.ts +337 -0
- package/src/transform.ts +199 -0
- package/src/websocket.ts +1369 -0
- package/client/src/pages/database.tsx +0 -708
- package/client/src/pages/files.tsx +0 -470
- package/client/src/pages/login-assist.tsx +0 -225
- package/dist/assets/index-DS4RbHWX.js +0 -124
- package/dist/assets/style-DS-m6WEr.css +0 -3
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { useEffect, useState, useCallback } from 'react'
|
|
2
|
+
import { Link } from 'react-router'
|
|
3
|
+
import { Bot, AlertCircle, Wifi, WifiOff, Activity, Package, Zap, ChevronRight, RefreshCw } from 'lucide-react'
|
|
4
|
+
import { useWebSocket, useSelector, selectConfigConnected } from '@zhin.js/client'
|
|
5
|
+
import { Button } from '../components/ui/button'
|
|
4
6
|
import { Card, CardContent } from '../components/ui/card'
|
|
5
7
|
import { Badge } from '../components/ui/badge'
|
|
6
8
|
import { Alert, AlertDescription } from '../components/ui/alert'
|
|
@@ -12,34 +14,48 @@ interface BotInfo {
|
|
|
12
14
|
adapter: string
|
|
13
15
|
connected: boolean
|
|
14
16
|
status: 'online' | 'offline'
|
|
17
|
+
pendingRequestCount?: number
|
|
18
|
+
pendingNoticeCount?: number
|
|
15
19
|
}
|
|
16
20
|
|
|
17
|
-
export default function
|
|
21
|
+
export default function BotManagePage() {
|
|
18
22
|
const [bots, setBots] = useState<BotInfo[]>([])
|
|
19
23
|
const [loading, setLoading] = useState(true)
|
|
20
24
|
const [error, setError] = useState<string | null>(null)
|
|
25
|
+
const connected = useSelector(selectConfigConnected)
|
|
26
|
+
const { sendRequest } = useWebSocket()
|
|
21
27
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const fetchBots = async () => {
|
|
28
|
+
const fetchBots = useCallback(async () => {
|
|
29
|
+
if (!connected) {
|
|
30
|
+
setLoading(false)
|
|
31
|
+
setError('WebSocket 未连接,请刷新页面')
|
|
32
|
+
return
|
|
33
|
+
}
|
|
29
34
|
try {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (data.success) { setBots(data.data); setError(null) }
|
|
34
|
-
else throw new Error('数据格式错误')
|
|
35
|
+
const data = await sendRequest<{ bots: BotInfo[] }>({ type: 'bot:list' })
|
|
36
|
+
setBots(data.bots || [])
|
|
37
|
+
setError(null)
|
|
35
38
|
} catch (err) {
|
|
36
39
|
setError((err as Error).message)
|
|
37
40
|
} finally {
|
|
38
41
|
setLoading(false)
|
|
39
42
|
}
|
|
40
|
-
}
|
|
43
|
+
}, [connected, sendRequest])
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (connected) {
|
|
47
|
+
setLoading(true)
|
|
48
|
+
fetchBots()
|
|
49
|
+
}
|
|
50
|
+
}, [connected, fetchBots])
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!connected) return
|
|
54
|
+
const interval = setInterval(fetchBots, 8000)
|
|
55
|
+
return () => clearInterval(interval)
|
|
56
|
+
}, [connected, fetchBots])
|
|
41
57
|
|
|
42
|
-
if (loading) {
|
|
58
|
+
if (loading && connected) {
|
|
43
59
|
return (
|
|
44
60
|
<div className="space-y-6">
|
|
45
61
|
<Skeleton className="h-8 w-48" />
|
|
@@ -63,80 +79,102 @@ export default function BotMangePage() {
|
|
|
63
79
|
|
|
64
80
|
return (
|
|
65
81
|
<div className="space-y-6">
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
82
|
+
<div className="flex items-start justify-between gap-4">
|
|
83
|
+
<div>
|
|
84
|
+
<h1 className="text-2xl font-bold tracking-tight">机器人管理</h1>
|
|
85
|
+
<div className="flex items-center gap-2 mt-1">
|
|
86
|
+
<span className="text-sm text-muted-foreground">共 {bots.length} 个机器人,</span>
|
|
87
|
+
<Badge variant="success">{bots.filter(b => b.connected).length}</Badge>
|
|
88
|
+
<span className="text-sm text-muted-foreground">个在线</span>
|
|
89
|
+
</div>
|
|
90
|
+
<p className="text-sm text-muted-foreground mt-2">
|
|
91
|
+
ICQQ 扫码/验证码登录请前往侧栏{' '}
|
|
92
|
+
<Link to="/icqq" className="text-primary underline-offset-4 hover:underline font-medium">
|
|
93
|
+
ICQQ 管理
|
|
94
|
+
</Link>
|
|
95
|
+
。
|
|
96
|
+
</p>
|
|
73
97
|
</div>
|
|
98
|
+
<Button variant="outline" size="sm" onClick={() => { setLoading(true); void fetchBots(); }} disabled={!connected || loading}>
|
|
99
|
+
<RefreshCw className={loading ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} />
|
|
100
|
+
刷新
|
|
101
|
+
</Button>
|
|
74
102
|
</div>
|
|
75
103
|
|
|
76
104
|
<Separator />
|
|
77
105
|
|
|
78
|
-
{/* Bot grid */}
|
|
79
106
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
80
107
|
{bots.map((bot, index) => (
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
108
|
+
<Link
|
|
109
|
+
key={`${bot.adapter}-${bot.name}-${index}`}
|
|
110
|
+
to={`/bots/${encodeURIComponent(bot.adapter)}/${encodeURIComponent(bot.name)}`}
|
|
111
|
+
className="block transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl"
|
|
112
|
+
>
|
|
113
|
+
<Card className="h-full cursor-pointer hover:border-primary/40">
|
|
114
|
+
<CardContent className="p-5 space-y-4">
|
|
115
|
+
<div className="flex justify-between items-center">
|
|
116
|
+
<div className="flex items-center gap-2">
|
|
117
|
+
<div className={`p-2 rounded-md ${bot.connected ? 'bg-emerald-100 dark:bg-emerald-900/30' : 'bg-muted'}`}>
|
|
118
|
+
<Bot className={`w-5 h-5 ${bot.connected ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'}`} />
|
|
119
|
+
</div>
|
|
120
|
+
<span className="text-lg font-bold">{bot.name}</span>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="flex items-center gap-1">
|
|
123
|
+
<Badge variant={bot.connected ? 'success' : 'secondary'}>
|
|
124
|
+
{bot.connected ? <><Wifi className="w-3 h-3 mr-1" />在线</> : <><WifiOff className="w-3 h-3 mr-1" />离线</>}
|
|
125
|
+
</Badge>
|
|
126
|
+
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
|
88
127
|
</div>
|
|
89
|
-
<span className="text-lg font-bold">{bot.name}</span>
|
|
90
|
-
</div>
|
|
91
|
-
<div className="relative">
|
|
92
|
-
<Badge variant={bot.connected ? 'success' : 'secondary'}>
|
|
93
|
-
{bot.connected ? <><Wifi className="w-3 h-3 mr-1" />在线</> : <><WifiOff className="w-3 h-3 mr-1" />离线</>}
|
|
94
|
-
</Badge>
|
|
95
128
|
</div>
|
|
96
|
-
</div>
|
|
97
129
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
</div>
|
|
130
|
+
<div className="flex items-center gap-2">
|
|
131
|
+
<span className="text-sm text-muted-foreground">适配器:</span>
|
|
132
|
+
<Badge variant="outline">{bot.adapter}</Badge>
|
|
133
|
+
</div>
|
|
103
134
|
|
|
104
|
-
|
|
135
|
+
<Separator />
|
|
105
136
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
137
|
+
<div className="space-y-2">
|
|
138
|
+
<div className="flex justify-between items-center p-2 rounded-md bg-muted/50">
|
|
139
|
+
<div className="flex items-center gap-2 text-sm">
|
|
140
|
+
<Activity className={`w-4 h-4 ${bot.status === 'online' ? 'text-emerald-500' : 'text-muted-foreground'}`} />
|
|
141
|
+
<span className="text-muted-foreground">运行状态</span>
|
|
142
|
+
</div>
|
|
143
|
+
<Badge variant={bot.status === 'online' ? 'success' : 'secondary'}>
|
|
144
|
+
{bot.status === 'online' ? '运行中' : '已停止'}
|
|
145
|
+
</Badge>
|
|
112
146
|
</div>
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
<Package className="w-4 h-4 text-muted-foreground" />
|
|
120
|
-
<span className="text-muted-foreground">适配器类型</span>
|
|
147
|
+
<div className="flex justify-between items-center p-2 rounded-md bg-muted/50">
|
|
148
|
+
<div className="flex items-center gap-2 text-sm">
|
|
149
|
+
<Package className="w-4 h-4 text-muted-foreground" />
|
|
150
|
+
<span className="text-muted-foreground">适配器类型</span>
|
|
151
|
+
</div>
|
|
152
|
+
<span className="text-sm font-medium">{bot.adapter}</span>
|
|
121
153
|
</div>
|
|
122
|
-
<
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
<span className=
|
|
154
|
+
<div className="flex justify-between items-center p-2 rounded-md bg-muted/50">
|
|
155
|
+
<div className="flex items-center gap-2 text-sm">
|
|
156
|
+
<Zap className="w-4 h-4 text-muted-foreground" />
|
|
157
|
+
<span className="text-muted-foreground">连接状态</span>
|
|
158
|
+
</div>
|
|
159
|
+
<span className={`text-sm font-medium ${bot.connected ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'}`}>
|
|
160
|
+
{bot.connected ? '已连接' : '未连接'}
|
|
161
|
+
</span>
|
|
128
162
|
</div>
|
|
129
|
-
<span className={`text-sm font-medium ${bot.connected ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'}`}>
|
|
130
|
-
{bot.connected ? '已连接' : '未连接'}
|
|
131
|
-
</span>
|
|
132
163
|
</div>
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
164
|
+
<div className="flex flex-wrap items-center justify-center gap-1.5 pt-1">
|
|
165
|
+
{(bot.pendingRequestCount ?? 0) + (bot.pendingNoticeCount ?? 0) > 0 && (
|
|
166
|
+
<span className="text-xs text-amber-600 dark:text-amber-400 font-medium">
|
|
167
|
+
{(bot.pendingRequestCount ?? 0) + (bot.pendingNoticeCount ?? 0)} 条待处理
|
|
168
|
+
</span>
|
|
169
|
+
)}
|
|
170
|
+
<p className="text-xs text-primary">点击进入管理</p>
|
|
171
|
+
</div>
|
|
172
|
+
</CardContent>
|
|
173
|
+
</Card>
|
|
174
|
+
</Link>
|
|
136
175
|
))}
|
|
137
176
|
</div>
|
|
138
177
|
|
|
139
|
-
{/* Empty state */}
|
|
140
178
|
{bots.length === 0 && (
|
|
141
179
|
<Card>
|
|
142
180
|
<CardContent className="flex flex-col items-center gap-4 py-12">
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { DatabaseType } from '@zhin.js/client'
|
|
2
|
+
|
|
3
|
+
export const DB_TYPE_LABELS: Record<DatabaseType, string> = {
|
|
4
|
+
related: '关系型',
|
|
5
|
+
document: '文档型',
|
|
6
|
+
keyvalue: '键值型',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const DIALECT_LABELS: Record<string, string> = {
|
|
10
|
+
sqlite: 'SQLite',
|
|
11
|
+
mysql: 'MySQL',
|
|
12
|
+
pg: 'PostgreSQL',
|
|
13
|
+
memory: 'Memory',
|
|
14
|
+
mongodb: 'MongoDB',
|
|
15
|
+
redis: 'Redis',
|
|
16
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { useState, useMemo, type MouseEvent } from 'react'
|
|
2
|
+
import { useDatabase } from '@zhin.js/client'
|
|
3
|
+
import type { DatabaseType, TableInfo } from '@zhin.js/client'
|
|
4
|
+
import { Database, Table2, Trash2, RefreshCw, Loader2, AlertCircle, Key } from 'lucide-react'
|
|
5
|
+
import { Card, CardContent } from '../../components/ui/card'
|
|
6
|
+
import { Button } from '../../components/ui/button'
|
|
7
|
+
import { Alert, AlertDescription } from '../../components/ui/alert'
|
|
8
|
+
import { Badge } from '../../components/ui/badge'
|
|
9
|
+
import { ScrollArea } from '../../components/ui/scroll-area'
|
|
10
|
+
import { PageHeader } from '../../components/PageHeader'
|
|
11
|
+
import { DB_TYPE_LABELS, DIALECT_LABELS } from './constants'
|
|
12
|
+
import { RelatedTableView } from './related-table-view'
|
|
13
|
+
import { DocumentCollectionView } from './document-collection-view'
|
|
14
|
+
import { KvBucketView } from './kv-bucket-view'
|
|
15
|
+
|
|
16
|
+
export default function DatabasePage() {
|
|
17
|
+
const {
|
|
18
|
+
info, tables, loading, error,
|
|
19
|
+
loadInfo, loadTables, dropTable,
|
|
20
|
+
select, insert, update, remove,
|
|
21
|
+
kvGet, kvSet, kvDelete, kvEntries,
|
|
22
|
+
} = useDatabase()
|
|
23
|
+
|
|
24
|
+
const [selectedTable, setSelectedTable] = useState<string | null>(null)
|
|
25
|
+
const selectedTableInfo = useMemo(() => tables.find((t: TableInfo) => t.name === selectedTable), [tables, selectedTable])
|
|
26
|
+
const dbType: DatabaseType = info?.type ?? 'related'
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="space-y-6">
|
|
30
|
+
<PageHeader
|
|
31
|
+
title="数据库管理"
|
|
32
|
+
description={`浏览和管理 ${DB_TYPE_LABELS[dbType]} 中的数据;左栏选择对象,右侧查看与编辑。`}
|
|
33
|
+
actions={
|
|
34
|
+
<div className="flex items-center gap-2">
|
|
35
|
+
{info && (
|
|
36
|
+
<Badge variant="secondary" className="font-normal">
|
|
37
|
+
{DIALECT_LABELS[info.dialect] || info.dialect} · {DB_TYPE_LABELS[info.type]}
|
|
38
|
+
</Badge>
|
|
39
|
+
)}
|
|
40
|
+
<Button variant="outline" size="sm" onClick={() => { loadInfo().catch(() => {}); loadTables().catch(() => {}) }} disabled={loading}>
|
|
41
|
+
<RefreshCw className={`w-4 h-4 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
|
|
42
|
+
</Button>
|
|
43
|
+
</div>
|
|
44
|
+
}
|
|
45
|
+
/>
|
|
46
|
+
|
|
47
|
+
{error && (
|
|
48
|
+
<Alert variant="destructive" className="py-2">
|
|
49
|
+
<AlertCircle className="h-4 w-4" />
|
|
50
|
+
<AlertDescription>{error}</AlertDescription>
|
|
51
|
+
</Alert>
|
|
52
|
+
)}
|
|
53
|
+
|
|
54
|
+
<Card className="overflow-hidden border-border/80 shadow-sm">
|
|
55
|
+
<CardContent className="p-0">
|
|
56
|
+
<div className="flex min-h-[min(520px,calc(100vh-11rem))]">
|
|
57
|
+
<div className="w-56 border-r flex flex-col shrink-0">
|
|
58
|
+
<div className="px-3 py-2 border-b bg-muted/30">
|
|
59
|
+
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
60
|
+
{dbType === 'related' ? '数据表' : dbType === 'document' ? '集合' : '桶'}
|
|
61
|
+
</span>
|
|
62
|
+
</div>
|
|
63
|
+
<ScrollArea className="flex-1">
|
|
64
|
+
<div className="py-1">
|
|
65
|
+
{loading && !tables.length ? (
|
|
66
|
+
<div className="flex items-center justify-center py-8">
|
|
67
|
+
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
|
68
|
+
</div>
|
|
69
|
+
) : !tables.length ? (
|
|
70
|
+
<p className="text-sm text-muted-foreground text-center py-8">暂无数据</p>
|
|
71
|
+
) : tables.map((t: TableInfo) => (
|
|
72
|
+
<button
|
|
73
|
+
key={t.name}
|
|
74
|
+
className={`
|
|
75
|
+
group w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left
|
|
76
|
+
hover:bg-accent transition-colors rounded-sm
|
|
77
|
+
${selectedTable === t.name ? 'bg-accent text-accent-foreground font-medium' : ''}
|
|
78
|
+
`}
|
|
79
|
+
onClick={() => setSelectedTable(t.name)}
|
|
80
|
+
>
|
|
81
|
+
{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" />}
|
|
82
|
+
<span className="truncate flex-1">{t.name}</span>
|
|
83
|
+
{t.columns && <Badge variant="secondary" className="ml-auto text-[10px] px-1 py-0">{Object.keys(t.columns).length}</Badge>}
|
|
84
|
+
<Button
|
|
85
|
+
size="sm" variant="ghost"
|
|
86
|
+
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-destructive shrink-0"
|
|
87
|
+
onClick={(e: MouseEvent) => {
|
|
88
|
+
e.stopPropagation()
|
|
89
|
+
const label = dbType === 'related' ? '表' : dbType === 'document' ? '集合' : '桶'
|
|
90
|
+
if (!confirm(`确定要删除${label} "${t.name}" 吗?此操作不可撤销!`)) return
|
|
91
|
+
dropTable(t.name).then(() => {
|
|
92
|
+
if (selectedTable === t.name) setSelectedTable(null)
|
|
93
|
+
}).catch(() => {})
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
<Trash2 className="w-3 h-3" />
|
|
97
|
+
</Button>
|
|
98
|
+
</button>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
</ScrollArea>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div className="flex-1 min-w-0 p-4">
|
|
105
|
+
{selectedTable ? (
|
|
106
|
+
<>
|
|
107
|
+
<div className="flex items-center gap-2 mb-4">
|
|
108
|
+
<Table2 className="w-5 h-5 text-muted-foreground" />
|
|
109
|
+
<h2 className="text-lg font-semibold">{selectedTable}</h2>
|
|
110
|
+
{selectedTableInfo?.columns && (
|
|
111
|
+
<div className="flex gap-1 ml-2 flex-wrap">
|
|
112
|
+
{(Object.entries(selectedTableInfo.columns) as [string, { type: string; primary?: boolean }][]).slice(0, 8).map(([col, def]) => (
|
|
113
|
+
<Badge key={col} variant="outline" className="text-[10px] px-1.5 py-0">
|
|
114
|
+
{col}{def.primary ? ' 🔑' : ''}: {def.type}
|
|
115
|
+
</Badge>
|
|
116
|
+
))}
|
|
117
|
+
{Object.keys(selectedTableInfo.columns).length > 8 && (
|
|
118
|
+
<Badge variant="outline" className="text-[10px] px-1.5 py-0">+{Object.keys(selectedTableInfo.columns).length - 8}</Badge>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{dbType === 'keyvalue' ? (
|
|
125
|
+
<KvBucketView
|
|
126
|
+
key={selectedTable}
|
|
127
|
+
tableName={selectedTable}
|
|
128
|
+
kvGet={kvGet}
|
|
129
|
+
kvSet={kvSet}
|
|
130
|
+
kvDelete={kvDelete}
|
|
131
|
+
kvEntries={kvEntries}
|
|
132
|
+
/>
|
|
133
|
+
) : dbType === 'document' ? (
|
|
134
|
+
<DocumentCollectionView
|
|
135
|
+
key={selectedTable}
|
|
136
|
+
tableName={selectedTable}
|
|
137
|
+
select={select}
|
|
138
|
+
insert={insert}
|
|
139
|
+
update={update}
|
|
140
|
+
remove={remove}
|
|
141
|
+
/>
|
|
142
|
+
) : (
|
|
143
|
+
<RelatedTableView
|
|
144
|
+
key={selectedTable}
|
|
145
|
+
tableName={selectedTable}
|
|
146
|
+
tableInfo={selectedTableInfo}
|
|
147
|
+
select={select}
|
|
148
|
+
insert={insert}
|
|
149
|
+
update={update}
|
|
150
|
+
remove={remove}
|
|
151
|
+
/>
|
|
152
|
+
)}
|
|
153
|
+
</>
|
|
154
|
+
) : (
|
|
155
|
+
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
156
|
+
<div className="text-center">
|
|
157
|
+
<Database className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
|
158
|
+
<p className="text-sm">
|
|
159
|
+
在左侧选择{dbType === 'related' ? '一个表' : dbType === 'document' ? '一个集合' : '一个桶'}开始管理
|
|
160
|
+
</p>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</CardContent>
|
|
167
|
+
</Card>
|
|
168
|
+
</div>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, type ChangeEvent } from 'react'
|
|
2
|
+
import type { SelectResult } from '@zhin.js/client'
|
|
3
|
+
import { Plus, Trash2, Pencil, RefreshCw, Loader2, AlertCircle, CheckCircle, ChevronLeft, ChevronRight, Save } from 'lucide-react'
|
|
4
|
+
import { Card, CardContent } from '../../components/ui/card'
|
|
5
|
+
import { Button } from '../../components/ui/button'
|
|
6
|
+
import { Alert, AlertDescription } from '../../components/ui/alert'
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogHeader,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
DialogFooter,
|
|
13
|
+
DialogClose,
|
|
14
|
+
} from '../../components/ui/dialog'
|
|
15
|
+
|
|
16
|
+
export function DocumentCollectionView({
|
|
17
|
+
tableName,
|
|
18
|
+
select,
|
|
19
|
+
insert,
|
|
20
|
+
update,
|
|
21
|
+
remove,
|
|
22
|
+
}: {
|
|
23
|
+
tableName: string
|
|
24
|
+
select: (table: string, page?: number, pageSize?: number, where?: any) => Promise<SelectResult>
|
|
25
|
+
insert: (table: string, row: any) => Promise<any>
|
|
26
|
+
update: (table: string, row: any, where: any) => Promise<any>
|
|
27
|
+
remove: (table: string, where: any) => Promise<any>
|
|
28
|
+
}) {
|
|
29
|
+
const [data, setData] = useState<SelectResult | null>(null)
|
|
30
|
+
const [loading, setLoading] = useState(false)
|
|
31
|
+
const [page, setPage] = useState(1)
|
|
32
|
+
const [msg, setMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
|
33
|
+
const [editDoc, setEditDoc] = useState<any>(null)
|
|
34
|
+
const [addDoc, setAddDoc] = useState(false)
|
|
35
|
+
const [jsonText, setJsonText] = useState('')
|
|
36
|
+
const pageSize = 20
|
|
37
|
+
|
|
38
|
+
const load = useCallback(async () => {
|
|
39
|
+
setLoading(true)
|
|
40
|
+
setMsg(null)
|
|
41
|
+
try {
|
|
42
|
+
const result = await select(tableName, page, pageSize)
|
|
43
|
+
setData(result)
|
|
44
|
+
} catch (err) {
|
|
45
|
+
setMsg({ type: 'error', text: (err as Error).message })
|
|
46
|
+
} finally {
|
|
47
|
+
setLoading(false)
|
|
48
|
+
}
|
|
49
|
+
}, [tableName, page, select])
|
|
50
|
+
|
|
51
|
+
useEffect(() => { load() }, [load])
|
|
52
|
+
|
|
53
|
+
const handleSave = async (isNew: boolean) => {
|
|
54
|
+
try {
|
|
55
|
+
const doc = JSON.parse(jsonText)
|
|
56
|
+
if (isNew) {
|
|
57
|
+
await insert(tableName, doc)
|
|
58
|
+
} else {
|
|
59
|
+
const { _id, ...rest } = doc
|
|
60
|
+
await update(tableName, rest, { _id: editDoc._id })
|
|
61
|
+
}
|
|
62
|
+
setMsg({ type: 'success', text: isNew ? '添加成功' : '更新成功' })
|
|
63
|
+
setEditDoc(null)
|
|
64
|
+
setAddDoc(false)
|
|
65
|
+
setJsonText('')
|
|
66
|
+
setTimeout(() => setMsg(null), 2000)
|
|
67
|
+
await load()
|
|
68
|
+
} catch (err) {
|
|
69
|
+
setMsg({ type: 'error', text: (err as Error).message })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const handleDelete = async (doc: any) => {
|
|
74
|
+
if (!confirm('确定要删除这条文档吗?')) return
|
|
75
|
+
try {
|
|
76
|
+
await remove(tableName, { _id: doc._id })
|
|
77
|
+
setMsg({ type: 'success', text: '删除成功' })
|
|
78
|
+
setTimeout(() => setMsg(null), 2000)
|
|
79
|
+
await load()
|
|
80
|
+
} catch (err) {
|
|
81
|
+
setMsg({ type: 'error', text: (err as Error).message })
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const totalPages = data ? Math.ceil(data.total / pageSize) : 0
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className="space-y-3">
|
|
89
|
+
{msg && (
|
|
90
|
+
<Alert variant={msg.type === 'error' ? 'destructive' : 'success'} className="py-2">
|
|
91
|
+
{msg.type === 'error' ? <AlertCircle className="h-4 w-4" /> : <CheckCircle className="h-4 w-4" />}
|
|
92
|
+
<AlertDescription>{msg.text}</AlertDescription>
|
|
93
|
+
</Alert>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
<div className="flex items-center gap-2">
|
|
97
|
+
<Button size="sm" variant="outline" onClick={load} disabled={loading}>
|
|
98
|
+
<RefreshCw className={`w-3.5 h-3.5 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
|
|
99
|
+
</Button>
|
|
100
|
+
<Button size="sm" onClick={() => { setAddDoc(true); setJsonText('{\n \n}') }}>
|
|
101
|
+
<Plus className="w-3.5 h-3.5 mr-1" />添加文档
|
|
102
|
+
</Button>
|
|
103
|
+
<span className="text-xs text-muted-foreground ml-auto">共 {data?.total ?? 0} 条 · 第 {page}/{totalPages || 1} 页</span>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div className="space-y-2">
|
|
107
|
+
{loading && !data ? (
|
|
108
|
+
<div className="text-center py-8"><Loader2 className="w-4 h-4 animate-spin inline-block" /></div>
|
|
109
|
+
) : !data?.rows?.length ? (
|
|
110
|
+
<p className="text-center py-8 text-muted-foreground text-sm">暂无文档</p>
|
|
111
|
+
) : data.rows.map((doc: any, i: number) => (
|
|
112
|
+
<Card key={doc._id || i} className="overflow-hidden">
|
|
113
|
+
<CardContent className="p-3">
|
|
114
|
+
<div className="flex items-start justify-between gap-2">
|
|
115
|
+
<pre className="text-xs font-mono flex-1 overflow-x-auto whitespace-pre-wrap break-all">{JSON.stringify(doc, null, 2)}</pre>
|
|
116
|
+
<div className="flex flex-col gap-1 shrink-0">
|
|
117
|
+
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => {
|
|
118
|
+
setEditDoc(doc)
|
|
119
|
+
setAddDoc(false)
|
|
120
|
+
setJsonText(JSON.stringify(doc, null, 2))
|
|
121
|
+
}}><Pencil className="w-3 h-3" /></Button>
|
|
122
|
+
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-destructive" onClick={() => handleDelete(doc)}><Trash2 className="w-3 h-3" /></Button>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</CardContent>
|
|
126
|
+
</Card>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{totalPages > 1 && (
|
|
131
|
+
<div className="flex items-center justify-center gap-2">
|
|
132
|
+
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p: number) => p - 1)}><ChevronLeft className="w-4 h-4" /></Button>
|
|
133
|
+
<span className="text-xs text-muted-foreground">{page} / {totalPages}</span>
|
|
134
|
+
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => setPage((p: number) => p + 1)}><ChevronRight className="w-4 h-4" /></Button>
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
<Dialog open={editDoc !== null || addDoc} onOpenChange={(open) => { if (!open) { setEditDoc(null); setAddDoc(false) } }}>
|
|
139
|
+
<DialogContent className="max-w-lg">
|
|
140
|
+
<DialogHeader><DialogTitle>{addDoc ? '添加文档' : '编辑文档'}</DialogTitle></DialogHeader>
|
|
141
|
+
<textarea
|
|
142
|
+
value={jsonText}
|
|
143
|
+
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setJsonText(e.target.value)}
|
|
144
|
+
className="w-full h-60 font-mono text-xs p-3 border rounded-md resize-none bg-background"
|
|
145
|
+
spellCheck={false}
|
|
146
|
+
/>
|
|
147
|
+
<DialogFooter>
|
|
148
|
+
<DialogClose asChild><Button variant="outline" size="sm">取消</Button></DialogClose>
|
|
149
|
+
<Button size="sm" onClick={() => handleSave(addDoc)}><Save className="w-3.5 h-3.5 mr-1" />保存</Button>
|
|
150
|
+
</DialogFooter>
|
|
151
|
+
</DialogContent>
|
|
152
|
+
</Dialog>
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './database-page'
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ChangeEvent } from 'react'
|
|
2
|
+
import { Input } from '../../components/ui/input'
|
|
3
|
+
|
|
4
|
+
export function JsonField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
|
5
|
+
return (
|
|
6
|
+
<div className="space-y-1">
|
|
7
|
+
<label className="text-xs font-medium text-muted-foreground">{label}</label>
|
|
8
|
+
<Input value={value} onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} className="font-mono text-xs" />
|
|
9
|
+
</div>
|
|
10
|
+
)
|
|
11
|
+
}
|