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:
@@ -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} 个文件` : '开始下载')
|
||||
}
|
||||
Reference in New Issue
Block a user