@zhin.js/console 1.0.50 → 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 +19 -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 +4262 -1
- package/dist/index.html +2 -2
- package/dist/radix-ui.js +1261 -1262
- package/dist/react-dom-client.js +2243 -2240
- package/dist/react-dom.js +15 -15
- package/dist/style.css +1 -3
- 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 +15 -16
- 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/index.js +0 -124
|
@@ -1,470 +0,0 @@
|
|
|
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, '&')
|
|
121
|
-
.replace(/</g, '<')
|
|
122
|
-
.replace(/>/g, '>')
|
|
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
|
-
}
|
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
import { useEffect, useState, useCallback } from 'react'
|
|
2
|
-
import { LogIn, QrCode, MessageSquare, MousePointer, Smartphone, AlertCircle } from 'lucide-react'
|
|
3
|
-
import { apiFetch } from '../utils/auth'
|
|
4
|
-
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card'
|
|
5
|
-
import { Badge } from '../components/ui/badge'
|
|
6
|
-
import { Button } from '../components/ui/button'
|
|
7
|
-
import { Input } from '../components/ui/input'
|
|
8
|
-
import { Alert, AlertDescription } from '../components/ui/alert'
|
|
9
|
-
import { Skeleton } from '../components/ui/skeleton'
|
|
10
|
-
import { Separator } from '../components/ui/separator'
|
|
11
|
-
|
|
12
|
-
interface PendingLoginTask {
|
|
13
|
-
id: string
|
|
14
|
-
adapter: string
|
|
15
|
-
botId: string
|
|
16
|
-
type: string
|
|
17
|
-
payload?: {
|
|
18
|
-
message?: string
|
|
19
|
-
image?: string
|
|
20
|
-
url?: string
|
|
21
|
-
[key: string]: unknown
|
|
22
|
-
}
|
|
23
|
-
createdAt: number
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const POLL_INTERVAL_MS = 2000
|
|
27
|
-
|
|
28
|
-
export default function LoginAssistPage() {
|
|
29
|
-
const [pending, setPending] = useState<PendingLoginTask[]>([])
|
|
30
|
-
const [loading, setLoading] = useState(true)
|
|
31
|
-
const [error, setError] = useState<string | null>(null)
|
|
32
|
-
const [submitting, setSubmitting] = useState<Record<string, boolean>>({})
|
|
33
|
-
const [inputValues, setInputValues] = useState<Record<string, string>>({})
|
|
34
|
-
|
|
35
|
-
const fetchPending = useCallback(async () => {
|
|
36
|
-
try {
|
|
37
|
-
const res = await apiFetch('/api/login-assist/pending')
|
|
38
|
-
if (!res.ok) throw new Error('获取待办失败')
|
|
39
|
-
const data = await res.json()
|
|
40
|
-
setPending(Array.isArray(data) ? data : [])
|
|
41
|
-
setError(null)
|
|
42
|
-
} catch (err) {
|
|
43
|
-
setError((err as Error).message)
|
|
44
|
-
} finally {
|
|
45
|
-
setLoading(false)
|
|
46
|
-
}
|
|
47
|
-
}, [])
|
|
48
|
-
|
|
49
|
-
useEffect(() => {
|
|
50
|
-
fetchPending()
|
|
51
|
-
const interval = setInterval(fetchPending, POLL_INTERVAL_MS)
|
|
52
|
-
return () => clearInterval(interval)
|
|
53
|
-
}, [fetchPending])
|
|
54
|
-
|
|
55
|
-
const handleSubmit = async (id: string, value: string | Record<string, unknown>) => {
|
|
56
|
-
setSubmitting((s) => ({ ...s, [id]: true }))
|
|
57
|
-
try {
|
|
58
|
-
const res = await apiFetch('/api/login-assist/submit', {
|
|
59
|
-
method: 'POST',
|
|
60
|
-
headers: { 'Content-Type': 'application/json' },
|
|
61
|
-
body: JSON.stringify({ id, value }),
|
|
62
|
-
})
|
|
63
|
-
if (!res.ok) throw new Error('提交失败')
|
|
64
|
-
setInputValues((v) => {
|
|
65
|
-
const next = { ...v }
|
|
66
|
-
delete next[id]
|
|
67
|
-
return next
|
|
68
|
-
})
|
|
69
|
-
await fetchPending()
|
|
70
|
-
} catch (err) {
|
|
71
|
-
setError((err as Error).message)
|
|
72
|
-
} finally {
|
|
73
|
-
setSubmitting((s) => ({ ...s, [id]: false }))
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const handleCancel = async (id: string) => {
|
|
78
|
-
try {
|
|
79
|
-
const res = await apiFetch('/api/login-assist/cancel', {
|
|
80
|
-
method: 'POST',
|
|
81
|
-
headers: { 'Content-Type': 'application/json' },
|
|
82
|
-
body: JSON.stringify({ id }),
|
|
83
|
-
})
|
|
84
|
-
if (res.ok) await fetchPending()
|
|
85
|
-
} catch {
|
|
86
|
-
// ignore
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const typeIcon: Record<string, React.ReactNode> = {
|
|
91
|
-
qrcode: <QrCode className="w-4 h-4" />,
|
|
92
|
-
sms: <MessageSquare className="w-4 h-4" />,
|
|
93
|
-
device: <Smartphone className="w-4 h-4" />,
|
|
94
|
-
slider: <MousePointer className="w-4 h-4" />,
|
|
95
|
-
}
|
|
96
|
-
const typeLabel: Record<string, string> = {
|
|
97
|
-
qrcode: '扫码登录',
|
|
98
|
-
sms: '短信验证码',
|
|
99
|
-
device: '设备验证',
|
|
100
|
-
slider: '滑块验证',
|
|
101
|
-
other: '其他',
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (loading && pending.length === 0) {
|
|
105
|
-
return (
|
|
106
|
-
<div className="space-y-6">
|
|
107
|
-
<Skeleton className="h-8 w-48" />
|
|
108
|
-
<Skeleton className="h-32 w-full" />
|
|
109
|
-
</div>
|
|
110
|
-
)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return (
|
|
114
|
-
<div className="space-y-6">
|
|
115
|
-
<div>
|
|
116
|
-
<h1 className="text-2xl font-bold tracking-tight">登录辅助</h1>
|
|
117
|
-
<p className="text-sm text-muted-foreground mt-1">
|
|
118
|
-
需要人为辅助登录的待办会出现在下方,在 Web 完成操作或刷新页面后仍可继续处理。
|
|
119
|
-
</p>
|
|
120
|
-
</div>
|
|
121
|
-
|
|
122
|
-
<Separator />
|
|
123
|
-
|
|
124
|
-
{error && (
|
|
125
|
-
<Alert variant="destructive">
|
|
126
|
-
<AlertCircle className="h-4 w-4" />
|
|
127
|
-
<AlertDescription>{error}</AlertDescription>
|
|
128
|
-
</Alert>
|
|
129
|
-
)}
|
|
130
|
-
|
|
131
|
-
{pending.length === 0 ? (
|
|
132
|
-
<Card>
|
|
133
|
-
<CardContent className="flex flex-col items-center gap-4 py-12">
|
|
134
|
-
<LogIn className="w-16 h-16 text-muted-foreground/30" />
|
|
135
|
-
<div className="text-center">
|
|
136
|
-
<h3 className="text-lg font-semibold">暂无待办</h3>
|
|
137
|
-
<p className="text-sm text-muted-foreground">当有机器人需要扫码、短信或滑块验证时,待办会显示在这里</p>
|
|
138
|
-
</div>
|
|
139
|
-
</CardContent>
|
|
140
|
-
</Card>
|
|
141
|
-
) : (
|
|
142
|
-
<div className="grid gap-4">
|
|
143
|
-
{pending.map((task) => (
|
|
144
|
-
<Card key={task.id}>
|
|
145
|
-
<CardHeader className="pb-2">
|
|
146
|
-
<div className="flex items-center justify-between">
|
|
147
|
-
<CardTitle className="text-base flex items-center gap-2">
|
|
148
|
-
<span className="shrink-0">{typeIcon[task.type] ?? <LogIn className="w-4 h-4" />}</span>
|
|
149
|
-
{typeLabel[task.type] ?? task.type}
|
|
150
|
-
</CardTitle>
|
|
151
|
-
<div className="flex items-center gap-2">
|
|
152
|
-
<Badge variant="outline">{task.adapter}</Badge>
|
|
153
|
-
<Badge variant="secondary">{task.botId}</Badge>
|
|
154
|
-
</div>
|
|
155
|
-
</div>
|
|
156
|
-
{task.payload?.message && (
|
|
157
|
-
<p className="text-sm text-muted-foreground mt-1">{task.payload.message}</p>
|
|
158
|
-
)}
|
|
159
|
-
</CardHeader>
|
|
160
|
-
<CardContent className="space-y-4">
|
|
161
|
-
{task.type === 'qrcode' && task.payload?.image && (
|
|
162
|
-
<div className="flex justify-center p-4 bg-muted/30 rounded-lg">
|
|
163
|
-
<img
|
|
164
|
-
src={task.payload.image}
|
|
165
|
-
alt="登录二维码"
|
|
166
|
-
className="max-w-[200px] w-full h-auto"
|
|
167
|
-
/>
|
|
168
|
-
</div>
|
|
169
|
-
)}
|
|
170
|
-
{task.type === 'slider' && task.payload?.url && (
|
|
171
|
-
<p className="text-sm break-all">
|
|
172
|
-
<span className="text-muted-foreground">滑块链接:</span>{' '}
|
|
173
|
-
<a href={task.payload.url} target="_blank" rel="noopener noreferrer" className="text-primary underline">
|
|
174
|
-
{task.payload.url}
|
|
175
|
-
</a>
|
|
176
|
-
</p>
|
|
177
|
-
)}
|
|
178
|
-
{(task.type === 'sms' || task.type === 'device' || task.type === 'slider') && (
|
|
179
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
180
|
-
<Input
|
|
181
|
-
placeholder={task.type === 'slider' ? '输入 ticket' : '输入验证码'}
|
|
182
|
-
className="max-w-xs"
|
|
183
|
-
value={inputValues[task.id] ?? ''}
|
|
184
|
-
onChange={(e) => setInputValues((v) => ({ ...v, [task.id]: e.target.value }))}
|
|
185
|
-
onKeyDown={(e) => {
|
|
186
|
-
if (e.key === 'Enter') {
|
|
187
|
-
const val = inputValues[task.id]?.trim()
|
|
188
|
-
if (val) handleSubmit(task.id, task.type === 'slider' ? { ticket: val } : val)
|
|
189
|
-
}
|
|
190
|
-
}}
|
|
191
|
-
/>
|
|
192
|
-
<Button
|
|
193
|
-
size="sm"
|
|
194
|
-
disabled={submitting[task.id] || !(inputValues[task.id]?.trim())}
|
|
195
|
-
onClick={() => {
|
|
196
|
-
const val = inputValues[task.id]?.trim()
|
|
197
|
-
if (val) handleSubmit(task.id, task.type === 'slider' ? { ticket: val } : val)
|
|
198
|
-
}}
|
|
199
|
-
>
|
|
200
|
-
{submitting[task.id] ? '提交中…' : '提交'}
|
|
201
|
-
</Button>
|
|
202
|
-
</div>
|
|
203
|
-
)}
|
|
204
|
-
{task.type === 'qrcode' && (
|
|
205
|
-
<div className="flex gap-2">
|
|
206
|
-
<Button
|
|
207
|
-
size="sm"
|
|
208
|
-
disabled={submitting[task.id]}
|
|
209
|
-
onClick={() => handleSubmit(task.id, { done: true })}
|
|
210
|
-
>
|
|
211
|
-
{submitting[task.id] ? '提交中…' : '我已扫码'}
|
|
212
|
-
</Button>
|
|
213
|
-
<Button size="sm" variant="outline" onClick={() => handleCancel(task.id)}>
|
|
214
|
-
取消
|
|
215
|
-
</Button>
|
|
216
|
-
</div>
|
|
217
|
-
)}
|
|
218
|
-
</CardContent>
|
|
219
|
-
</Card>
|
|
220
|
-
))}
|
|
221
|
-
</div>
|
|
222
|
-
)}
|
|
223
|
-
</div>
|
|
224
|
-
)
|
|
225
|
-
}
|