feat: 监控面板 / 登录鉴权 / 多平台切换 / MySQL
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

在上游 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:
2026-10-07 09:58:40 +08:00
parent 5d547f4586
commit 4e60524f37
88 changed files with 13224 additions and 436 deletions
+111
View File
@@ -0,0 +1,111 @@
import { useState } from 'react'
import { KeyRound, Loader2, ShieldAlert } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { authApi } from '@/lib/api'
interface LoginProps {
onSuccess: () => void
}
/**
* Full-screen login gate.
*
* Never an overlay on top of the app: the authenticated tree owns the log
* WebSocket, and mounting it behind a modal would open the socket before the
* user is authenticated. App.tsx therefore renders this *instead of* the app.
*/
export function Login({ onSuccess }: LoginProps) {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
if (!password || busy) return
setBusy(true)
setError('')
try {
await authApi.login(password)
setPassword('')
onSuccess()
} catch (err: unknown) {
const response = (err as { response?: { status?: number; data?: { detail?: string } } })
?.response
if (response?.status === 429) {
// Throttled, not wrong -- say so rather than implying a bad password.
setError(response.data?.detail ?? '尝试过于频繁,请稍后再试')
} else {
setError(response?.data?.detail ?? '登录失败')
}
} finally {
setBusy(false)
}
}
return (
<div className="fixed inset-0 cyber-grid bg-cyber-bg-primary flex items-center justify-center z-[100]">
<form
onSubmit={handleSubmit}
className="relative w-full max-w-md mx-4 p-6 rounded-lg glass-panel float-panel"
>
{/* Corner accents, matching the other full-screen gates */}
<div className="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-cyber-neon-cyan/60" />
<div className="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-cyber-neon-cyan/60" />
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-cyber-neon-cyan/60" />
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-cyber-neon-cyan/60" />
<div className="flex items-center justify-center gap-2 mb-1">
<KeyRound className="w-5 h-5 text-cyber-neon-cyan" />
<span className="font-mono font-bold tracking-wider text-cyber-text-primary">
综合采集平台
</span>
</div>
<p className="text-center text-[11px] font-mono text-cyber-text-muted mb-5">
需要登录才能访问控制面板
</p>
<div className="space-y-2">
<Label className="text-xs font-mono text-cyber-text-secondary" htmlFor="password">
登录密码
</Label>
<Input
id="password"
type="password"
autoFocus
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
className="h-10 font-mono"
/>
</div>
{error && (
<div className="mt-3 flex items-start gap-2 rounded-md border border-cyber-neon-pink/40 bg-cyber-neon-pink/10 px-3 py-2">
<ShieldAlert className="w-3.5 h-3.5 text-cyber-neon-pink mt-0.5 flex-shrink-0" />
<span className="text-[11px] font-mono text-cyber-neon-pink">{error}</span>
</div>
)}
<Button
type="submit"
variant="glow"
disabled={!password || busy}
className="w-full mt-4 font-mono"
>
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : '登 录'}
</Button>
<p className="mt-4 text-[10px] font-mono text-cyber-text-muted leading-relaxed">
首次启动的密码打印在服务端启动日志里。
忘记密码时,设置环境变量 <span className="text-cyber-neon-cyan">MC_PASSWORD</span> 后重启即可恢复,
并在登录后于设置页修改。
</p>
</form>
</div>
)
}
@@ -1,5 +1,5 @@
import type { ComponentType, ReactNode, KeyboardEvent } from 'react'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Database, Globe, Image as ImageIcon, KeyRound, MessageSquare, Play, Square, X } from 'lucide-react'
import { Label } from '@/components/ui/label'
@@ -8,7 +8,9 @@ import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import { useCrawlerStore } from '@/store/crawlerStore'
import { usePlatforms, useConfigOptions, useStartCrawler, useStopCrawler } from '@/hooks/useCrawler'
import { useConfigOptions, useStartCrawler, useStopCrawler } from '@/hooks/useCrawler'
import { useCurrentPlatform } from '@/hooks/usePlatform'
import { useCookieStatus } from '@/hooks/useMonitor'
import { ParsedIdList } from './ParsedIdList'
type SectionProps = {
@@ -137,9 +139,19 @@ export function CrawlerConfigPanel() {
const updateConfig = useCrawlerStore((state) => state.updateConfig)
const status = useCrawlerStore((state) => state.status)
const { data: platforms } = usePlatforms()
const { data: options } = useConfigOptions()
const { mutate: startCrawler, isPending: isStarting } = useStartCrawler()
// Follow the global platform selection rather than keeping its own copy.
const { platform: hostPlatform, capability: hostCapability } = useCurrentPlatform()
const { data: cookieStatus } = useCookieStatus()
const storedCookieOk = cookieStatus?.present ?? false
useEffect(() => {
if (config.platform !== hostPlatform) {
updateConfig({ platform: hostPlatform })
}
}, [hostPlatform, config.platform, updateConfig])
const { mutate: stopCrawler, isPending: isStopping } = useStopCrawler()
const isDisabled = status === 'running' || status === 'stopping'
@@ -165,22 +177,15 @@ export function CrawlerConfigPanel() {
icon={Globe}
>
<Field label={t('field.platform')}>
<Select
value={config.platform}
onValueChange={(value) => updateConfig({ platform: value })}
disabled={isDisabled}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder={t('field.platformPlaceholder')} />
</SelectTrigger>
<SelectContent>
{platforms?.map((platform) => (
<SelectItem key={platform.value} value={platform.value}>
{platform.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Driven by the global switcher in the header. A second platform
control here would be a second source of truth for the same
decision, and the two would drift. */}
<div className="flex items-center justify-between h-9 px-3 rounded-md border border-cyber-border-DEFAULT bg-cyber-bg-tertiary">
<span className="font-mono text-xs text-cyber-neon-cyan">
{hostCapability?.label ?? hostPlatform}
</span>
<span className="text-[10px] font-mono text-cyber-text-muted">由右上角切换</span>
</div>
</Field>
<div className="grid grid-cols-2 gap-3">
@@ -300,14 +305,23 @@ export function CrawlerConfigPanel() {
</Field>
{config.login_type === 'cookie' ? (
<Field label={t('field.cookies')} hint={t('field.cookiesHint')}>
<textarea
value={config.cookies}
onChange={(e) => updateConfig({ cookies: e.target.value })}
disabled={isDisabled}
placeholder={t('field.cookiesPlaceholder')}
className="min-h-[80px] w-full rounded-md border border-cyber-border-DEFAULT bg-cyber-bg-tertiary px-3 py-2 text-xs font-mono text-cyber-text-primary placeholder:text-cyber-text-muted focus-visible:outline-none focus-visible:border-cyber-neon-cyan/50 focus-visible:shadow-cyber-soft disabled:cursor-not-allowed disabled:opacity-50 transition-all resize-none"
/>
// The cookie value has exactly one home now: 设置 → 登录态. A paste
// box here was unpersisted, so it went stale between sessions and
// could silently disagree with the cookie the monitor uses.
<Field label={t('field.cookies')}>
<div className="flex items-center gap-2 h-9 px-3 rounded-md border border-cyber-border-DEFAULT bg-cyber-bg-tertiary">
<span
className={`status-dot ${
storedCookieOk ? 'status-dot-online' : 'status-dot-warning'
}`}
/>
<span className="font-mono text-[11px] text-cyber-text-secondary">
{storedCookieOk ? '复用「设置」中已保存的 Cookie' : '「设置」中尚未配置 Cookie'}
</span>
</div>
<p className="text-[10px] font-mono text-cyber-text-muted">
在「设置 → 登录态」里维护,此处不再单独填写
</p>
</Field>
) : null}
@@ -1,80 +0,0 @@
import { useTranslation } from 'react-i18next'
import { Sparkles, Heart } from 'lucide-react'
export function AuthorFooter() {
const { t } = useTranslation('license')
return (
<footer className="h-24 flex-shrink-0 glass-panel border-t border-cyber-border-subtle">
<div className="h-full px-6 flex items-center justify-center gap-6">
{/* Author Avatar */}
<div className="w-14 h-14 rounded-lg overflow-hidden border-2 border-cyber-neon-cyan/60 flex-shrink-0 shadow-glow-cyan-sm">
<img
src="/logos/my_logo.png"
alt="程序员阿江-Relakkes"
className="w-full h-full object-cover"
/>
</div>
{/* Author Info */}
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-lg font-bold text-cyber-text-primary">
{t('author.name')}
</span>
<Sparkles className="w-5 h-5 text-cyber-neon-cyan animate-pulse" />
</div>
<span className="text-sm text-cyber-text-muted hidden sm:inline">
{t('author.description')}
</span>
<div className="flex items-center gap-2 text-cyber-neon-cyan">
<Heart className="w-4 h-4 fill-current animate-pulse" />
<span className="text-sm font-medium">
{t('author.slogan')}
</span>
</div>
</div>
{/* Social Links */}
<div className="flex items-center gap-3">
<a
href="https://github.com/NanmiCoder"
target="_blank"
rel="noopener noreferrer"
className="w-11 h-11 rounded-lg flex items-center justify-center border border-cyber-border-subtle hover:border-cyber-neon-cyan hover:shadow-glow-cyan-sm transition-all bg-cyber-bg-tertiary hover:scale-110"
title="GitHub"
>
<img src="/logos/github.png" alt="GitHub" className="w-6 h-6 object-contain" />
</a>
<a
href="https://space.bilibili.com/434377496"
target="_blank"
rel="noopener noreferrer"
className="w-11 h-11 rounded-lg flex items-center justify-center border border-cyber-border-subtle hover:border-pink-400 hover:shadow-[0_0_10px_rgba(251,113,133,0.4)] transition-all bg-cyber-bg-tertiary hover:scale-110"
title="哔哩哔哩"
>
<img src="/logos/bilibili_logo.png" alt="Bilibili" className="w-6 h-6 object-contain" />
</a>
<a
href="https://www.xiaohongshu.com/user/profile/5f58bd990000000001003753"
target="_blank"
rel="noopener noreferrer"
className="w-11 h-11 rounded-lg flex items-center justify-center border border-cyber-border-subtle hover:border-red-400 hover:shadow-[0_0_10px_rgba(248,113,113,0.4)] transition-all bg-cyber-bg-tertiary hover:scale-110"
title="小红书"
>
<img src="/logos/xiaohongshu_logo.png" alt="小红书" className="w-6 h-6 object-contain" />
</a>
<a
href="https://www.douyin.com/user/MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE"
target="_blank"
rel="noopener noreferrer"
className="w-11 h-11 rounded-lg flex items-center justify-center border border-cyber-border-subtle hover:border-cyber-text-primary hover:shadow-[0_0_10px_rgba(255,255,255,0.3)] transition-all bg-cyber-bg-tertiary hover:scale-110"
title="抖音"
>
<img src="/logos/douyin.png" alt="抖音" className="w-6 h-6 object-contain" />
</a>
</div>
</div>
</footer>
)
}
@@ -0,0 +1,82 @@
import { ChevronDown, Layers } from 'lucide-react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useCurrentPlatform } from '@/hooks/usePlatform'
/**
* Global platform selector.
*
* One control scopes the whole panel -- 采集 / 监控 / 报表 / 设置 all read the
* same selection. Platforms whose monitoring is not wired up are still listed
* (so it is obvious what is coming) but are marked, because selecting one and
* finding only empty screens is worse than being told upfront.
*/
export function PlatformSwitcher() {
const { platform, setPlatform, platforms, capability, isLoading } = useCurrentPlatform()
const wiredCount = platforms.filter((entry) => entry.monitor_wired).length
return (
<div className="flex items-center gap-1.5">
<Layers className="w-3.5 h-3.5 text-cyber-text-muted flex-shrink-0" />
<Select value={platform} onValueChange={setPlatform} disabled={isLoading}>
<SelectTrigger className="h-8 w-[190px] text-[11px] font-mono">
<SelectValue placeholder="选择平台">
{capability ? (
<span className="flex items-center gap-1.5">
<span
className={
capability.monitor_wired ? 'text-cyber-neon-cyan' : 'text-cyber-text-muted'
}
>
{capability.label}
</span>
{!capability.monitor_wired && (
<span className="text-[9px] text-cyber-neon-orange">未接通</span>
)}
</span>
) : (
'选择平台'
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{platforms.map((entry) => (
<SelectItem key={entry.value} value={entry.value}>
<span className="flex items-center gap-2">
<span
className={entry.monitor_wired ? 'text-cyber-text-primary' : 'text-cyber-text-muted'}
>
{entry.label}
</span>
{entry.monitor_wired ? (
<span className="text-[9px] font-mono text-cyber-neon-green">已接通</span>
) : (
<span className="text-[9px] font-mono text-cyber-neon-orange">未接通</span>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{/* A one-line hint rather than a tooltip: the state of the other platforms
is not discoverable from the closed control. */}
{!isLoading && wiredCount < platforms.length && (
<span
className="hidden xl:inline text-[10px] font-mono text-cyber-text-muted"
title="爬虫模块支持这些平台,但监控层尚未接线,选中后无法创建任务"
>
<ChevronDown className="w-3 h-3 inline -mt-0.5" /> {wiredCount}/{platforms.length} 已接通
</span>
)}
</div>
)
}
+72 -32
View File
@@ -1,19 +1,32 @@
import { Bug, Wifi, AlertTriangle, Github } from 'lucide-react'
import { useState } from 'react'
import { Bug, Wifi, BarChart3, Cog, LogOut, Radar, Settings, Terminal } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { SystemSettingsDialog } from '@/components/settings/SystemSettingsDialog'
import { useCrawlerStore } from '@/store/crawlerStore'
import { useCrawlerStatus } from '@/hooks/useCrawler'
import type { AppView } from '@/App'
import { LanguageSwitch } from './LanguageSwitch'
import { PlatformSwitcher } from './PlatformSwitcher'
import { ThemeToggle } from './ThemeToggle'
interface SidebarProps {
onShowDisclaimer?: () => void
currentView: AppView
onViewChange: (view: AppView) => void
onLogout: () => void
}
export function Sidebar({ onShowDisclaimer }: SidebarProps) {
const NAV_ITEMS: Array<{ value: AppView; label: string; icon: typeof Terminal }> = [
{ value: 'crawler', label: '采集', icon: Terminal },
{ value: 'monitor', label: '监控', icon: Radar },
{ value: 'report', label: '报表', icon: BarChart3 },
{ value: 'settings', label: '设置', icon: Settings },
]
export function Sidebar({ currentView, onViewChange, onLogout }: SidebarProps) {
const { t } = useTranslation()
const { t: tLicense } = useTranslation('license')
const status = useCrawlerStore((state) => state.status)
const [systemSettingsOpen, setSystemSettingsOpen] = useState(false)
// Poll status
useCrawlerStatus()
@@ -23,21 +36,12 @@ export function Sidebar({ onShowDisclaimer }: SidebarProps) {
return (
<header className="h-14 flex-shrink-0 glass-panel border-b border-cyber-border-subtle relative z-10">
<div className="h-full px-4 flex items-center justify-between">
{/* Left: Logo and GitHub Star */}
{/* Left: Logo, run state, workspace switch */}
<div className="flex items-center gap-3">
<Bug className="w-5 h-5 text-cyber-neon-cyan" />
<span className="font-mono font-bold text-cyber-text-primary tracking-wider text-sm">
MediaCrawler
综合采集平台
</span>
<a
href="https://github.com/NanmiCoder/MediaCrawler"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-2 py-1 rounded-md border border-cyber-border-subtle hover:border-cyber-neon-cyan hover:shadow-glow-cyan-sm transition-all bg-cyber-bg-tertiary"
>
<Github className="w-4 h-4 text-cyber-text-secondary" />
<span className="text-xs font-mono text-cyber-text-secondary">Star</span>
</a>
{isRunning && (
<Badge variant="running" className="text-[10px]">
{t('status.active')}
@@ -46,26 +50,46 @@ export function Sidebar({ onShowDisclaimer }: SidebarProps) {
{isRunning && (
<span className="w-2 h-2 bg-cyber-neon-green rounded-full shadow-glow-green-sm animate-pulse-fast" />
)}
{/* Workspace switch */}
<div className="flex items-center gap-1 ml-2 p-0.5 rounded-md border border-cyber-border-subtle bg-cyber-bg-tertiary">
{NAV_ITEMS.map((item) => {
const Icon = item.icon
const active = currentView === item.value
return (
<button
key={item.value}
onClick={() => onViewChange(item.value)}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded text-[11px] font-mono transition-all ${
active
? 'bg-cyber-neon-cyan/20 text-cyber-neon-cyan border border-cyber-neon-cyan/40'
: 'text-cyber-text-muted border border-transparent hover:text-cyber-text-secondary'
}`}
>
<Icon className="w-3 h-3" />
{item.label}
</button>
)
})}
</div>
</div>
{/* Center: Warning Text */}
<button
onClick={onShowDisclaimer}
className="flex items-center gap-3 px-4 py-1.5 rounded-lg border border-cyber-neon-orange/50 bg-cyber-neon-orange/10 hover:bg-cyber-neon-orange/20 transition-all cursor-pointer"
>
<AlertTriangle className="w-4 h-4 text-cyber-neon-orange flex-shrink-0" />
<div className="flex items-center gap-4 text-xs font-mono">
<span className="text-cyber-neon-orange">
<span className="text-cyber-neon-pink font-bold">1.</span> {tLicense('content.line1')}
</span>
<span className="text-cyber-neon-orange">
<span className="text-cyber-neon-pink font-bold">2.</span> {tLicense('content.line2')}
</span>
</div>
</button>
{/* Right: Actions and Status */}
{/* Right: platform scope, then actions and status */}
<div className="flex items-center gap-3">
{/* Scopes every workspace at once. */}
<PlatformSwitcher />
{/* System-wide settings: deliberately separate from the platform-scoped
「设置」 page, because these values are not per platform. */}
<button
onClick={() => setSystemSettingsOpen(true)}
title="系统设置(对所有平台生效)"
className="flex items-center gap-1.5 px-2 py-1 rounded text-[11px] font-mono text-cyber-text-muted border border-transparent hover:text-cyber-neon-cyan hover:border-cyber-neon-cyan/40 transition-all"
>
<Cog className="w-3.5 h-3.5" />
系统设置
</button>
{/* Theme Toggle */}
<ThemeToggle />
{/* Language Switch */}
@@ -81,8 +105,24 @@ export function Sidebar({ onShowDisclaimer }: SidebarProps) {
<span className="status-dot status-dot-online" />
</div>
</div>
{/* Logging out unmounts the whole tree, which closes the log
WebSocket through the existing connection-count cleanup. */}
<button
onClick={onLogout}
title="退出登录"
className="flex items-center gap-1.5 px-2 py-1 rounded text-[11px] font-mono text-cyber-text-muted border border-transparent hover:text-cyber-neon-pink hover:border-cyber-neon-pink/40 transition-all"
>
<LogOut className="w-3 h-3" />
退出
</button>
</div>
</div>
<SystemSettingsDialog
open={systemSettingsOpen}
onOpenChange={setSystemSettingsOpen}
/>
</header>
)
}
@@ -0,0 +1,105 @@
import { Construction, MonitorSmartphone } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { useCurrentPlatform } from '@/hooks/usePlatform'
const MODE_LABELS: Record<string, string> = {
search: '关键词搜索',
detail: '指定内容',
creator: '创作者主页',
}
/**
* Shown instead of a workspace when the selected platform's monitoring is not
* wired up.
*
* Deliberately spells out what the platform *can* do, so the switcher reads as
* "not yet" rather than "broken" -- the upstream crawler does support these
* platforms; only this monitoring layer has not been hooked up to them.
*/
export function UnwiredPlatformNotice({ area }: { area: string }) {
const { capability } = useCurrentPlatform()
if (!capability) {
return (
<div className="flex-1 flex items-center justify-center">
<p className="text-[11px] font-mono text-cyber-text-muted">加载平台信息…</p>
</div>
)
}
return (
<div className="flex-1 flex items-center justify-center overflow-y-auto terminal-scroll">
<div className="max-w-lg w-full mx-4 rounded-lg glass-panel float-panel p-6 space-y-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-md border border-cyber-neon-orange/40 bg-cyber-neon-orange/10">
<Construction className="w-5 h-5 text-cyber-neon-orange" />
</div>
<div>
<h2 className="font-mono text-sm text-cyber-text-primary">
{capability.label} 的{area}尚未接通
</h2>
<p className="text-[11px] font-mono text-cyber-text-muted">
{capability.label}的爬虫模块是支持的,但监控层目前只接通了小红书
</p>
</div>
</div>
<div className="rounded-md border border-cyber-border-subtle bg-cyber-bg-tertiary/40 p-3 space-y-2">
<div className="flex items-start gap-2">
<span className="text-[10px] font-mono text-cyber-text-secondary w-20 flex-shrink-0 pt-0.5">
爬虫支持
</span>
<div className="flex flex-wrap gap-1">
{capability.crawler_modes.map((mode) => (
<Badge key={mode} variant="outline" className="text-[9px] px-1.5 py-0">
{MODE_LABELS[mode] ?? mode}
</Badge>
))}
</div>
</div>
<div className="flex items-start gap-2">
<span className="text-[10px] font-mono text-cyber-text-secondary w-20 flex-shrink-0 pt-0.5">
可采集指标
</span>
<div className="flex flex-wrap gap-1">
{capability.metrics.map((metric) => (
<Badge key={metric} variant="outline" className="text-[9px] px-1.5 py-0">
{capability.metric_labels[metric] ?? metric}
</Badge>
))}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-cyber-text-secondary w-20 flex-shrink-0">
评论层级
</span>
<span className="text-[10px] font-mono text-cyber-text-primary">
{capability.comment_levels} 级
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-cyber-text-secondary w-20 flex-shrink-0">
媒体下载
</span>
<span className="text-[10px] font-mono text-cyber-text-primary">
{capability.media ? '支持' : '不支持'}
</span>
</div>
</div>
<div className="flex items-start gap-2 text-[10px] font-mono text-cyber-text-muted">
<MonitorSmartphone className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<p>
切换到小红书即可正常使用。若要接通该平台,需要在
<span className="text-cyber-neon-cyan"> runner / ingest / 目标解析 </span>
三处补上平台适配(目前这三处是硬编码小红书的)。
</p>
</div>
</div>
</div>
)
}
@@ -1,144 +0,0 @@
import { useTranslation } from 'react-i18next'
import { ShieldAlert, ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/button'
const LICENSE_KEY = 'mediacrawler_license_accepted'
// 检查是否已经接受协议
export function isLicenseAccepted(): boolean {
return localStorage.getItem(LICENSE_KEY) === 'true'
}
// 清除协议接受状态
export function clearLicenseAccepted(): void {
localStorage.removeItem(LICENSE_KEY)
}
interface LicenseDisclaimerProps {
onAccept: () => void
}
export function LicenseDisclaimer({ onAccept }: LicenseDisclaimerProps) {
const { t } = useTranslation('license')
const handleConfirm = () => {
localStorage.setItem(LICENSE_KEY, 'true')
onAccept()
}
const handleDecline = () => {
// 尝试关闭当前标签页(不会关闭整个浏览器,只关闭当前tab)
try {
// 方式1: 直接关闭当前标签页
window.close()
// 方式2: 将当前标签页导航到空白页
setTimeout(() => {
window.location.href = 'about:blank'
}, 100)
} catch {
// 忽略错误
}
// 如果无法关闭(浏览器安全限制),显示拒绝访问页面
setTimeout(() => {
document.body.innerHTML = `
<div style="
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background: #0d1117;
color: #f85149;
font-family: 'JetBrains Mono', monospace;
text-align: center;
padding: 20px;
">
<div style="font-size: 48px; margin-bottom: 20px;">⛔</div>
<div style="font-size: 24px; font-weight: bold; margin-bottom: 10px;">访问已拒绝</div>
<div style="font-size: 14px; color: #8b949e;">您未同意使用条款,请关闭此标签页</div>
</div>
`
}, 200)
}
return (
<div className="fixed inset-0 bg-black/95 backdrop-blur-sm flex items-center justify-center z-[100] overflow-y-auto py-8">
<div className="bg-cyber-bg-panel border-2 border-cyber-neon-pink rounded-lg shadow-cyber-card p-6 max-w-2xl w-full mx-4 relative">
{/* Corner decorations - Pink/Red theme for seriousness */}
<div className="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-cyber-neon-pink" />
<div className="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-cyber-neon-pink" />
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-cyber-neon-pink" />
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-cyber-neon-pink" />
{/* Header with warning icon */}
<div className="flex items-center justify-center gap-3 mb-4">
<ShieldAlert className="w-8 h-8 text-cyber-neon-pink animate-pulse" />
<h2 className="text-xl font-mono font-bold text-cyber-neon-pink">
{t('title')}
</h2>
</div>
{/* Warning subtitle */}
<div className="text-center mb-4">
<span className="text-base font-mono text-cyber-neon-orange">
{t('warning')}
</span>
</div>
{/* Content box */}
<div className="bg-black/50 border border-cyber-neon-pink/30 rounded-lg p-4 mb-4">
<ul className="space-y-2 text-sm font-mono">
<li className="flex items-start gap-2">
<span className="text-cyber-neon-pink font-bold">1.</span>
<span className="text-cyber-text-primary">{t('content.line1')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-cyber-neon-pink font-bold">2.</span>
<span className="text-cyber-text-primary">{t('content.line2')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-cyber-neon-pink font-bold">3.</span>
<span className="text-cyber-text-primary">{t('content.line3')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-cyber-neon-pink font-bold">4.</span>
<span className="text-cyber-text-primary">{t('content.line4')}</span>
</li>
</ul>
</div>
{/* License Link */}
<div className="flex justify-center mb-6">
<a
href="https://github.com/NanmiCoder/MediaCrawler/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-cyber-neon-cyan hover:underline text-sm font-mono"
>
<ExternalLink className="w-4 h-4" />
{t('license')}
</a>
</div>
{/* Action buttons */}
<div className="flex gap-4">
<Button
onClick={handleDecline}
variant="outline"
className="flex-1 font-mono border-cyber-neon-pink/50 text-cyber-neon-pink hover:bg-cyber-neon-pink/10"
>
{t('decline')}
</Button>
<Button
onClick={handleConfirm}
className="flex-1 font-mono bg-cyber-neon-green text-black font-bold hover:bg-cyber-neon-green/90"
>
{t('confirm')}
</Button>
</div>
</div>
</div>
)
}
@@ -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>
)
}
@@ -0,0 +1,133 @@
import { useState } from 'react'
import { AlertTriangle, CheckCircle2, KeyRound, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useClearCookie, useCookieStatus, useSetCookie } from '@/hooks/useMonitor'
import { useCurrentPlatform } from '@/hooks/usePlatform'
import { formatDateTime, formatRelative } from '@/lib/monitorFormat'
/**
* Cookie health for unattended runs.
*
* The expiry signal that matters is `last_ok_at` (the last run that actually
* authenticated), not `updated_at`: a cookie can be saved long ago and still be
* working, or be fresh and already rejected.
*/
export function CookiePanel() {
const { capability, platform } = useCurrentPlatform()
// Scoped to the selected platform: each keeps its own login.
const { data: status } = useCookieStatus()
const setCookie = useSetCookie()
const clearCookie = useClearCookie()
const [draft, setDraft] = useState('')
const [editing, setEditing] = useState(false)
const showForm = !status?.present || editing
const label = capability?.label ?? platform
return (
<div className="rounded-lg glass-panel float-panel p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<KeyRound className="w-4 h-4 text-cyber-neon-cyan" />
<span className="font-mono text-xs tracking-wider text-cyber-text-primary">
{label}登录态
</span>
{status?.present ? (
<Badge variant="success" className="text-[10px]">
已配置
</Badge>
) : (
<Badge variant="warning" className="text-[10px]">
未配置
</Badge>
)}
</div>
<div className="flex items-center gap-2">
{status?.present && !editing && (
<>
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
替换
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => clearCookie.mutate()}
disabled={clearCookie.isPending}
>
<Trash2 className="w-3 h-3" />
</Button>
</>
)}
</div>
</div>
{status?.present && !editing && (
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 text-[11px] font-mono text-cyber-text-secondary">
<span>
长度 <span className="text-cyber-neon-cyan">{status.length}</span>
</span>
<span>
保存于 <span className="text-cyber-text-primary">{formatDateTime(status.updated_at)}</span>
</span>
<span className="flex items-center gap-1">
最近成功
{status.last_ok_at ? (
<>
<CheckCircle2 className="w-3 h-3 text-cyber-neon-green" />
<span className="text-cyber-neon-green">{formatRelative(status.last_ok_at)}</span>
</>
) : (
<>
<AlertTriangle className="w-3 h-3 text-cyber-neon-orange" />
<span className="text-cyber-neon-orange">尚无成功记录</span>
</>
)}
</span>
</div>
)}
{showForm && (
<div className="space-y-2">
<p className="text-[11px] font-mono text-cyber-text-muted leading-relaxed">
定时监控需要长期有效的登录态。在浏览器登录{label}后,从开发者工具复制完整 Cookie
粘贴到此处(需包含 <span className="text-cyber-neon-cyan">web_session</span>、
<span className="text-cyber-neon-cyan">a1</span>、
<span className="text-cyber-neon-cyan">webId</span>)。
Cookie 通常数周后失效,届时任务会提示重新粘贴。该 Cookie 只用于{label}。
</p>
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="web_session=xxxx; a1=xxxx; webId=xxxx; ..."
rows={4}
className="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"
/>
<div className="flex items-center gap-2">
<Button
size="sm"
disabled={!draft.trim() || setCookie.isPending}
onClick={() =>
setCookie.mutate(draft.trim(), {
onSuccess: () => {
setDraft('')
setEditing(false)
},
})
}
>
保存
</Button>
{status?.present && (
<Button variant="ghost" size="sm" onClick={() => setEditing(false)}>
取消
</Button>
)}
</div>
</div>
)}
</div>
)
}
+110
View File
@@ -0,0 +1,110 @@
import { CheckCheck, FilePlus2, MessageCirclePlus, TrendingUp, TriangleAlert } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useMarkEventsRead, useMonitorEvents } from '@/hooks/useMonitor'
import { formatRelative } from '@/lib/monitorFormat'
import type { EventType } from '@/types/monitor'
interface EventsFeedProps {
taskId: number | null
}
const EVENT_META: Record<
EventType,
{ label: string; icon: typeof FilePlus2; variant: 'success' | 'warning' | 'destructive' | 'default' }
> = {
// A comment that genuinely appeared since the last run.
new_comment_posted: { label: '新评论', icon: MessageCirclePlus, variant: 'success' },
// One that only just entered the visible top-N window - flagged differently
// so the distinction is never blurred.
new_comment_seen: { label: '新出现评论', icon: MessageCirclePlus, variant: 'default' },
new_note: { label: '新作品', icon: FilePlus2, variant: 'success' },
metric_delta: { label: '数据变化', icon: TrendingUp, variant: 'default' },
run_failed: { label: '采集失败', icon: TriangleAlert, variant: 'destructive' },
suspected_auth_failure: { label: '登录失效', icon: TriangleAlert, variant: 'warning' },
// Distinct from an auth failure: the login is fine, this target is the issue.
no_data_found: { label: '未抓到数据', icon: TriangleAlert, variant: 'warning' },
}
export function EventsFeed({ taskId }: EventsFeedProps) {
const { data: events, isLoading } = useMonitorEvents(taskId)
const markRead = useMarkEventsRead()
if (isLoading) {
return <p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">加载中…</p>
}
const unread = (events ?? []).filter((event) => !event.is_read).length
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-mono text-cyber-text-muted">
{unread > 0 ? `${unread} 条未读` : '全部已读'}
</span>
{unread > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => markRead.mutate(taskId ?? undefined)}
disabled={markRead.isPending}
>
<CheckCheck className="w-3 h-3 mr-1" />
全部标为已读
</Button>
)}
</div>
{!events || events.length === 0 ? (
<p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">
{taskId === null ? '请先选择一个监控任务' : '暂无变化事件'}
</p>
) : (
<div className="space-y-1">
{events.map((event) => {
const meta = EVENT_META[event.type] ?? {
label: event.type,
icon: TrendingUp,
variant: 'default' as const,
}
const Icon = meta.icon
return (
<div
key={event.id}
className={`flex items-start gap-2 rounded-md border px-3 py-2 ${
event.is_read
? 'border-cyber-border-subtle bg-cyber-bg-tertiary/30'
: 'border-cyber-neon-cyan/30 bg-cyber-neon-cyan/5'
}`}
>
<Icon
className={`w-3.5 h-3.5 mt-0.5 flex-shrink-0 ${
meta.variant === 'destructive'
? 'text-cyber-neon-pink'
: meta.variant === 'warning'
? 'text-cyber-neon-orange'
: 'text-cyber-neon-cyan'
}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant={meta.variant} className="text-[9px] px-1 py-0">
{meta.label}
</Badge>
<span className="text-[11px] font-mono text-cyber-text-primary break-words">
{event.title}
</span>
</div>
<div className="mt-0.5 text-[9px] font-mono text-cyber-text-muted">
任务 #{event.task_id} · {formatRelative(event.created_at)}
</div>
</div>
</div>
)
})}
</div>
)}
</div>
)
}
@@ -0,0 +1,198 @@
import { useState } from 'react'
import { Activity, BellRing, FileText, MessageSquare, Plus } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useMonitorOverview, useMonitorTasks } from '@/hooks/useMonitor'
import type { MonitorTask } from '@/types/monitor'
import { useCookieStatus, useWebhookStatus } from '@/hooks/useMonitor'
import { useCurrentPlatform } from '@/hooks/usePlatform'
import { UnwiredPlatformNotice } from '@/components/layout/UnwiredPlatformNotice'
import { CommentsFeed } from './CommentsFeed'
import { EventsFeed } from './EventsFeed'
import { NotesTable } from './NotesTable'
import { RunHistory } from './RunHistory'
import { StatTile } from './StatTile'
import { TaskCard } from './TaskCard'
import { TaskEditorDialog } from './TaskEditorDialog'
export function MonitorDashboard() {
const { data: overview } = useMonitorOverview()
const { data: tasks, isLoading } = useMonitorTasks()
const { data: cookieStatus } = useCookieStatus()
const { data: webhookStatus } = useWebhookStatus()
const cookieOk = cookieStatus?.present ?? false
const webhookOk = webhookStatus?.configured ?? false
const { isWired, isLoading: platformLoading } = useCurrentPlatform()
const [selectedTaskId, setSelectedTaskId] = useState<number | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
const [editingTask, setEditingTask] = useState<MonitorTask | null>(null)
const [onlyNew, setOnlyNew] = useState(false)
// Fall back to the first task so the detail panes are never empty for no
// reason; keep an explicit selection once the user makes one.
const selectedTask =
tasks?.find((task) => task.id === selectedTaskId) ?? (tasks?.length ? tasks[0] : null)
const openCreate = () => {
setEditingTask(null)
setEditorOpen(true)
}
const openEdit = (task: MonitorTask) => {
setEditingTask(task)
setEditorOpen(true)
}
// An unwired platform has no monitoring to show; an explanatory panel beats an
// empty dashboard that looks broken.
if (!platformLoading && !isWired) {
return <UnwiredPlatformNotice area="监控" />
}
return (
<div className="flex-1 flex flex-col gap-3 overflow-hidden min-h-0 relative z-10">
{/* KPI row - single numbers are tiles, not charts. */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 flex-shrink-0">
<StatTile
icon={Activity}
label="监控任务"
value={overview?.tasks ?? 0}
hint={`${overview?.enabled_tasks ?? 0} 个启用中`}
/>
<StatTile icon={FileText} label="已追踪作品" value={overview?.notes ?? 0} />
<StatTile icon={MessageSquare} label="已追踪评论" value={overview?.comments ?? 0} />
<StatTile
icon={BellRing}
label="未读变化"
value={overview?.unread_events ?? 0}
hint={`24h 内 ${overview?.events_24h ?? 0} 条`}
accent={(overview?.unread_events ?? 0) > 0}
/>
</div>
<div className="flex-1 flex gap-3 overflow-hidden min-h-0">
{/* Left: tasks + cookie */}
<div className="w-[340px] flex-shrink-0 flex flex-col gap-3 overflow-hidden">
<div className="flex items-center justify-between flex-shrink-0">
<span className="font-mono text-xs tracking-wider text-cyber-text-primary">
监控任务
</span>
<Button size="sm" onClick={openCreate}>
<Plus className="w-3 h-3 mr-1" />
新建
</Button>
</div>
<div className="flex-1 overflow-y-auto terminal-scroll space-y-2 pr-1">
{isLoading && (
<p className="py-6 text-center text-[11px] font-mono text-cyber-text-muted">
加载中…
</p>
)}
{tasks?.length === 0 && (
<p className="py-6 text-center text-[11px] font-mono text-cyber-text-muted">
还没有监控任务,点击「新建」开始
</p>
)}
{tasks?.map((task) => (
<TaskCard
key={task.id}
task={task}
selected={selectedTask?.id === task.id}
onSelect={() => setSelectedTaskId(task.id)}
onEdit={() => openEdit(task)}
/>
))}
</div>
<div className="flex-shrink-0">
{/* The panels themselves moved to the Settings page; this only keeps
the "is anything unconfigured?" signal where tasks are run. */}
<div className="rounded-lg glass-panel float-panel px-3 py-2 space-y-1">
<div className="flex items-center gap-2 text-[10px] font-mono">
<span className={cookieOk ? 'status-dot status-dot-online' : 'status-dot status-dot-warning'} />
<span className="text-cyber-text-secondary">
登录态 {cookieOk ? '已配置' : '未配置'}
</span>
<span className="mx-1 text-cyber-text-muted">·</span>
<span className={webhookOk ? 'status-dot status-dot-online' : 'status-dot status-dot-offline'} />
<span className="text-cyber-text-secondary">
通知 {webhookOk ? '已配置' : '未配置'}
</span>
</div>
<p className="text-[10px] font-mono text-cyber-text-muted">
在「设置」页配置登录态、通知与采集策略
</p>
</div>
</div>
</div>
{/* Right: detail panes for the selected task */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0 rounded-lg glass-panel float-panel">
{selectedTask ? (
<Tabs defaultValue="notes" className="flex-1 flex flex-col overflow-hidden">
<div className="px-3 pt-3 flex items-center justify-between gap-3 flex-shrink-0">
<TabsList>
<TabsTrigger value="notes">作品</TabsTrigger>
<TabsTrigger value="comments">评论</TabsTrigger>
<TabsTrigger value="events">
变化
{selectedTask.unread_events > 0 && (
<Badge variant="destructive" className="ml-1 text-[9px] px-1 py-0">
{selectedTask.unread_events}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="runs">运行历史</TabsTrigger>
</TabsList>
{selectedTask.mode === 'creator' && (
<span className="text-[10px] font-mono text-cyber-text-muted">
仅显示每个博主最新的 {selectedTask.max_notes_count} 条作品
</span>
)}
</div>
<TabsContent value="notes" className="flex-1 overflow-auto terminal-scroll px-3 pb-3 mt-2">
<div className="flex items-center gap-2 mb-2">
<Button
variant={onlyNew ? 'default' : 'outline'}
size="sm"
onClick={() => setOnlyNew((value) => !value)}
>
只看新增
</Button>
</div>
<NotesTable taskId={selectedTask.id} onlyNew={onlyNew} />
</TabsContent>
<TabsContent value="comments" className="flex-1 overflow-auto terminal-scroll px-3 pb-3 mt-2">
<CommentsFeed taskId={selectedTask.id} />
</TabsContent>
<TabsContent value="events" className="flex-1 overflow-auto terminal-scroll px-3 pb-3 mt-2">
<EventsFeed taskId={selectedTask.id} />
</TabsContent>
<TabsContent value="runs" className="flex-1 overflow-auto terminal-scroll px-3 pb-3 mt-2">
<RunHistory taskId={selectedTask.id} />
</TabsContent>
</Tabs>
) : (
<div className="flex-1 flex items-center justify-center">
<p className="text-[11px] font-mono text-cyber-text-muted">
创建或选择一个监控任务以查看数据
</p>
</div>
)}
</div>
</div>
<TaskEditorDialog open={editorOpen} onOpenChange={setEditorOpen} task={editingTask} />
</div>
)
}
@@ -0,0 +1,209 @@
import { useMemo, useState } from 'react'
import { useNoteSeries } from '@/hooks/useMonitor'
import { formatCount, formatDateTime } from '@/lib/monitorFormat'
type MetricKey = 'liked_count' | 'comment_count' | 'collected_count' | 'share_count'
const METRICS: Array<{ key: MetricKey; label: string }> = [
{ key: 'liked_count', label: '点赞' },
{ key: 'comment_count', label: '评论' },
{ key: 'collected_count', label: '收藏' },
{ key: 'share_count', label: '分享' },
]
const VIEW_W = 600
const VIEW_H = 160
const PAD_LEFT = 10
const PAD_RIGHT = 56
const PAD_TOP = 18
const PAD_BOTTOM = 22
interface NoteTrendChartProps {
noteId: string
taskId: number | null
noteTitle: string
}
/**
* Metric history for a single note.
*
* Deliberately one metric at a time rather than several overlaid: like and
* comment counts differ by orders of magnitude, so a shared axis would invent a
* misleading relationship between them. One series also means no legend is
* needed -- the picker names it.
*/
export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProps) {
const [metric, setMetric] = useState<MetricKey>('liked_count')
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
const { data: series, isLoading } = useNoteSeries(noteId, taskId)
// A point with no parsed value is a gap, not a zero.
const points = useMemo(
() => (series ?? []).filter((point) => point[metric] !== null),
[series, metric],
)
const geometry = useMemo(() => {
if (points.length < 2) return null
const values = points.map((point) => point[metric] as number)
const min = Math.min(...values)
const max = Math.max(...values)
// A flat series would divide by zero; give it a nominal band.
const span = max - min || 1
const innerW = VIEW_W - PAD_LEFT - PAD_RIGHT
const innerH = VIEW_H - PAD_TOP - PAD_BOTTOM
const xy = points.map((point, index) => {
const value = point[metric] as number
return {
x: PAD_LEFT + (index / (points.length - 1)) * innerW,
y: PAD_TOP + innerH - ((value - min) / span) * innerH,
value,
point,
}
})
return { xy, min, max }
}, [points, metric])
if (isLoading) {
return <div className="p-4 text-[11px] font-mono text-cyber-text-muted">加载中…</div>
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3 flex-wrap">
<span className="font-mono text-xs text-cyber-text-primary">
指标趋势 · <span className="text-cyber-text-secondary">{noteTitle || noteId}</span>
</span>
<div className="flex items-center gap-1">
{METRICS.map((option) => (
<button
key={option.key}
onClick={() => setMetric(option.key)}
className={`px-2 py-1 rounded text-[10px] font-mono transition-colors ${
metric === option.key
? 'bg-cyber-neon-cyan/20 text-cyber-neon-cyan border border-cyber-neon-cyan/40'
: 'text-cyber-text-muted border border-transparent hover:text-cyber-text-secondary'
}`}
>
{option.label}
</button>
))}
</div>
</div>
{!geometry ? (
<p className="py-6 text-center text-[11px] font-mono text-cyber-text-muted">
至少需要两轮采集才能画出趋势(当前 {points.length} 个有效数据点)
</p>
) : (
<div className="relative">
<svg
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
className="w-full h-40"
preserveAspectRatio="none"
onMouseLeave={() => setHoverIndex(null)}
>
{/* Recessive solid hairlines - never dashed. */}
{[0, 0.5, 1].map((ratio) => {
const y = PAD_TOP + (VIEW_H - PAD_TOP - PAD_BOTTOM) * ratio
return (
<line
key={ratio}
x1={PAD_LEFT}
x2={VIEW_W - PAD_RIGHT}
y1={y}
y2={y}
stroke="rgb(var(--cyber-text-muted) / 0.25)"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
)
})}
{hoverIndex !== null && geometry.xy[hoverIndex] && (
<line
x1={geometry.xy[hoverIndex].x}
x2={geometry.xy[hoverIndex].x}
y1={PAD_TOP}
y2={VIEW_H - PAD_BOTTOM}
stroke="rgb(var(--cyber-neon-cyan) / 0.5)"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
)}
<polyline
points={geometry.xy.map((node) => `${node.x},${node.y}`).join(' ')}
fill="none"
stroke="rgb(var(--cyber-neon-cyan))"
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
{/* Only the endpoint is labelled - a number on every point is noise. */}
<circle
cx={geometry.xy[geometry.xy.length - 1].x}
cy={geometry.xy[geometry.xy.length - 1].y}
r={4}
fill="rgb(var(--cyber-neon-cyan))"
stroke="rgb(var(--cyber-bg-primary))"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
/>
{geometry.xy.map((node, index) => (
<rect
key={index}
x={node.x - 6}
y={PAD_TOP}
width={12}
height={VIEW_H - PAD_TOP - PAD_BOTTOM}
fill="transparent"
onMouseEnter={() => setHoverIndex(index)}
/>
))}
</svg>
{/* Axis extremes live in text tokens, never the series colour. */}
<span className="absolute left-0 top-0 text-[9px] font-mono text-cyber-text-muted">
{formatCount(geometry.max)}
</span>
<span className="absolute left-0 bottom-5 text-[9px] font-mono text-cyber-text-muted">
{formatCount(geometry.min)}
</span>
<span className="absolute right-0 top-1/2 -translate-y-1/2 text-[11px] font-mono text-cyber-text-primary">
{formatCount(geometry.xy[geometry.xy.length - 1].value)}
</span>
{hoverIndex !== null && geometry.xy[hoverIndex] && (
<div
className="absolute -top-1 px-2 py-1 rounded border border-cyber-border-DEFAULT bg-cyber-bg-elevated text-[10px] font-mono text-cyber-text-primary pointer-events-none whitespace-nowrap"
style={{
left: `${(geometry.xy[hoverIndex].x / VIEW_W) * 100}%`,
transform: 'translateX(-50%)',
}}
>
<div className="text-cyber-neon-cyan">
{formatCount(geometry.xy[hoverIndex].value)}
</div>
<div className="text-cyber-text-muted">
{formatDateTime(geometry.xy[hoverIndex].point.captured_at)}
</div>
</div>
)}
</div>
)}
<p className="text-[10px] font-mono text-cyber-text-muted">
共 {points.length} 个数据点,每轮采集记录一次快照
</p>
</div>
)
}
+146
View File
@@ -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>
)
}
+302
View File
@@ -0,0 +1,302 @@
import { useEffect, useMemo, useState } from 'react'
import { Download, FilePlus2, MessageSquarePlus, ThumbsUp, Star, TriangleAlert } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { useMonitorTasks, useReport } from '@/hooks/useMonitor'
import { useCurrentPlatform } from '@/hooks/usePlatform'
import { UnwiredPlatformNotice } from '@/components/layout/UnwiredPlatformNotice'
import { monitorApi } from '@/lib/api'
import { formatCount } from '@/lib/monitorFormat'
import type { MetricKey, ReportRow } from '@/types/monitor'
import { StatTile } from './StatTile'
/** Local-date ISO string; toISOString() would shift across the UTC boundary. */
function toISODate(value: Date): string {
const pad = (n: number) => String(n).padStart(2, '0')
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`
}
const METRIC_COLUMNS: Array<{ key: MetricKey; label: string }> = [
{ key: 'liked_count_delta', label: '点赞 Δ' },
{ key: 'comment_count_delta', label: '评论 Δ' },
{ key: 'collected_count_delta', label: '收藏 Δ' },
{ key: 'share_count_delta', label: '分享 Δ' },
]
/** Neutral for zero, since most rows are zero and colouring them all is noise. */
function DeltaCell({ value }: { value: number }) {
if (value === 0) return <span className="text-cyber-text-muted">—</span>
return (
<span className={value > 0 ? 'text-cyber-neon-green' : 'text-cyber-neon-pink'}>
{value > 0 ? '+' : ''}
{formatCount(value)}
</span>
)
}
export function ReportView() {
const { isWired, isLoading: platformLoading } = useCurrentPlatform()
const { data: tasks } = useMonitorTasks()
const [selected, setSelected] = useState<number[] | null>(null)
const [endDate, setEndDate] = useState(() => toISODate(new Date()))
const [startDate, setStartDate] = useState(() => {
const d = new Date()
d.setDate(d.getDate() - 6)
return toISODate(d)
})
// Default to every task once the list arrives; the user narrows from there.
useEffect(() => {
if (tasks && selected === null) {
setSelected(tasks.map((task) => task.id))
}
}, [tasks, selected])
const activeTaskIds = selected ?? []
const { data: report, isLoading } = useReport(activeTaskIds, startDate, endDate)
const allSelected = useMemo(
() => Boolean(tasks?.length) && activeTaskIds.length === tasks?.length,
[tasks, activeTaskIds],
)
const toggleTask = (id: number) => {
setSelected((current) => {
const base = current ?? []
return base.includes(id) ? base.filter((value) => value !== id) : [...base, id]
})
}
const applyPreset = (days: number) => {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - (days - 1))
setStartDate(toISODate(start))
setEndDate(toISODate(end))
}
if (!platformLoading && !isWired) {
return <UnwiredPlatformNotice area="报表" />
}
const totals = report?.totals
const partialLabels = (report?.partial_metrics ?? [])
.map((field) => report?.metric_labels?.[field] ?? field)
.join('、')
return (
<div className="flex-1 flex flex-col gap-3 overflow-hidden min-h-0 relative z-10">
{/* Filters */}
<div className="rounded-lg glass-panel float-panel p-3 flex-shrink-0 space-y-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<span className="font-mono text-xs tracking-wider text-cyber-text-primary">
统计范围
</span>
<div className="flex items-center gap-1">
<input
type="date"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
className="rounded-md border border-cyber-border-DEFAULT bg-cyber-bg-tertiary px-2 py-1 font-mono text-[11px] text-cyber-text-primary outline-none focus-visible:border-cyber-neon-cyan/50"
/>
<span className="text-cyber-text-muted text-[11px] font-mono">至</span>
<input
type="date"
value={endDate}
onChange={(event) => setEndDate(event.target.value)}
className="rounded-md border border-cyber-border-DEFAULT bg-cyber-bg-tertiary px-2 py-1 font-mono text-[11px] text-cyber-text-primary outline-none focus-visible:border-cyber-neon-cyan/50"
/>
</div>
</div>
<div className="flex items-center gap-1">
{[7, 30, 90].map((days) => (
<Button key={days} variant="outline" size="sm" onClick={() => applyPreset(days)}>
近 {days} 天
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(
monitorApi.getExportUrl({
kind: 'report',
format: 'csv',
taskIds: activeTaskIds,
startDate,
endDate,
}),
'_blank',
)
}
>
<Download className="w-3 h-3 mr-1" />
导出
</Button>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[11px] font-mono text-cyber-text-secondary">
参与统计的任务(已选{' '}
<span className="text-cyber-neon-cyan">{activeTaskIds.length}</span> 个)
</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setSelected(allSelected ? [] : (tasks ?? []).map((task) => task.id))
}
disabled={!tasks?.length}
>
{allSelected ? '全不选' : '全选'}
</Button>
</div>
{tasks?.length ? (
<div className="flex flex-wrap gap-2">
{tasks.map((task) => {
const checked = activeTaskIds.includes(task.id)
return (
<label
key={task.id}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md border cursor-pointer transition-colors ${
checked
? 'border-cyber-neon-cyan/40 bg-cyber-neon-cyan/10'
: 'border-cyber-border-subtle bg-cyber-bg-tertiary'
}`}
>
<Checkbox
checked={checked}
onCheckedChange={() => toggleTask(task.id)}
/>
<span
className={`text-[11px] font-mono ${
checked ? 'text-cyber-neon-cyan' : 'text-cyber-text-muted'
}`}
>
{task.name}
</span>
</label>
)
})}
</div>
) : (
<p className="text-[11px] font-mono text-cyber-text-muted">
还没有监控任务,先去「监控」页建一个。
</p>
)}
</div>
</div>
{/* Headline totals */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 flex-shrink-0">
<StatTile icon={FilePlus2} label="新增作品" value={formatCount(totals?.new_notes ?? 0)} />
<StatTile
icon={MessageSquarePlus}
label="新增评论"
value={formatCount(totals?.new_comments ?? 0)}
/>
<StatTile
icon={ThumbsUp}
label="点赞增量"
value={formatCount(totals?.liked_count_delta ?? 0)}
hint={`覆盖 ${report?.note_count ?? 0} 篇作品`}
/>
<StatTile
icon={Star}
label="收藏增量"
value={formatCount(totals?.collected_count_delta ?? 0)}
/>
</div>
{/* Daily breakdown */}
<div className="flex-1 overflow-auto terminal-scroll rounded-lg glass-panel float-panel">
{activeTaskIds.length === 0 ? (
<p className="py-10 text-center text-[11px] font-mono text-cyber-text-muted">
请至少选择一个任务
</p>
) : isLoading ? (
<p className="py-10 text-center text-[11px] font-mono text-cyber-text-muted">统计中…</p>
) : (
<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="text-left font-normal py-2 px-3">日期</th>
<th className="text-right font-normal py-2 px-3">新增作品</th>
<th className="text-right font-normal py-2 px-3">新增评论</th>
{METRIC_COLUMNS.map((column) => (
<th key={column.key} className="text-right font-normal py-2 px-3">
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{report?.rows.map((row: ReportRow) => (
<tr key={row.date} className="border-t border-cyber-border-subtle">
<td className="py-2 px-3 text-cyber-text-primary">{row.date}</td>
<td className="py-2 px-3 text-right">
<span className={row.new_notes > 0 ? 'text-cyber-neon-green' : 'text-cyber-text-muted'}>
{row.new_notes || '—'}
</span>
</td>
<td className="py-2 px-3 text-right">
<span
className={row.new_comments > 0 ? 'text-cyber-neon-green' : 'text-cyber-text-muted'}
>
{row.new_comments || '—'}
</span>
</td>
{METRIC_COLUMNS.map((column) => (
<td key={column.key} className="py-2 px-3 text-right">
<DeltaCell value={row[column.key]} />
</td>
))}
</tr>
))}
{report && (
<tr className="border-t-2 border-cyber-neon-cyan/30 bg-cyber-bg-tertiary/60">
<td className="py-2 px-3 text-cyber-neon-cyan">合计</td>
<td className="py-2 px-3 text-right text-cyber-neon-cyan">
{report.totals.new_notes}
</td>
<td className="py-2 px-3 text-right text-cyber-neon-cyan">
{report.totals.new_comments}
</td>
{METRIC_COLUMNS.map((column) => (
<td key={column.key} className="py-2 px-3 text-right text-cyber-neon-cyan">
{report.totals[column.key] > 0 ? '+' : ''}
{formatCount(report.totals[column.key])}
</td>
))}
</tr>
)}
</tbody>
</table>
)}
</div>
{/* Honest caveats, stated where the numbers are read. */}
<div className="flex-shrink-0 space-y-1">
{partialLabels && (
<p className="flex items-center gap-1.5 text-[10px] font-mono text-cyber-neon-orange">
<TriangleAlert className="w-3 h-3" />
{partialLabels} 存在无法解析的数值,这些增量未计入统计,实际增幅会更高。
</p>
)}
<p className="text-[10px] font-mono text-cyber-text-muted">
「增量」= 当日末值 − 当日之前最后一次采到的值;作品首次出现的那天从 0 起算,
所以新增作品的全部点赞都计在其首次发现日。<br />
「新增评论」受接口限制只覆盖平台默认排序的前 N 条,沉底的新评论不计入。
</p>
</div>
</div>
)
}
@@ -0,0 +1,96 @@
import { Badge } from '@/components/ui/badge'
import { useTaskRuns } from '@/hooks/useMonitor'
import { formatDateTime, formatRelative } from '@/lib/monitorFormat'
import type { RunStatus } from '@/types/monitor'
interface RunHistoryProps {
taskId: number | null
}
const STATUS_META: Record<RunStatus, { label: string; variant: 'success' | 'warning' | 'destructive' | 'running' | 'idle' }> = {
success: { label: '成功', variant: 'success' },
partial: { label: '部分失败', variant: 'warning' },
failed: { label: '失败', variant: 'destructive' },
timeout: { label: '超时', variant: 'destructive' },
interrupted: { label: '已中断', variant: 'warning' },
running: { label: '运行中', variant: 'running' },
pending: { label: '排队中', variant: 'idle' },
}
export function RunHistory({ taskId }: RunHistoryProps) {
const { data: runs, isLoading } = useTaskRuns(taskId)
if (isLoading) {
return <p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">加载中…</p>
}
if (!runs || runs.length === 0) {
return (
<p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">
{taskId === null ? '请先选择一个监控任务' : '还没有运行记录'}
</p>
)
}
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="text-left font-normal py-2 px-2">状态</th>
<th className="text-left font-normal py-2 px-2">触发</th>
<th className="text-right font-normal py-2 px-2">抓到作品</th>
<th className="text-right font-normal py-2 px-2">新增作品</th>
<th className="text-right font-normal py-2 px-2">新增评论</th>
<th className="text-left font-normal py-2 px-2">开始时间</th>
<th className="text-left font-normal py-2 px-2">说明</th>
</tr>
</thead>
<tbody>
{runs.map((run) => {
const meta = STATUS_META[run.status] ?? STATUS_META.pending
return (
<tr key={run.id} className="border-t border-cyber-border-subtle">
<td className="py-2 px-2">
<div className="flex items-center gap-1.5">
<Badge variant={meta.variant} className="text-[9px] px-1 py-0">
{meta.label}
</Badge>
{run.is_baseline && (
<span className="text-[9px] text-cyber-text-muted" title="首次运行建立基线,不产生变化事件">
基线
</span>
)}
</div>
</td>
<td className="py-2 px-2 text-[10px] text-cyber-text-secondary">
{run.trigger === 'manual' ? '手动' : '定时'}
</td>
<td className="py-2 px-2 text-right text-cyber-text-primary">{run.notes_fetched}</td>
<td className="py-2 px-2 text-right">
<span className={run.new_notes > 0 ? 'text-cyber-neon-green' : 'text-cyber-text-muted'}>
{run.new_notes}
</span>
</td>
<td className="py-2 px-2 text-right">
<span
className={run.new_comments > 0 ? 'text-cyber-neon-green' : 'text-cyber-text-muted'}
>
{run.new_comments}
</span>
</td>
<td className="py-2 px-2 text-[10px] text-cyber-text-muted">
{formatDateTime(run.started_at)}
<span className="ml-1">({formatRelative(run.started_at)})</span>
</td>
<td className="py-2 px-2 text-[10px] text-cyber-neon-orange max-w-[240px] truncate">
{run.error_message ?? ''}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
+48
View File
@@ -0,0 +1,48 @@
import type { LucideIcon } from 'lucide-react'
interface StatTileProps {
label: string
value: number | string
icon: LucideIcon
/** Draws attention when non-zero, e.g. unread change events. */
accent?: boolean
hint?: string
}
/**
* A single headline number.
*
* Deliberately a tile and not a chart: one number's job is to be read, and
* wrapping it in a plot (a one-bar bar chart, a two-slice pie) is a known way
* to hide the number rather than show it.
*/
export function StatTile({ label, value, icon: Icon, accent = false, hint }: StatTileProps) {
return (
<div className="rounded-lg glass-panel float-panel px-3 py-2.5 flex items-center gap-3">
<div
className={`p-1.5 rounded-md border ${
accent
? 'border-cyber-neon-pink/40 bg-cyber-neon-pink/10'
: 'border-cyber-neon-cyan/30 bg-cyber-neon-cyan/10'
}`}
>
<Icon
className={`w-3.5 h-3.5 ${accent ? 'text-cyber-neon-pink' : 'text-cyber-neon-cyan'}`}
/>
</div>
<div className="min-w-0">
<div
className={`font-mono text-lg leading-none ${
accent ? 'text-cyber-neon-pink' : 'text-cyber-text-primary'
}`}
>
{value}
</div>
<div className="text-[10px] font-mono text-cyber-text-muted mt-1 truncate">
{label}
{hint ? <span className="text-cyber-text-secondary"> · {hint}</span> : null}
</div>
</div>
</div>
)
}
+156
View File
@@ -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>
)
}
@@ -0,0 +1,281 @@
import { useEffect, useState } 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 type { MonitorMode, MonitorTask, 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 天' },
]
export function TaskEditorDialog({ open, onOpenChange, task }: TaskEditorDialogProps) {
const isEdit = Boolean(task)
const createTask = useCreateTask()
const updateTask = useUpdateTask()
const { data: settings } = useSettings()
const [name, setName] = useState('')
const [mode, setMode] = useState<MonitorMode>('creator')
const [intervalMinutes, setIntervalMinutes] = useState('360')
const [maxNotes, setMaxNotes] = useState('20')
const [enableComments, setEnableComments] = useState(true)
const [maxComments, setMaxComments] = useState('50')
const [notifyEnabled, setNotifyEnabled] = useState(false)
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),
)
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)
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)
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 canSubmit = name.trim().length > 0 && targetList.length > 0 && !pending
const handleSubmit = () => {
const payload: TaskCreatePayload = {
name: name.trim(),
mode,
interval_minutes: Number(intervalMinutes),
max_notes_count: Number(maxNotes),
enable_comments: enableComments,
max_comments_count: Number(maxComments),
run_timeout_seconds: 3600,
enabled: true,
notify_enabled: notifyEnabled,
targets: targetList,
}
const done = () => onOpenChange(false)
if (isEdit && task) {
updateTask.mutate({ id: task.id, payload }, { 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">笔记(批量监控指定内容)</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>
<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>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs font-mono text-cyber-text-secondary">
{mode === 'creator' ? '博主主页链接或 ID' : '笔记链接或 ID'}
</Label>
<textarea
value={targets}
onChange={(event) => setTargets(event.target.value)}
rows={5}
placeholder={
mode === 'creator'
? '每行一个,支持完整主页链接或纯 ID:\nhttps://www.xiaohongshu.com/user/profile/5f58bd99...\n5f58bd990000000001003753'
: '每行一个,支持完整笔记链接或纯 ID:\nhttps://www.xiaohongshu.com/explore/6aa3d827...'
}
className={TEXTAREA_CLASS}
/>
<p className="text-[10px] font-mono text-cyber-text-muted">
已识别 <span className="text-cyber-neon-cyan">{targetList.length}</span> 个目标。
<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">
每轮最多采集作品数
</Label>
<Input
type="number"
min={1}
value={maxNotes}
onChange={(event) => setMaxNotes(event.target.value)}
className="h-9 text-xs"
/>
<p className="text-[10px] font-mono text-cyber-text-muted">
只取最新的前 N 条,决定了"该博主的作品"覆盖范围
</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="flex items-start gap-2 rounded-md border border-cyber-border-subtle bg-cyber-bg-tertiary/40 p-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">
仅在本任务**采集失败 / 登录态失效**或**发现新作品**时推送,
一轮只发一条汇总。需先在监控页配置 Webhook 地址。
</p>
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit}>
{isEdit ? '保存' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,125 @@
import { useState } from 'react'
import { Send, Trash2, Webhook } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useClearWebhook, useSetWebhook, useTestWebhook, useWebhookStatus } from '@/hooks/useMonitor'
/**
* 企业微信机器人 Webhook 配置。
*
* 地址里的 key 等同凭据,所以服务端只回传打码后的形式 —— 想换就得重新粘贴,
* 和 Cookie 的处理保持一致。
*/
export function WebhookPanel() {
const { data: status } = useWebhookStatus()
const setWebhook = useSetWebhook()
const clearWebhook = useClearWebhook()
const testWebhook = useTestWebhook()
const [draft, setDraft] = useState('')
const [editing, setEditing] = useState(false)
const showForm = !status?.configured || editing
return (
<div className="rounded-lg glass-panel float-panel p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Webhook className="w-4 h-4 text-cyber-neon-cyan" />
<span className="font-mono text-xs tracking-wider text-cyber-text-primary">
企业微信通知
</span>
{status?.configured ? (
<Badge variant="success" className="text-[10px]">
已配置
</Badge>
) : (
<Badge variant="idle" className="text-[10px]">
未配置
</Badge>
)}
</div>
{status?.configured && (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => testWebhook.mutate(undefined)}
disabled={testWebhook.isPending}
>
<Send className="w-3 h-3 mr-1" />
发测试
</Button>
{!editing && (
<Button variant="ghost" size="sm" onClick={() => setEditing(true)}>
替换
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => clearWebhook.mutate()}
disabled={clearWebhook.isPending}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
)}
</div>
{status?.configured && !editing && (
<p className="text-[11px] font-mono text-cyber-text-secondary break-all">
{status.masked}
</p>
)}
{showForm && (
<div className="space-y-2">
<p className="text-[11px] font-mono text-cyber-text-muted leading-relaxed">
在企业微信群里「添加群机器人」后复制 Webhook 地址。
只有**开启了推送开关**的任务才会发消息,且**一轮只发一条汇总**,
不会刷屏。
</p>
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx"
rows={2}
className="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"
/>
<div className="flex items-center gap-2">
<Button
size="sm"
disabled={!draft.trim() || setWebhook.isPending}
onClick={() =>
setWebhook.mutate(draft.trim(), {
onSuccess: () => {
setDraft('')
setEditing(false)
},
})
}
>
保存
</Button>
<Button
variant="outline"
size="sm"
disabled={!draft.trim() || testWebhook.isPending}
onClick={() => testWebhook.mutate(draft.trim())}
>
保存前先测
</Button>
{status?.configured && (
<Button variant="ghost" size="sm" onClick={() => setEditing(false)}>
取消
</Button>
)}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,164 @@
import { useState } from 'react'
import { ShieldCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { authApi } from '@/lib/api'
import type { SettingSpec } from '@/types/monitor'
/** Renders one field from its server-supplied spec. */
export function SettingField({
spec,
value,
onChange,
}: {
spec: SettingSpec
value: boolean | number | string
onChange: (next: boolean | number | string) => void
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<Label className="text-[11px] font-mono text-cyber-text-secondary">{spec.label}</Label>
{spec.type === 'bool' && (
<Checkbox checked={value === true} onCheckedChange={(c) => onChange(c === true)} />
)}
{spec.type === 'int' && (
<Input
type="number"
value={String(value)}
min={spec.minimum ?? undefined}
max={spec.maximum ?? undefined}
onChange={(event) => onChange(Number(event.target.value))}
className="h-8 w-28 text-xs"
/>
)}
{spec.type === 'str' && spec.choices && (
<Select value={String(value)} onValueChange={onChange}>
<SelectTrigger className="h-8 w-40 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{spec.choices.map((choice) => (
<SelectItem key={choice} value={choice}>
{choice}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{spec.type === 'str' && !spec.choices && (
<Input
value={String(value)}
onChange={(event) => onChange(event.target.value)}
className="h-8 w-64 text-xs"
/>
)}
</div>
{spec.help && (
<p className="text-[10px] font-mono text-cyber-text-muted leading-relaxed">{spec.help}</p>
)}
</div>
)
}
export function Section({
title,
description,
children,
}: {
title: string
description?: string
children: React.ReactNode
}) {
return (
<section className="rounded-lg glass-panel float-panel p-4 space-y-3">
<div>
<h3 className="font-mono text-xs tracking-wider text-cyber-text-primary">{title}</h3>
{description && (
<p className="mt-0.5 text-[10px] font-mono text-cyber-text-muted">{description}</p>
)}
</div>
{children}
</section>
)
}
export function ChangePassword() {
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const submit = async (event: React.FormEvent) => {
event.preventDefault()
if (!current || next.length < 8 || busy) return
setBusy(true)
setError('')
setMessage('')
try {
await authApi.changePassword(current, next)
setCurrent('')
setNext('')
setMessage('密码已更新,其他设备的登录已全部失效')
} catch (err: unknown) {
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
setError(detail ?? '修改失败')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={submit} className="space-y-2">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-[11px] font-mono text-cyber-text-secondary">当前密码</Label>
<Input
type="password"
value={current}
autoComplete="current-password"
onChange={(event) => setCurrent(event.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] font-mono text-cyber-text-secondary">
新密码(至少 8 位)
</Label>
<Input
type="password"
value={next}
autoComplete="new-password"
onChange={(event) => setNext(event.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
{error && <p className="text-[10px] font-mono text-cyber-neon-pink">{error}</p>}
{message && <p className="text-[10px] font-mono text-cyber-neon-green">{message}</p>}
<Button type="submit" size="sm" disabled={!current || next.length < 8 || busy}>
<ShieldCheck className="w-3 h-3 mr-1" />
修改密码
</Button>
<p className="text-[10px] font-mono text-cyber-text-muted">
修改后所有设备需重新登录。忘记密码时可用环境变量 MC_PASSWORD 恢复。
</p>
</form>
)
}
@@ -0,0 +1,141 @@
import { useEffect, useMemo, useState } from 'react'
import { KeyRound, QrCode, Save } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { CookiePanel } from '@/components/monitor/CookiePanel'
import { Section, SettingField } from '@/components/settings/SettingFields'
import { useSettings, useUpdateSettings } from '@/hooks/useMonitor'
import { useCurrentPlatform } from '@/hooks/usePlatform'
import { useCrawlerStore } from '@/store/crawlerStore'
import type { AppView } from '@/App'
import type { SettingSpec } from '@/types/monitor'
type Draft = Record<string, boolean | number | string>
/**
* Platform settings.
*
* Only the platform-scoped specs live here -- cookie, crawl pacing, proxies.
* Notification and account settings apply to the whole instance and would be a
* fiction inside a per-platform page, so they live in 系统设置 in the header.
*/
export function SettingsView({ onNavigate }: { onNavigate?: (view: AppView) => void }) {
const { platform, capability } = useCurrentPlatform()
const { data, isLoading } = useSettings()
const updateSettings = useUpdateSettings()
const [draft, setDraft] = useState<Draft>({})
// Seed the editable values once they arrive, and re-seed when the platform
// changes so the form never shows one platform's numbers under another's name.
useEffect(() => {
if (data?.values) setDraft(data.values as Draft)
}, [data])
const platformSpecs = useMemo(
() => (data?.specs ?? []).filter((spec) => spec.scope === 'platform' && spec.type !== 'secret'),
[data],
)
const saveable = platformSpecs.map((spec) => spec.key)
const dirty = useMemo(() => {
if (!data?.values) return {}
const changes: Draft = {}
for (const key of saveable) {
if (key in draft && data.values[key] !== draft[key]) changes[key] = draft[key]
}
return changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draft, data])
if (isLoading) {
return <p className="py-10 text-center text-[11px] font-mono text-cyber-text-muted">加载中…</p>
}
const label = capability?.label ?? platform
return (
<div className="flex-1 overflow-y-auto terminal-scroll pr-1">
<div className="max-w-3xl space-y-3 pb-6">
<div className="rounded-lg border border-cyber-neon-cyan/30 bg-cyber-neon-cyan/5 px-4 py-2">
<p className="text-[11px] font-mono text-cyber-text-secondary">
当前平台:
<span className="text-cyber-neon-cyan">{label}</span>
<span className="ml-2 text-cyber-text-muted">
以下设置只作用于该平台。通知与账号等全局项请用右上角的「系统设置」。
</span>
</p>
</div>
<Section title="登录态" description={`${label}的登录 Cookie,定时监控必须持久化登录态。`}>
<CookiePanel />
{/* Not a duplicate login flow: the crawler has no login-only mode, so a
QR login is a side effect of a real crawl -- which is exactly what
the 采集 page already does. This preselects it rather than
reimplementing it. */}
<div className="pt-3 mt-1 border-t border-cyber-border-subtle space-y-2">
<p className="text-[10px] font-mono text-cyber-text-muted leading-relaxed">
Cookie 不好使时,可以走一次**扫码登录**:二维码会显示在「采集」页的终端里。
扫码成功后浏览器 profile 会被更新,Cookie 的可靠性也会显著提升。
</p>
<Button
variant="outline"
size="sm"
disabled={!onNavigate}
onClick={() => {
// Preselect QR so the user lands on a page that is ready to scan.
useCrawlerStore.getState().updateConfig({ login_type: 'qrcode' })
onNavigate?.('crawler')
}}
>
<QrCode className="w-3 h-3 mr-1" />
去扫码登录
</Button>
</div>
</Section>
<Section
title="采集策略"
description="各平台风控容忍度不同,因此按平台分别配置。"
>
<div className="space-y-4">
{platformSpecs.map((spec: SettingSpec) => (
<SettingField
key={spec.key}
spec={spec}
value={draft[spec.key] ?? (spec.default as boolean | number | string)}
onChange={(next) => setDraft((prev) => ({ ...prev, [spec.key]: next }))}
/>
))}
</div>
</Section>
{Object.keys(dirty).length > 0 && (
<div className="sticky bottom-0 flex items-center justify-between gap-3 rounded-lg border border-cyber-neon-cyan/40 bg-cyber-bg-elevated px-4 py-2.5">
<span className="flex items-center gap-2 text-[11px] font-mono text-cyber-text-secondary">
<KeyRound className="w-3 h-3 text-cyber-neon-cyan" />
{Object.keys(dirty).length} 项未保存
</span>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setDraft((data?.values as Draft) ?? {})}
>
放弃
</Button>
<Button
size="sm"
disabled={updateSettings.isPending}
onClick={() => updateSettings.mutate(dirty)}
>
<Save className="w-3 h-3 mr-1" />
保存
</Button>
</div>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,122 @@
import { useEffect, useMemo, useState } from 'react'
import { Globe, Save } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { WebhookPanel } from '@/components/monitor/WebhookPanel'
import { ChangePassword, SettingField } from '@/components/settings/SettingFields'
import { useSettings, useUpdateSettings } from '@/hooks/useMonitor'
type Draft = Record<string, boolean | number | string>
/**
* System-wide settings.
*
* Kept out of the platform-scoped 设置 page on purpose: the notification webhook
* is a single group chat and the scheduler has a single active-hours window, so
* presenting them inside a per-platform page would imply a scope they do not
* have.
*/
export function SystemSettingsDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { data } = useSettings()
const updateSettings = useUpdateSettings()
const [draft, setDraft] = useState<Draft>({})
useEffect(() => {
if (open && data?.values) setDraft(data.values as Draft)
}, [open, data])
const systemSpecs = useMemo(
() => (data?.specs ?? []).filter((spec) => spec.scope === 'system' && spec.type !== 'secret'),
[data],
)
const dirty = useMemo(() => {
if (!data?.values) return {}
const changes: Draft = {}
for (const spec of systemSpecs) {
if (spec.key in draft && data.values[spec.key] !== draft[spec.key]) {
changes[spec.key] = draft[spec.key]
}
}
return changes
}, [draft, data, systemSpecs])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto terminal-scroll">
<DialogHeader>
<DialogTitle className="font-mono flex items-center gap-2">
<Globe className="w-4 h-4 text-cyber-neon-cyan" />
系统设置
</DialogTitle>
<DialogDescription className="font-mono text-xs">
这些设置对所有平台生效,与右上角选择的平台无关。
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
<div className="space-y-3">
<h3 className="font-mono text-xs tracking-wider text-cyber-text-primary">通知</h3>
<p className="text-[10px] font-mono text-cyber-text-muted">
所有平台共用同一个企业微信群,只有开启了推送开关的任务才会发消息。
</p>
<WebhookPanel />
</div>
<div className="space-y-4 border-t border-cyber-border-subtle pt-4">
<div>
<h3 className="font-mono text-xs tracking-wider text-cyber-text-primary">
调度
</h3>
<p className="mt-0.5 text-[10px] font-mono text-cyber-text-muted">
调度器全局只有一套时段规则,因此不按平台区分。
</p>
</div>
{systemSpecs.map((spec) => (
<SettingField
key={spec.key}
spec={spec}
value={draft[spec.key] ?? (spec.default as boolean | number | string)}
onChange={(next) => setDraft((prev) => ({ ...prev, [spec.key]: next }))}
/>
))}
</div>
<div className="space-y-3 border-t border-cyber-border-subtle pt-4">
<h3 className="font-mono text-xs tracking-wider text-cyber-text-primary">
账号安全
</h3>
<ChangePassword />
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
关闭
</Button>
<Button
disabled={Object.keys(dirty).length === 0 || updateSettings.isPending}
onClick={() => updateSettings.mutate(dirty)}
>
<Save className="w-3 h-3 mr-1" />
保存{Object.keys(dirty).length > 0 ? ` (${Object.keys(dirty).length})` : ''}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}