@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @zhin.js/console
2
2
 
3
+ ## 1.0.42
4
+
5
+ ### Patch Changes
6
+
7
+ - zhin.js@1.0.44
8
+ - @zhin.js/http@1.0.38
9
+ - @zhin.js/core@1.0.44
10
+
11
+ ## 1.0.41
12
+
13
+ ### Patch Changes
14
+
15
+ - 72ec4ba: fix: 新增插件,控制台调优
16
+ - Updated dependencies [72ec4ba]
17
+ - @zhin.js/http@1.0.37
18
+ - @zhin.js/client@1.0.11
19
+ - @zhin.js/core@1.0.43
20
+ - zhin.js@1.0.43
21
+
3
22
  ## 1.0.40
4
23
 
5
24
  ### Patch Changes
@@ -5,7 +5,7 @@
5
5
  import { useState, useEffect } from 'react'
6
6
  import { useConfig } from '@zhin.js/client'
7
7
  import type { PluginConfigFormProps, SchemaField } from './types.js'
8
- import { Settings, ChevronDown, CheckCircle, AlertCircle, X, Save, Loader2 } from 'lucide-react'
8
+ import { Settings, ChevronDown, CheckCircle, AlertCircle, AlertTriangle, X, Save, Loader2 } from 'lucide-react'
9
9
  import { FieldRenderer, isComplexField } from './FieldRenderer.js'
10
10
  import { NestedFieldRenderer } from './NestedFieldRenderer.js'
11
11
  import { Card } from '../ui/card'
@@ -25,12 +25,21 @@ export function PluginConfigForm({ pluginName, onSuccess }: Omit<PluginConfigFor
25
25
  if (config) setLocalConfig(config)
26
26
  }, [config])
27
27
 
28
+ const [warnMessage, setWarnMessage] = useState<string | null>(null)
29
+
28
30
  const handleSave = async () => {
29
31
  if (!connected) return
30
32
  try {
31
- await setConfig(localConfig)
32
- setSuccessMessage('配置已保存成功')
33
- setTimeout(() => { setIsExpanded(undefined); onSuccess?.(); setSuccessMessage(null) }, 1500)
33
+ const result = await setConfig(localConfig)
34
+ if (result?.reloaded) {
35
+ setSuccessMessage('配置已保存,插件已重载')
36
+ } else if (result?.message) {
37
+ setWarnMessage(result.message)
38
+ setSuccessMessage(null)
39
+ } else {
40
+ setSuccessMessage('配置已保存')
41
+ }
42
+ setTimeout(() => { setIsExpanded(undefined); onSuccess?.(); setSuccessMessage(null); setWarnMessage(null) }, 2500)
34
43
  } catch (err) {
35
44
  console.error('保存配置失败:', err)
36
45
  }
@@ -74,7 +83,7 @@ export function PluginConfigForm({ pluginName, onSuccess }: Omit<PluginConfigFor
74
83
  )
75
84
  }
76
85
 
77
- const fields = schema?.properties || schema?.dict || {}
86
+ const fields = schema?.object || schema?.properties || schema?.dict || {}
78
87
  if (!schema || !fields || Object.keys(fields).length === 0) return null
79
88
 
80
89
  return (
@@ -95,6 +104,12 @@ export function PluginConfigForm({ pluginName, onSuccess }: Omit<PluginConfigFor
95
104
  <AlertDescription>{successMessage}</AlertDescription>
96
105
  </Alert>
97
106
  )}
107
+ {warnMessage && !successMessage && (
108
+ <Alert className="mb-3 border-yellow-500/50 text-yellow-700 dark:text-yellow-400">
109
+ <AlertTriangle className="h-4 w-4" />
110
+ <AlertDescription>{warnMessage}</AlertDescription>
111
+ </Alert>
112
+ )}
98
113
  {error && (
99
114
  <Alert variant="destructive" className="mb-3">
100
115
  <AlertCircle className="h-4 w-4" />
@@ -23,6 +23,7 @@ export interface SchemaField {
23
23
 
24
24
  export interface Schema {
25
25
  type: string
26
+ object?: Record<string, SchemaField>
26
27
  properties?: Record<string, SchemaField>
27
28
  dict?: Record<string, SchemaField>
28
29
  description?: string
@@ -1,7 +1,7 @@
1
1
  import { StrictMode, useCallback, useEffect, useState } from 'react'
2
2
  import { createRoot } from 'react-dom/client'
3
3
  import { Provider as ReduxProvider } from 'react-redux'
4
- import { Home, Package, Bot, FileText } from 'lucide-react'
4
+ import { Home, Package, Bot, FileText, Settings, KeyRound } from 'lucide-react'
5
5
  import { store, DynamicRouter, persistor, addPage, useSelector, useWebSocket } from '@zhin.js/client'
6
6
  import DashboardLayout from './layouts/dashboard'
7
7
  import DashboardHome from './pages/dashboard-home'
@@ -9,6 +9,8 @@ import DashboardPlugins from './pages/dashboard-plugins'
9
9
  import DashboardPluginDetail from './pages/dashboard-plugin-detail'
10
10
  import DashboardBots from './pages/dashboard-bots'
11
11
  import DashboardLogs from './pages/dashboard-logs'
12
+ import DashboardConfig from './pages/dashboard-config'
13
+ import DashboardEnv from './pages/dashboard-env'
12
14
  import LoginPage from './pages/login'
13
15
  import { hasToken } from './utils/auth'
14
16
  import './style.css'
@@ -83,13 +85,29 @@ function RouteInitializer() {
83
85
  element: <DashboardPluginDetail />,
84
86
  meta: { hideInMenu: true }
85
87
  },
88
+ {
89
+ key: 'dashboard-config',
90
+ path: '/config',
91
+ title: '配置管理',
92
+ icon: <Settings className="w-4 h-4" />,
93
+ element: <DashboardConfig />,
94
+ meta: { order: 3 }
95
+ },
96
+ {
97
+ key: 'dashboard-env',
98
+ path: '/env',
99
+ title: '环境变量',
100
+ icon: <KeyRound className="w-4 h-4" />,
101
+ element: <DashboardEnv />,
102
+ meta: { order: 4 }
103
+ },
86
104
  {
87
105
  key: 'dashboard-bots',
88
106
  path: '/bots',
89
107
  title: '机器人',
90
108
  icon: <Bot className="w-4 h-4" />,
91
109
  element: <DashboardBots />,
92
- meta: { order: 3 }
110
+ meta: { order: 5 }
93
111
  },
94
112
  {
95
113
  key: 'dashboard-logs',
@@ -97,7 +115,7 @@ function RouteInitializer() {
97
115
  title: '系统日志',
98
116
  icon: <FileText className="w-4 h-4" />,
99
117
  element: <DashboardLogs />,
100
- meta: { order: 4 }
118
+ meta: { order: 6 }
101
119
  }
102
120
  ]
103
121
  }
@@ -0,0 +1,421 @@
1
+ import { useState, useEffect, useCallback, useMemo } from 'react'
2
+ import { useConfigYaml } from '@zhin.js/client'
3
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
4
+ import {
5
+ Settings, AlertCircle, CheckCircle, Save, Loader2, X,
6
+ RefreshCw, FileCode, FormInput
7
+ } from 'lucide-react'
8
+ import { Card, CardContent } from '../components/ui/card'
9
+ import { Badge } from '../components/ui/badge'
10
+ import { Button } from '../components/ui/button'
11
+ import { Alert, AlertDescription } from '../components/ui/alert'
12
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/tabs'
13
+ import { Textarea } from '../components/ui/textarea'
14
+ import { Input } from '../components/ui/input'
15
+ import { Separator } from '../components/ui/separator'
16
+
17
+ function GeneralConfigForm({
18
+ config,
19
+ pluginKeys,
20
+ onSave,
21
+ saving
22
+ }: {
23
+ config: Record<string, any>
24
+ pluginKeys: string[]
25
+ onSave: (patch: Record<string, any>) => Promise<void>
26
+ saving: boolean
27
+ }) {
28
+ const generalKeys = useMemo(() => {
29
+ const excludeSet = new Set(pluginKeys)
30
+ excludeSet.add('plugins')
31
+ return Object.keys(config).filter(k => !excludeSet.has(k))
32
+ }, [config, pluginKeys])
33
+
34
+ const [localValues, setLocalValues] = useState<Record<string, any>>({})
35
+ const [dirty, setDirty] = useState(false)
36
+
37
+ useEffect(() => {
38
+ const vals: Record<string, any> = {}
39
+ for (const key of generalKeys) {
40
+ vals[key] = config[key]
41
+ }
42
+ setLocalValues(vals)
43
+ setDirty(false)
44
+ }, [config, generalKeys])
45
+
46
+ const handleChange = (key: string, value: any) => {
47
+ setLocalValues(prev => ({ ...prev, [key]: value }))
48
+ setDirty(true)
49
+ }
50
+
51
+ const handleSave = async () => {
52
+ await onSave(localValues)
53
+ setDirty(false)
54
+ }
55
+
56
+ const handleReset = () => {
57
+ const vals: Record<string, any> = {}
58
+ for (const key of generalKeys) {
59
+ vals[key] = config[key]
60
+ }
61
+ setLocalValues(vals)
62
+ setDirty(false)
63
+ }
64
+
65
+ if (generalKeys.length === 0) {
66
+ return (
67
+ <Card>
68
+ <CardContent className="flex flex-col items-center gap-3 py-12">
69
+ <div className="flex items-center justify-center w-16 h-16 rounded-full bg-muted">
70
+ <Settings className="w-8 h-8 text-muted-foreground" />
71
+ </div>
72
+ <h3 className="text-lg font-semibold">暂无通用配置</h3>
73
+ <p className="text-sm text-muted-foreground">配置文件中未发现可编辑的通用字段</p>
74
+ </CardContent>
75
+ </Card>
76
+ )
77
+ }
78
+
79
+ return (
80
+ <div className="space-y-4">
81
+ <div className="space-y-3">
82
+ {generalKeys.map(key => (
83
+ <ConfigFieldEditor
84
+ key={key}
85
+ fieldKey={key}
86
+ value={localValues[key]}
87
+ onChange={val => handleChange(key, val)}
88
+ />
89
+ ))}
90
+ </div>
91
+ <div className="flex items-center gap-2 pt-2">
92
+ <Button size="sm" onClick={handleSave} disabled={saving || !dirty}>
93
+ {saving
94
+ ? <><Loader2 className="w-4 h-4 mr-1 animate-spin" />保存中...</>
95
+ : <><Save className="w-4 h-4 mr-1" />保存</>}
96
+ </Button>
97
+ {dirty && (
98
+ <Button variant="outline" size="sm" onClick={handleReset}>
99
+ <X className="w-4 h-4 mr-1" />撤销
100
+ </Button>
101
+ )}
102
+ {dirty && <span className="text-xs text-muted-foreground">有未保存的更改</span>}
103
+ </div>
104
+ </div>
105
+ )
106
+ }
107
+
108
+ function ConfigFieldEditor({
109
+ fieldKey,
110
+ value,
111
+ onChange
112
+ }: {
113
+ fieldKey: string
114
+ value: any
115
+ onChange: (val: any) => void
116
+ }) {
117
+ const valueType = typeof value
118
+
119
+ if (value === null || value === undefined) {
120
+ return (
121
+ <div className="p-3 rounded-lg bg-muted/50 border space-y-1.5">
122
+ <div className="flex items-center gap-1.5">
123
+ <span className="text-sm font-medium">{fieldKey}</span>
124
+ <Badge variant="outline" className="text-[10px] px-1 py-0">null</Badge>
125
+ </div>
126
+ <Input
127
+ value=""
128
+ placeholder="(空值)"
129
+ onChange={e => onChange(e.target.value || null)}
130
+ className="h-8 text-sm"
131
+ />
132
+ </div>
133
+ )
134
+ }
135
+
136
+ if (valueType === 'boolean') {
137
+ return (
138
+ <div className="p-3 rounded-lg bg-muted/50 border space-y-1.5">
139
+ <div className="flex items-center justify-between">
140
+ <div className="flex items-center gap-1.5">
141
+ <span className="text-sm font-medium">{fieldKey}</span>
142
+ <Badge variant="outline" className="text-[10px] px-1 py-0">boolean</Badge>
143
+ </div>
144
+ <button
145
+ type="button"
146
+ onClick={() => onChange(!value)}
147
+ className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
148
+ value ? 'bg-primary' : 'bg-muted-foreground/30'
149
+ }`}
150
+ >
151
+ <span
152
+ className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
153
+ value ? 'translate-x-4.5' : 'translate-x-0.5'
154
+ }`}
155
+ />
156
+ </button>
157
+ </div>
158
+ </div>
159
+ )
160
+ }
161
+
162
+ if (valueType === 'number') {
163
+ return (
164
+ <div className="p-3 rounded-lg bg-muted/50 border space-y-1.5">
165
+ <div className="flex items-center gap-1.5">
166
+ <span className="text-sm font-medium">{fieldKey}</span>
167
+ <Badge variant="outline" className="text-[10px] px-1 py-0">number</Badge>
168
+ </div>
169
+ <Input
170
+ type="number"
171
+ value={value}
172
+ onChange={e => onChange(Number(e.target.value))}
173
+ className="h-8 text-sm"
174
+ />
175
+ </div>
176
+ )
177
+ }
178
+
179
+ if (valueType === 'string') {
180
+ const isMultiline = value.includes('\n') || value.length > 80
181
+ return (
182
+ <div className="p-3 rounded-lg bg-muted/50 border space-y-1.5">
183
+ <div className="flex items-center gap-1.5">
184
+ <span className="text-sm font-medium">{fieldKey}</span>
185
+ <Badge variant="outline" className="text-[10px] px-1 py-0">string</Badge>
186
+ </div>
187
+ {isMultiline ? (
188
+ <Textarea
189
+ value={value}
190
+ onChange={e => onChange(e.target.value)}
191
+ className="text-sm font-mono min-h-[80px]"
192
+ />
193
+ ) : (
194
+ <Input
195
+ value={value}
196
+ onChange={e => onChange(e.target.value)}
197
+ className="h-8 text-sm"
198
+ />
199
+ )}
200
+ </div>
201
+ )
202
+ }
203
+
204
+ if (Array.isArray(value)) {
205
+ return (
206
+ <div className="p-3 rounded-lg bg-muted/50 border space-y-1.5">
207
+ <div className="flex items-center gap-1.5">
208
+ <span className="text-sm font-medium">{fieldKey}</span>
209
+ <Badge variant="outline" className="text-[10px] px-1 py-0">array[{value.length}]</Badge>
210
+ </div>
211
+ <Textarea
212
+ value={stringifyYaml(value).trim()}
213
+ onChange={e => {
214
+ try {
215
+ const parsed = parseYaml(e.target.value)
216
+ if (Array.isArray(parsed)) onChange(parsed)
217
+ } catch { /* ignore parse errors during typing */ }
218
+ }}
219
+ className="text-sm font-mono min-h-[80px]"
220
+ />
221
+ </div>
222
+ )
223
+ }
224
+
225
+ if (valueType === 'object') {
226
+ return (
227
+ <div className="p-3 rounded-lg bg-muted/50 border space-y-1.5">
228
+ <div className="flex items-center gap-1.5">
229
+ <span className="text-sm font-medium">{fieldKey}</span>
230
+ <Badge variant="outline" className="text-[10px] px-1 py-0">object</Badge>
231
+ </div>
232
+ <Textarea
233
+ value={stringifyYaml(value).trim()}
234
+ onChange={e => {
235
+ try {
236
+ const parsed = parseYaml(e.target.value)
237
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) onChange(parsed)
238
+ } catch { /* ignore parse errors during typing */ }
239
+ }}
240
+ className="text-sm font-mono min-h-[100px]"
241
+ />
242
+ </div>
243
+ )
244
+ }
245
+
246
+ return (
247
+ <div className="p-3 rounded-lg bg-muted/50 border space-y-1.5">
248
+ <div className="flex items-center gap-1.5">
249
+ <span className="text-sm font-medium">{fieldKey}</span>
250
+ <Badge variant="outline" className="text-[10px] px-1 py-0">{valueType}</Badge>
251
+ </div>
252
+ <Input
253
+ value={String(value)}
254
+ onChange={e => onChange(e.target.value)}
255
+ className="h-8 text-sm"
256
+ />
257
+ </div>
258
+ )
259
+ }
260
+
261
+ export default function DashboardConfig() {
262
+ const { yaml, pluginKeys, loading, error, load, save } = useConfigYaml()
263
+ const [mode, setMode] = useState<'form' | 'yaml'>('form')
264
+ const [yamlText, setYamlText] = useState('')
265
+ const [yamlDirty, setYamlDirty] = useState(false)
266
+ const [saving, setSaving] = useState(false)
267
+ const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
268
+
269
+ useEffect(() => {
270
+ if (yaml) {
271
+ setYamlText(yaml)
272
+ setYamlDirty(false)
273
+ }
274
+ }, [yaml])
275
+
276
+ const parsedConfig = useMemo(() => {
277
+ try {
278
+ return parseYaml(yaml) || {}
279
+ } catch {
280
+ return {}
281
+ }
282
+ }, [yaml])
283
+
284
+ const showMessage = useCallback((type: 'success' | 'error', text: string) => {
285
+ setMessage({ type, text })
286
+ setTimeout(() => setMessage(null), 3000)
287
+ }, [])
288
+
289
+ const handleYamlSave = async () => {
290
+ setSaving(true)
291
+ try {
292
+ await save(yamlText)
293
+ setYamlDirty(false)
294
+ showMessage('success', '配置已保存,需重启生效')
295
+ } catch (err) {
296
+ showMessage('error', `保存失败: ${err instanceof Error ? err.message : '未知错误'}`)
297
+ } finally {
298
+ setSaving(false)
299
+ }
300
+ }
301
+
302
+ const handleFormSave = async (patch: Record<string, any>) => {
303
+ setSaving(true)
304
+ try {
305
+ const currentParsed = parseYaml(yaml) || {}
306
+ const merged = { ...currentParsed, ...patch }
307
+ const newYaml = stringifyYaml(merged, { lineWidth: 0 })
308
+ await save(newYaml)
309
+ showMessage('success', '配置已保存,需重启生效')
310
+ } catch (err) {
311
+ showMessage('error', `保存失败: ${err instanceof Error ? err.message : '未知错误'}`)
312
+ } finally {
313
+ setSaving(false)
314
+ }
315
+ }
316
+
317
+ const handleRefresh = async () => {
318
+ try {
319
+ await load()
320
+ showMessage('success', '已刷新')
321
+ } catch {
322
+ showMessage('error', '刷新失败')
323
+ }
324
+ }
325
+
326
+ if (loading && !yaml) {
327
+ return (
328
+ <div className="space-y-4">
329
+ <h1 className="text-2xl font-bold tracking-tight">配置管理</h1>
330
+ <div className="flex items-center justify-center py-12">
331
+ <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
332
+ <span className="ml-2 text-sm text-muted-foreground">加载配置中...</span>
333
+ </div>
334
+ </div>
335
+ )
336
+ }
337
+
338
+ return (
339
+ <div className="space-y-4">
340
+ <div className="flex items-center justify-between">
341
+ <div>
342
+ <h1 className="text-2xl font-bold tracking-tight">配置管理</h1>
343
+ <p className="text-sm text-muted-foreground mt-1">
344
+ 管理 zhin.config.yml 中的通用配置项(不含插件配置)
345
+ </p>
346
+ </div>
347
+ <div className="flex items-center gap-2">
348
+ <Button variant="outline" size="sm" onClick={handleRefresh} disabled={loading}>
349
+ <RefreshCw className={`w-4 h-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
350
+ 刷新
351
+ </Button>
352
+ </div>
353
+ </div>
354
+
355
+ {message && (
356
+ <Alert variant={message.type === 'error' ? 'destructive' : 'success'} className="py-2">
357
+ {message.type === 'error'
358
+ ? <AlertCircle className="h-4 w-4" />
359
+ : <CheckCircle className="h-4 w-4" />}
360
+ <AlertDescription>{message.text}</AlertDescription>
361
+ </Alert>
362
+ )}
363
+
364
+ {error && !message && (
365
+ <Alert variant="destructive" className="py-2">
366
+ <AlertCircle className="h-4 w-4" />
367
+ <AlertDescription>{error}</AlertDescription>
368
+ </Alert>
369
+ )}
370
+
371
+ <Tabs value={mode} onValueChange={v => setMode(v as 'form' | 'yaml')}>
372
+ <TabsList>
373
+ <TabsTrigger value="form" className="gap-1.5">
374
+ <FormInput className="w-4 h-4" />
375
+ 表单模式
376
+ </TabsTrigger>
377
+ <TabsTrigger value="yaml" className="gap-1.5">
378
+ <FileCode className="w-4 h-4" />
379
+ YAML 模式
380
+ </TabsTrigger>
381
+ </TabsList>
382
+
383
+ <TabsContent value="form">
384
+ <GeneralConfigForm
385
+ config={parsedConfig}
386
+ pluginKeys={pluginKeys}
387
+ onSave={handleFormSave}
388
+ saving={saving}
389
+ />
390
+ </TabsContent>
391
+
392
+ <TabsContent value="yaml">
393
+ <div className="space-y-3">
394
+ <div className="relative">
395
+ <Textarea
396
+ value={yamlText}
397
+ onChange={e => { setYamlText(e.target.value); setYamlDirty(true) }}
398
+ className="font-mono text-sm min-h-[400px] resize-y"
399
+ placeholder="# zhin.config.yml"
400
+ spellCheck={false}
401
+ />
402
+ </div>
403
+ <div className="flex items-center gap-2">
404
+ <Button size="sm" onClick={handleYamlSave} disabled={saving || !yamlDirty}>
405
+ {saving
406
+ ? <><Loader2 className="w-4 h-4 mr-1 animate-spin" />保存中...</>
407
+ : <><Save className="w-4 h-4 mr-1" />保存</>}
408
+ </Button>
409
+ {yamlDirty && (
410
+ <Button variant="outline" size="sm" onClick={() => { setYamlText(yaml); setYamlDirty(false) }}>
411
+ <X className="w-4 h-4 mr-1" />撤销
412
+ </Button>
413
+ )}
414
+ {yamlDirty && <span className="text-xs text-muted-foreground">有未保存的更改</span>}
415
+ </div>
416
+ </div>
417
+ </TabsContent>
418
+ </Tabs>
419
+ </div>
420
+ )
421
+ }