@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +22 -0
  3. package/browser.tsconfig.json +19 -0
  4. package/client/src/components/PageHeader.tsx +26 -0
  5. package/client/src/components/ui/accordion.tsx +2 -1
  6. package/client/src/components/ui/badge.tsx +1 -3
  7. package/client/src/components/ui/scroll-area.tsx +5 -2
  8. package/client/src/components/ui/select.tsx +7 -3
  9. package/client/src/components/ui/separator.tsx +5 -2
  10. package/client/src/components/ui/tabs.tsx +4 -2
  11. package/client/src/layouts/dashboard.tsx +223 -121
  12. package/client/src/main.tsx +34 -34
  13. package/client/src/pages/bot-detail/MessageBody.tsx +110 -0
  14. package/client/src/pages/bot-detail/date-utils.ts +8 -0
  15. package/client/src/pages/bot-detail/index.tsx +798 -0
  16. package/client/src/pages/bot-detail/types.ts +92 -0
  17. package/client/src/pages/bot-detail/useBotConsole.tsx +600 -0
  18. package/client/src/pages/bots.tsx +111 -73
  19. package/client/src/pages/database/constants.ts +16 -0
  20. package/client/src/pages/database/database-page.tsx +170 -0
  21. package/client/src/pages/database/document-collection-view.tsx +155 -0
  22. package/client/src/pages/database/index.tsx +1 -0
  23. package/client/src/pages/database/json-field.tsx +11 -0
  24. package/client/src/pages/database/kv-bucket-view.tsx +169 -0
  25. package/client/src/pages/database/related-table-view.tsx +221 -0
  26. package/client/src/pages/env.tsx +38 -28
  27. package/client/src/pages/files/code-editor.tsx +85 -0
  28. package/client/src/pages/files/editor-constants.ts +9 -0
  29. package/client/src/pages/files/file-editor.tsx +133 -0
  30. package/client/src/pages/files/file-icons.tsx +25 -0
  31. package/client/src/pages/files/files-page.tsx +92 -0
  32. package/client/src/pages/files/hljs-global.d.ts +10 -0
  33. package/client/src/pages/files/index.tsx +1 -0
  34. package/client/src/pages/files/language.ts +18 -0
  35. package/client/src/pages/files/tree-node.tsx +69 -0
  36. package/client/src/pages/files/use-hljs-theme.ts +23 -0
  37. package/client/src/pages/logs.tsx +77 -22
  38. package/client/src/style.css +144 -0
  39. package/client/src/utils/parseComposerContent.ts +57 -0
  40. package/client/tailwind.config.js +1 -0
  41. package/client/tsconfig.json +3 -1
  42. package/dist/assets/index-COKXlFo2.js +124 -0
  43. package/dist/assets/style-kkLO-vsa.css +3 -0
  44. package/dist/client.js +482 -464
  45. package/dist/index.html +2 -2
  46. package/dist/style.css +1 -1
  47. package/lib/index.js +1010 -81
  48. package/lib/transform.js +16 -2
  49. package/lib/websocket.js +845 -28
  50. package/node.tsconfig.json +18 -0
  51. package/package.json +13 -15
  52. package/src/bin.ts +24 -0
  53. package/src/bot-db-models.ts +74 -0
  54. package/src/bot-hub.ts +240 -0
  55. package/src/bot-persistence.ts +270 -0
  56. package/src/build.ts +90 -0
  57. package/src/dev.ts +107 -0
  58. package/src/index.ts +337 -0
  59. package/src/transform.ts +199 -0
  60. package/src/websocket.ts +1369 -0
  61. package/client/src/pages/database.tsx +0 -708
  62. package/client/src/pages/files.tsx +0 -470
  63. package/client/src/pages/login-assist.tsx +0 -225
  64. package/dist/assets/index-DS4RbHWX.js +0 -124
  65. 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
+ }
@@ -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 EnvMangePage() {
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-4">
159
- <h1 className="text-2xl font-bold tracking-tight">环境变量</h1>
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-4">
170
- <div className="flex items-center justify-between">
171
- <div>
172
- <h1 className="text-2xl font-bold tracking-tight">环境变量</h1>
173
- <p className="text-sm text-muted-foreground mt-1">
174
- 管理 .env 文件中的环境变量,修改后需重启生效
175
- </p>
176
- </div>
177
- <Button variant="outline" size="sm" onClick={handleRefresh} disabled={loading}>
178
- <RefreshCw className={`w-4 h-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
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
- <EnvFileEditor
209
- filename={name}
210
- getFile={getFile}
211
- saveFile={saveFile}
212
- exists={files.find(f => f.name === name)?.exists ?? false}
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, '&amp;')
23
+ .replace(/</g, '&lt;')
24
+ .replace(/>/g, '&gt;')
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
+ }