Files
MediaCrawler/webui/src/components/layout/Sidebar.tsx
T
butubb c2b310c7bf
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(creator): 新增「运营」模块 —— 多账号扫码登录与创作者后台数据
侧边栏在「监控」右边加了「运营」:账号列表 → 点进二级详情看该账号的数据。

【为什么是独立模块而不是监控的子视图】两者形状不同:监控是公开数据(点赞/收藏/评论/分享)的每轮快照+差分;运营是创作者后台按日期给出的曝光/观看/完播率/涨粉。凭据不同、采集方式也不同 —— 那边要浏览器登录态,这边是纯请求。硬塞进同一个模型会同时污染两边。

【扫码登录的关键差异】监控的扫码把登录态写进浏览器默认 profile(爬虫要复用)。运营要的是 cookie 字符串(纯请求够用),所以每次登录开一个**临时上下文**,扫完取出 cookie 就丢弃 —— 登第二个账号不会把第一个顶掉,也不影响监控那个登录态,十个账号互不干扰。

【决策依据】tools/probe_creator_api.py 的 Phase 0 实测:签名可自造(XYW_:MD5 → base64 → AES-128-CBC,与 xhshow 内置实现常量逐字节一致);主站 cookie 即可认证创作者后台;接口与参数已与真实页面对齐。

后端:
- api/creator/models.py: creator_account / creator_note_stat。**复用 MonitorBase**,这样 create_all 与上一轮改成元数据驱动的 _ensure_columns 会自动覆盖新表
- api/creator/signing.py: XYW_ 签名,带三条实测结论(url= 前缀、appId=ugc、401 与 406 的区别)
- api/creator/client.py: 纯 httpx 客户端。字段名尚未亲眼验证过,所以写成多别名匹配;解析不出来存 None 而非 0
- api/creator/service.py: 账号 CRUD 与同步。cookie 绝不进入对外结构,只给 has_cookie
- api/creator/login.py: 临时上下文的扫码登录
- api/routers/creator.py: 8 条路由,全部带鉴权

前端:
- 侧边栏「运营」+ OperationView(账号列表 → 二级详情)+ AddAccountDialog
- 权限状态显眼呈现:pending 时照抄后台原话「已为您申请数据权限,次日可查看」,并说明此时同步返回 0 条是正常的,不是采集失败

测试:tests/test_creator_client.py 新增 48 例,含「cookie 不得出现在对外结构里」这条不变量,以及权限未生效时空壳响应的处理。
2026-10-07 16:30:45 +08:00

130 lines
5.3 KiB
TypeScript

import { useState } from 'react'
import { Briefcase, 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 {
currentView: AppView
onViewChange: (view: AppView) => void
onLogout: () => void
}
const NAV_ITEMS: Array<{ value: AppView; label: string; icon: typeof Terminal }> = [
{ value: 'crawler', label: '采集', icon: Terminal },
{ value: 'monitor', label: '监控', icon: Radar },
{ value: 'operation', label: '运营', icon: Briefcase },
{ value: 'report', label: '报表', icon: BarChart3 },
{ value: 'settings', label: '设置', icon: Settings },
]
export function Sidebar({ currentView, onViewChange, onLogout }: SidebarProps) {
const { t } = useTranslation()
const status = useCrawlerStore((state) => state.status)
const [systemSettingsOpen, setSystemSettingsOpen] = useState(false)
// Poll status
useCrawlerStatus()
const isRunning = status === 'running'
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, 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">
综合采集平台
</span>
{isRunning && (
<Badge variant="running" className="text-[10px]">
{t('status.active')}
</Badge>
)}
{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>
{/* 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 */}
<LanguageSwitch />
{/* Status Info */}
<div className="hidden lg:flex items-center gap-2 text-xs font-mono">
<span className="text-cyber-text-muted">{t('sidebar.api')}:</span>
<span className="text-cyber-neon-green">v1.0.0</span>
<div className="flex items-center gap-1.5">
<Wifi className="w-3 h-3 text-cyber-text-secondary" />
<span className="text-cyber-text-secondary">{t('sidebar.local')}</span>
<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>
)
}