@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.
@@ -141,7 +141,7 @@ function EnvFileEditor({
141
141
  )
142
142
  }
143
143
 
144
- export default function DashboardEnv() {
144
+ export default function EnvMangePage() {
145
145
  const { files, loading, error, listFiles, getFile, saveFile } = useEnvFiles()
146
146
  const [activeTab, setActiveTab] = useState('.env')
147
147
 
@@ -0,0 +1,470 @@
1
+ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
2
+ import { useFiles } from '@zhin.js/client'
3
+ import type { FileTreeNode } from '@zhin.js/client'
4
+ import {
5
+ FolderOpen, File, ChevronRight, ChevronDown, Save, Loader2,
6
+ RefreshCw, AlertCircle, CheckCircle, FileCode, X
7
+ } from 'lucide-react'
8
+ import { Card, CardContent } 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
+
14
+ // ── 文件图标 & 语言检测 ─────────────────────────────────────────────
15
+
16
+ function getFileIcon(name: string) {
17
+ const ext = name.split('.').pop()?.toLowerCase()
18
+ switch (ext) {
19
+ case 'ts':
20
+ case 'tsx':
21
+ return <FileCode className="w-4 h-4 text-blue-500" />
22
+ case 'js':
23
+ case 'jsx':
24
+ return <FileCode className="w-4 h-4 text-yellow-500" />
25
+ case 'json':
26
+ return <File className="w-4 h-4 text-green-500" />
27
+ case 'yml':
28
+ case 'yaml':
29
+ return <File className="w-4 h-4 text-red-400" />
30
+ case 'md':
31
+ return <File className="w-4 h-4 text-gray-400" />
32
+ case 'env':
33
+ return <File className="w-4 h-4 text-orange-500" />
34
+ default:
35
+ if (name.startsWith('.env')) return <File className="w-4 h-4 text-orange-500" />
36
+ return <File className="w-4 h-4 text-muted-foreground" />
37
+ }
38
+ }
39
+
40
+ // ── Highlight.js 集成 ───────────────────────────────────────────────
41
+
42
+ declare global {
43
+ interface Window {
44
+ hljs?: {
45
+ highlight: (code: string, options: { language: string }) => { value: string }
46
+ getLanguage: (name: string) => any
47
+ }
48
+ }
49
+ }
50
+
51
+ const HLJS_CDN = 'https://cdn.jsdelivr.net.cn/npm/@highlightjs/cdn-assets@11/styles'
52
+
53
+ function getLanguage(fileName: string): string | null {
54
+ const name = fileName.split('/').pop()?.toLowerCase() || ''
55
+ if (name === '.env' || name.startsWith('.env.')) return 'ini'
56
+ const ext = name.split('.').pop()?.toLowerCase()
57
+ switch (ext) {
58
+ case 'ts': case 'tsx': return 'typescript'
59
+ case 'js': case 'jsx': return 'javascript'
60
+ case 'css': return 'css'
61
+ case 'scss': return 'scss'
62
+ case 'less': return 'less'
63
+ case 'json': return 'json'
64
+ case 'yml': case 'yaml': return 'yaml'
65
+ case 'md': return 'markdown'
66
+ case 'xml': case 'html': return 'xml'
67
+ case 'sh': case 'bash': return 'bash'
68
+ default: return null
69
+ }
70
+ }
71
+
72
+ function useHljsTheme() {
73
+ useEffect(() => {
74
+ const linkId = 'hljs-theme-css'
75
+ let link = document.getElementById(linkId) as HTMLLinkElement | null
76
+ if (!link) {
77
+ link = document.createElement('link')
78
+ link.id = linkId
79
+ link.rel = 'stylesheet'
80
+ document.head.appendChild(link)
81
+ }
82
+ const update = () => {
83
+ const isDark = document.documentElement.classList.contains('dark')
84
+ link!.href = `${HLJS_CDN}/${isDark ? 'github-dark' : 'github'}.min.css`
85
+ }
86
+ update()
87
+ const obs = new MutationObserver(update)
88
+ obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
89
+ return () => obs.disconnect()
90
+ }, [])
91
+ }
92
+
93
+ const editorFontStyle = {
94
+ fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
95
+ fontSize: '13px',
96
+ lineHeight: '20px',
97
+ tabSize: 2,
98
+ whiteSpace: 'pre' as const,
99
+ }
100
+
101
+ function CodeEditor({
102
+ value,
103
+ onChange,
104
+ language,
105
+ }: {
106
+ value: string
107
+ onChange: (v: string) => void
108
+ language: string | null
109
+ }) {
110
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
111
+ const preRef = useRef<HTMLPreElement>(null)
112
+
113
+ const highlighted = useMemo(() => {
114
+ if (window.hljs && language && window.hljs.getLanguage(language)) {
115
+ try {
116
+ return window.hljs.highlight(value, { language }).value
117
+ } catch { /* fallback */ }
118
+ }
119
+ return value
120
+ .replace(/&/g, '&amp;')
121
+ .replace(/</g, '&lt;')
122
+ .replace(/>/g, '&gt;')
123
+ }, [value, language])
124
+
125
+ const handleScroll = useCallback(() => {
126
+ if (preRef.current && textareaRef.current) {
127
+ preRef.current.scrollTop = textareaRef.current.scrollTop
128
+ preRef.current.scrollLeft = textareaRef.current.scrollLeft
129
+ }
130
+ }, [])
131
+
132
+ const handleKeyDown = useCallback(
133
+ (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
134
+ if (e.key === 'Tab') {
135
+ e.preventDefault()
136
+ const ta = e.currentTarget
137
+ const start = ta.selectionStart
138
+ const end = ta.selectionEnd
139
+ const next = value.substring(0, start) + ' ' + value.substring(end)
140
+ onChange(next)
141
+ requestAnimationFrame(() => {
142
+ ta.selectionStart = ta.selectionEnd = start + 2
143
+ })
144
+ }
145
+ },
146
+ [value, onChange],
147
+ )
148
+
149
+ return (
150
+ <div className="relative h-full w-full overflow-hidden">
151
+ <pre
152
+ ref={preRef}
153
+ className="absolute inset-0 m-0 p-4 overflow-auto pointer-events-none"
154
+ style={editorFontStyle}
155
+ aria-hidden
156
+ >
157
+ <code
158
+ className={language ? `hljs language-${language}` : ''}
159
+ dangerouslySetInnerHTML={{ __html: highlighted + '\n' }}
160
+ style={{ background: 'transparent', padding: 0, display: 'block' }}
161
+ />
162
+ </pre>
163
+ <textarea
164
+ ref={textareaRef}
165
+ value={value}
166
+ onChange={(e) => onChange(e.target.value)}
167
+ onScroll={handleScroll}
168
+ onKeyDown={handleKeyDown}
169
+ wrap="off"
170
+ className="absolute inset-0 w-full h-full resize-none p-4 bg-transparent outline-none border-0"
171
+ style={{
172
+ ...editorFontStyle,
173
+ color: 'transparent',
174
+ caretColor: 'hsl(var(--foreground))',
175
+ WebkitTextFillColor: 'transparent',
176
+ }}
177
+ spellCheck={false}
178
+ autoCapitalize="off"
179
+ autoCorrect="off"
180
+ />
181
+ </div>
182
+ )
183
+ }
184
+
185
+ // ── 文件树节点组件 ──────────────────────────────────────────────────
186
+
187
+ function TreeNode({
188
+ node,
189
+ selectedPath,
190
+ onSelect,
191
+ depth = 0,
192
+ }: {
193
+ node: FileTreeNode
194
+ selectedPath: string | null
195
+ onSelect: (path: string) => void
196
+ depth?: number
197
+ }) {
198
+ const [expanded, setExpanded] = useState(depth < 1)
199
+ const isSelected = node.path === selectedPath
200
+ const isDir = node.type === 'directory'
201
+
202
+ return (
203
+ <div>
204
+ <button
205
+ className={`
206
+ w-full flex items-center gap-1.5 px-2 py-1 text-sm rounded-sm text-left
207
+ hover:bg-accent transition-colors
208
+ ${isSelected ? 'bg-accent text-accent-foreground font-medium' : ''}
209
+ `}
210
+ style={{ paddingLeft: `${depth * 16 + 8}px` }}
211
+ onClick={() => {
212
+ if (isDir) {
213
+ setExpanded(!expanded)
214
+ } else {
215
+ onSelect(node.path)
216
+ }
217
+ }}
218
+ >
219
+ {isDir ? (
220
+ expanded ? (
221
+ <ChevronDown className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
222
+ ) : (
223
+ <ChevronRight className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
224
+ )
225
+ ) : (
226
+ <span className="w-3.5" />
227
+ )}
228
+ {isDir ? (
229
+ <FolderOpen className="w-4 h-4 shrink-0 text-amber-500" />
230
+ ) : (
231
+ getFileIcon(node.name)
232
+ )}
233
+ <span className="truncate">{node.name}</span>
234
+ </button>
235
+ {isDir && expanded && node.children && (
236
+ <div>
237
+ {node.children.map((child) => (
238
+ <TreeNode
239
+ key={child.path}
240
+ node={child}
241
+ selectedPath={selectedPath}
242
+ onSelect={onSelect}
243
+ depth={depth + 1}
244
+ />
245
+ ))}
246
+ </div>
247
+ )}
248
+ </div>
249
+ )
250
+ }
251
+
252
+ // ── 文件编辑器组件 ──────────────────────────────────────────────────
253
+
254
+ function FileEditor({
255
+ filePath,
256
+ readFile,
257
+ saveFile,
258
+ onClose,
259
+ }: {
260
+ filePath: string
261
+ readFile: (path: string) => Promise<string>
262
+ saveFile: (path: string, content: string) => Promise<any>
263
+ onClose: () => void
264
+ }) {
265
+ const [content, setContent] = useState('')
266
+ const [originalContent, setOriginalContent] = useState('')
267
+ const [loading, setLoading] = useState(true)
268
+ const [saving, setSaving] = useState(false)
269
+ const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
270
+
271
+ const loadContent = useCallback(async () => {
272
+ setLoading(true)
273
+ setMessage(null)
274
+ try {
275
+ const text = await readFile(filePath)
276
+ setContent(text)
277
+ setOriginalContent(text)
278
+ } catch (err) {
279
+ setMessage({ type: 'error', text: `加载失败: ${err instanceof Error ? err.message : '未知错误'}` })
280
+ } finally {
281
+ setLoading(false)
282
+ }
283
+ }, [filePath, readFile])
284
+
285
+ useEffect(() => {
286
+ loadContent()
287
+ }, [loadContent])
288
+
289
+ const handleSave = async () => {
290
+ setSaving(true)
291
+ setMessage(null)
292
+ try {
293
+ await saveFile(filePath, content)
294
+ setOriginalContent(content)
295
+ setMessage({ type: 'success', text: '已保存' })
296
+ setTimeout(() => setMessage(null), 3000)
297
+ } catch (err) {
298
+ setMessage({ type: 'error', text: `保存失败: ${err instanceof Error ? err.message : '未知错误'}` })
299
+ } finally {
300
+ setSaving(false)
301
+ }
302
+ }
303
+
304
+ // Ctrl+S / Cmd+S 快捷键
305
+ useEffect(() => {
306
+ const handleKeyDown = (e: KeyboardEvent) => {
307
+ if ((e.metaKey || e.ctrlKey) && e.key === 's') {
308
+ e.preventDefault()
309
+ if (dirty && !saving) handleSave()
310
+ }
311
+ }
312
+ window.addEventListener('keydown', handleKeyDown)
313
+ return () => window.removeEventListener('keydown', handleKeyDown)
314
+ })
315
+
316
+ const dirty = content !== originalContent
317
+ const fileName = filePath.split('/').pop() || filePath
318
+
319
+ if (loading) {
320
+ return (
321
+ <div className="flex items-center justify-center h-full">
322
+ <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
323
+ <span className="ml-2 text-sm text-muted-foreground">加载中...</span>
324
+ </div>
325
+ )
326
+ }
327
+
328
+ return (
329
+ <div className="flex flex-col h-full">
330
+ {/* 标题栏 */}
331
+ <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/30">
332
+ <div className="flex items-center gap-2">
333
+ {getFileIcon(fileName)}
334
+ <span className="text-sm font-medium">{filePath}</span>
335
+ {dirty && <Badge variant="secondary" className="text-[10px] px-1.5 py-0">未保存</Badge>}
336
+ </div>
337
+ <div className="flex items-center gap-1">
338
+ <Button size="sm" variant="ghost" onClick={onClose} title="关闭">
339
+ <X className="w-4 h-4" />
340
+ </Button>
341
+ </div>
342
+ </div>
343
+
344
+ {/* 消息提示 */}
345
+ {message && (
346
+ <Alert variant={message.type === 'error' ? 'destructive' : 'success'} className="mx-4 mt-2 py-2">
347
+ {message.type === 'error'
348
+ ? <AlertCircle className="h-4 w-4" />
349
+ : <CheckCircle className="h-4 w-4" />}
350
+ <AlertDescription>{message.text}</AlertDescription>
351
+ </Alert>
352
+ )}
353
+
354
+ {/* 编辑区 */}
355
+ <div className="flex-1 min-h-0">
356
+ <CodeEditor
357
+ value={content}
358
+ onChange={setContent}
359
+ language={getLanguage(fileName)}
360
+ />
361
+ </div>
362
+
363
+ {/* 底部操作栏 */}
364
+ <div className="flex items-center gap-2 px-4 py-2 border-t bg-muted/30">
365
+ <Button size="sm" onClick={handleSave} disabled={saving || !dirty}>
366
+ {saving
367
+ ? <><Loader2 className="w-4 h-4 mr-1 animate-spin" />保存中...</>
368
+ : <><Save className="w-4 h-4 mr-1" />保存</>}
369
+ </Button>
370
+ {dirty && (
371
+ <Button variant="outline" size="sm" onClick={() => setContent(originalContent)}>
372
+ 撤销更改
373
+ </Button>
374
+ )}
375
+ <span className="text-xs text-muted-foreground ml-auto">
376
+ {content.split('\n').length} 行 · Ctrl+S 保存
377
+ </span>
378
+ </div>
379
+ </div>
380
+ )
381
+ }
382
+
383
+ // ── 主页面组件 ──────────────────────────────────────────────────────
384
+
385
+ export default function FileMangePage() {
386
+ useHljsTheme()
387
+ const { tree, loading, error, loadTree, readFile, saveFile } = useFiles()
388
+ const [selectedFile, setSelectedFile] = useState<string | null>(null)
389
+
390
+ return (
391
+ <div className="space-y-4">
392
+ {/* 标题栏 */}
393
+ <div className="flex items-center justify-between">
394
+ <div>
395
+ <h1 className="text-2xl font-bold tracking-tight">文件管理</h1>
396
+ <p className="text-sm text-muted-foreground mt-1">
397
+ 浏览和编辑工作空间中的配置文件和源代码
398
+ </p>
399
+ </div>
400
+ <Button variant="outline" size="sm" onClick={() => loadTree()} disabled={loading}>
401
+ <RefreshCw className={`w-4 h-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
402
+ 刷新
403
+ </Button>
404
+ </div>
405
+
406
+ {/* 错误提示 */}
407
+ {error && (
408
+ <Alert variant="destructive" className="py-2">
409
+ <AlertCircle className="h-4 w-4" />
410
+ <AlertDescription>{error}</AlertDescription>
411
+ </Alert>
412
+ )}
413
+
414
+ {/* 主体区域:左侧文件树 + 右侧编辑器 */}
415
+ <Card className="overflow-hidden">
416
+ <CardContent className="p-0">
417
+ <div className="flex" style={{ height: '600px' }}>
418
+ {/* 文件树 */}
419
+ <div className="w-64 border-r flex flex-col shrink-0">
420
+ <div className="px-3 py-2 border-b bg-muted/30">
421
+ <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">文件浏览器</span>
422
+ </div>
423
+ <ScrollArea className="flex-1">
424
+ <div className="py-1">
425
+ {loading && tree.length === 0 ? (
426
+ <div className="flex items-center justify-center py-8">
427
+ <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
428
+ </div>
429
+ ) : tree.length === 0 ? (
430
+ <p className="text-sm text-muted-foreground text-center py-8">暂无文件</p>
431
+ ) : (
432
+ tree.map((node) => (
433
+ <TreeNode
434
+ key={node.path}
435
+ node={node}
436
+ selectedPath={selectedFile}
437
+ onSelect={setSelectedFile}
438
+ />
439
+ ))
440
+ )}
441
+ </div>
442
+ </ScrollArea>
443
+ </div>
444
+
445
+ {/* 编辑器 */}
446
+ <div className="flex-1 min-w-0">
447
+ {selectedFile ? (
448
+ <FileEditor
449
+ key={selectedFile}
450
+ filePath={selectedFile}
451
+ readFile={readFile}
452
+ saveFile={saveFile}
453
+ onClose={() => setSelectedFile(null)}
454
+ />
455
+ ) : (
456
+ <div className="flex items-center justify-center h-full text-muted-foreground">
457
+ <div className="text-center">
458
+ <FolderOpen className="w-12 h-12 mx-auto mb-3 opacity-30" />
459
+ <p className="text-sm">在左侧选择一个文件开始编辑</p>
460
+ <p className="text-xs mt-1 opacity-60">支持 .env、src/、package.json 等关键文件</p>
461
+ </div>
462
+ </div>
463
+ )}
464
+ </div>
465
+ </div>
466
+ </CardContent>
467
+ </Card>
468
+ </div>
469
+ )
470
+ }
@@ -23,7 +23,7 @@ interface LogStats {
23
23
  oldestTimestamp: string | null
24
24
  }
25
25
 
26
- export default function DashboardLogs() {
26
+ export default function LogsPage() {
27
27
  const [logs, setLogs] = useState<LogEntry[]>([])
28
28
  const [stats, setStats] = useState<LogStats | null>(null)
29
29
  const [loading, setLoading] = useState(true)
@@ -48,7 +48,7 @@ function getIcon(iconName: string): LucideIcon {
48
48
  return iconMap[iconName] || Package
49
49
  }
50
50
 
51
- export default function DashboardPluginDetail() {
51
+ export default function PluginDetailPage() {
52
52
  const { name } = useParams<{ name: string }>()
53
53
  const navigate = useNavigate()
54
54
  const [plugin, setPlugin] = useState<PluginDetail | null>(null)
@@ -43,7 +43,7 @@ function getIcon(iconName: string): LucideIcon {
43
43
  return iconMap[iconName] || Package
44
44
  }
45
45
 
46
- export default function DashboardPlugins() {
46
+ export default function PluginsPage() {
47
47
  const navigate = useNavigate()
48
48
  const [plugins, setPlugins] = useState<Plugin[]>([])
49
49
  const [loading, setLoading] = useState(true)