@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
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { Save, Loader2, AlertCircle, CheckCircle, X } from 'lucide-react'
|
|
3
|
+
import { Badge } from '../../components/ui/badge'
|
|
4
|
+
import { Button } from '../../components/ui/button'
|
|
5
|
+
import { Alert, AlertDescription } from '../../components/ui/alert'
|
|
6
|
+
import { CodeEditor } from './code-editor'
|
|
7
|
+
import { getFileIcon } from './file-icons'
|
|
8
|
+
import { getLanguage } from './language'
|
|
9
|
+
|
|
10
|
+
export function FileEditor({
|
|
11
|
+
filePath,
|
|
12
|
+
readFile,
|
|
13
|
+
saveFile,
|
|
14
|
+
onClose,
|
|
15
|
+
}: {
|
|
16
|
+
filePath: string
|
|
17
|
+
readFile: (path: string) => Promise<string>
|
|
18
|
+
saveFile: (path: string, content: string) => Promise<unknown>
|
|
19
|
+
onClose: () => void
|
|
20
|
+
}) {
|
|
21
|
+
const [content, setContent] = useState('')
|
|
22
|
+
const [originalContent, setOriginalContent] = useState('')
|
|
23
|
+
const [loading, setLoading] = useState(true)
|
|
24
|
+
const [saving, setSaving] = useState(false)
|
|
25
|
+
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
|
26
|
+
|
|
27
|
+
const loadContent = useCallback(async () => {
|
|
28
|
+
setLoading(true)
|
|
29
|
+
setMessage(null)
|
|
30
|
+
try {
|
|
31
|
+
const text = await readFile(filePath)
|
|
32
|
+
setContent(text)
|
|
33
|
+
setOriginalContent(text)
|
|
34
|
+
} catch (err) {
|
|
35
|
+
setMessage({ type: 'error', text: `加载失败: ${err instanceof Error ? err.message : '未知错误'}` })
|
|
36
|
+
} finally {
|
|
37
|
+
setLoading(false)
|
|
38
|
+
}
|
|
39
|
+
}, [filePath, readFile])
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
loadContent()
|
|
43
|
+
}, [loadContent])
|
|
44
|
+
|
|
45
|
+
const handleSave = useCallback(async () => {
|
|
46
|
+
setSaving(true)
|
|
47
|
+
setMessage(null)
|
|
48
|
+
try {
|
|
49
|
+
await saveFile(filePath, content)
|
|
50
|
+
setOriginalContent(content)
|
|
51
|
+
setMessage({ type: 'success', text: '已保存' })
|
|
52
|
+
setTimeout(() => setMessage(null), 3000)
|
|
53
|
+
} catch (err) {
|
|
54
|
+
setMessage({ type: 'error', text: `保存失败: ${err instanceof Error ? err.message : '未知错误'}` })
|
|
55
|
+
} finally {
|
|
56
|
+
setSaving(false)
|
|
57
|
+
}
|
|
58
|
+
}, [filePath, content, saveFile])
|
|
59
|
+
|
|
60
|
+
const dirty = content !== originalContent
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
64
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
65
|
+
e.preventDefault()
|
|
66
|
+
if (dirty && !saving) void handleSave()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
window.addEventListener('keydown', handleKeyDown)
|
|
70
|
+
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
71
|
+
}, [dirty, saving, handleSave])
|
|
72
|
+
|
|
73
|
+
const fileName = filePath.split('/').pop() || filePath
|
|
74
|
+
|
|
75
|
+
if (loading) {
|
|
76
|
+
return (
|
|
77
|
+
<div className="flex items-center justify-center h-full">
|
|
78
|
+
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
|
79
|
+
<span className="ml-2 text-sm text-muted-foreground">加载中...</span>
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="flex flex-col h-full">
|
|
86
|
+
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/30">
|
|
87
|
+
<div className="flex items-center gap-2">
|
|
88
|
+
{getFileIcon(fileName)}
|
|
89
|
+
<span className="text-sm font-medium">{filePath}</span>
|
|
90
|
+
{dirty && <Badge variant="secondary" className="text-[10px] px-1.5 py-0">未保存</Badge>}
|
|
91
|
+
</div>
|
|
92
|
+
<div className="flex items-center gap-1">
|
|
93
|
+
<Button size="sm" variant="ghost" onClick={onClose} title="关闭">
|
|
94
|
+
<X className="w-4 h-4" />
|
|
95
|
+
</Button>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{message && (
|
|
100
|
+
<Alert variant={message.type === 'error' ? 'destructive' : 'success'} className="mx-4 mt-2 py-2">
|
|
101
|
+
{message.type === 'error'
|
|
102
|
+
? <AlertCircle className="h-4 w-4" />
|
|
103
|
+
: <CheckCircle className="h-4 w-4" />}
|
|
104
|
+
<AlertDescription>{message.text}</AlertDescription>
|
|
105
|
+
</Alert>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
<div className="flex-1 min-h-0">
|
|
109
|
+
<CodeEditor
|
|
110
|
+
value={content}
|
|
111
|
+
onChange={setContent}
|
|
112
|
+
language={getLanguage(fileName)}
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<div className="flex items-center gap-2 px-4 py-2 border-t bg-muted/30">
|
|
117
|
+
<Button size="sm" onClick={() => void handleSave()} disabled={saving || !dirty}>
|
|
118
|
+
{saving
|
|
119
|
+
? <><Loader2 className="w-4 h-4 mr-1 animate-spin" />保存中...</>
|
|
120
|
+
: <><Save className="w-4 h-4 mr-1" />保存</>}
|
|
121
|
+
</Button>
|
|
122
|
+
{dirty && (
|
|
123
|
+
<Button variant="outline" size="sm" onClick={() => setContent(originalContent)}>
|
|
124
|
+
撤销更改
|
|
125
|
+
</Button>
|
|
126
|
+
)}
|
|
127
|
+
<span className="text-xs text-muted-foreground ml-auto">
|
|
128
|
+
{content.split('\n').length} 行 · Ctrl+S 保存
|
|
129
|
+
</span>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { File, FileCode } from 'lucide-react'
|
|
2
|
+
|
|
3
|
+
export function getFileIcon(name: string) {
|
|
4
|
+
const ext = name.split('.').pop()?.toLowerCase()
|
|
5
|
+
switch (ext) {
|
|
6
|
+
case 'ts':
|
|
7
|
+
case 'tsx':
|
|
8
|
+
return <FileCode className="w-4 h-4 text-blue-500" />
|
|
9
|
+
case 'js':
|
|
10
|
+
case 'jsx':
|
|
11
|
+
return <FileCode className="w-4 h-4 text-yellow-500" />
|
|
12
|
+
case 'json':
|
|
13
|
+
return <File className="w-4 h-4 text-green-500" />
|
|
14
|
+
case 'yml':
|
|
15
|
+
case 'yaml':
|
|
16
|
+
return <File className="w-4 h-4 text-red-400" />
|
|
17
|
+
case 'md':
|
|
18
|
+
return <File className="w-4 h-4 text-gray-400" />
|
|
19
|
+
case 'env':
|
|
20
|
+
return <File className="w-4 h-4 text-orange-500" />
|
|
21
|
+
default:
|
|
22
|
+
if (name.startsWith('.env')) return <File className="w-4 h-4 text-orange-500" />
|
|
23
|
+
return <File className="w-4 h-4 text-muted-foreground" />
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useFiles } from '@zhin.js/client'
|
|
3
|
+
import { FolderOpen, Loader2, RefreshCw, AlertCircle } 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 { ScrollArea } from '../../components/ui/scroll-area'
|
|
8
|
+
import { useHljsTheme } from './use-hljs-theme'
|
|
9
|
+
import { TreeNode } from './tree-node'
|
|
10
|
+
import { FileEditor } from './file-editor'
|
|
11
|
+
|
|
12
|
+
export default function FileManagePage() {
|
|
13
|
+
useHljsTheme()
|
|
14
|
+
const { tree, loading, error, loadTree, readFile, saveFile } = useFiles()
|
|
15
|
+
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className="space-y-4">
|
|
19
|
+
<div className="flex items-center justify-between">
|
|
20
|
+
<div>
|
|
21
|
+
<h1 className="text-2xl font-bold tracking-tight">文件管理</h1>
|
|
22
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
23
|
+
浏览和编辑工作空间中的配置文件和源代码
|
|
24
|
+
</p>
|
|
25
|
+
</div>
|
|
26
|
+
<Button variant="outline" size="sm" onClick={() => loadTree()} disabled={loading}>
|
|
27
|
+
<RefreshCw className={`w-4 h-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
|
|
28
|
+
刷新
|
|
29
|
+
</Button>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
{error && (
|
|
33
|
+
<Alert variant="destructive" className="py-2">
|
|
34
|
+
<AlertCircle className="h-4 w-4" />
|
|
35
|
+
<AlertDescription>{error}</AlertDescription>
|
|
36
|
+
</Alert>
|
|
37
|
+
)}
|
|
38
|
+
|
|
39
|
+
<Card className="overflow-hidden">
|
|
40
|
+
<CardContent className="p-0">
|
|
41
|
+
<div className="flex" style={{ height: '600px' }}>
|
|
42
|
+
<div className="w-64 border-r flex flex-col shrink-0">
|
|
43
|
+
<div className="px-3 py-2 border-b bg-muted/30">
|
|
44
|
+
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">文件浏览器</span>
|
|
45
|
+
</div>
|
|
46
|
+
<ScrollArea className="flex-1">
|
|
47
|
+
<div className="py-1">
|
|
48
|
+
{loading && tree.length === 0 ? (
|
|
49
|
+
<div className="flex items-center justify-center py-8">
|
|
50
|
+
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
|
51
|
+
</div>
|
|
52
|
+
) : tree.length === 0 ? (
|
|
53
|
+
<p className="text-sm text-muted-foreground text-center py-8">暂无文件</p>
|
|
54
|
+
) : (
|
|
55
|
+
tree.map((node) => (
|
|
56
|
+
<TreeNode
|
|
57
|
+
key={node.path}
|
|
58
|
+
node={node}
|
|
59
|
+
selectedPath={selectedFile}
|
|
60
|
+
onSelect={setSelectedFile}
|
|
61
|
+
/>
|
|
62
|
+
))
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
</ScrollArea>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div className="flex-1 min-w-0">
|
|
69
|
+
{selectedFile ? (
|
|
70
|
+
<FileEditor
|
|
71
|
+
key={selectedFile}
|
|
72
|
+
filePath={selectedFile}
|
|
73
|
+
readFile={readFile}
|
|
74
|
+
saveFile={saveFile}
|
|
75
|
+
onClose={() => setSelectedFile(null)}
|
|
76
|
+
/>
|
|
77
|
+
) : (
|
|
78
|
+
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
79
|
+
<div className="text-center">
|
|
80
|
+
<FolderOpen className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
|
81
|
+
<p className="text-sm">在左侧选择一个文件开始编辑</p>
|
|
82
|
+
<p className="text-xs mt-1 opacity-60">支持 .env、src/、package.json 等关键文件</p>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</CardContent>
|
|
89
|
+
</Card>
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './files-page'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function getLanguage(fileName: string): string | null {
|
|
2
|
+
const name = fileName.split('/').pop()?.toLowerCase() || ''
|
|
3
|
+
if (name === '.env' || name.startsWith('.env.')) return 'ini'
|
|
4
|
+
const ext = name.split('.').pop()?.toLowerCase()
|
|
5
|
+
switch (ext) {
|
|
6
|
+
case 'ts': case 'tsx': return 'typescript'
|
|
7
|
+
case 'js': case 'jsx': return 'javascript'
|
|
8
|
+
case 'css': return 'css'
|
|
9
|
+
case 'scss': return 'scss'
|
|
10
|
+
case 'less': return 'less'
|
|
11
|
+
case 'json': return 'json'
|
|
12
|
+
case 'yml': case 'yaml': return 'yaml'
|
|
13
|
+
case 'md': return 'markdown'
|
|
14
|
+
case 'xml': case 'html': return 'xml'
|
|
15
|
+
case 'sh': case 'bash': return 'bash'
|
|
16
|
+
default: return null
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import type { FileTreeNode } from '@zhin.js/client'
|
|
3
|
+
import { FolderOpen, ChevronRight, ChevronDown } from 'lucide-react'
|
|
4
|
+
import { getFileIcon } from './file-icons'
|
|
5
|
+
|
|
6
|
+
export function TreeNode({
|
|
7
|
+
node,
|
|
8
|
+
selectedPath,
|
|
9
|
+
onSelect,
|
|
10
|
+
depth = 0,
|
|
11
|
+
}: {
|
|
12
|
+
node: FileTreeNode
|
|
13
|
+
selectedPath: string | null
|
|
14
|
+
onSelect: (path: string) => void
|
|
15
|
+
depth?: number
|
|
16
|
+
}) {
|
|
17
|
+
const [expanded, setExpanded] = useState(depth < 1)
|
|
18
|
+
const isSelected = node.path === selectedPath
|
|
19
|
+
const isDir = node.type === 'directory'
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div>
|
|
23
|
+
<button
|
|
24
|
+
className={`
|
|
25
|
+
w-full flex items-center gap-1.5 px-2 py-1 text-sm rounded-sm text-left
|
|
26
|
+
hover:bg-accent transition-colors
|
|
27
|
+
${isSelected ? 'bg-accent text-accent-foreground font-medium' : ''}
|
|
28
|
+
`}
|
|
29
|
+
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
|
30
|
+
onClick={() => {
|
|
31
|
+
if (isDir) {
|
|
32
|
+
setExpanded(!expanded)
|
|
33
|
+
} else {
|
|
34
|
+
onSelect(node.path)
|
|
35
|
+
}
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
{isDir ? (
|
|
39
|
+
expanded ? (
|
|
40
|
+
<ChevronDown className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
|
41
|
+
) : (
|
|
42
|
+
<ChevronRight className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
|
43
|
+
)
|
|
44
|
+
) : (
|
|
45
|
+
<span className="w-3.5" />
|
|
46
|
+
)}
|
|
47
|
+
{isDir ? (
|
|
48
|
+
<FolderOpen className="w-4 h-4 shrink-0 text-amber-500" />
|
|
49
|
+
) : (
|
|
50
|
+
getFileIcon(node.name)
|
|
51
|
+
)}
|
|
52
|
+
<span className="truncate">{node.name}</span>
|
|
53
|
+
</button>
|
|
54
|
+
{isDir && expanded && node.children && (
|
|
55
|
+
<div>
|
|
56
|
+
{node.children.map((child) => (
|
|
57
|
+
<TreeNode
|
|
58
|
+
key={child.path}
|
|
59
|
+
node={child}
|
|
60
|
+
selectedPath={selectedPath}
|
|
61
|
+
onSelect={onSelect}
|
|
62
|
+
depth={depth + 1}
|
|
63
|
+
/>
|
|
64
|
+
))}
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { HLJS_CDN } from './editor-constants'
|
|
3
|
+
|
|
4
|
+
export function useHljsTheme() {
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
const linkId = 'hljs-theme-css'
|
|
7
|
+
let link = document.getElementById(linkId) as HTMLLinkElement | null
|
|
8
|
+
if (!link) {
|
|
9
|
+
link = document.createElement('link')
|
|
10
|
+
link.id = linkId
|
|
11
|
+
link.rel = 'stylesheet'
|
|
12
|
+
document.head.appendChild(link)
|
|
13
|
+
}
|
|
14
|
+
const update = () => {
|
|
15
|
+
const isDark = document.documentElement.classList.contains('dark')
|
|
16
|
+
link!.href = `${HLJS_CDN}/${isDark ? 'github-dark' : 'github'}.min.css`
|
|
17
|
+
}
|
|
18
|
+
update()
|
|
19
|
+
const obs = new MutationObserver(update)
|
|
20
|
+
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
|
21
|
+
return () => obs.disconnect()
|
|
22
|
+
}, [])
|
|
23
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useEffect, useState, useRef } from 'react'
|
|
2
|
-
import { Info, AlertTriangle, XCircle, Circle, Trash2, RefreshCw, FileText, AlertCircle } from 'lucide-react'
|
|
1
|
+
import { useEffect, useState, useRef, useMemo } from 'react'
|
|
2
|
+
import { Info, AlertTriangle, XCircle, Circle, Trash2, RefreshCw, FileText, AlertCircle, Copy, Search } from 'lucide-react'
|
|
3
3
|
import { apiFetch } from '../utils/auth'
|
|
4
4
|
import { Card, CardContent } from '../components/ui/card'
|
|
5
5
|
import { Badge } from '../components/ui/badge'
|
|
@@ -9,6 +9,7 @@ import { Skeleton } from '../components/ui/skeleton'
|
|
|
9
9
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select'
|
|
10
10
|
import { Checkbox } from '../components/ui/checkbox'
|
|
11
11
|
import { cn } from '@zhin.js/client'
|
|
12
|
+
import { PageHeader } from '../components/PageHeader'
|
|
12
13
|
|
|
13
14
|
interface LogEntry {
|
|
14
15
|
level: 'info' | 'warn' | 'error'
|
|
@@ -31,7 +32,19 @@ export default function LogsPage() {
|
|
|
31
32
|
const [levelFilter, setLevelFilter] = useState<string>('all')
|
|
32
33
|
const [autoScroll, setAutoScroll] = useState(true)
|
|
33
34
|
const logsEndRef = useRef<HTMLDivElement>(null)
|
|
34
|
-
const
|
|
35
|
+
const prevRawLogCountRef = useRef(0)
|
|
36
|
+
const [textFilter, setTextFilter] = useState('')
|
|
37
|
+
|
|
38
|
+
const filteredLogs = useMemo(() => {
|
|
39
|
+
const q = textFilter.trim().toLowerCase()
|
|
40
|
+
if (!q) return logs
|
|
41
|
+
return logs.filter(
|
|
42
|
+
(l) =>
|
|
43
|
+
l.message.toLowerCase().includes(q) ||
|
|
44
|
+
l.source.toLowerCase().includes(q) ||
|
|
45
|
+
l.level.toLowerCase().includes(q),
|
|
46
|
+
)
|
|
47
|
+
}, [logs, textFilter])
|
|
35
48
|
|
|
36
49
|
useEffect(() => {
|
|
37
50
|
fetchLogs()
|
|
@@ -41,11 +54,11 @@ export default function LogsPage() {
|
|
|
41
54
|
}, [levelFilter])
|
|
42
55
|
|
|
43
56
|
useEffect(() => {
|
|
44
|
-
if (autoScroll && logs.length >
|
|
57
|
+
if (autoScroll && logs.length > prevRawLogCountRef.current) {
|
|
45
58
|
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
46
59
|
}
|
|
47
|
-
|
|
48
|
-
}, [logs, autoScroll])
|
|
60
|
+
prevRawLogCountRef.current = logs.length
|
|
61
|
+
}, [logs.length, autoScroll])
|
|
49
62
|
|
|
50
63
|
const fetchLogs = async () => {
|
|
51
64
|
try {
|
|
@@ -105,13 +118,20 @@ export default function LogsPage() {
|
|
|
105
118
|
)
|
|
106
119
|
}
|
|
107
120
|
|
|
121
|
+
const copyLine = async (text: string) => {
|
|
122
|
+
try {
|
|
123
|
+
await navigator.clipboard.writeText(text)
|
|
124
|
+
} catch {
|
|
125
|
+
// ignore
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
108
129
|
return (
|
|
109
|
-
<div className="space-y-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
</div>
|
|
130
|
+
<div className="space-y-6">
|
|
131
|
+
<PageHeader
|
|
132
|
+
title="系统日志"
|
|
133
|
+
description="实时查看与筛选系统运行日志;支持按级别与关键字过滤。"
|
|
134
|
+
/>
|
|
115
135
|
|
|
116
136
|
{/* Stats */}
|
|
117
137
|
{stats && (
|
|
@@ -144,9 +164,19 @@ export default function LogsPage() {
|
|
|
144
164
|
)}
|
|
145
165
|
|
|
146
166
|
{/* Toolbar */}
|
|
147
|
-
<Card>
|
|
167
|
+
<Card className="border-border/80 shadow-sm">
|
|
148
168
|
<CardContent className="flex justify-between items-center p-3 flex-wrap gap-3">
|
|
149
169
|
<div className="flex items-center gap-3 flex-wrap">
|
|
170
|
+
<div className="relative flex-1 min-w-[180px] max-w-xs">
|
|
171
|
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
|
172
|
+
<input
|
|
173
|
+
type="search"
|
|
174
|
+
placeholder="搜索消息、来源…"
|
|
175
|
+
value={textFilter}
|
|
176
|
+
onChange={(e) => setTextFilter(e.target.value)}
|
|
177
|
+
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
150
180
|
<Select value={levelFilter} onValueChange={setLevelFilter}>
|
|
151
181
|
<SelectTrigger className="w-32">
|
|
152
182
|
<SelectValue placeholder="所有级别" />
|
|
@@ -184,9 +214,9 @@ export default function LogsPage() {
|
|
|
184
214
|
</Card>
|
|
185
215
|
|
|
186
216
|
{/* Logs */}
|
|
187
|
-
<Card>
|
|
217
|
+
<Card className="border-border/80 shadow-sm">
|
|
188
218
|
<CardContent className="p-4">
|
|
189
|
-
<div className="max-h-[
|
|
219
|
+
<div className="max-h-[min(70vh,720px)] overflow-y-auto rounded-md border border-border/60 bg-muted/20 dark:bg-muted/30 p-2 space-y-1.5">
|
|
190
220
|
{error ? (
|
|
191
221
|
<Alert variant="destructive">
|
|
192
222
|
<AlertCircle className="h-4 w-4" />
|
|
@@ -197,24 +227,49 @@ export default function LogsPage() {
|
|
|
197
227
|
<FileText className="w-12 h-12 text-muted-foreground/30" />
|
|
198
228
|
<span className="text-sm text-muted-foreground">暂无日志</span>
|
|
199
229
|
</div>
|
|
230
|
+
) : filteredLogs.length === 0 ? (
|
|
231
|
+
<div className="flex flex-col items-center gap-2 py-10 text-sm text-muted-foreground">
|
|
232
|
+
没有符合「{textFilter}」的条目,请调整筛选条件
|
|
233
|
+
</div>
|
|
200
234
|
) : (
|
|
201
|
-
|
|
235
|
+
filteredLogs.map((log, index) => {
|
|
202
236
|
const style = getLevelStyle(log.level)
|
|
237
|
+
const lineText = `[${log.timestamp}] [${log.level}] ${log.source ? log.source + ' ' : ''}${log.message}`
|
|
203
238
|
return (
|
|
204
239
|
<div
|
|
205
240
|
key={`${log.timestamp}-${index}`}
|
|
206
|
-
className={cn(
|
|
241
|
+
className={cn(
|
|
242
|
+
'group relative p-3 rounded-md border border-border/50 bg-card/80 dark:bg-card/60 shadow-sm',
|
|
243
|
+
'border-l-[3px]',
|
|
244
|
+
style.border,
|
|
245
|
+
)}
|
|
207
246
|
>
|
|
208
|
-
<div className="flex items-center gap-2 mb-1">
|
|
209
|
-
<Badge variant={style.badge} className="gap-1 text-[10px] px-1.5 py-0">
|
|
247
|
+
<div className="flex items-center gap-2 mb-1 flex-wrap pr-8">
|
|
248
|
+
<Badge variant={style.badge} className="gap-1 text-[10px] px-1.5 py-0 font-medium">
|
|
210
249
|
{style.icon} {log.level.toUpperCase()}
|
|
211
250
|
</Badge>
|
|
212
|
-
<span className="text-[11px] text-muted-foreground">
|
|
251
|
+
<span className="text-[11px] tabular-nums text-muted-foreground">
|
|
213
252
|
{new Date(log.timestamp).toLocaleString()}
|
|
214
253
|
</span>
|
|
215
|
-
{log.source &&
|
|
254
|
+
{log.source && (
|
|
255
|
+
<Badge variant="outline" className="text-[10px] px-1.5 py-0 font-normal">
|
|
256
|
+
{log.source}
|
|
257
|
+
</Badge>
|
|
258
|
+
)}
|
|
216
259
|
</div>
|
|
217
|
-
<p className="text-
|
|
260
|
+
<p className="text-[13px] leading-relaxed font-mono text-foreground/95 whitespace-pre-wrap break-words pr-2">
|
|
261
|
+
{log.message}
|
|
262
|
+
</p>
|
|
263
|
+
<Button
|
|
264
|
+
type="button"
|
|
265
|
+
variant="ghost"
|
|
266
|
+
size="icon"
|
|
267
|
+
className="absolute top-2 right-2 h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
268
|
+
title="复制本行"
|
|
269
|
+
onClick={() => void copyLine(lineText)}
|
|
270
|
+
>
|
|
271
|
+
<Copy className="w-3.5 h-3.5" />
|
|
272
|
+
</Button>
|
|
218
273
|
</div>
|
|
219
274
|
)
|
|
220
275
|
})
|