Files
MeBox/web/src/pages/AdultSettingsPanel.tsx
T
truewhile 65d1ad5ae5 优化番号处理逻辑,添加对MetaTube的支持
优化番号处理逻辑,添加对MetaTube的支持
2026-08-21 00:06:49 +08:00

704 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FormEvent, useEffect, useState } from 'react'
import {
Activity,
CheckCircle2,
Cpu,
Database,
Eye,
EyeOff,
Globe,
Loader2,
Lock,
Save,
Server,
Sparkles,
XCircle,
} from 'lucide-react'
import toast from 'react-hot-toast'
import { adminAPI } from '../api/admin'
import { libraryAPI } from '../api/library'
import type { Library, Setting } from '../types'
import { adultSettingsGroup } from './settingsGroupAccess'
const POPULAR_METATUBE_PROVIDERS = [
{ id: '', label: '自动 (全部)' },
{ id: 'javdb', label: 'JavDB' },
{ id: 'javbus', label: 'JavBus' },
{ id: 'dmm', label: 'DMM / FANZA' },
{ id: 'fc2', label: 'FC2' },
{ id: 'gfriends', label: 'GFriends' },
{ id: 'airav', label: 'AirAV' },
{ id: 'heyzo', label: 'HEYZO' },
{ id: 'mgs', label: 'MGS' },
]
export function AdultSettingsPanel() {
const [values, setValues] = useState<Record<string, string>>({})
const [dirty, setDirty] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [libraries, setLibraries] = useState<Library[]>([])
const [showToken, setShowToken] = useState(false)
// Test connection state
const [testingMT, setTestingMT] = useState(false)
const [mtTestResult, setMtTestResult] = useState<{
success?: boolean
latency_ms?: number
providers?: string[]
error?: string
} | null>(null)
const [testingBuiltin, setTestingBuiltin] = useState(false)
const [builtinTestResult, setBuiltinTestResult] = useState<{
success?: boolean
latency_ms?: number
message?: string
error?: string
} | null>(null)
const refresh = async () => {
setLoading(true)
try {
const [all, libs] = await Promise.all([
adminAPI.listSettings(),
libraryAPI.list({ includeHidden: true }).catch(() => [] as Library[]),
])
const idx: Record<string, string> = {}
for (const item of adultSettingsGroup.items) {
if (item.defaultValue !== undefined) {
idx[item.key] = item.defaultValue
}
}
for (const s of all as Setting[]) {
if (s.key.startsWith('adult.')) {
idx[s.key] = s.value
}
}
setValues(idx)
setLibraries(libs as Library[])
setDirty(new Set())
} finally {
setLoading(false)
}
}
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const setVal = (key: string, value: string) => {
setValues((prev) => ({ ...prev, [key]: value }))
setDirty((prev) => new Set(prev).add(key))
}
const toggleVal = (key: string) => {
const current = values[key] === 'true' || values[key] === '1' || values[key] === 'on'
setVal(key, current ? 'false' : 'true')
}
const isChecked = (key: string, def = false) => {
const val = values[key]
if (val === undefined) return def
return val === 'true' || val === '1' || val === 'on'
}
const onSave = async (e?: FormEvent) => {
if (e) e.preventDefault()
if (dirty.size === 0) return
setSaving(true)
try {
for (const key of dirty) {
await adminAPI.updateSetting(key, values[key] ?? '')
}
toast.success(`已保存 ${dirty.size} 项配置`)
setDirty(new Set())
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
toast.error(msg)
} finally {
setSaving(false)
}
}
const runMetaTubeTest = async () => {
setTestingMT(true)
setMtTestResult(null)
try {
const res = await adminAPI.testAdultScraper({
engine: 'metatube',
server_url: values['adult.scraper.metatube_server'] || 'http://127.0.0.1:7700',
token: values['adult.scraper.metatube_token'] || '',
})
setMtTestResult(res)
if (res.success) {
toast.success(`MetaTube 连接成功 (${res.latency_ms ?? 0}ms)`)
} else {
toast.error(res.error || 'MetaTube 连接失败')
}
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
(err as Error).message ??
'测试失败'
setMtTestResult({ success: false, error: msg })
toast.error(msg)
} finally {
setTestingMT(false)
}
}
const runBuiltinTest = async () => {
setTestingBuiltin(true)
setBuiltinTestResult(null)
try {
const res = await adminAPI.testAdultScraper({
engine: 'builtin',
javdb_url: values['adult.scraper.builtin_javdb_url'] || 'https://javdb.com',
javbus_url: values['adult.scraper.builtin_javbus_url'] || '',
cookie: values['adult.scraper.builtin_cookie'] || '',
})
setBuiltinTestResult(res)
if (res.success) {
toast.success(`内置刮削源连接成功 (${res.latency_ms ?? 0}ms)`)
} else {
toast.error(res.error || '内置源连接失败')
}
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
(err as Error).message ??
'测试失败'
setBuiltinTestResult({ success: false, error: msg })
toast.error(msg)
} finally {
setTestingBuiltin(false)
}
}
const selectedLibraryIDs = parseLibraryIDs(values['adult.library_ids'] || '[]')
const toggleLibrary = (id: string, checked: boolean) => {
const next = checked
? Array.from(new Set([...selectedLibraryIDs, id]))
: selectedLibraryIDs.filter((item) => item !== id)
setVal('adult.library_ids', JSON.stringify(next))
}
const engine = values['adult.scraper.engine'] || 'builtin'
if (loading) {
return (
<div className="flex justify-center py-12 text-ink-50">
<Loader2 className="animate-spin" />
</div>
)
}
return (
<form onSubmit={onSave} className="space-y-6">
{/* 1. 全局访问与隔离卡片 */}
<div className="glass-panel space-y-4">
<div className="flex items-center gap-3 border-b border-gray-200/60 pb-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-pink-500/10 text-pink-500">
<Lock size={18} />
</div>
<div>
<h2 className="text-base font-semibold text-ink-600">访问与媒体库隔离控制</h2>
<p className="text-xs text-sand-500">
设置全局成人内容显示规则、指定成人影视库与访问 PIN 保护
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{/* 主开关 */}
<div className="rounded-2xl border border-gray-200/60 bg-white/50 p-4">
<label className="flex cursor-pointer items-start justify-between gap-4">
<div>
<span className="block text-sm font-medium text-ink-600">启用成人内容</span>
<span className="mt-0.5 block text-xs text-sand-500">
全局开关。关闭后系统彻底禁用所有成人库与成人刮削;开启后普通用户仍默认隐藏,可在个人资料或 Bot 自行开启。
</span>
</div>
<input
type="checkbox"
className="mt-1 h-5 w-5 accent-pink-500"
checked={isChecked('adult.enabled', true)}
onChange={() => toggleVal('adult.enabled')}
/>
</label>
</div>
{/* PIN 保护 */}
<div className="rounded-2xl border border-gray-200/60 bg-white/50 p-4">
<label className="flex cursor-pointer items-start justify-between gap-4">
<div>
<span className="block text-sm font-medium text-ink-600">访问需要 PIN 码保护</span>
<span className="mt-0.5 block text-xs text-sand-500">
开启后,客户端或网页端浏览成人库内容需输入数字 PIN 码
</span>
</div>
<input
type="checkbox"
className="mt-1 h-5 w-5 accent-pink-500"
checked={isChecked('adult.require_pin', false)}
onChange={() => toggleVal('adult.require_pin')}
/>
</label>
{isChecked('adult.require_pin', false) && (
<div className="mt-3">
<input
type="text"
className="input-base text-sm"
placeholder="请输入 4-8 位数字 PIN"
value={values['adult.pin'] ?? ''}
onChange={(e) => setVal('adult.pin', e.target.value)}
/>
</div>
)}
</div>
</div>
{/* 成人媒体库绑定 */}
<div className="rounded-2xl border border-gray-200/60 bg-white/50 p-4">
<div className="mb-2">
<span className="block text-sm font-medium text-ink-600">指定成人媒体库</span>
<span className="text-xs text-sand-500">
指定哪些媒体库属于成人分类。指定后,搜索、分类及 Emby/Jellyfin 协议都会统一应用隔离与过滤规则。
</span>
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{libraries.length === 0 && (
<div className="col-span-full py-2 text-xs text-sand-500">暂无媒体库</div>
)}
{libraries.map((lib) => {
const checked = selectedLibraryIDs.includes(lib.id)
return (
<label
key={lib.id}
className={
'flex cursor-pointer items-start gap-2.5 rounded-xl border p-2.5 transition ' +
(checked
? 'border-pink-500/40 bg-pink-500/5'
: 'border-gray-200/60 bg-white/80 hover:bg-gray-50')
}
>
<input
type="checkbox"
className="mt-0.5 h-4 w-4 accent-pink-500"
checked={checked}
onChange={(e) => toggleLibrary(lib.id, e.target.checked)}
/>
<div className="min-w-0">
<span className="block truncate text-xs font-medium text-ink-600">
{lib.name}
</span>
<span className="block truncate font-mono text-[11px] text-sand-500">
{lib.path}
</span>
</div>
</label>
)
})}
</div>
</div>
</div>
{/* 2. 番号刮削引擎配置卡片 */}
<div className="glass-panel space-y-5">
<div className="flex items-center gap-3 border-b border-gray-200/60 pb-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary-500/10 text-brand-500">
<Sparkles size={18} />
</div>
<div>
<h2 className="text-base font-semibold text-ink-600">番号刮削配置模块</h2>
<p className="text-xs text-sand-500">
支持切换本项目内置番号刮削源或接入 MetaTube 独立服务端,进行精准影视与女优元数据抓取
</p>
</div>
</div>
{/* 引擎模式选择 */}
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-sand-500">
选择番号刮削模式
</label>
<div className="grid gap-3 sm:grid-cols-3">
<button
type="button"
onClick={() => setVal('adult.scraper.engine', 'builtin')}
className={
'flex flex-col items-start gap-1.5 rounded-2xl border p-4 text-left transition ' +
(engine === 'builtin'
? 'border-brand-500 bg-primary-500/10 ring-1 ring-brand-500'
: 'border-gray-200/60 bg-white/50 hover:bg-white/80')
}
>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<Database size={16} className={engine === 'builtin' ? 'text-brand-500' : 'text-sand-500'} />
<span className="font-semibold text-sm text-ink-600">本项目内置刮削</span>
</div>
{engine === 'builtin' && <CheckCircle2 size={16} className="text-brand-500" />}
</div>
<span className="text-xs text-sand-500">
集成 JavDB / JavBus 多源并发解析,免部署外部服务端,内置年龄绕过
</span>
</button>
<button
type="button"
onClick={() => setVal('adult.scraper.engine', 'metatube')}
className={
'flex flex-col items-start gap-1.5 rounded-2xl border p-4 text-left transition ' +
(engine === 'metatube'
? 'border-brand-500 bg-primary-500/10 ring-1 ring-brand-500'
: 'border-gray-200/60 bg-white/50 hover:bg-white/80')
}
>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<Server size={16} className={engine === 'metatube' ? 'text-brand-500' : 'text-sand-500'} />
<span className="font-semibold text-sm text-ink-600">MetaTube 服务端</span>
</div>
{engine === 'metatube' && <CheckCircle2 size={16} className="text-brand-500" />}
</div>
<span className="text-xs text-sand-500">
连接独立 MetaTube Server 容器,支持多数据源聚合、女优头像与高级插件特性
</span>
</button>
<button
type="button"
onClick={() => setVal('adult.scraper.engine', 'auto')}
className={
'flex flex-col items-start gap-1.5 rounded-2xl border p-4 text-left transition ' +
(engine === 'auto'
? 'border-brand-500 bg-primary-500/10 ring-1 ring-brand-500'
: 'border-gray-200/60 bg-white/50 hover:bg-white/80')
}
>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<Cpu size={16} className={engine === 'auto' ? 'text-brand-500' : 'text-sand-500'} />
<span className="font-semibold text-sm text-ink-600">智能混合模式</span>
</div>
{engine === 'auto' && <CheckCircle2 size={16} className="text-brand-500" />}
</div>
<span className="text-xs text-sand-500">
优先请求 MetaTube 服务端,若无匹配或服务离线则自动回退至内置多源
</span>
</button>
</div>
</div>
{/* 3. MetaTube 专属配置模块 */}
{(engine === 'metatube' || engine === 'auto') && (
<div className="rounded-2xl border border-primary-500/20 bg-primary-500/5 p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-brand-500" />
<span className="text-sm font-semibold text-ink-600">MetaTube 后端配置</span>
</div>
<a
href="https://github.com/metatube-community/metatube-server"
target="_blank"
rel="noreferrer"
className="text-xs text-brand-500 hover:underline flex items-center gap-1"
>
<Globe size={12} /> MetaTube 官方文档
</a>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div>
<label className="block text-xs font-medium text-ink-100">
MetaTube 服务端地址 (Server URL)
</label>
<input
type="text"
className="input-base mt-1 text-sm font-mono"
placeholder="http://127.0.0.1:7700"
value={values['adult.scraper.metatube_server'] ?? 'http://127.0.0.1:7700'}
onChange={(e) => setVal('adult.scraper.metatube_server', e.target.value)}
/>
<p className="mt-1 text-[11px] text-sand-500">
独立部署的 MetaTube Server 地址,如 http://127.0.0.1:7700 或内网 Docker IP
</p>
</div>
<div>
<label className="block text-xs font-medium text-ink-100">
认证 Token (API Token)
</label>
<div className="relative mt-1">
<input
type={showToken ? 'text' : 'password'}
className="input-base pr-10 text-sm font-mono"
placeholder="留空表示服务端未启用 Token 保护"
value={values['adult.scraper.metatube_token'] ?? ''}
onChange={(e) => setVal('adult.scraper.metatube_token', e.target.value)}
/>
<button
type="button"
onClick={() => setShowToken(!showToken)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-sand-500 hover:text-ink-600"
>
{showToken ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
<p className="mt-1 text-[11px] text-sand-500">
MetaTube 服务端配置的 TOKEN 环境变量
</p>
</div>
</div>
{/* Provider 选择器 */}
<div>
<label className="block text-xs font-medium text-ink-100">
首选 / 默认 Provider 数据源
</label>
<div className="mt-1 flex flex-wrap gap-1.5">
{POPULAR_METATUBE_PROVIDERS.map((p) => {
const active = (values['adult.scraper.metatube_provider'] ?? '') === p.id
return (
<button
key={p.id}
type="button"
onClick={() => setVal('adult.scraper.metatube_provider', p.id)}
className={
'rounded-lg px-2.5 py-1 text-xs transition ' +
(active
? 'bg-primary-500 text-white font-medium shadow-sm'
: 'bg-white/80 text-ink-100 border border-gray-200/60 hover:bg-sand-100/50')
}
>
{p.label}
</button>
)
})}
</div>
</div>
{/* 测试 MetaTube 连接按钮 */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-primary-500/20 pt-3">
<button
type="button"
disabled={testingMT}
onClick={runMetaTubeTest}
className="inline-flex items-center gap-1.5 rounded-xl border border-brand-500/40 bg-brand-500/10 px-3 py-1.5 text-xs font-medium text-brand-500 transition hover:bg-brand-500/20 disabled:opacity-50"
>
{testingMT ? <Loader2 size={13} className="animate-spin" /> : <Activity size={13} />}
测试 MetaTube 连接
</button>
{mtTestResult && (
<div className="flex items-center gap-2 text-xs">
{mtTestResult.success ? (
<span className="inline-flex items-center gap-1 text-emerald-600 font-medium">
<CheckCircle2 size={14} /> 连接成功 ({mtTestResult.latency_ms}ms)
{mtTestResult.providers && mtTestResult.providers.length > 0 && (
<span className="text-sand-500 font-normal">
· 支持 {mtTestResult.providers.length} 个源
</span>
)}
</span>
) : (
<span className="inline-flex items-center gap-1 text-red-500 font-medium">
<XCircle size={14} /> {mtTestResult.error}
</span>
)}
</div>
)}
</div>
</div>
)}
{/* 4. 本地内置刮削源专属配置 */}
{(engine === 'builtin' || engine === 'auto') && (
<div className="rounded-2xl border border-gray-200/60 bg-white/50 p-4 space-y-4">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-brand-500" />
<span className="text-sm font-semibold text-ink-600">内置多源刮削配置</span>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div>
<label className="block text-xs font-medium text-ink-100">
JavDB 主源 URL
</label>
<input
type="text"
className="input-base mt-1 text-sm font-mono"
placeholder="https://javdb.com"
value={values['adult.scraper.builtin_javdb_url'] ?? 'https://javdb.com'}
onChange={(e) => setVal('adult.scraper.builtin_javdb_url', e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium text-ink-100">
年龄验证 Cookie 凭据
</label>
<input
type="text"
className="input-base mt-1 text-sm font-mono"
placeholder="age=verified; existmag=all"
value={values['adult.scraper.builtin_cookie'] ?? 'age=verified; existmag=all'}
onChange={(e) => setVal('adult.scraper.builtin_cookie', e.target.value)}
/>
</div>
<div className="col-span-full">
<label className="block text-xs font-medium text-ink-100">
JavBus 备用镜像源列表 (换行或逗号分隔)
</label>
<textarea
rows={2}
className="input-base mt-1 text-xs font-mono"
placeholder={'https://javbus.sbs\nhttps://www.javbus.com\nhttps://www.cdnbus.cyou'}
value={
values['adult.scraper.builtin_javbus_url'] ??
'https://javbus.sbs,https://www.javbus.com,https://www.cdnbus.cyou,https://www.javsee.cyou,https://www.busjav.cyou'
}
onChange={(e) => setVal('adult.scraper.builtin_javbus_url', e.target.value)}
/>
</div>
</div>
{/* 测试内置源按钮 */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-200/60 pt-3">
<button
type="button"
disabled={testingBuiltin}
onClick={runBuiltinTest}
className="inline-flex items-center gap-1.5 rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-ink-600 transition hover:bg-gray-50 disabled:opacity-50"
>
{testingBuiltin ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Activity size={13} />
)}
测试内置源连接
</button>
{builtinTestResult && (
<div className="flex items-center gap-2 text-xs">
{builtinTestResult.success ? (
<span className="inline-flex items-center gap-1 text-emerald-600 font-medium">
<CheckCircle2 size={14} /> 连接成功 ({builtinTestResult.latency_ms}ms)
</span>
) : (
<span className="inline-flex items-center gap-1 text-red-500 font-medium">
<XCircle size={14} /> {builtinTestResult.error}
</span>
)}
</div>
)}
</div>
</div>
)}
{/* 5. 通用高级刮削选项 */}
<div className="rounded-2xl border border-gray-200/60 bg-white/50 p-4 space-y-3">
<span className="block text-sm font-semibold text-ink-600">元数据与图片增强选项</span>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-gray-200/60 bg-white/80 p-3 hover:bg-gray-50">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 accent-primary-500"
checked={isChecked('adult.scraper.crop_cover', true)}
onChange={() => toggleVal('adult.scraper.crop_cover')}
/>
<div>
<span className="block text-xs font-semibold text-ink-600">封面智能裁剪海报</span>
<span className="mt-0.5 block text-[11px] text-sand-500">
自动将 DVD 宽版封套裁剪为正面竖版海报,并保留完整封套作为背景大图
</span>
</div>
</label>
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-gray-200/60 bg-white/80 p-3 hover:bg-gray-50">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 accent-primary-500"
checked={isChecked('adult.scraper.enable_actor', true)}
onChange={() => toggleVal('adult.scraper.enable_actor')}
/>
<div>
<span className="block text-xs font-semibold text-ink-600">刮削女优详情与头像</span>
<span className="mt-0.5 block text-[11px] text-sand-500">
抓取女优名字、别名、头像并自动聚合至媒体元数据与分类标签
</span>
</div>
</label>
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-gray-200/60 bg-white/80 p-3 hover:bg-gray-50">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 accent-primary-500"
checked={isChecked('adult.scraper.enable_trailer', false)}
onChange={() => toggleVal('adult.scraper.enable_trailer')}
/>
<div>
<span className="block text-xs font-semibold text-ink-600">刮削预告片视频</span>
<span className="mt-0.5 block text-[11px] text-sand-500">
尝试解析官方样片/预告片并保存链接
</span>
</div>
</label>
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-gray-200/60 bg-white/80 p-3 hover:bg-gray-50">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 accent-primary-500"
checked={isChecked('adult.scraper.badge', false)}
onChange={() => toggleVal('adult.scraper.badge')}
/>
<div>
<span className="block text-xs font-semibold text-ink-600">添加无码/角标标记</span>
<span className="mt-0.5 block text-[11px] text-sand-500">
为无码破解或流出作品添加识别角标
</span>
</div>
</label>
</div>
</div>
{/* 底部保存条 */}
<div className="flex items-center justify-between pt-2">
<span className="text-xs text-sand-500">
{dirty.size > 0 ? `有 ${dirty.size} 项修改未保存` : '所有设置已是最新'}
</span>
<button
type="submit"
disabled={saving || dirty.size === 0}
className="neon-button disabled:opacity-50"
>
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
保存配置
</button>
</div>
</div>
</form>
)
}
function parseLibraryIDs(raw: string): string[] {
try {
const parsed = JSON.parse(raw || '[]')
if (Array.isArray(parsed)) {
return parsed.map((item) => String(item).trim()).filter(Boolean)
}
} catch {
return raw
.split(/[,\n;,]/)
.map((item) => item.trim())
.filter(Boolean)
}
return []
}