@zhin.js/console 1.0.40 → 1.0.42

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,219 @@
1
+ import { useState, useEffect, useCallback } from 'react'
2
+ import { useEnvFiles } from '@zhin.js/client'
3
+ import {
4
+ KeyRound, AlertCircle, CheckCircle, Save, Loader2,
5
+ RefreshCw, FileWarning, Eye, EyeOff
6
+ } from 'lucide-react'
7
+ import { Card, CardContent } from '../components/ui/card'
8
+ import { Badge } from '../components/ui/badge'
9
+ import { Button } from '../components/ui/button'
10
+ import { Alert, AlertDescription } from '../components/ui/alert'
11
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/tabs'
12
+ import { Textarea } from '../components/ui/textarea'
13
+
14
+ const SENSITIVE_PATTERN = /^(.*(?:PASSWORD|SECRET|TOKEN|KEY|PRIVATE|CREDENTIAL).*?=\s*)(.+)$/gim
15
+
16
+ function maskSensitiveValues(content: string): string {
17
+ return content.replace(SENSITIVE_PATTERN, (_match, prefix, value) => {
18
+ if (value.startsWith('${') || value.trim() === '') return prefix + value
19
+ const visible = value.length > 4 ? value.slice(0, 2) : ''
20
+ return prefix + visible + '●'.repeat(Math.min(value.length - visible.length, 20))
21
+ })
22
+ }
23
+
24
+ function EnvFileEditor({
25
+ filename,
26
+ getFile,
27
+ saveFile,
28
+ exists,
29
+ }: {
30
+ filename: string
31
+ getFile: (f: string) => Promise<string>
32
+ saveFile: (f: string, c: string) => Promise<any>
33
+ exists: boolean
34
+ }) {
35
+ const [content, setContent] = useState('')
36
+ const [originalContent, setOriginalContent] = useState('')
37
+ const [loading, setLoading] = useState(false)
38
+ const [saving, setSaving] = useState(false)
39
+ const [masked, setMasked] = useState(true)
40
+ const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
41
+ const [loaded, setLoaded] = useState(false)
42
+
43
+ const loadContent = useCallback(async () => {
44
+ setLoading(true)
45
+ try {
46
+ const text = await getFile(filename)
47
+ setContent(text)
48
+ setOriginalContent(text)
49
+ setLoaded(true)
50
+ } catch (err) {
51
+ setMessage({ type: 'error', text: `加载失败: ${err instanceof Error ? err.message : '未知错误'}` })
52
+ } finally {
53
+ setLoading(false)
54
+ }
55
+ }, [filename, getFile])
56
+
57
+ useEffect(() => {
58
+ loadContent()
59
+ }, [loadContent])
60
+
61
+ const handleSave = async () => {
62
+ setSaving(true)
63
+ setMessage(null)
64
+ try {
65
+ await saveFile(filename, content)
66
+ setOriginalContent(content)
67
+ setMessage({ type: 'success', text: '已保存,需重启生效' })
68
+ setTimeout(() => setMessage(null), 3000)
69
+ } catch (err) {
70
+ setMessage({ type: 'error', text: `保存失败: ${err instanceof Error ? err.message : '未知错误'}` })
71
+ } finally {
72
+ setSaving(false)
73
+ }
74
+ }
75
+
76
+ const dirty = content !== originalContent
77
+
78
+ const displayContent = masked && !dirty ? maskSensitiveValues(content) : content
79
+
80
+ if (loading && !loaded) {
81
+ return (
82
+ <div className="flex items-center justify-center py-8">
83
+ <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
84
+ <span className="ml-2 text-sm text-muted-foreground">加载中...</span>
85
+ </div>
86
+ )
87
+ }
88
+
89
+ return (
90
+ <div className="space-y-3">
91
+ {!exists && !dirty && (
92
+ <Alert className="py-2 border-yellow-500/50 text-yellow-700 dark:text-yellow-400">
93
+ <FileWarning className="h-4 w-4" />
94
+ <AlertDescription>文件不存在,保存后将自动创建</AlertDescription>
95
+ </Alert>
96
+ )}
97
+
98
+ {message && (
99
+ <Alert variant={message.type === 'error' ? 'destructive' : 'success'} className="py-2">
100
+ {message.type === 'error'
101
+ ? <AlertCircle className="h-4 w-4" />
102
+ : <CheckCircle className="h-4 w-4" />}
103
+ <AlertDescription>{message.text}</AlertDescription>
104
+ </Alert>
105
+ )}
106
+
107
+ <div className="relative">
108
+ <Textarea
109
+ value={displayContent}
110
+ onChange={e => { setContent(e.target.value); setMasked(false) }}
111
+ onFocus={() => setMasked(false)}
112
+ className="font-mono text-sm min-h-[350px] resize-y"
113
+ placeholder="KEY=VALUE"
114
+ spellCheck={false}
115
+ />
116
+ </div>
117
+
118
+ <div className="flex items-center gap-2">
119
+ <Button size="sm" onClick={handleSave} disabled={saving || !dirty}>
120
+ {saving
121
+ ? <><Loader2 className="w-4 h-4 mr-1 animate-spin" />保存中...</>
122
+ : <><Save className="w-4 h-4 mr-1" />保存</>}
123
+ </Button>
124
+ {dirty && (
125
+ <Button variant="outline" size="sm" onClick={() => { setContent(originalContent); setMasked(true) }}>
126
+ 撤销
127
+ </Button>
128
+ )}
129
+ <Button
130
+ variant="ghost"
131
+ size="sm"
132
+ onClick={() => setMasked(prev => !prev)}
133
+ disabled={dirty}
134
+ title={masked ? '显示敏感值' : '隐藏敏感值'}
135
+ >
136
+ {masked ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
137
+ </Button>
138
+ {dirty && <span className="text-xs text-muted-foreground">有未保存的更改</span>}
139
+ </div>
140
+ </div>
141
+ )
142
+ }
143
+
144
+ export default function DashboardEnv() {
145
+ const { files, loading, error, listFiles, getFile, saveFile } = useEnvFiles()
146
+ const [activeTab, setActiveTab] = useState('.env')
147
+
148
+ const handleRefresh = async () => {
149
+ try {
150
+ await listFiles()
151
+ } catch {
152
+ // hook will set error state
153
+ }
154
+ }
155
+
156
+ if (loading && files.length === 0) {
157
+ 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">
161
+ <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
162
+ <span className="ml-2 text-sm text-muted-foreground">加载中...</span>
163
+ </div>
164
+ </div>
165
+ )
166
+ }
167
+
168
+ 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>
182
+
183
+ {error && (
184
+ <Alert variant="destructive" className="py-2">
185
+ <AlertCircle className="h-4 w-4" />
186
+ <AlertDescription>{error}</AlertDescription>
187
+ </Alert>
188
+ )}
189
+
190
+ <Tabs value={activeTab} onValueChange={setActiveTab}>
191
+ <TabsList>
192
+ {['.env', '.env.development', '.env.production'].map(name => {
193
+ const fileInfo = files.find(f => f.name === name)
194
+ return (
195
+ <TabsTrigger key={name} value={name} className="gap-1.5">
196
+ <KeyRound className="w-3.5 h-3.5" />
197
+ {name}
198
+ {fileInfo && !fileInfo.exists && (
199
+ <Badge variant="outline" className="text-[10px] px-1 py-0 ml-1">新</Badge>
200
+ )}
201
+ </TabsTrigger>
202
+ )
203
+ })}
204
+ </TabsList>
205
+
206
+ {['.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
+ />
214
+ </TabsContent>
215
+ ))}
216
+ </Tabs>
217
+ </div>
218
+ )
219
+ }