Files
MediaCrawler/webui/src/components/monitor/TaskEditorDialog.tsx
T
butubb 8f4e5586e9
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(schedule): 任务支持「每天定时 / 每周定时」,用选择器而不是手写 cron
- schedule.py: 新增调度计算模块(纯函数,便于单测)。三种模式:interval(每 N 分钟)/ daily(选钟点)/ weekly(选星期 + 钟点)
- 钟点模式是「固定时刻」而非「固定延迟」——从日历重算,所以某轮跑晚了不会把之后每一轮都拖晚。interval 保持原语义:从上一轮开始计时
- 抖动只加给 interval。给「每天 9:00」也加抖动就成了 9:00–9:01 随机触发,操作者选的时间被悄悄改掉,只会像 bug
- 模型 / schema / service: 新增 schedule_mode / schedule_hours / schedule_days / schedule_minute。时钟字段存逗号分隔文本——几个小整数、永远整体读写,单开一张表只会换来 join。interval_minutes 保留且仍是默认值,已有任务不受影响
- service: 改动任何调度字段都按合并后的状态重算 next_run_at。重新启用也算改动,否则停用一个月再打开会带着一个月前的 next_run_at,一保存就立即触发
- 前端: 运行方式三选一 + 小时/星期胶囊多选 + 分钟下拉,并实时预览结果句子。任务卡片改显示后端拼好的 schedule_label,避免列表和编辑器对同一计划给出两种说法
- 校验: 钟点模式至少选一个时间,按周至少选一个星期

tests/test_schedule.py 新增 24 个用例,含「恰好等于当前时刻的档位归属下一天」这个会让调度器自循环的边界。
2026-10-07 15:33:17 +08:00

452 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useCreateTask, useSettings, useUpdateTask } from '@/hooks/useMonitor'
import { describeSchedule } from '@/lib/monitorFormat'
import type {
MonitorMode,
MonitorTask,
ScheduleMode,
TaskCreatePayload,
} from '@/types/monitor'
interface TaskEditorDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
task?: MonitorTask | null
}
const TEXTAREA_CLASS =
'w-full rounded-md border border-cyber-border-DEFAULT bg-cyber-bg-tertiary p-2 font-mono text-[11px] text-cyber-text-primary outline-none focus-visible:border-cyber-neon-cyan/50'
// The backend enforces this floor too; every run launches a browser and hits
// XHS several times, so tight polling is what triggers rate limiting.
const INTERVAL_OPTIONS = [
{ value: '30', label: '30 分钟' },
{ value: '60', label: '1 小时' },
{ value: '120', label: '2 小时' },
{ value: '360', label: '6 小时' },
{ value: '720', label: '12 小时' },
{ value: '1440', label: '1 天' },
{ value: '4320', label: '3 天' },
{ value: '10080', label: '7 天' },
]
const SCHEDULE_MODE_OPTIONS: Array<{ value: ScheduleMode; label: string }> = [
{ value: 'interval', label: '固定间隔' },
{ value: 'daily', label: '每天定时' },
{ value: 'weekly', label: '每周定时' },
]
const HOURS = Array.from({ length: 24 }, (_, hour) => hour)
const MINUTES = Array.from({ length: 60 }, (_, minute) => minute)
const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
const pad = (value: number) => String(value).padStart(2, '0')
function toggleNumber(values: number[], value: number): number[] {
return values.includes(value)
? values.filter((item) => item !== value)
: [...values, value].sort((a, b) => a - b)
}
/** A selectable pill. Used for the hours of the day and the weekdays. */
function Chip({
active,
onClick,
children,
}: {
active: boolean
onClick: () => void
children: ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
className={`px-1.5 py-0.5 rounded border text-[10px] font-mono transition-colors ${
active
? 'border-cyber-neon-cyan/60 bg-cyber-neon-cyan/20 text-cyber-neon-cyan'
: 'border-cyber-border-DEFAULT text-cyber-text-muted hover:text-cyber-text-secondary'
}`}
>
{children}
</button>
)
}
export function TaskEditorDialog({ open, onOpenChange, task }: TaskEditorDialogProps) {
const isEdit = Boolean(task)
const createTask = useCreateTask()
const updateTask = useUpdateTask()
const { data: settings } = useSettings()
const [name, setName] = useState('')
const [mode, setMode] = useState<MonitorMode>('creator')
const [intervalMinutes, setIntervalMinutes] = useState('360')
const [scheduleMode, setScheduleMode] = useState<ScheduleMode>('interval')
const [scheduleHours, setScheduleHours] = useState<number[]>([])
const [scheduleDays, setScheduleDays] = useState<number[]>([])
const [scheduleMinute, setScheduleMinute] = useState('0')
const [maxNotes, setMaxNotes] = useState('20')
const [enableComments, setEnableComments] = useState(true)
const [maxComments, setMaxComments] = useState('50')
const [notifyEnabled, setNotifyEnabled] = useState(false)
const [targets, setTargets] = useState('')
// Reset the form whenever the dialog is (re)opened. For a new task the
// starting values come from the Settings page, so those defaults actually
// govern rather than being overwritten by hardcoded numbers here.
useEffect(() => {
if (!open) return
setName(task?.name ?? '')
setMode(task?.mode ?? 'creator')
setIntervalMinutes(
String(task?.interval_minutes ?? settings?.values['collect.default_interval_minutes'] ?? 360),
)
setScheduleMode(task?.schedule_mode ?? 'interval')
setScheduleHours(task?.schedule_hours ?? [])
setScheduleDays(task?.schedule_days ?? [])
setScheduleMinute(String(task?.schedule_minute ?? 0))
setMaxNotes(
String(task?.max_notes_count ?? settings?.values['collect.default_max_notes'] ?? 20),
)
setEnableComments(task?.enable_comments ?? true)
setMaxComments(
String(task?.max_comments_count ?? settings?.values['collect.default_max_comments'] ?? 50),
)
setNotifyEnabled(task?.notify_enabled ?? false)
setTargets(task ? task.targets.map((t) => t.raw_value || t.external_id).join('\n') : '')
}, [open, task, settings])
const targetList = targets
.split(/[\n,]+/)
.map((value) => value.trim())
.filter(Boolean)
// Switching into a clock mode with nothing chosen would be invalid, so seed a
// sensible starting point -- the operator then edits rather than fills a blank.
const selectScheduleMode = (next: ScheduleMode) => {
setScheduleMode(next)
if (next !== 'interval' && scheduleHours.length === 0) setScheduleHours([9])
if (next === 'weekly' && scheduleDays.length === 0) setScheduleDays([0, 1, 2, 3, 4])
}
const pending = createTask.isPending || updateTask.isPending
// Note-mode and creator-mode targets are different shapes, so switching mode
// would silently mis-parse the list. The backend decides parsing from the
// task's stored mode, hence mode is fixed once created.
const clockIncomplete =
scheduleMode !== 'interval' &&
(scheduleHours.length === 0 || (scheduleMode === 'weekly' && scheduleDays.length === 0))
const canSubmit =
name.trim().length > 0 && targetList.length > 0 && !pending && !clockIncomplete
const handleSubmit = () => {
const payload: TaskCreatePayload = {
name: name.trim(),
mode,
interval_minutes: Number(intervalMinutes),
schedule_mode: scheduleMode,
// Interval mode has no clock times, so send none rather than leave stale
// ones behind from a mode the operator tried and abandoned.
schedule_hours: scheduleMode === 'interval' ? [] : scheduleHours,
schedule_days: scheduleMode === 'weekly' ? scheduleDays : [],
schedule_minute: Number(scheduleMinute),
max_notes_count: Number(maxNotes),
enable_comments: enableComments,
max_comments_count: Number(maxComments),
run_timeout_seconds: 3600,
enabled: true,
notify_enabled: notifyEnabled,
targets: targetList,
}
const done = () => onOpenChange(false)
if (isEdit && task) {
updateTask.mutate({ id: task.id, payload }, { onSuccess: done })
} else {
createTask.mutate(payload, { onSuccess: done })
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto terminal-scroll">
<DialogHeader>
<DialogTitle className="font-mono">
{isEdit ? '编辑监控任务' : '新建监控任务'}
</DialogTitle>
<DialogDescription className="font-mono text-xs">
定时重复采集目标,并与上一次快照对比,产出新增作品、新增评论和互动数据变化。
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label className="text-xs font-mono text-cyber-text-secondary">任务名称</Label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="例如:竞品博主监控"
className="h-9 text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label className="text-xs font-mono text-cyber-text-secondary">监控类型</Label>
<Select
value={mode}
onValueChange={(value) => setMode(value as MonitorMode)}
disabled={isEdit}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="creator">博主(监控其作品)</SelectItem>
<SelectItem value="note">笔记(批量监控指定内容)</SelectItem>
</SelectContent>
</Select>
{isEdit && (
<p className="text-[10px] font-mono text-cyber-text-muted">
类型创建后不可更改
</p>
)}
</div>
<div className="space-y-2">
<Label className="text-xs font-mono text-cyber-text-secondary">运行计划</Label>
<div className="flex gap-1">
{SCHEDULE_MODE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => selectScheduleMode(option.value)}
className={`flex-1 rounded border px-1 py-1.5 text-[10px] font-mono transition-colors ${
scheduleMode === option.value
? 'border-cyber-neon-cyan/60 bg-cyber-neon-cyan/20 text-cyber-neon-cyan'
: 'border-cyber-border-DEFAULT text-cyber-text-muted hover:text-cyber-text-secondary'
}`}
>
{option.label}
</button>
))}
</div>
</div>
</div>
{scheduleMode === 'interval' ? (
<div className="space-y-2">
<Label className="text-xs font-mono text-cyber-text-secondary">采集间隔</Label>
<Select value={intervalMinutes} onValueChange={setIntervalMinutes}>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INTERVAL_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] font-mono text-cyber-text-muted">
从上一轮<span className="text-cyber-text-secondary">开始</span>计时,
所以某轮跑得久也不会让下一轮紧接着触发。
</p>
</div>
) : (
<div className="space-y-3 rounded-md border border-cyber-border-subtle bg-cyber-bg-tertiary/40 p-3">
{scheduleMode === 'weekly' && (
<div className="space-y-1.5">
<div className="text-[10px] font-mono text-cyber-text-secondary">星期</div>
<div className="flex flex-wrap gap-1">
{WEEKDAYS.map((label, day) => (
<Chip
key={day}
active={scheduleDays.includes(day)}
onClick={() => setScheduleDays((prev) => toggleNumber(prev, day))}
>
{label}
</Chip>
))}
</div>
</div>
)}
<div className="space-y-1.5">
<div className="text-[10px] font-mono text-cyber-text-secondary">
时间(可多选,点一次选中、再点取消)
</div>
<div className="flex flex-wrap gap-1">
{HOURS.map((hour) => (
<Chip
key={hour}
active={scheduleHours.includes(hour)}
onClick={() => setScheduleHours((prev) => toggleNumber(prev, hour))}
>
{pad(hour)}
</Chip>
))}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-cyber-text-secondary">分钟</span>
<Select value={scheduleMinute} onValueChange={setScheduleMinute}>
<SelectTrigger className="h-7 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-56">
{MINUTES.map((minute) => (
<SelectItem key={minute} value={String(minute)}>
{pad(minute)}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[10px] font-mono text-cyber-text-muted">
所有时间点共用
</span>
</div>
<div className="flex items-center justify-between gap-3 border-t border-cyber-border-subtle pt-2">
<span className="text-[11px] font-mono text-cyber-neon-cyan">
{describeSchedule(
scheduleMode,
Number(intervalMinutes),
scheduleHours,
scheduleDays,
Number(scheduleMinute),
)}
</span>
<span className="text-[10px] font-mono text-cyber-text-muted">
按服务器本地时间
</span>
</div>
</div>
)}
<div className="space-y-2">
<Label className="text-xs font-mono text-cyber-text-secondary">
{mode === 'creator' ? '博主主页链接或 ID' : '笔记链接或 ID'}
</Label>
<textarea
value={targets}
onChange={(event) => setTargets(event.target.value)}
rows={5}
placeholder={
mode === 'creator'
? '每行一个,支持完整主页链接或纯 ID:\nhttps://www.xiaohongshu.com/user/profile/5f58bd99...\n5f58bd990000000001003753'
: '每行一个,支持完整笔记链接或纯 ID:\nhttps://www.xiaohongshu.com/explore/6aa3d827...'
}
className={TEXTAREA_CLASS}
/>
<p className="text-[10px] font-mono text-cyber-text-muted">
已识别 <span className="text-cyber-neon-cyan">{targetList.length}</span> 个目标。
<span className="text-cyber-neon-orange">建议只填纯 ID</span>
——链接里的 xsec_token 会过期,纯 ID 永久有效。
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label className="text-xs font-mono text-cyber-text-secondary">
每轮最多采集作品数
</Label>
<Input
type="number"
min={1}
value={maxNotes}
onChange={(event) => setMaxNotes(event.target.value)}
className="h-9 text-xs"
/>
<p className="text-[10px] font-mono text-cyber-text-muted">
只取最新的前 N 条,决定了"该博主的作品"覆盖范围
</p>
</div>
<div className="space-y-2">
<Label className="text-xs font-mono text-cyber-text-secondary">每篇评论抓取条数</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={maxComments}
disabled={!enableComments}
onChange={(event) => setMaxComments(event.target.value)}
className="h-9 text-xs"
/>
<div className="flex items-center gap-1.5 flex-shrink-0">
<Checkbox
id="enable-comments"
checked={enableComments}
onCheckedChange={(checked) => setEnableComments(checked === true)}
/>
<label
htmlFor="enable-comments"
className="text-[10px] font-mono text-cyber-text-secondary cursor-pointer"
>
抓评论
</label>
</div>
</div>
<p className="text-[10px] font-mono text-cyber-neon-orange">
接口无时间排序,只能取平台默认排序的前 N 条做对比,N 越大越容易发现新评论
</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-md border border-cyber-border-subtle bg-cyber-bg-tertiary/40 p-3">
<Checkbox
id="notify-enabled"
checked={notifyEnabled}
onCheckedChange={(checked) => setNotifyEnabled(checked === true)}
/>
<div className="space-y-0.5">
<label
htmlFor="notify-enabled"
className="text-xs font-mono text-cyber-text-primary cursor-pointer"
>
推送企业微信通知
</label>
<p className="text-[10px] font-mono text-cyber-text-muted">
仅在本任务**采集失败 / 登录态失效**或**发现新作品**时推送,
一轮只发一条汇总。需先在监控页配置 Webhook 地址。
</p>
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit}>
{isEdit ? '保存' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}