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,146 @@
|
||||
import { Fragment, useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useMonitorNotes } from '@/hooks/useMonitor'
|
||||
import { formatCount, formatDelta, formatRelative } from '@/lib/monitorFormat'
|
||||
import type { MonitorNote, NoteMetrics } from '@/types/monitor'
|
||||
import { NoteTrendChart } from './NoteTrendChart'
|
||||
|
||||
interface NotesTableProps {
|
||||
taskId: number | null
|
||||
onlyNew: boolean
|
||||
}
|
||||
|
||||
/** A metric with its change vs the previous run underneath. */
|
||||
function MetricCell({ value, delta }: { value: number | null; delta: number | null }) {
|
||||
const deltaText = formatDelta(delta)
|
||||
return (
|
||||
<div className="text-right">
|
||||
<div className="font-mono text-xs text-cyber-text-primary">{formatCount(value)}</div>
|
||||
{deltaText && (
|
||||
<div
|
||||
className={`font-mono text-[9px] ${
|
||||
(delta ?? 0) > 0 ? 'text-cyber-neon-green' : 'text-cyber-neon-pink'
|
||||
}`}
|
||||
>
|
||||
{deltaText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const METRIC_COLUMNS: Array<{ key: keyof NoteMetrics; label: string }> = [
|
||||
{ key: 'liked_count', label: '点赞' },
|
||||
{ key: 'comment_count', label: '评论' },
|
||||
{ key: 'collected_count', label: '收藏' },
|
||||
{ key: 'share_count', label: '分享' },
|
||||
]
|
||||
|
||||
export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
|
||||
const { data: notes, isLoading } = useMonitorNotes(taskId, onlyNew)
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">加载中…</p>
|
||||
}
|
||||
|
||||
if (!notes || notes.length === 0) {
|
||||
return (
|
||||
<p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">
|
||||
{taskId === null
|
||||
? '请先选择一个监控任务'
|
||||
: onlyNew
|
||||
? '最近一轮没有新增作品'
|
||||
: '还没有采集到作品,运行一次任务后即可看到'}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const isExpanded = (note: MonitorNote) => expanded === `${note.task_id}:${note.note_id}`
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto terminal-scroll">
|
||||
<table className="w-full text-xs font-mono">
|
||||
<thead className="sticky top-0 bg-cyber-bg-tertiary">
|
||||
<tr className="text-cyber-text-secondary text-[10px]">
|
||||
<th className="w-6" />
|
||||
<th className="text-left font-normal py-2 px-2">作品</th>
|
||||
{METRIC_COLUMNS.map((column) => (
|
||||
<th key={column.key} className="text-right font-normal py-2 px-2">
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="text-right font-normal py-2 px-2">首次发现</th>
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{notes.map((note) => {
|
||||
const open = isExpanded(note)
|
||||
return (
|
||||
<Fragment key={`${note.task_id}:${note.note_id}`}>
|
||||
<tr
|
||||
onClick={() => setExpanded(open ? null : `${note.task_id}:${note.note_id}`)}
|
||||
className="border-t border-cyber-border-subtle hover:bg-cyber-bg-elevated/50 cursor-pointer"
|
||||
>
|
||||
<td className="py-2 px-1 text-cyber-text-muted">
|
||||
{open ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
</td>
|
||||
<td className="py-2 px-2 max-w-[320px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{note.is_new && (
|
||||
<Badge variant="success" className="text-[9px] px-1 py-0 flex-shrink-0">
|
||||
NEW
|
||||
</Badge>
|
||||
)}
|
||||
<span className="truncate text-cyber-text-primary" title={note.title}>
|
||||
{note.title || note.note_id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-cyber-text-muted truncate">
|
||||
{note.note_id} · {note.snapshot_count} 次快照
|
||||
</div>
|
||||
</td>
|
||||
{METRIC_COLUMNS.map((column) => (
|
||||
<td key={column.key} className="py-2 px-2">
|
||||
<MetricCell value={note.metrics[column.key]} delta={note.deltas[column.key]} />
|
||||
</td>
|
||||
))}
|
||||
<td className="py-2 px-2 text-right text-[10px] text-cyber-text-muted">
|
||||
{formatRelative(note.first_seen_at)}
|
||||
</td>
|
||||
<td className="py-2 px-1">
|
||||
{note.note_url && (
|
||||
<a
|
||||
href={note.note_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="text-cyber-text-muted hover:text-cyber-neon-cyan"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{open && (
|
||||
<tr className="bg-cyber-bg-secondary/40">
|
||||
<td colSpan={METRIC_COLUMNS.length + 4} className="px-4 py-3">
|
||||
<NoteTrendChart
|
||||
noteId={note.note_id}
|
||||
taskId={note.task_id}
|
||||
noteTitle={note.title}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user