Skip to content

Commit 4f84d6b

Browse files
author
linyuan.yang
committed
up
1 parent a2b89c8 commit 4f84d6b

12 files changed

Lines changed: 519 additions & 24 deletions

File tree

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
<script setup lang="ts">
2+
import { ref, computed } from 'vue'
3+
import { useI18n } from 'vue-i18n'
4+
import { apiFetch } from '@/shared/api'
5+
import { useToast, useConfirm, SModal, SButton, SBadge } from 'sbot-ui'
6+
import { WEB_CHANNEL_ID } from 'sbot.commons'
7+
8+
const { t, locale } = useI18n()
9+
const { show } = useToast()
10+
const { confirm } = useConfirm()
11+
12+
interface SchedulerRow {
13+
id: number
14+
expr: string
15+
message: string
16+
targetId: string | null
17+
aiProcess: boolean
18+
lastRun: number | null
19+
nextRun: number | null
20+
runCount: number
21+
maxRuns: number
22+
}
23+
24+
interface ChannelSessionRow {
25+
id: number
26+
channelId: string
27+
sessionId: string
28+
}
29+
30+
type UIType = 'daily' | 'weekly' | 'monthly' | 'once' | 'interval' | 'hourly' | 'custom'
31+
32+
const TYPE_VARIANT: Record<UIType, 'info' | 'accent' | 'success' | 'warning' | 'neutral'> = {
33+
interval: 'info',
34+
hourly: 'accent',
35+
daily: 'success',
36+
weekly: 'warning',
37+
monthly: 'warning',
38+
once: 'accent',
39+
custom: 'neutral',
40+
}
41+
42+
const visible = ref(false)
43+
const dbSessionId = ref<number | null>(null)
44+
const sessionIdRef = ref<string | null>(null)
45+
const sessionLabel = ref('')
46+
const loading = ref(false)
47+
const all = ref<SchedulerRow[]>([])
48+
49+
const filtered = computed(() => {
50+
if (dbSessionId.value == null) return []
51+
const target = String(dbSessionId.value)
52+
return all.value.filter(r => r.targetId === target)
53+
})
54+
55+
interface CronFields { s: string; m: string; h: string; dom: string; mon: string; dow: string }
56+
57+
function parseCron(expr: string): CronFields | null {
58+
const p = expr.trim().split(/\s+/)
59+
if (p.length === 6) return { s: p[0], m: p[1], h: p[2], dom: p[3], mon: p[4], dow: p[5] }
60+
if (p.length === 5) return { s: '0', m: p[0], h: p[1], dom: p[2], mon: p[3], dow: p[4] }
61+
return null
62+
}
63+
64+
function detectUIType(expr: string): UIType {
65+
const f = parseCron(expr)
66+
if (!f) return 'custom'
67+
const { s, m, h, dom, mon, dow } = f
68+
const allRest = dom === '*' && mon === '*' && dow === '*'
69+
if (/^\*\/\d+$/.test(s) && m === '*' && h === '*' && allRest) return 'interval'
70+
if (s === '0' && /^\*\/\d+$/.test(m) && h === '*' && allRest) return 'interval'
71+
if (s === '0' && m === '0' && /^\*\/\d+$/.test(h) && allRest) return 'interval'
72+
if (/^\d+$/.test(s) && /^\d+$/.test(m) && h === '*' && allRest) return 'hourly'
73+
if (/^\d+$/.test(s) && /^\d+$/.test(m) && /^\d+$/.test(h)) {
74+
if (dom === '*' && mon === '*' && /^\d$/.test(dow)) return 'weekly'
75+
if (/^\d+$/.test(dom) && /^\d+$/.test(mon) && dow === '*') return 'once'
76+
if (/^\d+$/.test(dom) && mon === '*' && dow === '*') return 'monthly'
77+
if (allRest) return 'daily'
78+
}
79+
return 'custom'
80+
}
81+
82+
function describeExpr(expr: string): string {
83+
const f = parseCron(expr)
84+
if (!f) return expr
85+
const { s, m, h, dom, mon, dow } = f
86+
const pad = (v: string) => String(parseInt(v)).padStart(2, '0')
87+
const allRest = dom === '*' && mon === '*' && dow === '*'
88+
89+
if (/^\*\/\d+$/.test(s) && m === '*' && h === '*' && allRest)
90+
return t('scheduler.cron_every_n_seconds', { n: s.slice(2) })
91+
if (s === '0' && /^\*\/\d+$/.test(m) && h === '*' && allRest)
92+
return t('scheduler.cron_every_n_minutes', { n: m.slice(2) })
93+
if (s === '0' && m === '0' && /^\*\/\d+$/.test(h) && allRest)
94+
return t('scheduler.cron_every_n_hours', { n: h.slice(2) })
95+
96+
if (/^\d+$/.test(s) && /^\d+$/.test(m) && h === '*' && allRest) {
97+
if (s === '0' && m === '0') return t('scheduler.cron_hourly')
98+
if (s === '0') return t('scheduler.cron_hourly_at', { minute: pad(m) })
99+
return t('scheduler.cron_hourly_at_sec', { minute: pad(m), second: pad(s) })
100+
}
101+
102+
if (/^\d+$/.test(s) && /^\d+$/.test(m) && /^\d+$/.test(h)) {
103+
const time = s === '0'
104+
? `${pad(h)}:${pad(m)}`
105+
: `${pad(h)}:${pad(m)}:${pad(s)}`
106+
if (dom === '*' && mon === '*' && /^\d$/.test(dow))
107+
return t('scheduler.cron_weekly', { day: t(`scheduler.weekday_${dow}`), time })
108+
if (/^\d+$/.test(dom) && /^\d+$/.test(mon) && dow === '*')
109+
return t('scheduler.cron_once', { month: parseInt(mon), day: parseInt(dom), time })
110+
if (/^\d+$/.test(dom) && mon === '*' && dow === '*')
111+
return t('scheduler.cron_monthly', { day: dom, time })
112+
if (allRest)
113+
return t('scheduler.cron_daily', { time })
114+
}
115+
116+
return expr
117+
}
118+
119+
function formatTime(ts: number | null): string {
120+
if (!ts) return '-'
121+
return new Date(ts).toLocaleString(locale.value === 'zh' ? 'zh-CN' : undefined)
122+
}
123+
124+
async function load() {
125+
loading.value = true
126+
try {
127+
const [timersRes, sessionsRes] = await Promise.all([
128+
apiFetch('/api/schedulers'),
129+
apiFetch(`/api/channel-sessions?channelId=${encodeURIComponent(WEB_CHANNEL_ID)}`),
130+
])
131+
all.value = timersRes.data || []
132+
if (dbSessionId.value == null && sessionIdRef.value) {
133+
const sessions: ChannelSessionRow[] = sessionsRes.data || []
134+
const row = sessions.find(s => s.channelId === WEB_CHANNEL_ID && s.sessionId === sessionIdRef.value)
135+
dbSessionId.value = row?.id ?? null
136+
}
137+
} catch (e: any) {
138+
show(e?.message || String(e), 'error')
139+
} finally {
140+
loading.value = false
141+
}
142+
}
143+
144+
async function remove(row: SchedulerRow) {
145+
if (!await confirm(t('scheduler.confirm_delete', { id: row.id }), { danger: true })) return
146+
try {
147+
await apiFetch(`/api/schedulers/${row.id}`, 'DELETE')
148+
show(t('common.deleted'))
149+
await load()
150+
} catch (e: any) {
151+
show(e?.message || String(e), 'error')
152+
}
153+
}
154+
155+
function open(id: number, label: string) {
156+
dbSessionId.value = id
157+
sessionIdRef.value = null
158+
sessionLabel.value = label
159+
all.value = []
160+
visible.value = true
161+
load()
162+
}
163+
164+
function openBySessionId(sid: string, label: string) {
165+
dbSessionId.value = null
166+
sessionIdRef.value = sid
167+
sessionLabel.value = label
168+
all.value = []
169+
visible.value = true
170+
load()
171+
}
172+
173+
defineExpose({ open, openBySessionId })
174+
</script>
175+
176+
<template>
177+
<SModal v-model:visible="visible" width="lg">
178+
<template #header>
179+
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
180+
<h3 class="s-modal-title">{{ t('scheduler.title') }}</h3>
181+
<SBadge variant="neutral" size="sm">{{ sessionLabel }}</SBadge>
182+
</div>
183+
</template>
184+
185+
<template #toolbar>
186+
<SButton type="outline" size="sm" :disabled="loading" @click="load">
187+
{{ loading ? t('common.loading') : t('common.refresh') }}
188+
</SButton>
189+
</template>
190+
191+
<div v-if="loading" class="modal-loading">{{ t('common.loading') }}</div>
192+
<div v-else-if="filtered.length === 0" class="modal-empty">{{ t('scheduler.empty') }}</div>
193+
<ul v-else class="sched-list">
194+
<li v-for="row in filtered" :key="row.id" class="sched-row">
195+
<div class="sched-row-main">
196+
<span class="sched-row-id">#{{ row.id }}</span>
197+
<SBadge :variant="TYPE_VARIANT[detectUIType(row.expr)]" size="sm" pill>
198+
{{ t(`scheduler.type_${detectUIType(row.expr)}`) }}
199+
</SBadge>
200+
<span class="sched-row-desc" :title="row.expr">{{ describeExpr(row.expr) }}</span>
201+
<SBadge :variant="row.aiProcess ? 'accent' : 'neutral'" size="sm">
202+
{{ row.aiProcess ? t('scheduler.mode_ai') : t('scheduler.mode_raw') }}
203+
</SBadge>
204+
</div>
205+
<div class="sched-row-meta">
206+
<span class="sched-row-message" :title="row.message">{{ row.message }}</span>
207+
<span class="sched-row-time">⏱ {{ t('scheduler.next_run_col') }}: {{ formatTime(row.nextRun) }}</span>
208+
<span v-if="row.lastRun" class="sched-row-time">↺ {{ t('scheduler.last_run_col') }}: {{ formatTime(row.lastRun) }}</span>
209+
<span class="sched-row-time">
210+
×{{ row.runCount }}<template v-if="row.maxRuns > 0">/{{ row.maxRuns }}</template>
211+
</span>
212+
</div>
213+
<div class="sched-row-ops">
214+
<SButton type="danger" size="sm" @click="remove(row)">{{ t('common.delete') }}</SButton>
215+
</div>
216+
</li>
217+
</ul>
218+
</SModal>
219+
</template>
220+
221+
<style scoped>
222+
.modal-loading,
223+
.modal-empty {
224+
text-align: center;
225+
color: var(--sui-fg-disabled);
226+
padding: 60px 0;
227+
font-size: var(--sui-fs-lg);
228+
}
229+
230+
.sched-list {
231+
list-style: none;
232+
margin: 0;
233+
padding: 0;
234+
display: flex;
235+
flex-direction: column;
236+
}
237+
.sched-row {
238+
display: grid;
239+
grid-template-columns: 1fr auto;
240+
grid-template-rows: auto auto;
241+
gap: 4px var(--sui-sp-3);
242+
align-items: center;
243+
padding: var(--sui-sp-3) 0;
244+
border-bottom: 1px solid var(--sui-border);
245+
}
246+
.sched-row:last-child { border-bottom: none; }
247+
.sched-row-main {
248+
display: flex;
249+
align-items: center;
250+
gap: var(--sui-sp-3);
251+
min-width: 0;
252+
flex-wrap: wrap;
253+
}
254+
.sched-row-id {
255+
font-family: var(--sui-font-mono);
256+
font-size: var(--sui-fs-xs);
257+
color: var(--sui-fg-disabled);
258+
flex-shrink: 0;
259+
}
260+
.sched-row-desc {
261+
font-size: var(--sui-fs-md);
262+
color: var(--sui-fg);
263+
cursor: help;
264+
}
265+
.sched-row-meta {
266+
grid-column: 1;
267+
display: flex;
268+
flex-wrap: wrap;
269+
align-items: center;
270+
gap: var(--sui-sp-3);
271+
padding-left: 28px;
272+
}
273+
.sched-row-message {
274+
font-size: var(--sui-fs-sm);
275+
color: var(--sui-fg-secondary);
276+
max-width: 360px;
277+
overflow: hidden;
278+
text-overflow: ellipsis;
279+
white-space: nowrap;
280+
}
281+
.sched-row-time {
282+
font-size: var(--sui-fs-xs);
283+
color: var(--sui-fg-disabled);
284+
white-space: nowrap;
285+
}
286+
.sched-row-ops {
287+
grid-column: 2;
288+
grid-row: 1 / span 2;
289+
display: flex;
290+
gap: var(--sui-sp-2);
291+
align-items: center;
292+
flex-shrink: 0;
293+
}
294+
</style>

packages/admin/src/i18n/en.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,18 @@ export default {
1919
mcp: 'Tools',
2020
skills: 'Skills',
2121
prompts: 'Prompts',
22-
group_admin: 'Admin',
22+
group_monitor: 'Monitor',
2323
heartbeats: 'Heartbeats',
24-
scheduler: 'Scheduler',
2524
processes: 'ACP Processes',
25+
group_tasks: 'Tasks',
26+
scheduler: 'Scheduler',
2627
todo: 'Todo',
28+
group_system: 'System',
2729
explorer: 'Explorer',
2830
logs: 'Logs',
2931
about: 'About',
32+
sidebar_collapse: 'Collapse sidebar',
33+
sidebar_expand: 'Expand sidebar',
3034
app_title: 'SBot Settings',
3135
reload: 'Reload Config',
3236
reload_success: 'Configuration reloaded',
@@ -416,6 +420,7 @@ export default {
416420
cat_insight: 'Insight',
417421
cat_intent: 'Intent Filter',
418422
cat_heartbeat: 'Heartbeat',
423+
cat_todo: 'Todo',
419424
create_file: 'New File',
420425
create_title: 'New Prompt',
421426
filename: 'Filename',

packages/admin/src/i18n/zh.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,18 @@ export default {
1919
mcp: '工具管理',
2020
skills: '技能管理',
2121
prompts: '提示词管理',
22-
group_admin: '管理',
22+
group_monitor: '监控',
2323
heartbeats: '心跳管理',
24-
scheduler: '计时器管理',
2524
processes: 'ACP进程管理',
25+
group_tasks: '任务',
26+
scheduler: '计时器管理',
2627
todo: '待办事项',
28+
group_system: '系统',
2729
explorer: '资源管理器',
2830
logs: '日志',
2931
about: '关于',
32+
sidebar_collapse: '折叠侧栏',
33+
sidebar_expand: '展开侧栏',
3034
app_title: 'SBot 设置',
3135
reload: '重载配置',
3236
reload_success: '配置已重载',
@@ -416,6 +420,7 @@ export default {
416420
cat_insight: '经验洞察',
417421
cat_intent: '意图过滤',
418422
cat_heartbeat: '心跳',
423+
cat_todo: '待办',
419424
create_file: '新建文件',
420425
create_title: '新建 Prompt',
421426
filename: '文件名',

0 commit comments

Comments
 (0)