@zhin.js/console 2.0.0 → 2.0.3

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,44 @@
1
1
  # @zhin.js/console
2
2
 
3
+ ## 2.0.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [a257f3f]
8
+ - @zhin.js/agent@0.1.3
9
+ - zhin.js@1.0.61
10
+ - @zhin.js/http@1.0.56
11
+ - @zhin.js/core@1.1.3
12
+
13
+ ## 2.0.2
14
+
15
+ ### Patch Changes
16
+
17
+ - 5073d4c: chore: chore: update TypeScript version to ^5.9.3 across all plugins and packages
18
+ feat: enhance ai-text-as-image output registration with off handler for cleanup
19
+ fix: remove unnecessary logging in ensureBuiltinFontsCached function
20
+ refactor: simplify action handlers in html-renderer tools
21
+ chore: add README files for queue-sandbox-poc and event-delivery packages
22
+ chore: adjust pnpm workspace configuration to exclude games directory
23
+ chore: update tsconfig to include plugins directory for TypeScript compilation
24
+ - Updated dependencies [5073d4c]
25
+ - @zhin.js/agent@0.1.2
26
+ - @zhin.js/core@1.1.2
27
+ - zhin.js@1.0.60
28
+ - @zhin.js/http@1.0.55
29
+
30
+ ## 2.0.1
31
+
32
+ ### Patch Changes
33
+
34
+ - c212bf7: fix: 适配器优化
35
+ - Updated dependencies [c212bf7]
36
+ - @zhin.js/agent@0.1.1
37
+ - @zhin.js/client@1.0.15
38
+ - @zhin.js/core@1.1.1
39
+ - zhin.js@1.0.59
40
+ - @zhin.js/http@1.0.54
41
+
3
42
  ## 2.0.0
4
43
 
5
44
  ### Patch Changes
@@ -43,7 +43,7 @@ export default function DashboardLayout() {
43
43
  const navigate = useNavigate()
44
44
  const sidebarOpen = useSelector((state) => state.ui.sidebarOpen)
45
45
  const activeMenu = useSelector((state) => state.ui.activeMenu)
46
- const routes = useSelector((state) => state.route.routes)
46
+ const routes = useSelector((state) => state.route?.routes || [])
47
47
  const [searchQ, setSearchQ] = useState("")
48
48
 
49
49
  const menuRoutes = useMemo(() => {
@@ -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, Settings, KeyRound, FolderOpen, Database } from 'lucide-react'
4
+ import { Home, Package, Bot, FileText, Settings, KeyRound, FolderOpen, Database, Clock, Store } 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 HomePage from './pages/dashboard'
@@ -14,6 +14,8 @@ import ConfigPage from './pages/config'
14
14
  import EnvManagePage from './pages/env'
15
15
  import FileManagePage from './pages/files'
16
16
  import DatabasePage from './pages/database'
17
+ import CronPage from './pages/cron'
18
+ import MarketplacePage from './pages/marketplace'
17
19
  import LoginPage from './pages/login'
18
20
  import { hasToken } from './utils/auth'
19
21
  import './style.css'
@@ -90,13 +92,21 @@ function RouteInitializer() {
90
92
  element: <LogsPage />,
91
93
  meta: { group: '系统', order: 2, fullWidth: true },
92
94
  },
95
+ {
96
+ key: 'cronPage',
97
+ path: '/cron',
98
+ title: '定时任务',
99
+ icon: <Clock className="w-4 h-4" />,
100
+ element: <CronPage />,
101
+ meta: { group: '系统', order: 3 },
102
+ },
93
103
  {
94
104
  key: 'pluginsPage',
95
105
  path: '/plugins',
96
106
  title: '插件管理',
97
107
  icon: <Package className="w-4 h-4" />,
98
108
  element: <PluginsPage />,
99
- meta: { group: '扩展', order: 3 },
109
+ meta: { group: '扩展', order: 4 },
100
110
  },
101
111
  {
102
112
  key: 'pluginDetailPage',
@@ -105,13 +115,21 @@ function RouteInitializer() {
105
115
  element: <PluginDetailPage />,
106
116
  meta: { hideInMenu: true },
107
117
  },
118
+ {
119
+ key: 'marketplacePage',
120
+ path: '/marketplace',
121
+ title: '插件市场',
122
+ icon: <Store className="w-4 h-4" />,
123
+ element: <MarketplacePage />,
124
+ meta: { group: '扩展', order: 5 },
125
+ },
108
126
  {
109
127
  key: 'configPage',
110
128
  path: '/config',
111
129
  title: '配置管理',
112
130
  icon: <Settings className="w-4 h-4" />,
113
131
  element: <ConfigPage />,
114
- meta: { group: '配置与数据', order: 4 },
132
+ meta: { group: '配置与数据', order: 6 },
115
133
  },
116
134
  {
117
135
  key: 'envManagePage',
@@ -119,7 +137,7 @@ function RouteInitializer() {
119
137
  title: '环境变量',
120
138
  icon: <KeyRound className="w-4 h-4" />,
121
139
  element: <EnvManagePage />,
122
- meta: { group: '配置与数据', order: 5 },
140
+ meta: { group: '配置与数据', order: 6 },
123
141
  },
124
142
  {
125
143
  key: 'fileManagePage',
@@ -127,7 +145,7 @@ function RouteInitializer() {
127
145
  title: '文件管理',
128
146
  icon: <FolderOpen className="w-4 h-4" />,
129
147
  element: <FileManagePage />,
130
- meta: { group: '配置与数据', order: 6 },
148
+ meta: { group: '配置与数据', order: 7 },
131
149
  },
132
150
  {
133
151
  key: 'databasePage',
@@ -135,7 +153,7 @@ function RouteInitializer() {
135
153
  title: '数据库',
136
154
  icon: <Database className="w-4 h-4" />,
137
155
  element: <DatabasePage />,
138
- meta: { group: '配置与数据', order: 7, fullWidth: true },
156
+ meta: { group: '配置与数据', order: 8, fullWidth: true },
139
157
  },
140
158
  {
141
159
  key: 'botDetailPage',
@@ -0,0 +1,529 @@
1
+ import { useEffect, useState, useCallback, type ChangeEvent } from 'react'
2
+ import { Clock, Plus, Trash2, AlertCircle, Pause, Play, RefreshCw, Timer, Cpu, ChevronDown, ChevronUp, Copy, Check } from 'lucide-react'
3
+ import { useWebSocket, useSelector, selectConfigConnected } from '@zhin.js/client'
4
+ import { Button } from '../components/ui/button'
5
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../components/ui/card'
6
+ import { Badge } from '../components/ui/badge'
7
+ import { Alert, AlertDescription } from '../components/ui/alert'
8
+ import { Skeleton } from '../components/ui/skeleton'
9
+ import { Input } from '../components/ui/input'
10
+ import { Textarea } from '../components/ui/textarea'
11
+ import { Separator } from '../components/ui/separator'
12
+ import {
13
+ Dialog, DialogContent, DialogHeader, DialogFooter,
14
+ DialogTitle, DialogDescription, DialogClose,
15
+ } from '../components/ui/dialog'
16
+
17
+ interface MemoryCron {
18
+ type: 'memory'
19
+ expression: string
20
+ running: boolean
21
+ nextExecution: string | null
22
+ plugin: string
23
+ }
24
+
25
+ interface CronJobContext {
26
+ platform?: string
27
+ botId?: string
28
+ senderId?: string
29
+ sceneId?: string
30
+ scope?: string
31
+ }
32
+
33
+ interface PersistentCron {
34
+ type: 'persistent'
35
+ id: string
36
+ cronExpression: string
37
+ prompt: string
38
+ label?: string
39
+ enabled: boolean
40
+ context?: CronJobContext
41
+ createdAt: number
42
+ }
43
+
44
+ interface BotInfo {
45
+ name: string
46
+ adapter: string
47
+ connected: boolean
48
+ }
49
+
50
+ const EMPTY_CONTEXT: CronJobContext = { platform: '', botId: '', senderId: '', sceneId: '', scope: '' }
51
+
52
+ export default function CronPage() {
53
+ const [memoryCrons, setMemoryCrons] = useState<MemoryCron[]>([])
54
+ const [persistentCrons, setPersistentCrons] = useState<PersistentCron[]>([])
55
+ const [bots, setBots] = useState<BotInfo[]>([])
56
+ const [loading, setLoading] = useState(true)
57
+ const [error, setError] = useState<string | null>(null)
58
+ const [addDialogOpen, setAddDialogOpen] = useState(false)
59
+ const [deleteTarget, setDeleteTarget] = useState<PersistentCron | null>(null)
60
+ const [submitting, setSubmitting] = useState(false)
61
+ const [newCron, setNewCron] = useState({ cronExpression: '', prompt: '', label: '', context: { ...EMPTY_CONTEXT } })
62
+ const [expandedId, setExpandedId] = useState<string | null>(null)
63
+ const [expandedMemIdx, setExpandedMemIdx] = useState<number | null>(null)
64
+ const [copiedId, setCopiedId] = useState<string | null>(null)
65
+ const connected = useSelector(selectConfigConnected)
66
+ const { sendRequest } = useWebSocket()
67
+
68
+ const fetchCrons = useCallback(async () => {
69
+ if (!connected) {
70
+ setLoading(false)
71
+ setError('WebSocket 未连接,请刷新页面')
72
+ return
73
+ }
74
+ try {
75
+ const data = await sendRequest<{ memory: MemoryCron[]; persistent: PersistentCron[] }>({ type: 'cron:list' })
76
+ setMemoryCrons(data.memory || [])
77
+ setPersistentCrons(data.persistent || [])
78
+ // Also fetch bots for context selector
79
+ try {
80
+ const botData = await sendRequest<{ bots: BotInfo[] }>({ type: 'bot:list' })
81
+ setBots(botData.bots || [])
82
+ } catch { /* ignore */ }
83
+ setError(null)
84
+ } catch (err) {
85
+ setError((err as Error).message)
86
+ } finally {
87
+ setLoading(false)
88
+ }
89
+ }, [connected, sendRequest])
90
+
91
+ useEffect(() => {
92
+ if (connected) {
93
+ setLoading(true)
94
+ fetchCrons()
95
+ }
96
+ }, [connected, fetchCrons])
97
+
98
+ const handleAdd = async () => {
99
+ if (!newCron.cronExpression || !newCron.prompt) return
100
+ setSubmitting(true)
101
+ try {
102
+ // Build context, omitting empty fields
103
+ const ctx: CronJobContext = {}
104
+ if (newCron.context.platform) ctx.platform = newCron.context.platform
105
+ if (newCron.context.botId) ctx.botId = newCron.context.botId
106
+ if (newCron.context.senderId) ctx.senderId = newCron.context.senderId
107
+ if (newCron.context.sceneId) ctx.sceneId = newCron.context.sceneId
108
+ if (newCron.context.scope) ctx.scope = newCron.context.scope
109
+ const hasContext = Object.keys(ctx).length > 0
110
+ await sendRequest({
111
+ type: 'cron:add',
112
+ cronExpression: newCron.cronExpression,
113
+ prompt: newCron.prompt,
114
+ label: newCron.label,
115
+ context: hasContext ? ctx : undefined,
116
+ })
117
+ setAddDialogOpen(false)
118
+ setNewCron({ cronExpression: '', prompt: '', label: '', context: { ...EMPTY_CONTEXT } })
119
+ await fetchCrons()
120
+ } catch (err) {
121
+ setError((err as Error).message)
122
+ } finally {
123
+ setSubmitting(false)
124
+ }
125
+ }
126
+
127
+ const handleDelete = async () => {
128
+ if (!deleteTarget) return
129
+ setSubmitting(true)
130
+ try {
131
+ await sendRequest({ type: 'cron:remove', id: deleteTarget.id })
132
+ setDeleteTarget(null)
133
+ await fetchCrons()
134
+ } catch (err) {
135
+ setError((err as Error).message)
136
+ } finally {
137
+ setSubmitting(false)
138
+ }
139
+ }
140
+
141
+ const handleToggle = async (job: PersistentCron) => {
142
+ try {
143
+ if (job.enabled) {
144
+ await sendRequest({ type: 'cron:pause', id: job.id })
145
+ } else {
146
+ await sendRequest({ type: 'cron:resume', id: job.id })
147
+ }
148
+ await fetchCrons()
149
+ } catch (err) {
150
+ setError((err as Error).message)
151
+ }
152
+ }
153
+
154
+ if (loading && connected) {
155
+ return (
156
+ <div className="space-y-6">
157
+ <Skeleton className="h-8 w-48" />
158
+ <div className="grid grid-cols-1 gap-4">
159
+ {[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32" />)}
160
+ </div>
161
+ </div>
162
+ )
163
+ }
164
+
165
+ if (error) {
166
+ return (
167
+ <div className="space-y-4">
168
+ <Alert variant="destructive">
169
+ <AlertCircle className="h-4 w-4" />
170
+ <AlertDescription>{error}</AlertDescription>
171
+ </Alert>
172
+ <Button variant="outline" size="sm" onClick={() => { setError(null); setLoading(true); fetchCrons() }}>
173
+ <RefreshCw className="w-4 h-4 mr-1" /> 重试
174
+ </Button>
175
+ </div>
176
+ )
177
+ }
178
+
179
+ return (
180
+ <div className="space-y-6">
181
+ {/* Header */}
182
+ <div className="flex items-center justify-between">
183
+ <div>
184
+ <h2 className="text-2xl font-bold tracking-tight">定时任务</h2>
185
+ <p className="text-muted-foreground text-sm mt-1">
186
+ 管理持久化定时任务和查看插件注册的内存任务
187
+ </p>
188
+ </div>
189
+ <div className="flex items-center gap-2">
190
+ <Button variant="outline" size="sm" onClick={() => { setLoading(true); fetchCrons() }}>
191
+ <RefreshCw className="w-4 h-4 mr-1" /> 刷新
192
+ </Button>
193
+ <Button size="sm" onClick={() => setAddDialogOpen(true)}>
194
+ <Plus className="w-4 h-4 mr-1" /> 新建任务
195
+ </Button>
196
+ </div>
197
+ </div>
198
+
199
+ {/* Persistent Cron Jobs */}
200
+ <div>
201
+ <div className="flex items-center gap-2 mb-3">
202
+ <Timer className="w-5 h-5 text-primary" />
203
+ <h3 className="text-lg font-semibold">持久化任务</h3>
204
+ <Badge variant="secondary">{persistentCrons.length}</Badge>
205
+ </div>
206
+ {persistentCrons.length === 0 ? (
207
+ <Card>
208
+ <CardContent className="py-8 text-center text-muted-foreground">
209
+ <Clock className="w-10 h-10 mx-auto mb-3 opacity-40" />
210
+ <p>暂无持久化定时任务</p>
211
+ <p className="text-xs mt-1">点击「新建任务」添加一个定时 AI 任务</p>
212
+ </CardContent>
213
+ </Card>
214
+ ) : (
215
+ <div className="grid grid-cols-1 gap-3">
216
+ {persistentCrons.map((job) => {
217
+ const isExpanded = expandedId === job.id
218
+ return (
219
+ <Card key={job.id} className={!job.enabled ? 'opacity-60' : ''}>
220
+ <CardContent className="py-4">
221
+ <div
222
+ className="flex items-start justify-between gap-4 cursor-pointer"
223
+ onClick={() => setExpandedId(isExpanded ? null : job.id)}
224
+ >
225
+ <div className="flex-1 min-w-0">
226
+ <div className="flex items-center gap-2 mb-1">
227
+ {isExpanded ? <ChevronUp className="w-4 h-4 shrink-0 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 shrink-0 text-muted-foreground" />}
228
+ <span className="font-medium truncate">
229
+ {job.label || job.id}
230
+ </span>
231
+ <Badge variant={job.enabled ? 'default' : 'outline'} className="text-xs shrink-0">
232
+ {job.enabled ? '运行中' : '已暂停'}
233
+ </Badge>
234
+ </div>
235
+ <div className="flex items-center gap-3 text-xs text-muted-foreground mb-2 ml-6">
236
+ <code className="bg-muted px-1.5 py-0.5 rounded">{job.cronExpression}</code>
237
+ <span>创建于 {new Date(job.createdAt).toLocaleString()}</span>
238
+ </div>
239
+ {!isExpanded && (
240
+ <p className="text-sm text-muted-foreground line-clamp-1 ml-6">{job.prompt}</p>
241
+ )}
242
+ </div>
243
+ <div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
244
+ <Button
245
+ variant="ghost"
246
+ size="icon"
247
+ className="h-8 w-8"
248
+ title={job.enabled ? '暂停' : '恢复'}
249
+ onClick={() => handleToggle(job)}
250
+ >
251
+ {job.enabled ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
252
+ </Button>
253
+ <Button
254
+ variant="ghost"
255
+ size="icon"
256
+ className="h-8 w-8 text-destructive hover:text-destructive"
257
+ title="删除"
258
+ onClick={() => setDeleteTarget(job)}
259
+ >
260
+ <Trash2 className="w-4 h-4" />
261
+ </Button>
262
+ </div>
263
+ </div>
264
+ {isExpanded && (
265
+ <div className="mt-3 ml-6 space-y-3 border-t pt-3">
266
+ <div>
267
+ <div className="flex items-center justify-between mb-1">
268
+ <span className="text-xs font-medium text-muted-foreground">任务 ID</span>
269
+ <Button
270
+ variant="ghost"
271
+ size="icon"
272
+ className="h-6 w-6"
273
+ title="复制 ID"
274
+ onClick={() => {
275
+ navigator.clipboard.writeText(job.id)
276
+ setCopiedId(job.id)
277
+ setTimeout(() => setCopiedId(null), 1500)
278
+ }}
279
+ >
280
+ {copiedId === job.id ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
281
+ </Button>
282
+ </div>
283
+ <code className="text-xs bg-muted px-2 py-1 rounded block">{job.id}</code>
284
+ </div>
285
+ <div>
286
+ <span className="text-xs font-medium text-muted-foreground block mb-1">Cron 表达式</span>
287
+ <code className="text-sm bg-muted px-2 py-1 rounded block">{job.cronExpression}</code>
288
+ </div>
289
+ <div>
290
+ <span className="text-xs font-medium text-muted-foreground block mb-1">Prompt</span>
291
+ <pre className="text-sm bg-muted px-3 py-2 rounded whitespace-pre-wrap break-words max-h-60 overflow-y-auto">{job.prompt}</pre>
292
+ </div>
293
+ {job.context && Object.values(job.context).some(Boolean) && (
294
+ <div>
295
+ <span className="text-xs font-medium text-muted-foreground block mb-1">执行上下文</span>
296
+ <div className="bg-muted px-3 py-2 rounded text-xs space-y-1">
297
+ {job.context.platform && <p><span className="text-muted-foreground">平台:</span> {job.context.platform}</p>}
298
+ {job.context.botId && <p><span className="text-muted-foreground">Bot:</span> {job.context.botId}</p>}
299
+ {job.context.senderId && <p><span className="text-muted-foreground">发送者:</span> {job.context.senderId}</p>}
300
+ {job.context.sceneId && <p><span className="text-muted-foreground">场景:</span> {job.context.sceneId}</p>}
301
+ {job.context.scope && <p><span className="text-muted-foreground">类型:</span> {job.context.scope}</p>}
302
+ </div>
303
+ </div>
304
+ )}
305
+ <div className="flex items-center gap-4 text-xs text-muted-foreground">
306
+ <span>状态: {job.enabled ? '✅ 运行中' : '⏸️ 已暂停'}</span>
307
+ <span>创建于: {new Date(job.createdAt).toLocaleString()}</span>
308
+ </div>
309
+ </div>
310
+ )}
311
+ </CardContent>
312
+ </Card>
313
+ )
314
+ })}
315
+ </div>
316
+ )}
317
+ </div>
318
+
319
+ <Separator />
320
+
321
+ {/* Memory Cron Jobs (read-only) */}
322
+ <div>
323
+ <div className="flex items-center gap-2 mb-3">
324
+ <Cpu className="w-5 h-5 text-muted-foreground" />
325
+ <h3 className="text-lg font-semibold">内存任务(插件注册)</h3>
326
+ <Badge variant="outline">{memoryCrons.length}</Badge>
327
+ </div>
328
+ {memoryCrons.length === 0 ? (
329
+ <Card>
330
+ <CardContent className="py-6 text-center text-muted-foreground text-sm">
331
+ 暂无插件注册的内存定时任务
332
+ </CardContent>
333
+ </Card>
334
+ ) : (
335
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
336
+ {memoryCrons.map((cron, idx) => {
337
+ const isExpanded = expandedMemIdx === idx
338
+ return (
339
+ <Card key={idx} className={!cron.running ? 'opacity-60' : ''}>
340
+ <CardContent className="py-3">
341
+ <div
342
+ className="flex items-center justify-between mb-1 cursor-pointer"
343
+ onClick={() => setExpandedMemIdx(isExpanded ? null : idx)}
344
+ >
345
+ <div className="flex items-center gap-2">
346
+ {isExpanded ? <ChevronUp className="w-4 h-4 shrink-0 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 shrink-0 text-muted-foreground" />}
347
+ <code className="text-sm bg-muted px-1.5 py-0.5 rounded">{cron.expression}</code>
348
+ </div>
349
+ <Badge variant={cron.running ? 'default' : 'outline'} className="text-xs">
350
+ {cron.running ? '运行中' : '已停止'}
351
+ </Badge>
352
+ </div>
353
+ <div className="text-xs text-muted-foreground space-y-0.5 ml-6">
354
+ <p>插件: {cron.plugin}</p>
355
+ {cron.nextExecution && (
356
+ <p>下次执行: {new Date(cron.nextExecution).toLocaleString()}</p>
357
+ )}
358
+ </div>
359
+ {isExpanded && (
360
+ <div className="mt-3 ml-6 space-y-2 border-t pt-3">
361
+ <div>
362
+ <span className="text-xs font-medium text-muted-foreground block mb-1">Cron 表达式</span>
363
+ <code className="text-sm bg-muted px-2 py-1 rounded block">{cron.expression}</code>
364
+ </div>
365
+ <div>
366
+ <span className="text-xs font-medium text-muted-foreground block mb-1">所属插件</span>
367
+ <code className="text-sm bg-muted px-2 py-1 rounded block">{cron.plugin}</code>
368
+ </div>
369
+ <div className="flex items-center gap-4 text-xs text-muted-foreground">
370
+ <span>状态: {cron.running ? '✅ 运行中' : '⏹️ 已停止'}</span>
371
+ {cron.nextExecution && (
372
+ <span>下次执行: {new Date(cron.nextExecution).toLocaleString()}</span>
373
+ )}
374
+ </div>
375
+ </div>
376
+ )}
377
+ </CardContent>
378
+ </Card>
379
+ )
380
+ })}
381
+ </div>
382
+ )}
383
+ </div>
384
+
385
+ {/* Add Dialog */}
386
+ <Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
387
+ <DialogContent>
388
+ <DialogHeader>
389
+ <DialogTitle>新建定时任务</DialogTitle>
390
+ <DialogDescription>
391
+ 创建一个持久化定时任务,到点时会将 Prompt 发送给 AI 执行。
392
+ </DialogDescription>
393
+ </DialogHeader>
394
+ <div className="space-y-4 py-2">
395
+ <div>
396
+ <label className="text-sm font-medium mb-1.5 block">标签(可选)</label>
397
+ <Input
398
+ placeholder="例如:每日摘要"
399
+ value={newCron.label}
400
+ onChange={(e) => setNewCron((p) => ({ ...p, label: e.target.value }))}
401
+ />
402
+ </div>
403
+ <div>
404
+ <label className="text-sm font-medium mb-1.5 block">Cron 表达式</label>
405
+ <Input
406
+ placeholder="分 时 日 月 周,例如:0 9 * * *"
407
+ value={newCron.cronExpression}
408
+ onChange={(e) => setNewCron((p) => ({ ...p, cronExpression: e.target.value }))}
409
+ />
410
+ <p className="text-xs text-muted-foreground mt-1">
411
+ 5 字段格式:分(0-59) 时(0-23) 日(1-31) 月(1-12) 周(0-7)
412
+ </p>
413
+ </div>
414
+ <div>
415
+ <label className="text-sm font-medium mb-1.5 block">Prompt</label>
416
+ <Textarea
417
+ placeholder="触发时发送给 AI 的指令..."
418
+ rows={4}
419
+ value={newCron.prompt}
420
+ onChange={(e) => setNewCron((p) => ({ ...p, prompt: e.target.value }))}
421
+ />
422
+ </div>
423
+ <Separator />
424
+ <div>
425
+ <label className="text-sm font-medium mb-1.5 block">执行上下文(可选)</label>
426
+ <p className="text-xs text-muted-foreground mb-3">
427
+ 指定任务执行时的身份信息,如不填则以 system 身份执行
428
+ </p>
429
+ <div className="grid grid-cols-2 gap-3">
430
+ <div>
431
+ <label className="text-xs text-muted-foreground mb-1 block">适配器</label>
432
+ <select
433
+ className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
434
+ value={newCron.context.platform || ''}
435
+ onChange={(e) => {
436
+ const platform = e.target.value
437
+ setNewCron((p) => ({ ...p, context: { ...p.context, platform, botId: '' } }))
438
+ }}
439
+ >
440
+ <option value="">不指定</option>
441
+ {[...new Set(bots.map((b) => b.adapter))].map((adapter) => (
442
+ <option key={adapter} value={adapter}>{adapter}</option>
443
+ ))}
444
+ </select>
445
+ </div>
446
+ <div>
447
+ <label className="text-xs text-muted-foreground mb-1 block">Bot</label>
448
+ <select
449
+ className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
450
+ value={newCron.context.botId || ''}
451
+ disabled={!newCron.context.platform}
452
+ onChange={(e) => setNewCron((p) => ({ ...p, context: { ...p.context, botId: e.target.value } }))}
453
+ >
454
+ <option value="">不指定</option>
455
+ {bots
456
+ .filter((b) => b.adapter === newCron.context.platform)
457
+ .map((b) => (
458
+ <option key={b.name} value={b.name}>{b.name}</option>
459
+ ))}
460
+ </select>
461
+ </div>
462
+ <div>
463
+ <label className="text-xs text-muted-foreground mb-1 block">场景类型</label>
464
+ <select
465
+ className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
466
+ value={newCron.context.scope || ''}
467
+ onChange={(e) => setNewCron((p) => ({ ...p, context: { ...p.context, scope: e.target.value } }))}
468
+ >
469
+ <option value="">不指定</option>
470
+ <option value="private">私聊 (private)</option>
471
+ <option value="group">群聊 (group)</option>
472
+ <option value="channel">频道 (channel)</option>
473
+ </select>
474
+ </div>
475
+ <div>
476
+ <label className="text-xs text-muted-foreground mb-1 block">发送者 ID</label>
477
+ <Input
478
+ placeholder="用户 ID"
479
+ value={newCron.context.senderId || ''}
480
+ onChange={(e) => setNewCron((p) => ({ ...p, context: { ...p.context, senderId: e.target.value } }))}
481
+ />
482
+ </div>
483
+ <div>
484
+ <label className="text-xs text-muted-foreground mb-1 block">场景 ID</label>
485
+ <Input
486
+ placeholder="群号/频道ID"
487
+ value={newCron.context.sceneId || ''}
488
+ onChange={(e) => setNewCron((p) => ({ ...p, context: { ...p.context, sceneId: e.target.value } }))}
489
+ />
490
+ </div>
491
+ </div>
492
+ </div>
493
+ </div>
494
+ <DialogFooter>
495
+ <DialogClose asChild>
496
+ <Button variant="outline">取消</Button>
497
+ </DialogClose>
498
+ <Button
499
+ onClick={handleAdd}
500
+ disabled={submitting || !newCron.cronExpression || !newCron.prompt}
501
+ >
502
+ {submitting ? '创建中...' : '创建'}
503
+ </Button>
504
+ </DialogFooter>
505
+ </DialogContent>
506
+ </Dialog>
507
+
508
+ {/* Delete Confirm Dialog */}
509
+ <Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
510
+ <DialogContent>
511
+ <DialogHeader>
512
+ <DialogTitle>确认删除</DialogTitle>
513
+ <DialogDescription>
514
+ 确定要删除任务「{deleteTarget?.label || deleteTarget?.id}」吗?此操作不可撤销。
515
+ </DialogDescription>
516
+ </DialogHeader>
517
+ <DialogFooter>
518
+ <DialogClose asChild>
519
+ <Button variant="outline">取消</Button>
520
+ </DialogClose>
521
+ <Button variant="destructive" onClick={handleDelete} disabled={submitting}>
522
+ {submitting ? '删除中...' : '删除'}
523
+ </Button>
524
+ </DialogFooter>
525
+ </DialogContent>
526
+ </Dialog>
527
+ </div>
528
+ )
529
+ }