优化番号处理逻辑,添加对MetaTube的支持

优化番号处理逻辑,添加对MetaTube的支持
This commit is contained in:
truewhile
2026-08-21 00:06:49 +08:00
parent aa15cfe1f1
commit 65d1ad5ae5
25 changed files with 2283 additions and 83 deletions
+18
View File
@@ -54,4 +54,22 @@ export const adminAPI = {
systemUpdateCheck: () => api.post<SystemUpdateStatus>('/admin/system/update/check').then((r) => r.data),
systemUpdateApply: () => api.post<SystemUpdateStatus>('/admin/system/update/apply').then((r) => r.data),
testAdultScraper: (payload: {
engine?: string
server_url?: string
token?: string
javdb_url?: string
javbus_url?: string
cookie?: string
}) =>
api
.post<{
success: boolean
latency_ms?: number
providers?: string[]
message?: string
error?: string
}>('/admin/adult/test-scraper', payload)
.then((r) => r.data),
}
+2
View File
@@ -27,4 +27,6 @@ export const apiConfigsAPI = {
update: (provider: string, patch: APIConfigPatch) =>
api.put<APIConfig>(`/admin/api-configs/${provider}`, patch).then((r) => r.data),
remove: (provider: string) => api.delete(`/admin/api-configs/${provider}`).then((r) => r.data),
test: (provider: string) =>
api.post<{ result: string; error?: string }>(`/admin/api-configs/${provider}/test`).then((r) => r.data),
}
+19 -13
View File
@@ -181,18 +181,24 @@ function EditingRow({
<td colSpan={4} className="px-4 py-3">
<form onSubmit={submit} className="flex flex-wrap items-end gap-3">
<span className="text-sm font-medium text-ink-600">{item.provider}</span>
{!isAdult && (
<label className="flex-1 text-xs text-ink-50">
API Key
<input
className="input-base mt-1"
type="password"
placeholder={item.has_key ? '•••••••••••• (留空保留原值)' : '输入密钥'}
value={apiKey}
onChange={(e) => setAPIKey(e.target.value)}
/>
</label>
)}
<label className="flex-1 text-xs text-ink-50">
{isAdult ? 'Cookie 凭据 (可选)' : 'API Key'}
<input
className="input-base mt-1"
type="password"
placeholder={
isAdult
? item.has_key
? '•••••••••••• (已配置,留空保留)'
: '可选,默认已内置年龄验证 Cookie'
: item.has_key
? '•••••••••••• (留空保留原值)'
: '输入密钥'
}
value={apiKey}
onChange={(e) => setAPIKey(e.target.value)}
/>
</label>
<label className="flex-1 text-xs text-ink-50">
{isAdult ? '主源 URL' : 'Base URL'}
<input
@@ -239,7 +245,7 @@ function EditingRow({
function apiConfigConfigured(item: APIConfig): boolean {
if (item.provider === 'adult') {
return Boolean(item.base_url?.trim() || item.extra?.trim())
return Boolean(item.has_key || item.base_url?.trim() || item.extra?.trim())
}
return item.has_key
}
+38 -16
View File
@@ -53,6 +53,7 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
const [extra, setExtra] = useState(item.extra ?? '')
const [enabled, setEnabled] = useState(item.enabled)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const isAdult = item.provider === 'adult'
const submit = async (e: FormEvent) => {
@@ -77,8 +78,22 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
}
const testKey = async () => {
// No /test endpoint yet — render a hint instead.
toast(`已配置 ${item.has_key ? '✓' : '✗'} 密钥(在线测试请用对应功能页面)`)
setTesting(true)
try {
const res = await apiConfigsAPI.test(item.provider)
if (res.result === 'success') {
toast.success(`${item.provider} 连接/验证测试成功`)
} else {
toast.error(`${item.provider} 测试失败: ${res.error || res.result}`)
}
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'连接测试失败'
toast.error(msg)
} finally {
setTesting(false)
}
}
const removeKey = async () => {
@@ -106,18 +121,24 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
</p>
</div>
<div className="space-y-2">
{!isAdult && (
<label className="block text-xs text-ink-50">
API Key (留空保留原值)
<input
className="input-base mt-1"
type="password"
placeholder={item.has_key ? '••••••••••••' : '尚未配置'}
value={apiKey}
onChange={(e) => setAPIKey(e.target.value)}
/>
</label>
)}
<label className="block text-xs text-ink-50">
{isAdult ? 'Cookie 凭据 (可选,留空保留原值)' : 'API Key (留空保留原值)'}
<input
className="input-base mt-1"
type="password"
placeholder={
isAdult
? item.has_key
? '•••••••••••• (已配置)'
: '可选,默认内置年龄验证,可填自定义 Cookie'
: item.has_key
? '••••••••••••'
: '尚未配置'
}
value={apiKey}
onChange={(e) => setAPIKey(e.target.value)}
/>
</label>
<label className="block text-xs text-ink-50">
{isAdult ? '主源 URL' : 'Base URL (可选)'}
<input
@@ -152,10 +173,11 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
</button>
<button
type="button"
disabled={testing}
onClick={testKey}
className="neon-button !text-xs !border-sand-400/40 !text-ink-100"
>
<Eye size={12} /> 状态
<Eye size={12} /> {testing ? '测试中…' : '测试连接'}
</button>
{item.has_key && (
<button
@@ -174,7 +196,7 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
function apiConfigConfigured(item: APIConfig): boolean {
if (item.provider === 'adult') {
return Boolean(item.base_url?.trim() || item.extra?.trim())
return Boolean(item.has_key || item.base_url?.trim() || item.extra?.trim())
}
return item.has_key
}
+703
View File
@@ -0,0 +1,703 @@
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 []
}
+6 -4
View File
@@ -5,10 +5,11 @@ import toast from 'react-hot-toast'
import { adminAPI } from '../api/admin'
import { libraryAPI } from '../api/library'
import type { Library, Setting } from '../types'
import { SettingRow } from './SettingsRow'
import { ALL_KEYS, GROUPS } from './settingsGroups'
import { SystemUpdatePanel } from './SystemUpdatePanel'
import { AdultSettingsPanel } from './AdultSettingsPanel'
import { RecognitionWordsPanel } from './RecognitionWordsPanel'
import { SettingRow } from './SettingsRow'
import { SystemUpdatePanel } from './SystemUpdatePanel'
import { ALL_KEYS, GROUPS } from './settingsGroups'
export function SettingsPage() {
const [activeGroup, setActiveGroup] = useState(GROUPS[0].key)
@@ -109,7 +110,8 @@ export function SettingsPage() {
<div className="space-y-4">
{group.key === 'system-update' && <SystemUpdatePanel />}
{group.key === 'recognition-words' && <RecognitionWordsPanel />}
{group.items.length > 0 && (
{group.key === 'adult' && <AdultSettingsPanel />}
{group.key !== 'adult' && group.items.length > 0 && (
<form onSubmit={onSave} className="glass-panel space-y-4">
{group.description && <p className="text-xs text-sand-500">{group.description}</p>}
{group.items.map((it) => (
+84 -1
View File
@@ -3,7 +3,7 @@ import type { SettingGroup } from './settingsGroupTypes'
export const adultSettingsGroup: SettingGroup = {
key: 'adult',
label: 'Adult / NSFW',
description: '成人内容隔离开关 (默认隐藏)',
description: '成人内容隔离与番号刮削配置 (支持 MetaTube 服务端与内置多源)',
items: [
{
key: 'adult.enabled',
@@ -30,5 +30,88 @@ export const adultSettingsGroup: SettingGroup = {
type: 'text',
hint: '4-8 位数字',
},
{
key: 'adult.scraper.engine',
label: '番号刮削引擎',
type: 'select',
hint: '选择使用内置刮削源、MetaTube 独立服务端或智能混合模式',
defaultValue: 'builtin',
options: [
{ value: 'builtin', label: '本项目内置刮削 (JavDB / JavBus 多源)' },
{ value: 'metatube', label: 'MetaTube 后端服务 (推荐)' },
{ value: 'auto', label: '智能混合 (优先 MetaTube,失败回退内置)' },
],
},
{
key: 'adult.scraper.metatube_server',
label: 'MetaTube 服务端地址',
type: 'text',
placeholder: 'http://127.0.0.1:7700',
defaultValue: 'http://127.0.0.1:7700',
hint: 'MetaTube Server 服务端 URL(如 http://127.0.0.1:7700)',
},
{
key: 'adult.scraper.metatube_token',
label: 'MetaTube 访问 Token',
type: 'text',
placeholder: '留空表示无 Token 保护',
hint: 'MetaTube 服务端配置的 API Bearer Token',
},
{
key: 'adult.scraper.metatube_provider',
label: 'MetaTube 首选 Provider',
type: 'text',
placeholder: '例如 javdb, javbus, dmm, fc2, gfriends (留空为全部)',
hint: '指定首选或默认查询的 Provider 数据源',
},
{
key: 'adult.scraper.enable_actor',
label: '刮削女优/演员详情与头像',
type: 'toggle',
defaultValue: 'true',
hint: '自动刮削女优简介、三围、生日及头像,并整合至标签',
},
{
key: 'adult.scraper.enable_trailer',
label: '刮削预告片',
type: 'toggle',
defaultValue: 'false',
hint: '自动抓取影片的预告片/试看片段链接',
},
{
key: 'adult.scraper.crop_cover',
label: '封面智能裁剪海报',
type: 'toggle',
defaultValue: 'true',
hint: '将宽版 DVD 封套大图智能裁剪为竖版正面海报,并保留完整封套作为背景大图',
},
{
key: 'adult.scraper.badge',
label: '添加无码/角标标记',
type: 'toggle',
defaultValue: 'false',
hint: '为识别出的无码/破解作品在海报添加角标标记',
},
{
key: 'adult.scraper.builtin_javdb_url',
label: '内置 JavDB 地址',
type: 'text',
placeholder: 'https://javdb.com',
defaultValue: 'https://javdb.com',
},
{
key: 'adult.scraper.builtin_javbus_url',
label: '内置 JavBus 镜像列表',
type: 'textarea',
placeholder: 'https://javbus.sbs\nhttps://www.javbus.com\nhttps://www.cdnbus.cyou',
defaultValue: 'https://javbus.sbs,https://www.javbus.com,https://www.cdnbus.cyou,https://www.javsee.cyou,https://www.busjav.cyou',
},
{
key: 'adult.scraper.builtin_cookie',
label: '内置源年龄验证 Cookie',
type: 'text',
placeholder: 'age=verified; existmag=all',
defaultValue: 'age=verified; existmag=all',
},
],
}