@zhin.js/console 1.0.59 → 2.0.2

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.
@@ -1,12 +1,17 @@
1
1
  import { useEffect, useState } from 'react'
2
2
  import { useNavigate } from 'react-router'
3
- import { Bot, AlertCircle, Activity, Package, Clock, Cpu, MemoryStick, FileText, TrendingUp } from 'lucide-react'
3
+ import { Bot, AlertCircle, Activity, Package, Clock, Cpu, MemoryStick, FileText, TrendingUp, RotateCw } from 'lucide-react'
4
4
  import { apiFetch } from '../utils/auth'
5
+ import { useWebSocket } from '@zhin.js/client'
5
6
  import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../components/ui/card'
6
7
  import { Badge } from '../components/ui/badge'
7
8
  import { Button } from '../components/ui/button'
8
9
  import { Alert, AlertDescription } from '../components/ui/alert'
9
10
  import { Skeleton } from '../components/ui/skeleton'
11
+ import {
12
+ Dialog, DialogContent, DialogHeader, DialogFooter,
13
+ DialogTitle, DialogDescription, DialogClose,
14
+ } from '../components/ui/dialog'
10
15
 
11
16
  interface Stats {
12
17
  plugins: { total: number; active: number }
@@ -31,6 +36,9 @@ export default function HomePage() {
31
36
  const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null)
32
37
  const [loading, setLoading] = useState(true)
33
38
  const [error, setError] = useState<string | null>(null)
39
+ const [restartDialogOpen, setRestartDialogOpen] = useState(false)
40
+ const [restarting, setRestarting] = useState(false)
41
+ const { sendRequest } = useWebSocket()
34
42
 
35
43
  useEffect(() => {
36
44
  fetchData()
@@ -68,6 +76,19 @@ export default function HomePage() {
68
76
 
69
77
  const formatMemory = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(2)} MB`
70
78
 
79
+ const handleRestart = async () => {
80
+ setRestarting(true)
81
+ try {
82
+ await sendRequest({ type: 'system:restart' })
83
+ } catch {
84
+ // 连接断开是预期行为(进程重启中)
85
+ }
86
+ // 显示重启中状态,然后自动刷新页面
87
+ setTimeout(() => {
88
+ window.location.reload()
89
+ }, 3000)
90
+ }
91
+
71
92
  if (loading) {
72
93
  return (
73
94
  <div className="space-y-6">
@@ -223,9 +244,47 @@ export default function HomePage() {
223
244
  <span className="text-xs text-muted-foreground">查看运行日志</span>
224
245
  </div>
225
246
  </Button>
247
+ <Button
248
+ variant="outline"
249
+ className="h-auto p-4 justify-start border-orange-200 hover:border-orange-400 hover:bg-orange-50 dark:border-orange-800 dark:hover:border-orange-600 dark:hover:bg-orange-950"
250
+ onClick={() => setRestartDialogOpen(true)}
251
+ disabled={restarting}
252
+ >
253
+ <div className="flex flex-col items-start gap-1">
254
+ <RotateCw className={`h-5 w-5 mb-1 text-orange-500 ${restarting ? 'animate-spin' : ''}`} />
255
+ <span className="font-medium">{restarting ? '重启中...' : '重启服务'}</span>
256
+ <span className="text-xs text-muted-foreground">{restarting ? '请稍候,页面将自动刷新' : '重启机器人进程'}</span>
257
+ </div>
258
+ </Button>
226
259
  </div>
227
260
  </CardContent>
228
261
  </Card>
262
+
263
+ {/* Restart confirmation dialog */}
264
+ <Dialog open={restartDialogOpen} onOpenChange={setRestartDialogOpen}>
265
+ <DialogContent>
266
+ <DialogHeader>
267
+ <DialogTitle>确认重启</DialogTitle>
268
+ <DialogDescription>
269
+ 确定要重启机器人进程吗?所有连接将临时断开,进行中的对话会被中断。进程将在几秒内自动恢复。
270
+ </DialogDescription>
271
+ </DialogHeader>
272
+ <DialogFooter>
273
+ <DialogClose asChild>
274
+ <Button variant="outline">取消</Button>
275
+ </DialogClose>
276
+ <Button
277
+ variant="destructive"
278
+ onClick={() => {
279
+ setRestartDialogOpen(false)
280
+ handleRestart()
281
+ }}
282
+ >
283
+ 确认重启
284
+ </Button>
285
+ </DialogFooter>
286
+ </DialogContent>
287
+ </Dialog>
229
288
  </div>
230
289
  )
231
290
  }
@@ -0,0 +1,464 @@
1
+ import { useEffect, useState, useMemo, useCallback } from 'react'
2
+ import {
3
+ Search, Package, Download, ExternalLink, AlertCircle,
4
+ ArrowUpDown, RefreshCw, ShieldCheck, Globe,
5
+ type LucideIcon,
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 { Input } from '../components/ui/input'
11
+ import { Alert, AlertDescription } from '../components/ui/alert'
12
+ import { Skeleton } from '../components/ui/skeleton'
13
+ import { Separator } from '../components/ui/separator'
14
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/tabs'
15
+ import {
16
+ Dialog, DialogContent, DialogHeader, DialogFooter,
17
+ DialogTitle, DialogDescription, DialogClose,
18
+ } from '../components/ui/dialog'
19
+
20
+ interface MarketPlugin {
21
+ name: string
22
+ version: string
23
+ description: string
24
+ author: string
25
+ isOfficial: boolean
26
+ keywords: string[]
27
+ npm: string
28
+ date: string
29
+ }
30
+
31
+ interface PluginDetail {
32
+ name: string
33
+ version: string
34
+ description: string
35
+ license: string
36
+ author: string
37
+ homepage: string
38
+ repository: string
39
+ keywords: string[]
40
+ engines: Record<string, string>
41
+ peerDependencies: Record<string, string>
42
+ downloads: { weekly: number; monthly: number }
43
+ readme: string
44
+ versions: string[]
45
+ lastPublish: string
46
+ }
47
+
48
+ interface UpdateInfo {
49
+ name: string
50
+ current: string
51
+ latest: string
52
+ }
53
+
54
+ type SortKey = 'name' | 'date'
55
+ type Category = '' | 'adapter' | 'service' | 'util' | 'game' | 'feature'
56
+
57
+ const CATEGORIES: { value: Category; label: string; icon: LucideIcon }[] = [
58
+ { value: '', label: '全部', icon: Package },
59
+ { value: 'adapter', label: '适配器', icon: Globe },
60
+ { value: 'service', label: '服务', icon: ShieldCheck },
61
+ { value: 'util', label: '工具', icon: Package },
62
+ { value: 'game', label: '游戏', icon: Package },
63
+ { value: 'feature', label: '特性', icon: Package },
64
+ ]
65
+
66
+ function formatDate(dateStr: string) {
67
+ if (!dateStr) return ''
68
+ return new Date(dateStr).toLocaleDateString('zh-CN')
69
+ }
70
+
71
+ function formatDownloads(n: number): string {
72
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
73
+ return String(n)
74
+ }
75
+
76
+ export default function MarketplacePage() {
77
+ const [plugins, setPlugins] = useState<MarketPlugin[]>([])
78
+ const [loading, setLoading] = useState(true)
79
+ const [error, setError] = useState<string | null>(null)
80
+ const [search, setSearch] = useState('')
81
+ const [category, setCategory] = useState<Category>('')
82
+ const [officialOnly, setOfficialOnly] = useState(false)
83
+ const [sortKey, setSortKey] = useState<SortKey>('name')
84
+ const [updates, setUpdates] = useState<UpdateInfo[]>([])
85
+ const [updatesLoading, setUpdatesLoading] = useState(false)
86
+
87
+ // Detail dialog
88
+ const [detailOpen, setDetailOpen] = useState(false)
89
+ const [detailLoading, setDetailLoading] = useState(false)
90
+ const [detail, setDetail] = useState<PluginDetail | null>(null)
91
+
92
+ const fetchPlugins = useCallback(async () => {
93
+ setLoading(true)
94
+ setError(null)
95
+ try {
96
+ const params = new URLSearchParams()
97
+ if (search) params.set('q', search)
98
+ if (category) params.set('category', category)
99
+ if (officialOnly) params.set('official', 'true')
100
+ params.set('limit', '50')
101
+
102
+ const res = await fetch(`/pub/marketplace/search?${params}`)
103
+ if (!res.ok) throw new Error('搜索失败')
104
+ const data = await res.json()
105
+ if (data.success) {
106
+ setPlugins(data.data)
107
+ } else {
108
+ throw new Error(data.error || '数据格式错误')
109
+ }
110
+ } catch (err) {
111
+ setError((err as Error).message)
112
+ } finally {
113
+ setLoading(false)
114
+ }
115
+ }, [search, category, officialOnly])
116
+
117
+ useEffect(() => {
118
+ fetchPlugins()
119
+ }, [fetchPlugins])
120
+
121
+ const checkUpdates = useCallback(async () => {
122
+ setUpdatesLoading(true)
123
+ try {
124
+ const token = localStorage.getItem('zhin_api_token')
125
+ const res = await fetch('/api/marketplace/updates', {
126
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
127
+ })
128
+ if (res.ok) {
129
+ const data = await res.json()
130
+ if (data.success) setUpdates(data.data)
131
+ }
132
+ } catch { /* ignore */ }
133
+ finally { setUpdatesLoading(false) }
134
+ }, [])
135
+
136
+ const openDetail = useCallback(async (name: string) => {
137
+ setDetailOpen(true)
138
+ setDetailLoading(true)
139
+ setDetail(null)
140
+ try {
141
+ const res = await fetch(`/pub/marketplace/detail/${encodeURIComponent(name)}`)
142
+ if (res.ok) {
143
+ const data = await res.json()
144
+ if (data.success) setDetail(data.data)
145
+ }
146
+ } catch { /* ignore */ }
147
+ finally { setDetailLoading(false) }
148
+ }, [])
149
+
150
+ const sorted = useMemo(() => {
151
+ const arr = [...plugins]
152
+ if (sortKey === 'date') {
153
+ arr.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
154
+ } else {
155
+ arr.sort((a, b) => a.name.localeCompare(b.name))
156
+ }
157
+ return arr
158
+ }, [plugins, sortKey])
159
+
160
+ return (
161
+ <div className="space-y-4">
162
+ {/* Header */}
163
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
164
+ <div>
165
+ <h1 className="text-2xl font-bold tracking-tight">插件市场</h1>
166
+ <p className="text-sm text-muted-foreground mt-1">
167
+ 探索 Zhin.js 生态中的 {plugins.length} 个插件
168
+ </p>
169
+ </div>
170
+ <div className="flex gap-2">
171
+ <Button
172
+ variant="outline"
173
+ size="sm"
174
+ onClick={checkUpdates}
175
+ disabled={updatesLoading}
176
+ >
177
+ <RefreshCw className={`w-4 h-4 mr-1 ${updatesLoading ? 'animate-spin' : ''}`} />
178
+ 检查更新
179
+ </Button>
180
+ </div>
181
+ </div>
182
+
183
+ {/* Updates banner */}
184
+ {updates.length > 0 && (
185
+ <Alert>
186
+ <RefreshCw className="h-4 w-4" />
187
+ <AlertDescription>
188
+ 有 {updates.length} 个插件可更新:
189
+ {updates.map(u => (
190
+ <Badge key={u.name} variant="secondary" className="ml-1">
191
+ {u.name} {u.current} → {u.latest}
192
+ </Badge>
193
+ ))}
194
+ </AlertDescription>
195
+ </Alert>
196
+ )}
197
+
198
+ {/* Search & Filters */}
199
+ <div className="flex flex-col sm:flex-row gap-3">
200
+ <div className="relative flex-1">
201
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
202
+ <Input
203
+ placeholder="搜索插件名称、描述..."
204
+ value={search}
205
+ onChange={e => setSearch(e.target.value)}
206
+ className="pl-9"
207
+ />
208
+ </div>
209
+ <div className="flex gap-2 flex-wrap">
210
+ <Button
211
+ variant={officialOnly ? 'default' : 'outline'}
212
+ size="sm"
213
+ onClick={() => setOfficialOnly(!officialOnly)}
214
+ >
215
+ <ShieldCheck className="w-4 h-4 mr-1" />
216
+ 仅官方
217
+ </Button>
218
+ <Button
219
+ variant="outline"
220
+ size="sm"
221
+ onClick={() => setSortKey(sortKey === 'name' ? 'date' : 'name')}
222
+ >
223
+ <ArrowUpDown className="w-4 h-4 mr-1" />
224
+ {sortKey === 'name' ? '名称' : '更新时间'}
225
+ </Button>
226
+ </div>
227
+ </div>
228
+
229
+ {/* Category Tabs */}
230
+ <Tabs value={category} onValueChange={v => setCategory(v as Category)}>
231
+ <TabsList>
232
+ {CATEGORIES.map(c => (
233
+ <TabsTrigger key={c.value} value={c.value}>
234
+ {c.label}
235
+ </TabsTrigger>
236
+ ))}
237
+ </TabsList>
238
+ </Tabs>
239
+
240
+ {/* Error */}
241
+ {error && (
242
+ <Alert variant="destructive">
243
+ <AlertCircle className="h-4 w-4" />
244
+ <AlertDescription>{error}</AlertDescription>
245
+ </Alert>
246
+ )}
247
+
248
+ {/* Loading */}
249
+ {loading && (
250
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
251
+ {[...Array(6)].map((_, i) => <Skeleton key={i} className="h-40" />)}
252
+ </div>
253
+ )}
254
+
255
+ {/* Plugin Grid */}
256
+ {!loading && (
257
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
258
+ {sorted.map(plugin => (
259
+ <Card
260
+ key={plugin.name}
261
+ className="cursor-pointer transition-all hover:shadow-md hover:-translate-y-0.5"
262
+ onClick={() => openDetail(plugin.name)}
263
+ >
264
+ <CardContent className="p-4 space-y-3">
265
+ {/* Name & Badge */}
266
+ <div className="flex justify-between items-start">
267
+ <div className="flex items-center gap-2 min-w-0">
268
+ <Package className="w-4 h-4 shrink-0 text-muted-foreground" />
269
+ <span className="font-semibold text-sm truncate">{plugin.name}</span>
270
+ </div>
271
+ <div className="flex gap-1 shrink-0">
272
+ {plugin.isOfficial && (
273
+ <Badge variant="default" className="text-[10px]">官方</Badge>
274
+ )}
275
+ <Badge variant="secondary" className="text-[10px]">v{plugin.version}</Badge>
276
+ </div>
277
+ </div>
278
+
279
+ <p className="text-xs text-muted-foreground line-clamp-2">
280
+ {plugin.description || '暂无描述'}
281
+ </p>
282
+
283
+ <Separator />
284
+
285
+ {/* Footer */}
286
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
287
+ <span>{plugin.author}</span>
288
+ <span>{formatDate(plugin.date)}</span>
289
+ </div>
290
+
291
+ {/* Keywords */}
292
+ {plugin.keywords.length > 0 && (
293
+ <div className="flex flex-wrap gap-1">
294
+ {plugin.keywords.slice(0, 4).map(kw => (
295
+ <Badge key={kw} variant="outline" className="text-[10px] px-1.5 py-0">
296
+ {kw}
297
+ </Badge>
298
+ ))}
299
+ {plugin.keywords.length > 4 && (
300
+ <Badge variant="outline" className="text-[10px] px-1.5 py-0">
301
+ +{plugin.keywords.length - 4}
302
+ </Badge>
303
+ )}
304
+ </div>
305
+ )}
306
+ </CardContent>
307
+ </Card>
308
+ ))}
309
+ </div>
310
+ )}
311
+
312
+ {/* Empty */}
313
+ {!loading && sorted.length === 0 && (
314
+ <Card>
315
+ <CardContent className="flex flex-col items-center gap-3 py-12">
316
+ <Package className="w-12 h-12 text-muted-foreground" />
317
+ <h3 className="text-lg font-semibold">未找到插件</h3>
318
+ <p className="text-sm text-muted-foreground">尝试调整搜索条件或分类筛选</p>
319
+ </CardContent>
320
+ </Card>
321
+ )}
322
+
323
+ {/* Detail Dialog */}
324
+ <Dialog open={detailOpen} onOpenChange={setDetailOpen}>
325
+ <DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
326
+ {detailLoading ? (
327
+ <div className="space-y-3">
328
+ <Skeleton className="h-6 w-48" />
329
+ <Skeleton className="h-4 w-full" />
330
+ <Skeleton className="h-20 w-full" />
331
+ </div>
332
+ ) : detail ? (
333
+ <>
334
+ <DialogHeader>
335
+ <DialogTitle className="flex items-center gap-2">
336
+ <Package className="w-5 h-5" />
337
+ {detail.name}
338
+ <Badge variant="secondary">v{detail.version}</Badge>
339
+ </DialogTitle>
340
+ <DialogDescription>{detail.description}</DialogDescription>
341
+ </DialogHeader>
342
+
343
+ <div className="space-y-4">
344
+ {/* Stats */}
345
+ <div className="grid grid-cols-3 gap-2 text-center">
346
+ <div className="rounded-md bg-secondary p-2">
347
+ <div className="text-lg font-bold">{formatDownloads(detail.downloads.weekly)}</div>
348
+ <div className="text-[10px] text-muted-foreground">周下载</div>
349
+ </div>
350
+ <div className="rounded-md bg-secondary p-2">
351
+ <div className="text-lg font-bold">{formatDownloads(detail.downloads.monthly)}</div>
352
+ <div className="text-[10px] text-muted-foreground">月下载</div>
353
+ </div>
354
+ <div className="rounded-md bg-secondary p-2">
355
+ <div className="text-lg font-bold">{detail.versions.length}</div>
356
+ <div className="text-[10px] text-muted-foreground">版本数</div>
357
+ </div>
358
+ </div>
359
+
360
+ <Separator />
361
+
362
+ {/* Metadata */}
363
+ <div className="space-y-2 text-sm">
364
+ {detail.author && (
365
+ <div className="flex justify-between">
366
+ <span className="text-muted-foreground">作者</span>
367
+ <span>{detail.author}</span>
368
+ </div>
369
+ )}
370
+ {detail.license && (
371
+ <div className="flex justify-between">
372
+ <span className="text-muted-foreground">许可证</span>
373
+ <span>{detail.license}</span>
374
+ </div>
375
+ )}
376
+ {detail.lastPublish && (
377
+ <div className="flex justify-between">
378
+ <span className="text-muted-foreground">最后发布</span>
379
+ <span>{formatDate(detail.lastPublish)}</span>
380
+ </div>
381
+ )}
382
+ {detail.engines && Object.keys(detail.engines).length > 0 && (
383
+ <div className="flex justify-between">
384
+ <span className="text-muted-foreground">Node.js</span>
385
+ <span>{detail.engines.node || '-'}</span>
386
+ </div>
387
+ )}
388
+ </div>
389
+
390
+ {/* Peer Dependencies */}
391
+ {detail.peerDependencies && Object.keys(detail.peerDependencies).length > 0 && (
392
+ <>
393
+ <Separator />
394
+ <div>
395
+ <h4 className="text-sm font-medium mb-2">对等依赖</h4>
396
+ <div className="flex flex-wrap gap-1">
397
+ {Object.entries(detail.peerDependencies).map(([name, ver]) => (
398
+ <Badge key={name} variant="outline" className="text-xs">
399
+ {name}@{ver}
400
+ </Badge>
401
+ ))}
402
+ </div>
403
+ </div>
404
+ </>
405
+ )}
406
+
407
+ {/* README excerpt */}
408
+ {detail.readme && (
409
+ <>
410
+ <Separator />
411
+ <div>
412
+ <h4 className="text-sm font-medium mb-2">README</h4>
413
+ <p className="text-xs text-muted-foreground whitespace-pre-line leading-relaxed">
414
+ {detail.readme}
415
+ </p>
416
+ </div>
417
+ </>
418
+ )}
419
+
420
+ {/* Install command */}
421
+ <Separator />
422
+ <div>
423
+ <h4 className="text-sm font-medium mb-2">安装命令</h4>
424
+ <code className="block text-xs bg-secondary rounded-md p-2">
425
+ pnpm add {detail.name}
426
+ </code>
427
+ </div>
428
+ </div>
429
+
430
+ <DialogFooter className="gap-2">
431
+ {detail.homepage && (
432
+ <Button variant="outline" size="sm" asChild>
433
+ <a href={detail.homepage} target="_blank" rel="noopener noreferrer">
434
+ <ExternalLink className="w-3 h-3 mr-1" /> 主页
435
+ </a>
436
+ </Button>
437
+ )}
438
+ {detail.npm || (
439
+ <Button variant="outline" size="sm" asChild>
440
+ <a
441
+ href={`https://www.npmjs.com/package/${detail.name}`}
442
+ target="_blank"
443
+ rel="noopener noreferrer"
444
+ >
445
+ <Download className="w-3 h-3 mr-1" /> npm
446
+ </a>
447
+ </Button>
448
+ )}
449
+ <DialogClose asChild>
450
+ <Button variant="secondary" size="sm">关闭</Button>
451
+ </DialogClose>
452
+ </DialogFooter>
453
+ </>
454
+ ) : (
455
+ <Alert variant="destructive">
456
+ <AlertCircle className="h-4 w-4" />
457
+ <AlertDescription>加载插件详情失败</AlertDescription>
458
+ </Alert>
459
+ )}
460
+ </DialogContent>
461
+ </Dialog>
462
+ </div>
463
+ )
464
+ }