@zhin.js/console 1.0.51 → 1.0.53
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 +21 -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
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import type { KvEntry } from '@zhin.js/client'
|
|
3
|
+
import { Plus, Trash2, Pencil, RefreshCw, Loader2, AlertCircle, CheckCircle, Save, Key } from 'lucide-react'
|
|
4
|
+
import { Button } from '../../components/ui/button'
|
|
5
|
+
import { Alert, AlertDescription } from '../../components/ui/alert'
|
|
6
|
+
import {
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
DialogFooter,
|
|
12
|
+
DialogClose,
|
|
13
|
+
} from '../../components/ui/dialog'
|
|
14
|
+
import { Input } from '../../components/ui/input'
|
|
15
|
+
import type { ChangeEvent } from 'react'
|
|
16
|
+
|
|
17
|
+
export function KvBucketView({
|
|
18
|
+
tableName,
|
|
19
|
+
kvGet: _kvGet,
|
|
20
|
+
kvSet,
|
|
21
|
+
kvDelete,
|
|
22
|
+
kvEntries,
|
|
23
|
+
}: {
|
|
24
|
+
tableName: string
|
|
25
|
+
kvGet: (table: string, key: string) => Promise<{ key: string; value: any }>
|
|
26
|
+
kvSet: (table: string, key: string, value: any, ttl?: number) => Promise<any>
|
|
27
|
+
kvDelete: (table: string, key: string) => Promise<any>
|
|
28
|
+
kvEntries: (table: string) => Promise<{ entries: KvEntry[] }>
|
|
29
|
+
}) {
|
|
30
|
+
const [entries, setEntries] = useState<KvEntry[]>([])
|
|
31
|
+
const [loading, setLoading] = useState(false)
|
|
32
|
+
const [msg, setMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
|
33
|
+
const [editEntry, setEditEntry] = useState<KvEntry | null>(null)
|
|
34
|
+
const [addEntry, setAddEntry] = useState(false)
|
|
35
|
+
const [keyInput, setKeyInput] = useState('')
|
|
36
|
+
const [valueInput, setValueInput] = useState('')
|
|
37
|
+
|
|
38
|
+
const load = useCallback(async () => {
|
|
39
|
+
setLoading(true)
|
|
40
|
+
setMsg(null)
|
|
41
|
+
try {
|
|
42
|
+
const result = await kvEntries(tableName)
|
|
43
|
+
setEntries(result.entries)
|
|
44
|
+
} catch (err) {
|
|
45
|
+
setMsg({ type: 'error', text: (err as Error).message })
|
|
46
|
+
} finally {
|
|
47
|
+
setLoading(false)
|
|
48
|
+
}
|
|
49
|
+
}, [tableName, kvEntries])
|
|
50
|
+
|
|
51
|
+
useEffect(() => { load() }, [load])
|
|
52
|
+
|
|
53
|
+
const handleSave = async (isNew: boolean) => {
|
|
54
|
+
try {
|
|
55
|
+
let val: any
|
|
56
|
+
try { val = JSON.parse(valueInput) } catch { val = valueInput }
|
|
57
|
+
await kvSet(tableName, keyInput, val)
|
|
58
|
+
setMsg({ type: 'success', text: isNew ? '添加成功' : '更新成功' })
|
|
59
|
+
setEditEntry(null)
|
|
60
|
+
setAddEntry(false)
|
|
61
|
+
setKeyInput('')
|
|
62
|
+
setValueInput('')
|
|
63
|
+
setTimeout(() => setMsg(null), 2000)
|
|
64
|
+
await load()
|
|
65
|
+
} catch (err) {
|
|
66
|
+
setMsg({ type: 'error', text: (err as Error).message })
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const handleDelete = async (key: string) => {
|
|
71
|
+
if (!confirm(`确定要删除键 "${key}" 吗?`)) return
|
|
72
|
+
try {
|
|
73
|
+
await kvDelete(tableName, key)
|
|
74
|
+
setMsg({ type: 'success', text: '删除成功' })
|
|
75
|
+
setTimeout(() => setMsg(null), 2000)
|
|
76
|
+
await load()
|
|
77
|
+
} catch (err) {
|
|
78
|
+
setMsg({ type: 'error', text: (err as Error).message })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className="space-y-3">
|
|
84
|
+
{msg && (
|
|
85
|
+
<Alert variant={msg.type === 'error' ? 'destructive' : 'success'} className="py-2">
|
|
86
|
+
{msg.type === 'error' ? <AlertCircle className="h-4 w-4" /> : <CheckCircle className="h-4 w-4" />}
|
|
87
|
+
<AlertDescription>{msg.text}</AlertDescription>
|
|
88
|
+
</Alert>
|
|
89
|
+
)}
|
|
90
|
+
|
|
91
|
+
<div className="flex items-center gap-2">
|
|
92
|
+
<Button size="sm" variant="outline" onClick={load} disabled={loading}>
|
|
93
|
+
<RefreshCw className={`w-3.5 h-3.5 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
|
|
94
|
+
</Button>
|
|
95
|
+
<Button size="sm" onClick={() => { setAddEntry(true); setKeyInput(''); setValueInput('') }}>
|
|
96
|
+
<Plus className="w-3.5 h-3.5 mr-1" />添加键值
|
|
97
|
+
</Button>
|
|
98
|
+
<span className="text-xs text-muted-foreground ml-auto">共 {entries.length} 个键</span>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div
|
|
102
|
+
className="border rounded-md max-h-[min(60vh,560px)] overflow-auto overscroll-contain touch-pan-x touch-pan-y bg-card"
|
|
103
|
+
role="region"
|
|
104
|
+
aria-label="键值表,可左右滑动"
|
|
105
|
+
>
|
|
106
|
+
<table className="text-sm border-collapse min-w-full w-max">
|
|
107
|
+
<thead className="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm">
|
|
108
|
+
<tr className="border-b border-border/80">
|
|
109
|
+
<th className="px-3 py-2 text-left font-medium text-muted-foreground whitespace-nowrap min-w-[8rem]">Key</th>
|
|
110
|
+
<th className="px-3 py-2 text-left font-medium text-muted-foreground whitespace-nowrap min-w-[12rem]">Value</th>
|
|
111
|
+
<th className="px-3 py-2 text-right font-medium text-muted-foreground whitespace-nowrap w-24 min-w-[5.5rem]">操作</th>
|
|
112
|
+
</tr>
|
|
113
|
+
</thead>
|
|
114
|
+
<tbody>
|
|
115
|
+
{loading && !entries.length ? (
|
|
116
|
+
<tr><td colSpan={3} className="text-center py-8"><Loader2 className="w-4 h-4 animate-spin inline-block" /></td></tr>
|
|
117
|
+
) : !entries.length ? (
|
|
118
|
+
<tr><td colSpan={3} className="text-center py-8 text-muted-foreground">暂无数据</td></tr>
|
|
119
|
+
) : entries.map((entry) => (
|
|
120
|
+
<tr key={entry.key} className="border-b border-border/60 hover:bg-muted/30 transition-colors">
|
|
121
|
+
<td className="px-3 py-1.5 font-mono text-xs whitespace-nowrap align-top">
|
|
122
|
+
<Key className="w-3 h-3 inline-block mr-1 text-muted-foreground shrink-0" />
|
|
123
|
+
{entry.key}
|
|
124
|
+
</td>
|
|
125
|
+
<td className="px-3 py-1.5 font-mono text-xs whitespace-nowrap align-top" title={typeof entry.value === 'object' ? JSON.stringify(entry.value) : String(entry.value ?? '')}>
|
|
126
|
+
{typeof entry.value === 'object' ? JSON.stringify(entry.value) : String(entry.value ?? '')}
|
|
127
|
+
</td>
|
|
128
|
+
<td className="px-3 py-1.5 text-right space-x-1 whitespace-nowrap">
|
|
129
|
+
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => {
|
|
130
|
+
setEditEntry(entry)
|
|
131
|
+
setAddEntry(false)
|
|
132
|
+
setKeyInput(entry.key)
|
|
133
|
+
setValueInput(typeof entry.value === 'object' ? JSON.stringify(entry.value, null, 2) : String(entry.value ?? ''))
|
|
134
|
+
}}><Pencil className="w-3 h-3" /></Button>
|
|
135
|
+
<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>
|
|
136
|
+
</td>
|
|
137
|
+
</tr>
|
|
138
|
+
))}
|
|
139
|
+
</tbody>
|
|
140
|
+
</table>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<Dialog open={editEntry !== null || addEntry} onOpenChange={(open) => { if (!open) { setEditEntry(null); setAddEntry(false) } }}>
|
|
144
|
+
<DialogContent className="max-w-md">
|
|
145
|
+
<DialogHeader><DialogTitle>{addEntry ? '添加键值' : '编辑键值'}</DialogTitle></DialogHeader>
|
|
146
|
+
<div className="space-y-3 py-2">
|
|
147
|
+
<div className="space-y-1">
|
|
148
|
+
<label className="text-xs font-medium text-muted-foreground">Key</label>
|
|
149
|
+
<Input value={keyInput} onChange={(e: ChangeEvent<HTMLInputElement>) => setKeyInput(e.target.value)} className="font-mono text-xs" disabled={!addEntry} />
|
|
150
|
+
</div>
|
|
151
|
+
<div className="space-y-1">
|
|
152
|
+
<label className="text-xs font-medium text-muted-foreground">Value (JSON 或纯文本)</label>
|
|
153
|
+
<textarea
|
|
154
|
+
value={valueInput}
|
|
155
|
+
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setValueInput(e.target.value)}
|
|
156
|
+
className="w-full h-32 font-mono text-xs p-3 border rounded-md resize-none bg-background"
|
|
157
|
+
spellCheck={false}
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
<DialogFooter>
|
|
162
|
+
<DialogClose asChild><Button variant="outline" size="sm">取消</Button></DialogClose>
|
|
163
|
+
<Button size="sm" onClick={() => handleSave(addEntry)}><Save className="w-3.5 h-3.5 mr-1" />保存</Button>
|
|
164
|
+
</DialogFooter>
|
|
165
|
+
</DialogContent>
|
|
166
|
+
</Dialog>
|
|
167
|
+
</div>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useMemo } from 'react'
|
|
2
|
+
import type { TableInfo, SelectResult } from '@zhin.js/client'
|
|
3
|
+
import { Plus, Trash2, Pencil, RefreshCw, Loader2, AlertCircle, CheckCircle, ChevronLeft, ChevronRight, Save } from 'lucide-react'
|
|
4
|
+
import { Button } from '../../components/ui/button'
|
|
5
|
+
import { Alert, AlertDescription } from '../../components/ui/alert'
|
|
6
|
+
import {
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
DialogFooter,
|
|
12
|
+
DialogClose,
|
|
13
|
+
} from '../../components/ui/dialog'
|
|
14
|
+
import { JsonField } from './json-field'
|
|
15
|
+
|
|
16
|
+
export function RelatedTableView({
|
|
17
|
+
tableName,
|
|
18
|
+
tableInfo,
|
|
19
|
+
select,
|
|
20
|
+
insert,
|
|
21
|
+
update,
|
|
22
|
+
remove,
|
|
23
|
+
}: {
|
|
24
|
+
tableName: string
|
|
25
|
+
tableInfo?: TableInfo
|
|
26
|
+
select: (table: string, page?: number, pageSize?: number, where?: any) => Promise<SelectResult>
|
|
27
|
+
insert: (table: string, row: any) => Promise<any>
|
|
28
|
+
update: (table: string, row: any, where: any) => Promise<any>
|
|
29
|
+
remove: (table: string, where: any) => Promise<any>
|
|
30
|
+
}) {
|
|
31
|
+
const [data, setData] = useState<SelectResult | null>(null)
|
|
32
|
+
const [loading, setLoading] = useState(false)
|
|
33
|
+
const [page, setPage] = useState(1)
|
|
34
|
+
const [msg, setMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
|
35
|
+
const [editRow, setEditRow] = useState<any>(null)
|
|
36
|
+
const [addRow, setAddRow] = useState(false)
|
|
37
|
+
const [formData, setFormData] = useState<Record<string, string>>({})
|
|
38
|
+
const pageSize = 50
|
|
39
|
+
|
|
40
|
+
const load = useCallback(async () => {
|
|
41
|
+
setLoading(true)
|
|
42
|
+
setMsg(null)
|
|
43
|
+
try {
|
|
44
|
+
const result = await select(tableName, page, pageSize)
|
|
45
|
+
setData(result)
|
|
46
|
+
} catch (err) {
|
|
47
|
+
setMsg({ type: 'error', text: (err as Error).message })
|
|
48
|
+
} finally {
|
|
49
|
+
setLoading(false)
|
|
50
|
+
}
|
|
51
|
+
}, [tableName, page, select])
|
|
52
|
+
|
|
53
|
+
useEffect(() => { load() }, [load])
|
|
54
|
+
|
|
55
|
+
const columns = useMemo(() => {
|
|
56
|
+
if (tableInfo?.columns) return Object.keys(tableInfo.columns)
|
|
57
|
+
if (data?.rows?.length) return Object.keys(data.rows[0])
|
|
58
|
+
return []
|
|
59
|
+
}, [tableInfo, data])
|
|
60
|
+
|
|
61
|
+
const primaryKey = useMemo(() => {
|
|
62
|
+
if (!tableInfo?.columns) return columns[0] || 'id'
|
|
63
|
+
const pk = Object.entries(tableInfo.columns).find(([, col]: [string, any]) => col.primary)
|
|
64
|
+
return pk ? pk[0] : columns[0] || 'id'
|
|
65
|
+
}, [tableInfo, columns])
|
|
66
|
+
|
|
67
|
+
const handleSave = async (isNew: boolean) => {
|
|
68
|
+
try {
|
|
69
|
+
const row: any = {}
|
|
70
|
+
for (const col of columns) {
|
|
71
|
+
const val = formData[col]
|
|
72
|
+
if (val === undefined || val === '') continue
|
|
73
|
+
try { row[col] = JSON.parse(val) } catch { row[col] = val }
|
|
74
|
+
}
|
|
75
|
+
if (isNew) {
|
|
76
|
+
await insert(tableName, row)
|
|
77
|
+
} else {
|
|
78
|
+
const where: any = { [primaryKey]: editRow[primaryKey] }
|
|
79
|
+
await update(tableName, row, where)
|
|
80
|
+
}
|
|
81
|
+
setMsg({ type: 'success', text: isNew ? '添加成功' : '更新成功' })
|
|
82
|
+
setEditRow(null)
|
|
83
|
+
setAddRow(false)
|
|
84
|
+
setFormData({})
|
|
85
|
+
setTimeout(() => setMsg(null), 2000)
|
|
86
|
+
await load()
|
|
87
|
+
} catch (err) {
|
|
88
|
+
setMsg({ type: 'error', text: (err as Error).message })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const handleDelete = async (row: any) => {
|
|
93
|
+
if (!confirm('确定要删除这条记录吗?')) return
|
|
94
|
+
try {
|
|
95
|
+
await remove(tableName, { [primaryKey]: row[primaryKey] })
|
|
96
|
+
setMsg({ type: 'success', text: '删除成功' })
|
|
97
|
+
setTimeout(() => setMsg(null), 2000)
|
|
98
|
+
await load()
|
|
99
|
+
} catch (err) {
|
|
100
|
+
setMsg({ type: 'error', text: (err as Error).message })
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const openEdit = (row: any) => {
|
|
105
|
+
setEditRow(row)
|
|
106
|
+
setAddRow(false)
|
|
107
|
+
const fd: Record<string, string> = {}
|
|
108
|
+
for (const col of columns) {
|
|
109
|
+
fd[col] = typeof row[col] === 'object' ? JSON.stringify(row[col]) : String(row[col] ?? '')
|
|
110
|
+
}
|
|
111
|
+
setFormData(fd)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const openAdd = () => {
|
|
115
|
+
setEditRow(null)
|
|
116
|
+
setAddRow(true)
|
|
117
|
+
const fd: Record<string, string> = {}
|
|
118
|
+
for (const col of columns) fd[col] = ''
|
|
119
|
+
setFormData(fd)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const totalPages = data ? Math.ceil(data.total / pageSize) : 0
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className="space-y-3">
|
|
126
|
+
{msg && (
|
|
127
|
+
<Alert variant={msg.type === 'error' ? 'destructive' : 'success'} className="py-2">
|
|
128
|
+
{msg.type === 'error' ? <AlertCircle className="h-4 w-4" /> : <CheckCircle className="h-4 w-4" />}
|
|
129
|
+
<AlertDescription>{msg.text}</AlertDescription>
|
|
130
|
+
</Alert>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
<div className="flex items-center gap-2">
|
|
134
|
+
<Button size="sm" variant="outline" onClick={load} disabled={loading}>
|
|
135
|
+
<RefreshCw className={`w-3.5 h-3.5 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
|
|
136
|
+
</Button>
|
|
137
|
+
<Button size="sm" onClick={openAdd}><Plus className="w-3.5 h-3.5 mr-1" />添加</Button>
|
|
138
|
+
<span className="text-xs text-muted-foreground ml-auto">
|
|
139
|
+
共 {data?.total ?? 0} 条 · 第 {page}/{totalPages || 1} 页
|
|
140
|
+
</span>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<div
|
|
144
|
+
className="border rounded-md max-h-[min(70vh,720px)] overflow-auto overscroll-contain scroll-smooth touch-pan-x touch-pan-y bg-card"
|
|
145
|
+
role="region"
|
|
146
|
+
aria-label="数据表,可左右滑动查看宽表"
|
|
147
|
+
>
|
|
148
|
+
<table className="text-sm border-collapse min-w-full w-max">
|
|
149
|
+
<thead className="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm shadow-sm">
|
|
150
|
+
<tr className="border-b">
|
|
151
|
+
{columns.map((col: string) => (
|
|
152
|
+
<th
|
|
153
|
+
key={col}
|
|
154
|
+
className="px-3 py-2 text-left font-medium text-muted-foreground whitespace-nowrap border-b border-border/80"
|
|
155
|
+
>
|
|
156
|
+
{col}
|
|
157
|
+
</th>
|
|
158
|
+
))}
|
|
159
|
+
<th className="px-3 py-2 text-right font-medium text-muted-foreground whitespace-nowrap border-b border-border/80 w-24 min-w-[5.5rem]">
|
|
160
|
+
操作
|
|
161
|
+
</th>
|
|
162
|
+
</tr>
|
|
163
|
+
</thead>
|
|
164
|
+
<tbody>
|
|
165
|
+
{loading && !data ? (
|
|
166
|
+
<tr><td colSpan={columns.length + 1} className="text-center py-8"><Loader2 className="w-4 h-4 animate-spin inline-block" /></td></tr>
|
|
167
|
+
) : !data?.rows?.length ? (
|
|
168
|
+
<tr><td colSpan={columns.length + 1} className="text-center py-8 text-muted-foreground">暂无数据</td></tr>
|
|
169
|
+
) : data.rows.map((row: any, i: number) => (
|
|
170
|
+
<tr key={i} className="border-b border-border/60 hover:bg-muted/30 transition-colors">
|
|
171
|
+
{columns.map((col: string) => (
|
|
172
|
+
<td
|
|
173
|
+
key={col}
|
|
174
|
+
className="px-3 py-1.5 font-mono text-xs whitespace-nowrap align-top"
|
|
175
|
+
title={typeof row[col] === 'object' ? JSON.stringify(row[col]) : String(row[col] ?? '')}
|
|
176
|
+
>
|
|
177
|
+
{typeof row[col] === 'object' ? JSON.stringify(row[col]) : String(row[col] ?? '')}
|
|
178
|
+
</td>
|
|
179
|
+
))}
|
|
180
|
+
<td className="px-3 py-1.5 text-right space-x-1 whitespace-nowrap w-24 min-w-[5.5rem]">
|
|
181
|
+
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => openEdit(row)}><Pencil className="w-3 h-3" /></Button>
|
|
182
|
+
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-destructive" onClick={() => handleDelete(row)}><Trash2 className="w-3 h-3" /></Button>
|
|
183
|
+
</td>
|
|
184
|
+
</tr>
|
|
185
|
+
))}
|
|
186
|
+
</tbody>
|
|
187
|
+
</table>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{totalPages > 1 && (
|
|
191
|
+
<div className="flex items-center justify-center gap-2">
|
|
192
|
+
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p: number) => p - 1)}><ChevronLeft className="w-4 h-4" /></Button>
|
|
193
|
+
<span className="text-xs text-muted-foreground">{page} / {totalPages}</span>
|
|
194
|
+
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => setPage((p: number) => p + 1)}><ChevronRight className="w-4 h-4" /></Button>
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
<Dialog open={editRow !== null || addRow} onOpenChange={(open) => { if (!open) { setEditRow(null); setAddRow(false) } }}>
|
|
199
|
+
<DialogContent className="max-w-md max-h-[80vh] overflow-y-auto">
|
|
200
|
+
<DialogHeader>
|
|
201
|
+
<DialogTitle>{addRow ? '添加记录' : '编辑记录'}</DialogTitle>
|
|
202
|
+
</DialogHeader>
|
|
203
|
+
<div className="space-y-3 py-2">
|
|
204
|
+
{columns.map((col: string) => (
|
|
205
|
+
<JsonField
|
|
206
|
+
key={col}
|
|
207
|
+
label={col}
|
|
208
|
+
value={formData[col] ?? ''}
|
|
209
|
+
onChange={(v: string) => setFormData((prev: Record<string, string>) => ({ ...prev, [col]: v }))}
|
|
210
|
+
/>
|
|
211
|
+
))}
|
|
212
|
+
</div>
|
|
213
|
+
<DialogFooter>
|
|
214
|
+
<DialogClose asChild><Button variant="outline" size="sm">取消</Button></DialogClose>
|
|
215
|
+
<Button size="sm" onClick={() => handleSave(addRow)}><Save className="w-3.5 h-3.5 mr-1" />保存</Button>
|
|
216
|
+
</DialogFooter>
|
|
217
|
+
</DialogContent>
|
|
218
|
+
</Dialog>
|
|
219
|
+
</div>
|
|
220
|
+
)
|
|
221
|
+
}
|
package/client/src/pages/env.tsx
CHANGED
|
@@ -4,7 +4,8 @@ import {
|
|
|
4
4
|
KeyRound, AlertCircle, CheckCircle, Save, Loader2,
|
|
5
5
|
RefreshCw, FileWarning, Eye, EyeOff
|
|
6
6
|
} from 'lucide-react'
|
|
7
|
-
import { Card, CardContent } from '../components/ui/card'
|
|
7
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'
|
|
8
|
+
import { PageHeader } from '../components/PageHeader'
|
|
8
9
|
import { Badge } from '../components/ui/badge'
|
|
9
10
|
import { Button } from '../components/ui/button'
|
|
10
11
|
import { Alert, AlertDescription } from '../components/ui/alert'
|
|
@@ -141,7 +142,7 @@ function EnvFileEditor({
|
|
|
141
142
|
)
|
|
142
143
|
}
|
|
143
144
|
|
|
144
|
-
export default function
|
|
145
|
+
export default function EnvManagePage() {
|
|
145
146
|
const { files, loading, error, listFiles, getFile, saveFile } = useEnvFiles()
|
|
146
147
|
const [activeTab, setActiveTab] = useState('.env')
|
|
147
148
|
|
|
@@ -155,9 +156,9 @@ export default function EnvMangePage() {
|
|
|
155
156
|
|
|
156
157
|
if (loading && files.length === 0) {
|
|
157
158
|
return (
|
|
158
|
-
<div className="space-y-
|
|
159
|
-
<
|
|
160
|
-
<div className="flex items-center justify-center py-12">
|
|
159
|
+
<div className="space-y-6">
|
|
160
|
+
<PageHeader title="环境变量" description="加载文件列表…" />
|
|
161
|
+
<div className="flex items-center justify-center py-12 rounded-lg border border-dashed">
|
|
161
162
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
162
163
|
<span className="ml-2 text-sm text-muted-foreground">加载中...</span>
|
|
163
164
|
</div>
|
|
@@ -166,19 +167,17 @@ export default function EnvMangePage() {
|
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
return (
|
|
169
|
-
<div className="space-y-
|
|
170
|
-
<
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
</Button>
|
|
181
|
-
</div>
|
|
170
|
+
<div className="space-y-6">
|
|
171
|
+
<PageHeader
|
|
172
|
+
title="环境变量"
|
|
173
|
+
description="管理 .env / .env.* 中的键值;含 PASSWORD、TOKEN、SECRET 等关键字的行可在未编辑时自动遮罩显示。保存后需重启进程生效。"
|
|
174
|
+
actions={
|
|
175
|
+
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={loading}>
|
|
176
|
+
<RefreshCw className={`w-4 h-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
|
|
177
|
+
刷新
|
|
178
|
+
</Button>
|
|
179
|
+
}
|
|
180
|
+
/>
|
|
182
181
|
|
|
183
182
|
{error && (
|
|
184
183
|
<Alert variant="destructive" className="py-2">
|
|
@@ -187,12 +186,12 @@ export default function EnvMangePage() {
|
|
|
187
186
|
</Alert>
|
|
188
187
|
)}
|
|
189
188
|
|
|
190
|
-
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
191
|
-
<TabsList>
|
|
189
|
+
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
|
190
|
+
<TabsList className="w-full justify-start flex-wrap h-auto gap-1 bg-muted/40 p-1">
|
|
192
191
|
{['.env', '.env.development', '.env.production'].map(name => {
|
|
193
192
|
const fileInfo = files.find(f => f.name === name)
|
|
194
193
|
return (
|
|
195
|
-
<TabsTrigger key={name} value={name} className="gap-1.5">
|
|
194
|
+
<TabsTrigger key={name} value={name} className="gap-1.5 data-[state=active]:shadow-sm">
|
|
196
195
|
<KeyRound className="w-3.5 h-3.5" />
|
|
197
196
|
{name}
|
|
198
197
|
{fileInfo && !fileInfo.exists && (
|
|
@@ -204,13 +203,24 @@ export default function EnvMangePage() {
|
|
|
204
203
|
</TabsList>
|
|
205
204
|
|
|
206
205
|
{['.env', '.env.development', '.env.production'].map(name => (
|
|
207
|
-
<TabsContent key={name} value={name}>
|
|
208
|
-
<
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
206
|
+
<TabsContent key={name} value={name} className="mt-0">
|
|
207
|
+
<Card className="border-border/80 shadow-sm">
|
|
208
|
+
<CardHeader className="pb-2">
|
|
209
|
+
<CardTitle className="text-base font-semibold">{name}</CardTitle>
|
|
210
|
+
<CardDescription>
|
|
211
|
+
键值对一行一条,格式 <code className="rounded bg-muted px-1 py-0.5 text-xs">KEY=VALUE</code>
|
|
212
|
+
;编辑时始终显示真实内容,保存前请确认环境安全。
|
|
213
|
+
</CardDescription>
|
|
214
|
+
</CardHeader>
|
|
215
|
+
<CardContent>
|
|
216
|
+
<EnvFileEditor
|
|
217
|
+
filename={name}
|
|
218
|
+
getFile={getFile}
|
|
219
|
+
saveFile={saveFile}
|
|
220
|
+
exists={files.find(f => f.name === name)?.exists ?? false}
|
|
221
|
+
/>
|
|
222
|
+
</CardContent>
|
|
223
|
+
</Card>
|
|
214
224
|
</TabsContent>
|
|
215
225
|
))}
|
|
216
226
|
</Tabs>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useMemo, useCallback, useRef, type KeyboardEvent } from 'react'
|
|
2
|
+
import { editorFontStyle } from './editor-constants'
|
|
3
|
+
export function CodeEditor({
|
|
4
|
+
value,
|
|
5
|
+
onChange,
|
|
6
|
+
language,
|
|
7
|
+
}: {
|
|
8
|
+
value: string
|
|
9
|
+
onChange: (v: string) => void
|
|
10
|
+
language: string | null
|
|
11
|
+
}) {
|
|
12
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
13
|
+
const preRef = useRef<HTMLPreElement>(null)
|
|
14
|
+
|
|
15
|
+
const highlighted = useMemo(() => {
|
|
16
|
+
if (window.hljs && language && window.hljs.getLanguage(language)) {
|
|
17
|
+
try {
|
|
18
|
+
return window.hljs.highlight(value, { language }).value
|
|
19
|
+
} catch { /* fallback */ }
|
|
20
|
+
}
|
|
21
|
+
return value
|
|
22
|
+
.replace(/&/g, '&')
|
|
23
|
+
.replace(/</g, '<')
|
|
24
|
+
.replace(/>/g, '>')
|
|
25
|
+
}, [value, language])
|
|
26
|
+
|
|
27
|
+
const handleScroll = useCallback(() => {
|
|
28
|
+
if (preRef.current && textareaRef.current) {
|
|
29
|
+
preRef.current.scrollTop = textareaRef.current.scrollTop
|
|
30
|
+
preRef.current.scrollLeft = textareaRef.current.scrollLeft
|
|
31
|
+
}
|
|
32
|
+
}, [])
|
|
33
|
+
|
|
34
|
+
const handleKeyDown = useCallback(
|
|
35
|
+
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
36
|
+
if (e.key === 'Tab') {
|
|
37
|
+
e.preventDefault()
|
|
38
|
+
const ta = e.currentTarget
|
|
39
|
+
const start = ta.selectionStart
|
|
40
|
+
const end = ta.selectionEnd
|
|
41
|
+
const next = value.substring(0, start) + ' ' + value.substring(end)
|
|
42
|
+
onChange(next)
|
|
43
|
+
requestAnimationFrame(() => {
|
|
44
|
+
ta.selectionStart = ta.selectionEnd = start + 2
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
[value, onChange],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="relative h-full w-full overflow-hidden">
|
|
53
|
+
<pre
|
|
54
|
+
ref={preRef}
|
|
55
|
+
className="absolute inset-0 m-0 p-4 overflow-auto pointer-events-none"
|
|
56
|
+
style={editorFontStyle}
|
|
57
|
+
aria-hidden
|
|
58
|
+
>
|
|
59
|
+
<code
|
|
60
|
+
className={language ? `hljs language-${language}` : ''}
|
|
61
|
+
dangerouslySetInnerHTML={{ __html: highlighted + '\n' }}
|
|
62
|
+
style={{ background: 'transparent', padding: 0, display: 'block' }}
|
|
63
|
+
/>
|
|
64
|
+
</pre>
|
|
65
|
+
<textarea
|
|
66
|
+
ref={textareaRef}
|
|
67
|
+
value={value}
|
|
68
|
+
onChange={(e) => onChange(e.target.value)}
|
|
69
|
+
onScroll={handleScroll}
|
|
70
|
+
onKeyDown={handleKeyDown}
|
|
71
|
+
wrap="off"
|
|
72
|
+
className="absolute inset-0 w-full h-full resize-none p-4 bg-transparent outline-none border-0"
|
|
73
|
+
style={{
|
|
74
|
+
...editorFontStyle,
|
|
75
|
+
color: 'transparent',
|
|
76
|
+
caretColor: 'hsl(var(--foreground))',
|
|
77
|
+
WebkitTextFillColor: 'transparent',
|
|
78
|
+
}}
|
|
79
|
+
spellCheck={false}
|
|
80
|
+
autoCapitalize="off"
|
|
81
|
+
autoCorrect="off"
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const HLJS_CDN = 'https://cdn.jsdelivr.net.cn/npm/@highlightjs/cdn-assets@11/styles'
|
|
2
|
+
|
|
3
|
+
export const editorFontStyle = {
|
|
4
|
+
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
|
5
|
+
fontSize: '13px',
|
|
6
|
+
lineHeight: '20px',
|
|
7
|
+
tabSize: 2,
|
|
8
|
+
whiteSpace: 'pre' as const,
|
|
9
|
+
}
|