Files
MediaCrawler/webui/src/components/monitor/TaskEditorDialog.tsx
T
butubb f3ea088c75
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
fix(monitor): 从界面上建的任务永远是小红书任务,平台从没被传上去
现象:站在抖音页面上新建任务,任务跑到小红书列表里去了;界面上还写着「笔记」。

根因在前端:TaskCreatePayload 里**根本没有 platform 字段**,handleSubmit 拼的 payload
自然也不带它,而后端是 `payload.get("platform") or PLATFORM_XHS` —— 于是不管在哪个
平台标签下建任务,落下来的都是小红书任务。以前只支持小红书,两边都看不出问题。

* TaskCreatePayload 补上 platform(并在注释里写明为什么它是必填),handleSubmit 带上
  当前平台。
* 更新任务时不带 platform:平台创建后不可更改,带着会让「平台能改」看起来像真的。
* 三处写死的「笔记」改成按平台取措辞(能力矩阵的 target_hints.note_label):
  · 任务编辑器的类型选择项「笔记(批量监控指定内容)」
  · 「笔记模式下此项不生效」那句提示
  · 任务卡片上的类型徽章 —— 它按**任务自己的**平台取词,不是当前平台,因为卡片未必
    只出现在同平台的列表里
  抖音管它们叫「作品」,小红书叫「笔记」,写死一个对另一个就是错的。

测试 +1:后端这一半也守住 —— 建任务时显式给了平台,就必须落到那个平台,且不得出现在
另一个平台的列表里。前端那半边是 UI,测不了,但后端守住能挡住「给了不用」这类退化。

注意:这次是纯前端漏传,后端那个「缺省回退小红书」的行为本身没变(有测试断言它是
有意为之的兼容行为)。要彻底消灭这类静默错误,可以把缺省值去掉、让 platform 必填 ——
那会破坏 API 兼容性,目前没有任何别的调用方,需要的话说一声。
2026-10-10 15:13:37 +08:00

542 lines
23 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 { useCurrentPlatform } from '@/hooks/usePlatform'
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)
/**
* Above this many works per run, warn about the request volume.
*
* The runner serialises crawls (max_concurrency_num=1) and each note also pulls
* up to `max_comments_count` comments, so the cost is works × (1 + comments) and
* the default run timeout is an hour.
*/
const NOTE_VOLUME_WARN = 500
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 { capability, platform } = useCurrentPlatform()
const hints = capability?.target_hints
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 [notifyFailures, setNotifyFailures] = useState(true)
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)
setNotifyFailures(task?.notify_failures ?? true)
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(),
// 当前平台。漏掉这一项,后端会退回小红书 —— 表现是「在抖音页面建的任务
// 跑到小红书列表里去了」,而且不报任何错。
platform,
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,
notify_failures: notifyFailures,
targets: targetList,
}
const done = () => onOpenChange(false)
if (isEdit && task) {
// 平台创建后不可更改,更新请求里就不带它了 —— 带着会让「平台能被改」这件事
// 看起来像是真的。
const updatePayload: Partial<TaskCreatePayload> = { ...payload }
delete updatePayload.platform
updateTask.mutate({ id: task.id, payload: updatePayload }, { 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">{`${hints?.note_label ?? '笔记'}(批量监控指定内容)`}</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'
? `${hints?.creator_label ?? '博主主页'}链接或 ID`
: `${hints?.note_label ?? '笔记'}链接或 ID`}
</Label>
<textarea
value={targets}
onChange={(event) => setTargets(event.target.value)}
rows={5}
placeholder={`每行一个,支持完整链接或纯 ID:\n${(mode === 'creator' ? hints?.creator : hints?.note) ?? ''}`}
className={TEXTAREA_CLASS}
/>
<p className="text-[10px] font-mono text-cyber-text-muted">
已识别 <span className="text-cyber-neon-cyan">{targetList.length}</span> 个目标。
{/* 「只填纯 ID」是小红书专属的劝告:它链接里的 xsec_token 会过期。
抖音的链接不带令牌,永久有效,那句话对它没有意义。 */}
{hints?.token_expires && (
<>
<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">
{mode === 'creator' ? '每个博主最多采集作品数' : '最多采集作品数'}
</Label>
<Input
type="number"
min={1}
value={maxNotes}
onChange={(event) => setMaxNotes(event.target.value)}
disabled={mode === 'note'}
className="h-9 text-xs"
/>
{mode === 'creator' ? (
<>
{/* 这是「每个博主」的上限,不是一轮的总量 —— 爬虫里这个值是在
per-creator 的函数内比较的(client.py get_all_notes_by_creator),
而外层 for 循环会遍历全部目标。所以 100 个目标 × 20 篇 = 单轮
最多 2000 篇。标签写成「每轮最多」会让人以为超出的会被丢弃。 */}
<p className="text-[10px] font-mono text-cyber-text-muted">
这是<span className="text-cyber-text-secondary">每个博主</span>的上限,
不是一轮的总量。只取该博主最新的前 N 条,超出的不会补抓。
</p>
<p className="text-[10px] font-mono text-cyber-text-secondary">
{targetList.length} 个目标 × {maxNotes} 篇 × 每人 1 次
→ 单轮最多{' '}
<span className="text-cyber-neon-cyan">
{targetList.length * (Number(maxNotes) || 0)}
</span>{' '}
篇
</p>
{targetList.length * (Number(maxNotes) || 0) > NOTE_VOLUME_WARN && (
<p className="text-[10px] font-mono text-cyber-neon-orange leading-relaxed">
单轮量偏大:每篇还要抓最多 {maxComments} 条评论,且并发为 1。
容易触发平台限流,也可能跑不完就被任务超时(默认 1 小时)中断。
建议调低这个数,或拆成几个任务。
</p>
)}
</>
) : (
<p className="text-[10px] font-mono text-cyber-neon-orange">
{hints?.note_label ?? '笔记'}模式下此项不生效:你列出的每个链接都会被逐条抓取。
</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="rounded-md border border-cyber-border-subtle bg-cyber-bg-tertiary/40 p-3 space-y-3">
{/* 两类通知的性质完全不同,所以分成两个开关:
异常低频且意味着任务已经停止工作 —— 默认开;
新作品可能每轮都有 —— 默认关,否则会刷屏。 */}
<div className="flex items-start gap-2">
<Checkbox
id="notify-failures"
checked={notifyFailures}
onCheckedChange={(checked) => setNotifyFailures(checked === true)}
/>
<div className="space-y-0.5">
<label
htmlFor="notify-failures"
className="text-xs font-mono text-cyber-text-primary cursor-pointer"
>
推送异常通知(建议保持开启)
</label>
<p className="text-[10px] font-mono text-cyber-text-muted leading-relaxed">
登录态失效、采集进程失败、一篇都没抓到时推送。
<span className="text-cyber-neon-cyan">
异常意味着这个任务从此默默采不到任何东西
</span>
—— 关掉的话你不会知道,直到某天发现数据停在几周前。
</p>
</div>
</div>
<div className="flex items-start gap-2 border-t border-cyber-border-subtle pt-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 leading-relaxed">
发现新作品时推送。监控多个博主时可能每轮都有,容易刷屏,所以默认关闭。
</p>
</div>
</div>
<p className="border-t border-cyber-border-subtle pt-2 text-[10px] font-mono text-cyber-text-muted">
两者都是一轮只发一条汇总。需先在右上角「系统设置」里配置企业微信 Webhook 地址。
</p>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit}>
{isEdit ? '保存' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}