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