现象:站在抖音页面上新建任务,任务跑到小红书列表里去了;界面上还写着「笔记」。
根因在前端:TaskCreatePayload 里**根本没有 platform 字段**,handleSubmit 拼的 payload
自然也不带它,而后端是 `payload.get("platform") or PLATFORM_XHS` —— 于是不管在哪个
平台标签下建任务,落下来的都是小红书任务。以前只支持小红书,两边都看不出问题。
* TaskCreatePayload 补上 platform(并在注释里写明为什么它是必填),handleSubmit 带上
当前平台。
* 更新任务时不带 platform:平台创建后不可更改,带着会让「平台能改」看起来像真的。
* 三处写死的「笔记」改成按平台取措辞(能力矩阵的 target_hints.note_label):
· 任务编辑器的类型选择项「笔记(批量监控指定内容)」
· 「笔记模式下此项不生效」那句提示
· 任务卡片上的类型徽章 —— 它按**任务自己的**平台取词,不是当前平台,因为卡片未必
只出现在同平台的列表里
抖音管它们叫「作品」,小红书叫「笔记」,写死一个对另一个就是错的。
测试 +1:后端这一半也守住 —— 建任务时显式给了平台,就必须落到那个平台,且不得出现在
另一个平台的列表里。前端那半边是 UI,测不了,但后端守住能挡住「给了不用」这类退化。
注意:这次是纯前端漏传,后端那个「缺省回退小红书」的行为本身没变(有测试断言它是
有意为之的兼容行为)。要彻底消灭这类静默错误,可以把缺省值去掉、让 platform 必填 ——
那会破坏 API 兼容性,目前没有任何别的调用方,需要的话说一声。
165 lines
5.6 KiB
TypeScript
165 lines
5.6 KiB
TypeScript
import { Bell, CalendarClock, Pencil, Play, Trash2 } from 'lucide-react'
|
|
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { useDeleteTask, useRunTaskNow, useUpdateTask } from '@/hooks/useMonitor'
|
|
import { useCurrentPlatform } from '@/hooks/usePlatform'
|
|
import { formatRelative } from '@/lib/monitorFormat'
|
|
import type { MonitorTask } from '@/types/monitor'
|
|
|
|
interface TaskCardProps {
|
|
task: MonitorTask
|
|
selected: boolean
|
|
onSelect: () => void
|
|
onEdit: () => void
|
|
}
|
|
|
|
/** Maps a task's last run status onto a badge variant. */
|
|
function statusVariant(status: string) {
|
|
switch (status) {
|
|
case 'success':
|
|
return 'success' as const
|
|
case 'running':
|
|
case 'pending':
|
|
return 'running' as const
|
|
case 'partial':
|
|
return 'warning' as const
|
|
case 'failed':
|
|
case 'timeout':
|
|
return 'destructive' as const
|
|
default:
|
|
return 'idle' as const
|
|
}
|
|
}
|
|
|
|
const STATUS_LABEL: Record<string, string> = {
|
|
idle: '未运行',
|
|
success: '成功',
|
|
partial: '部分失败',
|
|
failed: '失败',
|
|
timeout: '超时',
|
|
running: '运行中',
|
|
pending: '排队中',
|
|
interrupted: '已中断',
|
|
}
|
|
|
|
export function TaskCard({ task, selected, onSelect, onEdit }: TaskCardProps) {
|
|
const updateTask = useUpdateTask()
|
|
const deleteTask = useDeleteTask()
|
|
const runNow = useRunTaskNow()
|
|
// 按**任务自己的**平台取措辞,而不是当前平台 —— 卡片未必只出现在同平台的列表里。
|
|
// 抖音管它们叫「作品」,小红书叫「笔记」,写死一个对另一个就是错的。
|
|
const { platforms } = useCurrentPlatform()
|
|
const noteNoun =
|
|
platforms.find((entry) => entry.value === task.platform)?.target_hints?.note_label ??
|
|
'笔记'
|
|
|
|
// A suspected cookie failure surfaces here so it is visible without opening
|
|
// the event feed.
|
|
const authTrouble = task.last_status === 'partial'
|
|
|
|
return (
|
|
<div
|
|
onClick={onSelect}
|
|
className={`rounded-lg glass-panel float-panel p-3 cursor-pointer transition-all ${
|
|
selected
|
|
? 'border-cyber-neon-cyan/60 shadow-glow-cyan-sm'
|
|
: 'border-cyber-border-subtle hover:border-cyber-neon-cyan/30'
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-mono text-sm text-cyber-text-primary truncate">{task.name}</span>
|
|
<Badge variant="outline" className="text-[10px]">
|
|
{task.mode === 'creator' ? '博主' : noteNoun}
|
|
</Badge>
|
|
<Badge variant={statusVariant(task.last_status)} className="text-[10px]">
|
|
{STATUS_LABEL[task.last_status] ?? task.last_status}
|
|
</Badge>
|
|
{task.unread_events > 0 && (
|
|
<span className="flex items-center gap-1 text-[10px] font-mono text-cyber-neon-pink">
|
|
<Bell className="w-3 h-3" />
|
|
{task.unread_events}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-1.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] font-mono text-cyber-text-secondary">
|
|
<span>
|
|
目标 <span className="text-cyber-neon-cyan">{task.target_count}</span> 个
|
|
</span>
|
|
{/* 由后端拼好,列表和编辑弹窗因此不会对同一个计划给出两种说法 */}
|
|
<span>{task.schedule_label}</span>
|
|
<span className="flex items-center gap-1">
|
|
<CalendarClock className="w-3 h-3" />
|
|
{task.enabled ? formatRelative(task.next_run_at) : '已暂停'}
|
|
</span>
|
|
<span className="text-cyber-text-muted">
|
|
上次 {formatRelative(task.last_run_at)}
|
|
</span>
|
|
</div>
|
|
|
|
{authTrouble && (
|
|
<p className="mt-1.5 text-[10px] font-mono text-cyber-neon-orange">
|
|
{task.last_error ?? '疑似登录态失效,请检查 Cookie'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title={task.enabled ? '暂停' : '启用'}
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
updateTask.mutate({ id: task.id, payload: { enabled: !task.enabled } })
|
|
}}
|
|
>
|
|
<span
|
|
className={`status-dot ${task.enabled ? 'status-dot-online' : 'status-dot-offline'}`}
|
|
/>
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title="立即运行"
|
|
disabled={runNow.isPending}
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
runNow.mutate(task.id)
|
|
}}
|
|
>
|
|
<Play className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title="编辑"
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
onEdit()
|
|
}}
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title="删除"
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
if (confirm(`确定删除监控任务「${task.name}」?已采集的历史数据也会一并删除。`)) {
|
|
deleteTask.mutate(task.id)
|
|
}
|
|
}}
|
|
>
|
|
<Trash2 className="w-4 h-4 text-cyber-neon-pink" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|