feat(video-analysis): 群策略 v3 / 群文件投递通道 / Web 管理页

- policy.py:per-group 正交策略(自动解析 / 自动策略 / 禁用策略 / 存储 A·B·C /
  公网 / 链接 / 群文件 + 平台限定),list.json v1/v2 → v3 自动迁移,
  写入统一走 PolicyStore(加锁 + .tmp 原子替换 + 字段归一)
- 群文件并行通道 group_file.py:打包 zip(可选 pyzipper AES-256)后优先走 S3 预签名、
  本地直传兜底;设了密码但 pyzipper 不可用就放弃上传,不退化成明文
- list_proc.py 收敛到「视频策略」统一入口,权限判定改走 policy
- Web 管理页 /hub/video_analysis(群策略 + 链接解析面板)与 services/web_jobs.py
  (只复用纯函数层,Web 上下文不发消息;内存任务表 + 并发闸门 + 超时)
- 媒体命名统一到 utils.py({作者}_{作者id}/{作品名}[_短码]),cleanup 回收空目录
- 测试:policy / 命名 / 群文件 / web_jobs 四组

顺带 pyproject 的 pytest 加 testpaths=tests(避免收进 debug/ 下的调试脚本)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-22 14:23:32 +08:00
co-authored by Claude Code
parent 51b08ccb68
commit 4badcfcf32
29 changed files with 4838 additions and 670 deletions
+40 -11
View File
@@ -13,7 +13,8 @@
"@heroui/styles": "^3.2.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.3"
"react-router-dom": "^7.18.3",
"yet-another-react-lightbox": "^3.32.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
@@ -30,6 +31,7 @@
"resolved": "https://registry.npmjs.org/@adobe/react-spectrum/-/react-spectrum-3.47.3.tgz",
"integrity": "sha512-tWZG59+xTbXIqeZB4uyuo2qljk3nRK66IkdBmMgbl2p6peApkKbrtJ/YOM8rW4gemdmv7qoJlbIHvy8aJkYsCQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@internationalized/date": "^3.12.3",
"@react-types/shared": "^3.36.1",
@@ -83,7 +85,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",
"integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@formatjs/fast-memoize": "2.2.7",
"@formatjs/intl-localematcher": "0.6.2",
@@ -96,7 +97,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",
"integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
@@ -106,7 +106,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz",
"integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@formatjs/ecma402-abstract": "2.3.6",
"@formatjs/icu-skeleton-parser": "1.8.16",
@@ -118,7 +117,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz",
"integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@formatjs/ecma402-abstract": "2.3.6",
"tslib": "^2.8.0"
@@ -129,7 +127,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz",
"integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
@@ -204,7 +201,6 @@
"resolved": "https://registry.npmjs.org/@internationalized/message/-/message-3.1.10.tgz",
"integrity": "sha512-nc0Or6EdWHqZRcsXb6P9hBIpLsfSl/ILh0rk5h/OVBpzmhdExXtPy2cQtWsq8XKRBpRHwDNnAHt4OpolcB7dog==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/helpers": "^0.5.0",
"intl-messageformat": "^10.1.0"
@@ -1163,6 +1159,7 @@
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1173,6 +1170,7 @@
"integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -1257,8 +1255,7 @@
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
@@ -1349,7 +1346,6 @@
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz",
"integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@formatjs/ecma402-abstract": "2.3.6",
"@formatjs/fast-memoize": "2.2.7",
@@ -1697,6 +1693,7 @@
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1749,6 +1746,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1758,6 +1756,7 @@
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.51.0.tgz",
"integrity": "sha512-AyWLw0XR38cFPwBu/ErgGaVrc5dupLEKmRlMXTGvFKOtbaGRQ2+yQJkjVhpdHhoRhU4+G+tJDFeHDTS8tK3bfQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@internationalized/date": "^3.12.3",
"@internationalized/number": "^3.6.7",
@@ -1779,6 +1778,7 @@
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.20.0.tgz",
"integrity": "sha512-BMbpIgoV9aELeBrB0Y120NgoigHb5OdcJwc+4e7uSnbTbamea6lo+gqcc4LAxzMaK3Jf+7LI1oCDE6yANsmxIQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@internationalized/date": "^3.12.3",
"@internationalized/string": "^3.2.10",
@@ -1798,6 +1798,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -1974,7 +1975,8 @@
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.3.3",
@@ -2051,6 +2053,7 @@
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
@@ -2383,6 +2386,32 @@
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/yet-another-react-lightbox": {
"version": "3.32.2",
"resolved": "https://registry.npmjs.org/yet-another-react-lightbox/-/yet-another-react-lightbox-3.32.2.tgz",
"integrity": "sha512-F4HtHQfUNpvkj+AmECgWM4XRdCqMY5gXpKgOUx39+T+FyxLe8II4SK/pwMyYj2X54KH9lFSQeHYY1/GfYf3SdA==",
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/igordanchenko"
},
"peerDependencies": {
"@types/react": "^16 || ^17 || ^18 || ^19",
"@types/react-dom": "^16 || ^17 || ^18 || ^19",
"react": "^16.8.0 || ^17 || ^18 || ^19",
"react-dom": "^16.8.0 || ^17 || ^18 || ^19"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
}
}
}
+2 -1
View File
@@ -14,7 +14,8 @@
"@heroui/styles": "^3.2.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.3"
"react-router-dom": "^7.18.3",
"yet-another-react-lightbox": "^3.32.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
@@ -0,0 +1,881 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Button, Card, Chip, Drawer, Input, Label, ListBox, Select, Spinner, Switch, TextArea, TextField,
Tooltip, TooltipContent, toast, useOverlayState,
} from '@heroui/react'
import { ArrowDownToLine, ArrowUpRightFromSquare, CirclePlay, TrashBin, Xmark } from '@gravity-ui/icons'
import Lightbox from 'yet-another-react-lightbox'
import type { LightboxProps, Slide } from 'yet-another-react-lightbox'
import Inline from 'yet-another-react-lightbox/plugins/inline'
import Video from 'yet-another-react-lightbox/plugins/video'
import Captions from 'yet-another-react-lightbox/plugins/captions'
import Counter from 'yet-another-react-lightbox/plugins/counter'
import Thumbnails from 'yet-another-react-lightbox/plugins/thumbnails'
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import 'yet-another-react-lightbox/styles.css'
import 'yet-another-react-lightbox/plugins/captions.css'
import 'yet-another-react-lightbox/plugins/counter.css'
import 'yet-another-react-lightbox/plugins/thumbnails.css'
import { api, hubGroups } from '../../api/client'
import { PageHead, Tabs, selectCls, tdCls, thCls, type TabItem } from '../../components/ui'
import { fmtBytes, fmtTime } from '../../lib/format'
const va = api('video_analysis')
function data(res: any) {
if (res && typeof res === 'object' && typeof res.status === 'number') {
if (res.status !== 0) throw new Error(res.msg || '请求失败')
return res.data
}
return res
}
interface Policy {
auto: boolean
auto_link: string[]
ban_link: string[]
plan: string
upload_public: boolean
send_link: boolean
upload_group_file: boolean
group_file_platforms: string[]
sends_link?: boolean
}
interface GroupRow { group_id: string; group_name: string; online: boolean; policy: Policy }
interface JobFile {
name: string
kind: 'image' | 'video' | 'file'
size: number
local_url: string
public_url: string
error: string
}
interface WebJob {
id: string
url: string
platform: string | null
title: string
status: string
stage: string
files: JobFile[]
cover_url: string
error: string
created_at: number
updated_at: number
}
interface Overview {
groups: GroupRow[]
default: Policy
blacklist: string[]
platforms: string[]
plans: string[]
online: boolean
}
interface GroupOption { group_id: number; group_name: string }
const EMPTY: Policy = {
auto: false, auto_link: [], ban_link: [], plan: 'C',
upload_public: false, send_link: false, upload_group_file: false,
group_file_platforms: [],
}
const groupFileText = (p: Policy) => {
if (!p.upload_group_file) return '关'
return p.group_file_platforms.length ? `开·仅${p.group_file_platforms.join('/')}` : '开'
}
const onOff = (v: boolean) => (v ? '开' : '关')
// ─────────────────────── 链接解析(web_jobs.py) ───────────────────────
const POLL_MS = 1500
const STATUS_TEXT: Record<string, string> = { queued: '排队中', running: '解析中', done: '完成', failed: '失败' }
const isActive = (j: WebJob) => j.status === 'queued' || j.status === 'running'
// 运行中显示 stage(解析中/上传中),其余按状态表
const jobStatusText = (j: WebJob) => (j.status === 'running' ? j.stage || '解析中' : STATUS_TEXT[j.status] || j.status)
// Chip 的语义色(HeroUI 就这五个:accent/danger/default/success/warning)
const STATUS_COLOR: Record<string, 'accent' | 'danger' | 'default' | 'success' | 'warning'> = {
queued: 'default', running: 'warning', done: 'success', failed: 'danger',
}
async function copyText(text: string) {
try { await navigator.clipboard.writeText(text); return true }
catch {
const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select()
const ok = document.execCommand('copy'); ta.remove(); return ok
}
}
// 浮层(yet-another-react-lightbox)默认文案是英文,跟页面其它部分对齐一下
// (插件各自往 Labels 上加了键:Zoom in/out 来自 Zoom、Caption 来自 Captions)
const LIGHTBOX_LABELS: LightboxProps['labels'] = {
Previous: '上一张',
Next: '下一张',
Close: '关闭',
Slide: '媒体',
Carousel: '媒体列表',
Lightbox: '媒体预览',
Caption: '文件信息',
'Zoom in': '放大',
'Zoom out': '缩小',
}
// 预览用的 MIME:产物统一 mp4(video_downloader 写死了 merge_output_format),
// 其余几种只是兜底,浏览器放不了的容器会在播放器里显示加载失败,不会白屏
const VIDEO_MIME: Record<string, string> = {
mp4: 'video/mp4', m4v: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
mkv: 'video/x-matroska', ts: 'video/mp2t', flv: 'video/x-flv',
}
function videoMime(name: string) {
return VIDEO_MIME[name.split('.').pop()?.toLowerCase() || ''] || 'video/mp4'
}
function toSlide(file: JobFile, src: string): Slide {
const title = `${file.name} · ${fmtBytes(file.size)}`
if (file.kind === 'video') {
return { type: 'video', title, sources: [{ src, type: videoMime(file.name) }] }
}
return { src, alt: file.name, title }
}
type MainTab = 'parse' | 'policy'
const MAIN_TABS: TabItem[] = [
{ key: 'parse', label: '链接解析' },
{ key: 'policy', label: '群策略' },
]
export default function VideoAnalysisPage() {
const [tab, setTab] = useState<MainTab>('parse')
// 页面撑满 main 的内容盒(main = 100vh - 顶栏 4rem - 自己的 padding p-4/p-6),
// 下面的工作区才能靠 flex-1 拿到"剩下的高度"——比写死 calc(100vh - 13rem)
// 这种算法稳:页头和顶栏 tab 改高度也不会把布局撑出滚动条。
return (
<div className="flex h-[calc(100vh-6rem)] flex-col gap-4 md:h-[calc(100vh-7rem)]">
<div className="shrink-0 space-y-4">
<PageHead
title="视频解析"
actions={<span className="text-sm text-slate-400">粘链接解析预览 · 群策略配置</span>}
/>
<Tabs tabs={MAIN_TABS} active={tab} onChange={v => setTab(v as MainTab)} />
</div>
{/* 滚动收在内容区里(群策略那个长表格不再撑动整页) */}
<div className="min-h-0 flex-1 overflow-y-auto">
{tab === 'parse' && <ParseView />}
{tab === 'policy' && <PolicyView />}
</div>
</div>
)
}
/** 群策略:白名单群列表 + 默认策略 + 全局黑名单(原页面主体) */
function PolicyView() {
const [ov, setOv] = useState<Overview | null>(null)
const [botGroups, setBotGroups] = useState<GroupOption[]>([])
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState<{ group_id: string; policy: Policy; isNew: boolean } | null>(null)
const [saving, setSaving] = useState(false)
const drawer = useOverlayState()
const load = useCallback(async () => {
setLoading(true); setErr('')
try {
setOv(await data(await va.get('overview')))
} catch (e: any) {
setErr(e.message || '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
// 群列表只在「添加群」的下拉里用;bot 未连接时用输入框兜底
useEffect(() => { hubGroups().then(d => setBotGroups((d && d.items) || [])).catch(() => setBotGroups([])) }, [])
const openNew = () => {
setEditing({ group_id: botGroups[0] ? String(botGroups[0].group_id) : '', policy: { ...EMPTY }, isNew: true })
drawer.open()
}
const openEdit = (row: GroupRow) => {
const p = row.policy
setEditing({
group_id: row.group_id,
isNew: false,
policy: {
auto: p.auto, auto_link: [...p.auto_link], ban_link: [...p.ban_link], plan: p.plan,
upload_public: p.upload_public, send_link: p.send_link, upload_group_file: p.upload_group_file,
group_file_platforms: [...p.group_file_platforms],
},
})
drawer.open()
}
const saveGroup = async () => {
if (!editing) return
if (!/^\d+$/.test(editing.group_id)) { toast.danger('群号必须是纯数字'); return }
setSaving(true)
try {
await va.post('group', { group_id: editing.group_id, policy: editing.policy })
toast.success(`群 ${editing.group_id} 策略已保存`)
drawer.close()
await load()
} catch (e: any) { toast.danger(e.message || '保存失败') } finally { setSaving(false) }
}
const removeGroup = async (row: GroupRow) => {
if (!window.confirm(`确定把群 ${row.group_name || row.group_id} 移出白名单?该群将不再解析链接。`)) return
try { await va.del(`group/${row.group_id}`); toast.success('已移出白名单'); await load() } catch (e: any) { toast.danger(e.message) }
}
if (loading && !ov) return <div className="flex justify-center py-20"><Spinner /></div>
return (
<>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-slate-400">
{ov && !ov.online
? <span className="text-amber-600">bot 未连接,群名显示不出来</span>
: '白名单:列表里的群才会解析'}
</p>
<Button size="sm" onPress={openNew}>添加群</Button>
</div>
{err && <p className="text-xs text-red-500">{err}</p>}
{ov && (
<>
<Card className="border border-slate-200 bg-white">
<Card.Header><Card.Title>群策略(白名单:列表里的群才会解析)</Card.Title></Card.Header>
<Card.Content>
<table className="w-full">
<thead>
<tr>
<th className={thCls}>群</th><th className={thCls}>自动解析</th><th className={thCls}>存储</th>
<th className={thCls}>公网</th><th className={thCls}>链接</th><th className={thCls}>群文件</th>
<th className={thCls}>自动策略</th><th className={thCls}>禁用策略</th><th className={thCls}></th>
</tr>
</thead>
<tbody>
{ov.groups.map(r => (
<tr key={r.group_id} className="transition-colors hover:bg-slate-50">
<td className={tdCls}>
{r.group_name ? `${r.group_name}(${r.group_id})` : r.group_id}
{!r.online && <span className="ml-2 text-xs text-slate-400">不在群列表</span>}
</td>
<td className={tdCls}>{onOff(r.policy.auto)}</td>
<td className={tdCls}>{r.policy.plan}</td>
<td className={tdCls}>{onOff(r.policy.upload_public)}</td>
<td className={tdCls}>
{r.policy.sends_link ? '开' : (r.policy.send_link ? '开·无公网' : '关')}
</td>
<td className={tdCls + ' whitespace-nowrap'}>{groupFileText(r.policy)}</td>
<td className={tdCls + ' max-w-[180px] truncate'}>{r.policy.auto_link.join('、') || '—'}</td>
<td className={tdCls + ' max-w-[180px] truncate'}>{r.policy.ban_link.join('、') || '—'}</td>
<td className={tdCls + ' whitespace-nowrap text-right'}>
<Button size="sm" variant="tertiary" onPress={() => openEdit(r)}>编辑</Button>
<Button size="sm" variant="tertiary" className="text-red-500" onPress={() => removeGroup(r)}>移除</Button>
</td>
</tr>
))}
{!ov.groups.length && (
<tr><td colSpan={9} className={tdCls + ' text-center text-slate-400'}>还没有配置任何群,点右上角「添加群」</td></tr>
)}
</tbody>
</table>
</Card.Content>
</Card>
<DefaultCard ov={ov} onSaved={load} />
<BlacklistCard ov={ov} onSaved={load} />
</>
)}
<Drawer state={drawer}>
<Drawer.Backdrop>
<Drawer.Content placement="right">
<Drawer.Dialog className="w-[560px] max-w-[88vw]">
<Drawer.Header>
<Drawer.Heading>
{editing?.isNew ? '添加群' : `群 ${editing?.group_id} 策略`}
</Drawer.Heading>
<Drawer.CloseTrigger />
</Drawer.Header>
<Drawer.Body>
{editing && ov && (
<div className="space-y-5">
{editing.isNew && (
<div className="space-y-2">
{botGroups.length > 0 ? (
<Select selectedKey={editing.group_id} onSelectionChange={k => k && setEditing({ ...editing, group_id: String(k) })}>
<Label>选择群</Label>
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
<Select.Popover>
<ListBox>
{botGroups.map(g => (
<ListBox.Item key={g.group_id} id={String(g.group_id)}>
{`${g.group_name}(${g.group_id})`}
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
) : (
<TextField>
<Label>群号</Label>
<Input value={editing.group_id} onChange={e => setEditing({ ...editing, group_id: e.target.value.trim() })} />
</TextField>
)}
</div>
)}
<PolicyEditor
value={editing.policy}
platforms={ov.platforms}
plans={ov.plans}
onChange={p => setEditing({ ...editing, policy: p })}
/>
</div>
)}
</Drawer.Body>
<Drawer.Footer>
<Button variant="tertiary" onPress={drawer.close}>取消</Button>
<Button isDisabled={saving} onPress={saveGroup}>{saving ? '保存中…' : '保存'}</Button>
</Drawer.Footer>
</Drawer.Dialog>
</Drawer.Content>
</Drawer.Backdrop>
</Drawer>
</>
)
}
function PolicyEditor({
value, platforms, plans, onChange,
}: {
value: Policy
platforms: string[]
plans: string[]
onChange: (p: Policy) => void
}) {
const set = (patch: Partial<Policy>) => onChange({ ...value, ...patch })
return (
<div className="space-y-5">
<Switch isSelected={value.auto} onChange={v => set({ auto: v })}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>自动解析(群里出现链接就解析,无需 @bot)</Switch.Content>
</Switch>
<div className="grid grid-cols-2 items-end gap-4">
<div>
<Select selectedKey={value.plan} onSelectionChange={k => k && set({ plan: String(k) })}>
<Label>存储策略</Label>
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
<Select.Popover>
<ListBox>{plans.map(p => <ListBox.Item key={p} id={p}>{`局域网 ${p}`}</ListBox.Item>)}</ListBox>
</Select.Popover>
</Select>
</div>
<Switch isSelected={value.upload_public} onChange={v => set({ upload_public: v })}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>上传公网</Switch.Content>
</Switch>
</div>
<div className="space-y-2">
<p className="text-sm text-slate-600">自动策略 <span className="text-xs text-slate-400">(命中平台即使关掉自动解析也会解析)</span></p>
<PlatformChips value={value.auto_link} platforms={platforms} tone="auto" onChange={v => set({ auto_link: v })} />
</div>
<div className="space-y-2">
<p className="text-sm text-slate-600">禁用策略 <span className="text-xs text-slate-400">(命中平台自动/手动都不解析)</span></p>
<PlatformChips value={value.ban_link} platforms={platforms} tone="ban" onChange={v => set({ ban_link: v })} />
</div>
<div className="space-y-3">
<Switch isSelected={value.send_link} isDisabled={!value.upload_public} onChange={v => set({ send_link: v })}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>发送下载链接</Switch.Content>
</Switch>
{!value.upload_public && <p className="text-xs text-slate-400">没上传公网时链接指向局域网 S3,群友打不开,所以这一项需要先开「上传公网」</p>}
<Switch isSelected={value.upload_group_file} onChange={v => set({ upload_group_file: v })}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>同时上传群文件</Switch.Content>
</Switch>
{value.upload_group_file && (
<div className="space-y-2 pl-1">
<p className="text-xs text-slate-500">
限定平台 <span className="text-slate-400">(一个都不选 = 所有平台都传;选了则其余平台只发消息、不传群文件)</span>
</p>
<PlatformChips
value={value.group_file_platforms}
platforms={platforms}
tone="file"
onChange={v => set({ group_file_platforms: v })}
/>
</div>
)}
</div>
</div>
)
}
function PlatformChips({
value, platforms, tone, onChange,
}: {
value: string[]
platforms: string[]
tone: 'auto' | 'ban' | 'file'
onChange: (v: string[]) => void
}) {
const activeCls = tone === 'ban'
? 'bg-red-50 text-red-600 border-red-300'
: tone === 'file'
? 'bg-indigo-50 text-indigo-700 border-indigo-300'
: 'bg-emerald-50 text-emerald-700 border-emerald-300'
return (
<div className="flex flex-wrap gap-1.5">
{platforms.map(p => {
const active = value.includes(p)
return (
<button
key={p}
type="button"
onClick={() => onChange(active ? value.filter(x => x !== p) : [...value, p])}
className={'rounded-full border px-3 py-1 text-xs transition-colors ' + (active ? activeCls : 'border-slate-200 bg-white text-slate-500 hover:border-slate-300')}
>
{p}
</button>
)
})}
</div>
)
}
function DefaultCard({ ov, onSaved }: { ov: Overview; onSaved: () => Promise<void> }) {
const [policy, setPolicy] = useState<Policy>(ov.default)
const [busy, setBusy] = useState(false)
useEffect(() => { setPolicy(ov.default) }, [ov.default])
const save = async () => {
setBusy(true)
try { await va.post('default', { policy }); toast.success('默认策略已保存'); await onSaved() }
catch (e: any) { toast.danger(e.message || '保存失败') } finally { setBusy(false) }
}
return (
<Card className="border border-slate-200 bg-white">
<Card.Header>
<Card.Title>默认策略 <span className="ml-2 text-xs font-normal text-slate-400">私聊与未配置群使用</span></Card.Title>
</Card.Header>
<Card.Content className="space-y-4">
<PolicyEditor value={policy} platforms={ov.platforms} plans={ov.plans} onChange={setPolicy} />
<Button isDisabled={busy} onPress={save}>{busy ? '保存中…' : '保存默认策略'}</Button>
</Card.Content>
</Card>
)
}
function BlacklistCard({ ov, onSaved }: { ov: Overview; onSaved: () => Promise<void> }) {
const [text, setText] = useState(ov.blacklist.join('\n'))
const [busy, setBusy] = useState(false)
useEffect(() => { setText(ov.blacklist.join('\n')) }, [ov.blacklist])
const save = async () => {
const list = text.split(/[\n,,\s]+/).map(x => x.trim()).filter(Boolean)
setBusy(true)
try { await va.post('blacklist', { blacklist: list }); toast.success('黑名单已保存'); await onSaved() }
catch (e: any) { toast.danger(e.message || '保存失败') } finally { setBusy(false) }
}
return (
<Card className="border border-slate-200 bg-white">
<Card.Header>
<Card.Title>全局黑名单 <span className="ml-2 text-xs font-normal text-slate-400">这些 QQ 在所有群都不解析</span></Card.Title>
</Card.Header>
<Card.Content className="space-y-3">
<TextField>
<Label>QQ 号(每行一个)</Label>
<TextArea rows={4} value={text} onChange={e => setText(e.target.value)} placeholder="123456789" />
</TextField>
<Button isDisabled={busy} onPress={save}>{busy ? '保存中…' : '保存黑名单'}</Button>
</Card.Content>
</Card>
)
}
/** 链接解析工作区:左栏「输入 + 任务列表」,右栏播放器。
*
* 存储按**默认策略**走(不是某个群的策略),所以这里没有群号可选。
* 任务表在 bot 内存里,重启即清空 —— 前端按"查不到就算了"处理。
*/
function ParseView() {
const [text, setText] = useState('')
const [force, setForce] = useState(false)
const [jobs, setJobs] = useState<WebJob[]>([])
const [busy, setBusy] = useState(false)
const [playingId, setPlayingId] = useState('')
const [refreshing, setRefreshing] = useState('')
const pollRef = useRef(0)
const timerRef = useRef<number | null>(null)
function stopTimer() {
if (timerRef.current != null) { window.clearTimeout(timerRef.current); timerRef.current = null }
}
// setTimeout 递归轮询(不用 setInterval:请求慢时不会堆叠)
function schedule(list: WebJob[]) {
stopTimer()
if (!list.some(isActive)) return // 全部终态 → 停
const id = pollRef.current
timerRef.current = window.setTimeout(() => { if (pollRef.current === id) void tick(++pollRef.current) }, POLL_MS)
}
async function tick(id: number) {
try {
const d = data(await va.get('jobs'))
if (pollRef.current !== id) return // 代际不符:期间又发起了新的一轮
const list: WebJob[] = (d && d.jobs) || []
setJobs(list)
schedule(list)
} catch (e: any) {
if (pollRef.current !== id) return
toast.danger(e.message || '任务列表加载失败')
}
}
useEffect(() => {
void tick(++pollRef.current)
return () => { pollRef.current += 1; stopTimer() }
}, [])
const submit = async () => {
if (!text.trim()) { toast.danger('先粘一条链接'); return }
setBusy(true)
try {
const d = data(await va.post('parse', { text, force }))
toast.success(`已提交 ${((d && d.jobs) || []).length} 个任务`)
await tick(++pollRef.current)
} catch (e: any) {
toast.danger(e.message || '提交失败')
} finally { setBusy(false) }
}
const refreshJob = async (id: string) => {
setRefreshing(id)
try {
await va.post(`jobs/${id}/refresh`, {})
toast.success('链接已刷新')
await tick(++pollRef.current)
} catch (e: any) {
toast.danger(e.message || '刷新失败')
} finally { setRefreshing('') }
}
const removeJob = async (id: string) => {
try {
await va.del(`jobs/${id}`)
if (playingId === id) setPlayingId('') // 正在看的那条被清了 → 播放器回到空状态
await tick(++pollRef.current)
} catch (e: any) {
toast.danger(e.message || '清理失败')
}
}
const clearFinished = async () => {
try {
const d = data(await va.post('jobs/clear', {}))
toast.success(`已清理 ${(d && d.removed) || 0} 条任务`)
await tick(++pollRef.current)
} catch (e: any) {
toast.danger(e.message || '清理失败')
}
}
const playing = jobs.find(j => j.id === playingId) || null
return (
<div className="space-y-4 lg:grid lg:h-full lg:grid-cols-3 lg:items-stretch lg:gap-4 lg:space-y-0">
{/* 左栏:输入框 + 任务列表(列表自己滚动,输入框固定在顶部);整页 1/3 : 2/3 */}
<div className="flex min-h-0 flex-col gap-4 lg:col-span-1">
<Card className="shrink-0 border border-slate-200 bg-white">
<Card.Content className="space-y-3">
<TextField>
<Label>链接(可多行粘贴,自动挑出其中的 URL)</Label>
<TextArea
rows={4}
value={text}
onChange={e => setText(e.target.value)}
placeholder={'https://v.douyin.com/xxxxxxx\nhttps://www.bilibili.com/video/BVxxxxxxxx'}
/>
</TextField>
<div className="flex flex-wrap items-center gap-3">
<Button isDisabled={busy} onPress={submit}>{busy ? '解析中…' : '解析'}</Button>
<Button variant="tertiary" isDisabled={!text} onPress={() => setText('')}>清空</Button>
<Switch isSelected={force} onChange={v => setForce(v)}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>强制重解析</Switch.Content>
</Switch>
</div>
</Card.Content>
</Card>
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden border border-slate-200 bg-white">
{/* Card.Header 默认是「竖向 + 居中」的列,这里要标题和按钮同一行(flex-row 得显式写) */}
<Card.Header className="flex shrink-0 flex-row flex-wrap items-center gap-2">
<Card.Title className="min-w-0 flex-1">
任务列表
<span className="ml-2 text-xs font-normal text-slate-400">
同一条链接在跑时复用同一个任务 · {jobs.length} 条
</span>
</Card.Title>
{jobs.some(j => !isActive(j)) && (
<Button size="sm" variant="tertiary" onPress={clearFinished}>清理已完成</Button>
)}
</Card.Header>
<Card.Content className="min-h-0 flex-1 space-y-2 overflow-y-auto">
{jobs.map(j => (
<TaskCard
key={j.id}
job={j}
playing={j.id === playingId}
onPlay={() => setPlayingId(j.id)}
onDownload={() => downloadFiles(j)}
onRemove={() => removeJob(j.id)}
/>
))}
{!jobs.length && (
<p className="py-8 text-center text-xs text-slate-400">
还没有任务。任务表在内存里,bot 重启后会清空,重新解析即可。
</p>
)}
</Card.Content>
</Card>
</div>
{/* 右栏:播放器 */}
<div className="flex min-h-0 flex-col lg:col-span-2">
<PlayerPanel
job={playing}
refreshing={!!playing && refreshing === playing.id}
onRefresh={() => playing && refreshJob(playing.id)}
onClose={() => setPlayingId('')}
/>
</div>
</div>
)
}
/** 任务卡:缩略图 + 标题/状态 + 三个动作(播放 / 下载 / 打开作品链接)。
*
* 列表在 380px 的窄栏里,三个按钮只放图标(文案进 title/aria-label),
* 要完整文案的话看右栏播放器的头部按钮。
*/
function TaskCard({
job, playing, onPlay, onDownload, onRemove,
}: {
job: WebJob
playing: boolean
onPlay: () => void
onDownload: () => void
onRemove: () => void
}) {
const first = job.files[0]
const failed = job.files.filter(f => f.error).length
const ready = job.files.length > 0
return (
<div
onClick={onPlay}
className={'flex items-center gap-2 rounded-lg border p-2 transition-colors ' + (playing ? 'border-indigo-300 bg-indigo-50/50' : 'border-slate-200 hover:border-slate-300')}
>
{/* 封面:后端给(有图取第一张图,纯视频是抽的关键帧),没有就退回类型占位 */}
<div className="grid size-14 shrink-0 place-items-center overflow-hidden rounded-md border border-slate-200 bg-slate-50">
{job.cover_url
? <img src={job.cover_url} alt="" className="size-full object-cover" />
: <span className="text-[10px] text-slate-400">{first ? (first.kind === 'video' ? '视频' : '文件') : '···'}</span>}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-slate-700" title={job.title || job.url}>{job.title || job.url}</p>
<div className="mt-1 flex flex-wrap items-center gap-1">
<Chip size="sm" color={STATUS_COLOR[job.status] || 'default'}>{jobStatusText(job)}</Chip>
{job.platform && <Chip size="sm" color="accent">{job.platform}</Chip>}
<span className="text-[11px] text-slate-400">{ready ? `${job.files.length} 个文件` : fmtTime(job.updated_at)}</span>
</div>
{job.error && <p className="mt-0.5 truncate text-[11px] text-red-500" title={job.error}>{job.error}</p>}
{!job.error && failed > 0 && <p className="mt-0.5 text-[11px] text-red-500">{failed} 个文件没传上去</p>}
</div>
{/* gap-2:按钮紧挨着(原来 2px)容易点错,按下去的背景动画也会挨到一起 */}
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
<Button size="sm" variant="tertiary" isDisabled={!ready} onPress={onPlay} aria-label="播放">
<CirclePlay className="size-4" />
</Button>
<TooltipContent>播放</TooltipContent>
</Tooltip>
<Tooltip>
<Button size="sm" variant="tertiary" isDisabled={!ready} onPress={onDownload} aria-label="下载">
<ArrowDownToLine className="size-4" />
</Button>
<TooltipContent>下载</TooltipContent>
</Tooltip>
<Tooltip>
<Button
size="sm"
variant="tertiary"
onPress={() => window.open(job.url, '_blank', 'noopener,noreferrer')}
aria-label="打开作品链接"
>
<ArrowUpRightFromSquare className="size-4" />
</Button>
<TooltipContent>打开作品链接</TooltipContent>
</Tooltip>
<Tooltip>
<Button size="sm" variant="tertiary" onPress={onRemove} aria-label="清理任务">
<TrashBin className="size-4" />
</Button>
<TooltipContent>清理任务</TooltipContent>
</Tooltip>
</div>
</div>
)
}
/** 右栏播放器:内联的 yet-another-react-lightbox(图片/视频混排 + 缩略图条 + 缩放)。
*
* 用 Inline 插件而不是浮层:它自己接管开合(`open` 恒为 true、去掉关闭按钮、
* 不锁滚动),容器要**确定高度**才能把内部的 100% 撑起来 —— 外层 grid 给了固定高度,
* 这里靠 min-h-0 + flex-1 接力。
*/
function PlayerPanel({
job, refreshing, onRefresh, onClose,
}: {
job: WebJob | null
refreshing: boolean
onRefresh: () => void
onClose: () => void
}) {
const [index, setIndex] = useState(0)
const [copied, setCopied] = useState(false)
const jobId = job?.id || ''
useEffect(() => { setIndex(0) }, [jobId])
// 换成 slides 时顺手记一份「有序文件表」,两边下标才能对上(缺链接的文件会被跳过)
const { slides, files } = useMemo(() => {
const built: Slide[] = []
const mapped: JobFile[] = []
;(job?.files || []).forEach(file => {
const src = file.public_url || file.local_url
if (!src) return
mapped.push(file)
built.push(toSlide(file, src))
})
return { slides: built, files: mapped }
}, [job?.files])
// 单文件任务不需要缩略图条和页码(注册了就会渲染,只能在插件列表上省)
const plugins = useMemo(
() => (slides.length > 1 ? [Inline, Video, Captions, Counter, Thumbnails, Zoom] : [Inline, Video, Captions, Zoom]),
[slides.length],
)
const at = Math.min(index, Math.max(slides.length - 1, 0))
const current = files[at]
const copy = async () => {
if (!current) return
const ok = await copyText(current.public_url || current.local_url)
if (!ok) { toast.danger('复制失败,请手动选中链接'); return }
setCopied(true); window.setTimeout(() => setCopied(false), 2000)
}
return (
<Card className="flex min-h-[360px] flex-col overflow-hidden border border-slate-200 bg-white lg:h-full lg:min-h-0">
<Card.Header className="shrink-0 space-y-1 border-b border-slate-100">
<div className="flex flex-wrap items-center gap-2">
<Card.Title className="min-w-0 flex-1 truncate">{job ? (job.title || job.url) : '播放器'}</Card.Title>
{job?.platform && <Chip size="sm" color="accent">{job.platform}</Chip>}
{slides.length > 0 && <span className="text-xs text-slate-400">{at + 1}/{slides.length}</span>}
{current && <Button size="sm" variant="tertiary" onPress={copy}>{copied ? '已复制' : '复制链接'}</Button>}
{job && slides.length > 0 && !isActive(job) && (
<Button size="sm" variant="tertiary" isDisabled={refreshing} onPress={onRefresh}>
{refreshing ? '刷新中…' : '刷新链接'}
</Button>
)}
{job && (
<Tooltip>
<Button size="sm" variant="tertiary" onPress={onClose} aria-label="关闭播放器">
<Xmark className="size-4" />
</Button>
<TooltipContent>关闭播放器(清空这栏)</TooltipContent>
</Tooltip>
)}
</div>
{current && (
<p className="truncate text-xs text-slate-400" title={current.name}>
{current.name} · {fmtBytes(current.size)}
{current.public_url ? ' · 公网链接' : ' · 局域网链接,1 小时过期,可点「刷新链接」'}
</p>
)}
</Card.Header>
<Card.Content className="min-h-0 flex-1">
{job && slides.length > 0 ? (
<Lightbox
key={jobId}
// inline 容器(宽高 100%)由 Inline 插件自己注入,**别手写 inline 属性**:
// 用户 props 会盖掉插件塞的那份,容器高度塌成 0,播放器就只剩一片白
className="h-full w-full"
slides={slides}
plugins={plugins}
labels={LIGHTBOX_LABELS}
// 页码默认钉在左上角,正好压住 Captions 的标题条(那条通栏、也在顶部)
// → 挪到右下角;容器样式走 counter.container(插件会把它摊到页码 div 上)
counter={{ container: { style: { top: 'auto', left: 'auto', right: 0, bottom: 0 } } }}
index={at}
on={{ view: ({ index: i }) => setIndex(i) }}
/>
) : (
<div className="grid h-full place-items-center p-6 text-center">
<p className="text-sm text-slate-400">
{job
? (job.status === 'failed' ? (job.error || '解析失败') : '这条任务还没有可预览的文件')
: '在左边粘链接、点「解析」,再在任务列表里点「播放」'}
</p>
</div>
)}
</Card.Content>
</Card>
)
}
/** 下载任务的全部文件。
*
* 用隐藏的 `<a download>` 逐个点击,而不是 fetch + blob:局域网 MinIO 给对象带了
* `Content-Disposition: attachment`,顶层导航直接落盘 —— 不吃内存也不用管 CORS。
* 跨域时 `download` 属性会被浏览器忽略,靠服务端那个头兜底(最坏情况只是新标签打开)。
*/
function downloadFiles(job: WebJob) {
const targets = job.files
.map(f => ({ url: f.public_url || f.local_url, name: f.name }))
.filter(f => f.url)
if (!targets.length) return
targets.forEach((f, i) => {
window.setTimeout(() => {
const a = document.createElement('a')
a.href = f.url
a.download = f.name
a.rel = 'noreferrer'
document.body.appendChild(a)
a.click()
a.remove()
}, i * 250)
})
toast.success(targets.length > 1 ? `开始下载 ${targets.length} 个文件` : '开始下载')
}