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,305 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
ExternalLink,
|
||||
LayoutList,
|
||||
Rows3,
|
||||
ThumbsUp,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
useCommentNoteOptions,
|
||||
useMonitorComments,
|
||||
useMonitorCommentsGrouped,
|
||||
} from '@/hooks/useMonitor'
|
||||
import { monitorApi } from '@/lib/api'
|
||||
import { formatRelative } from '@/lib/monitorFormat'
|
||||
import type { CommentBucket, MonitorComment } from '@/types/monitor'
|
||||
|
||||
interface CommentsFeedProps {
|
||||
taskId: number | null
|
||||
}
|
||||
|
||||
const ALL_NOTES = '__all__'
|
||||
|
||||
/** Cover + truncated title + link, identifying the work a comment sits under. */
|
||||
function NoteBadge({
|
||||
title,
|
||||
cover,
|
||||
url,
|
||||
noteId,
|
||||
compact = false,
|
||||
}: {
|
||||
title: string
|
||||
cover: string
|
||||
url: string
|
||||
noteId: string
|
||||
compact?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{cover ? (
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className={`rounded object-cover bg-cyber-bg-tertiary flex-shrink-0 ${
|
||||
compact ? 'w-6 h-8' : 'w-8 h-10'
|
||||
}`}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`rounded bg-cyber-bg-tertiary flex-shrink-0 ${
|
||||
compact ? 'w-6 h-8' : 'w-8 h-10'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate text-[10px] font-mono text-cyber-text-secondary" title={title || noteId}>
|
||||
{title || noteId}
|
||||
</span>
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-cyber-text-muted hover:text-cyber-neon-cyan flex-shrink-0"
|
||||
title="打开原文"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommentRow({ comment, showNote }: { comment: MonitorComment; showNote: boolean }) {
|
||||
return (
|
||||
<div className="rounded-md border border-cyber-border-subtle bg-cyber-bg-tertiary/40 px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2 text-[10px] font-mono">
|
||||
<span className="text-cyber-text-secondary truncate">{comment.nickname || '匿名'}</span>
|
||||
<span className="flex items-center gap-2 flex-shrink-0 text-cyber-text-muted">
|
||||
{comment.like_count !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<ThumbsUp className="w-3 h-3" />
|
||||
{comment.like_count}
|
||||
</span>
|
||||
)}
|
||||
{comment.sub_comment_count > 0 && <span>{comment.sub_comment_count} 条回复</span>}
|
||||
<span>{formatRelative(comment.first_seen_at)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-[11px] font-mono text-cyber-text-primary break-words">
|
||||
{comment.content}
|
||||
</p>
|
||||
|
||||
{/* Only in the flat view: in a grouped view the header already says which
|
||||
work these belong to, so repeating it on every row is noise. */}
|
||||
{showNote && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-cyber-border-subtle">
|
||||
<NoteBadge
|
||||
title={comment.note_title}
|
||||
cover={comment.note_cover}
|
||||
url={comment.note_url}
|
||||
noteId={comment.note_id}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleGroup({
|
||||
bucket,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
bucket: CommentBucket
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-cyber-border-subtle overflow-hidden">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 bg-cyber-bg-tertiary/60 hover:bg-cyber-bg-elevated/60 transition-colors text-left"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-cyber-text-muted flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-cyber-text-muted flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<NoteBadge
|
||||
title={bucket.note_title}
|
||||
cover={bucket.note_cover}
|
||||
url={bucket.note_url}
|
||||
noteId={bucket.note_id}
|
||||
/>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 flex-shrink-0">
|
||||
{bucket.comments.length} 条
|
||||
</Badge>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="p-2 space-y-1.5">
|
||||
{bucket.comments.map((comment) => (
|
||||
<CommentRow
|
||||
key={comment.comment_id}
|
||||
comment={comment}
|
||||
showNote={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CommentsFeed({ taskId }: CommentsFeedProps) {
|
||||
const [grouped, setGrouped] = useState(true)
|
||||
const [noteFilter, setNoteFilter] = useState<string>(ALL_NOTES)
|
||||
const [openNotes, setOpenNotes] = useState<Set<string> | null>(null)
|
||||
|
||||
const activeNote = noteFilter === ALL_NOTES ? null : noteFilter
|
||||
const { data: noteOptions } = useCommentNoteOptions(taskId)
|
||||
// Both hooks always run; react-query caches, so the inactive one costs one
|
||||
// request and no rerender churn when toggling views.
|
||||
const { data: groups, isLoading: loadingGroups } = useMonitorCommentsGrouped(taskId, activeNote)
|
||||
const { data: flat, isLoading: loadingFlat } = useMonitorComments(taskId, activeNote)
|
||||
|
||||
// Expand the newest group only, so the panel does not open as a wall of text.
|
||||
const expanded = useMemo(() => {
|
||||
if (openNotes !== null) return openNotes
|
||||
return new Set(groups && groups.length ? [groups[0].note_id] : [])
|
||||
}, [openNotes, groups])
|
||||
|
||||
const toggleGroup = (noteId: string) => {
|
||||
const next = new Set(expanded)
|
||||
if (next.has(noteId)) next.delete(noteId)
|
||||
else next.add(noteId)
|
||||
setOpenNotes(next)
|
||||
}
|
||||
|
||||
const total = grouped ? (groups ?? []).reduce((sum, g) => sum + g.comments.length, 0) : (flat ?? []).length
|
||||
const isLoading = grouped ? loadingGroups : loadingFlat
|
||||
|
||||
if (taskId === null) {
|
||||
return (
|
||||
<p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">
|
||||
请先选择一个监控任务
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant={grouped ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setGrouped(true)
|
||||
// Re-derive the default expansion for the new view.
|
||||
setOpenNotes(null)
|
||||
}}
|
||||
>
|
||||
<Rows3 className="w-3 h-3 mr-1" />
|
||||
按作品分组
|
||||
</Button>
|
||||
<Button
|
||||
variant={grouped ? 'outline' : 'default'}
|
||||
size="sm"
|
||||
onClick={() => setGrouped(false)}
|
||||
>
|
||||
<LayoutList className="w-3 h-3 mr-1" />
|
||||
平铺
|
||||
</Button>
|
||||
|
||||
<Select value={noteFilter} onValueChange={setNoteFilter}>
|
||||
<SelectTrigger className="h-8 text-[11px] w-[240px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_NOTES}>全部作品</SelectItem>
|
||||
{noteOptions?.map((note) => (
|
||||
<SelectItem key={note.note_id} value={note.note_id}>
|
||||
{`${note.note_title || note.note_id} (${note.comment_count})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<span className="text-[10px] font-mono text-cyber-text-muted">共 {total} 条</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
monitorApi.getExportUrl({
|
||||
kind: 'comments',
|
||||
taskId: taskId ?? undefined,
|
||||
noteId: activeNote ?? undefined,
|
||||
}),
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
导出 CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Honest about what is NOT here. */}
|
||||
<p className="text-[10px] font-mono text-cyber-neon-orange">
|
||||
评论接口没有时间排序,这里只有平台默认排序下抓取到的前 N 条,
|
||||
<span className="font-bold">沉底的新评论不会出现</span>。可在任务里调大「每篇评论抓取条数」。
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">加载中…</p>
|
||||
) : total === 0 ? (
|
||||
<p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">
|
||||
还没有采集到评论
|
||||
</p>
|
||||
) : grouped ? (
|
||||
<div className="space-y-2">
|
||||
{groups?.map((bucket) => (
|
||||
<CollapsibleGroup
|
||||
key={bucket.note_id}
|
||||
bucket={bucket}
|
||||
open={expanded.has(bucket.note_id)}
|
||||
onToggle={() => toggleGroup(bucket.note_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{flat?.map((comment) => (
|
||||
<CommentRow
|
||||
key={`${comment.note_id}:${comment.comment_id}`}
|
||||
comment={comment}
|
||||
showNote
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user