mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 11:46:36 +08:00
优化番号处理逻辑,添加对MetaTube的支持
优化番号处理逻辑,添加对MetaTube的支持
This commit is contained in:
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user