采集任务支持手动触发 + 定时调度(cron)

后端:
- 新增 Scheduler + ScheduledTask 核心模块(croniter 解析 cron 表达式)
- 新增 Schedule 数据模型(alembic 0016)
- 新增 /api/v1/admin/schedules CRUD 接口
- lifespan 启动时注册默认定时任务并加载数据库配置
- 默认任务:每日比赛/积分榜/30分钟统计回填(默认禁用)

前端:
- 设置页新增「定时任务」管理区块
- 支持:新建/启用禁用/立即执行/删除定时任务
- 显示:任务ID、类型、cron表达式、上次执行时间/状态

依赖:新增 croniter>=2.0

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
This commit is contained in:
shangfangjian
2026-09-21 01:36:54 +08:00
co-authored by new-provider/LongCat-2.0 <
parent 1fc799a5b0
commit 0a5a14cbbb
9 changed files with 461 additions and 0 deletions
+77
View File
@@ -13,7 +13,9 @@ import {
fetchSettings, updateSetting, clearSetting,
testLLMConnection, fetchLLMUsageStats, fetchLLMModels,
fetchKeyRingStatus, resetKeyRingCooldown,
fetchSchedules, createSchedule, updateSchedule, deleteSchedule, runScheduleNow,
} from '../dal'
import type { ScheduleItem } from '../dal'
import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../api'
import type { LLMUsageStats, DataSourceSetting } from '../types'
import type { KeyRingStatusResponse } from '../dal'
@@ -49,6 +51,10 @@ export default function SettingsPage() {
const [pwdBusy, setPwdBusy] = useState(false)
const [pwdNotice, setPwdNotice] = useState<{ ok: boolean; text: string } | null>(null)
// 定时任务
const [schedules, setSchedules] = useState<ScheduleItem[]>([])
const [schedulesLoading, setSchedulesLoading] = useState(true)
// ── 数据加载 ──
const loadSettings = useCallback(async () => {
setSettingsLoading(true)
@@ -89,6 +95,7 @@ export default function SettingsPage() {
loadLlmStats()
loadKeyRing()
fetchAuthState().then(s => setPasswordOrigin(s.password_origin ?? null)).catch(() => {})
fetchSchedules().then(setSchedules).catch(() => []).finally(() => setSchedulesLoading(false))
}, [loadSettings, loadLlmStats, loadKeyRing])
const dataSourceSettings = allSettings.filter(s => DATA_SOURCE_KEYS.includes(s.key))
@@ -362,6 +369,76 @@ export default function SettingsPage() {
</CardBody>
</Card>
</section>
{/* ── 4. 定时任务 ── */}
<section>
<h2 className="section-head mb-3"></h2>
<Card>
<CardHeader
title="采集调度"
description="配置 cron 表达式定时触发采集任务"
action={
<button
onClick={() => {
createSchedule({ id: `schedule-${Date.now()}`, task: 'events', cron: '0 8 * * *', leagues: undefined, enabled: false })
.then(() => fetchSchedules().then(setSchedules))
}}
className="btn btn-sm"
>
+
</button>
}
/>
<CardBody>
{schedulesLoading ? (
<SkeletonBlock className="h-10 w-full" />
) : schedules.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-400">,+ </p>
) : (
<div className="space-y-2">
{schedules.map(s => (
<div key={s.id} className="flex flex-col gap-2 border-b border-ink-100 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full ${s.enabled ? 'bg-emerald-500' : 'bg-ink-300'}`} />
<span className="text-xs font-medium text-ink-800">{s.id}</span>
<Badge status={s.enabled ? 'success' : 'muted'}>{s.task}</Badge>
</div>
<p className="font-mono text-2xs text-ink-500">{s.cron}</p>
{s.last_run_at && (
<p className="text-2xs text-ink-400">
: {new Date(s.last_run_at).toLocaleString('zh-CN', { hour12: false })}
{s.last_status === 'success' ? ' ✓' : s.last_status === 'failed' ? ' ✗' : ''}
</p>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => runScheduleNow(s.id)}
className="btn btn-sm"
>
</button>
<button
onClick={() => updateSchedule(s.id, { enabled: !s.enabled }).then(() => fetchSchedules().then(setSchedules))}
className={`btn btn-sm ${s.enabled ? '' : 'btn-solid'}`}
>
{s.enabled ? '禁用' : '启用'}
</button>
<button
onClick={() => deleteSchedule(s.id).then(() => fetchSchedules().then(setSchedules))}
className="btn btn-sm btn-danger"
>
</button>
</div>
</div>
))}
</div>
)}
</CardBody>
</Card>
</section>
</div>
)
}