feat: 监控面板 / 登录鉴权 / 多平台切换 / MySQL
在上游 MediaCrawler 之上新增一层: - 监控层 api/monitor/ —— 多博主/多笔记的定时采集、指标快照差分、报表、 企业微信通知。每轮采集写入独立目录,差分才成立。 - WebUI 登录鉴权 api/auth.py —— PBKDF2 口令 + 服务端会话,/api 全接口防护。 WebSocket 单独加依赖:BaseHTTPMiddleware 对 ws 作用域直接放行,覆盖不到。 - 全局平台切换 + 能力矩阵 —— 如实区分「爬虫模块支持」与「监控层已接线」, 未接通的平台直接拒绝建任务,而不是静默跑空。 - 监控库改用 MySQL 5.7(可回退 SQLite 供测试):逐表强制 utf8mb4 (服务端与库默认都是 latin1),启动校验所连 schema 以防写错库, 连接池 recycle + pre_ping 应对 MySQL 的 8 小时空闲断连。 修复上游缺陷: - xhs/core.py: 主页抓取失败会跳掉整个博主,导致一条作品都抓不到, 而那份资料只喂给一个空函数。改为尽力而为,失败不中断。 - xhs/login.py: cookie 登录只注入 web_session,冷启动签名会失败。 新增 INJECT_ALL_COOKIES 开关(默认关闭,原有行为不变)。 - requirements.txt: 补上 websockets。它在上游 pyproject.toml 里有声明、 这里漏了,导致 uvicorn 没有 WebSocket 能力,实时日志流从未工作。 改动过的上游文件清单及合并方式见 UPSTREAM.md。 测试:492 passed(另有 1 个既有的 Windows/gbk 上游测试失败,与本改动无关)
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
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 { formatInterval, 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()
|
||||
|
||||
// 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' ? '博主' : '笔记'}
|
||||
</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>间隔 {formatInterval(task.interval_minutes)}</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user