Files
bili-sync-ai/web/src/routes/settings/+page.svelte
2026-02-28 22:55:01 +08:00

1038 lines
34 KiB
Svelte
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.
<script lang="ts">
import { onMount, tick } from 'svelte';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import PasswordInput from '$lib/components/custom/password-input.svelte';
import QrLogin from '$lib/components/custom/qr-login.svelte';
import NotifierDialog from './NotifierDialog.svelte';
import { InfoIcon, QrCodeIcon } from '@lucide/svelte/icons';
import api from '$lib/api';
import { toast } from 'svelte-sonner';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import type { Config, ApiError, Notifier, Credential } from '$lib/types';
let frontendToken = ''; // 前端认证token
let config: Config | null = null;
let formData: Config | null = null;
let saving = false;
let loading = false;
let intervalInput: string = '1200';
// Notifier 管理相关
let showNotifierDialog = false;
let editingNotifier: Notifier | null = null;
let editingNotifierIndex: number | null = null;
let isEditing = false;
// QR 登录 Dialog 相关
let showQrLoginDialog = false;
let qrLoginComponent: QrLogin;
function openAddNotifierDialog() {
editingNotifier = null;
editingNotifierIndex = null;
isEditing = false;
showNotifierDialog = true;
}
function openEditNotifierDialog(notifier: Notifier, index: number) {
editingNotifier = { ...notifier };
editingNotifierIndex = index;
isEditing = true;
showNotifierDialog = true;
}
function closeNotifierDialog() {
showNotifierDialog = false;
editingNotifier = null;
editingNotifierIndex = null;
isEditing = false;
}
function addNotifier(notifier: Notifier) {
if (!formData) return;
if (!formData.notifiers) {
formData.notifiers = [];
}
formData.notifiers = [...formData.notifiers, notifier];
closeNotifierDialog();
}
function updateNotifier(index: number, notifier: Notifier) {
if (!formData?.notifiers) return;
const newNotifiers = [...formData.notifiers];
newNotifiers[index] = notifier;
formData.notifiers = newNotifiers;
closeNotifierDialog();
}
function deleteNotifier(index: number) {
if (!formData?.notifiers) return;
formData.notifiers = formData.notifiers.filter((_, i) => i !== index);
}
async function testNotifier(notifier: Notifier) {
try {
await api.testNotifier(notifier);
toast.success('测试通知发送成功');
} catch (error) {
console.error('测试通知失败:', error);
toast.error('测试通知失败', {
description: (error as ApiError).message
});
}
}
async function loadConfig() {
loading = true;
try {
const response = await api.getConfig();
config = response.data;
formData = { ...config };
// 根据 interval 的类型初始化输入框
if (typeof formData.interval === 'number') {
intervalInput = String(formData.interval);
} else {
intervalInput = formData.interval;
}
} catch (error) {
console.error('加载配置失败:', error);
toast.error('加载配置失败', {
description: (error as ApiError).message
});
throw error;
} finally {
loading = false;
}
}
async function authenticateFrontend() {
if (!frontendToken.trim()) {
toast.error('请输入前端认证Token');
return;
}
try {
api.setAuthToken(frontendToken.trim());
await loadConfig();
toast.success('前端认证成功');
} catch (error) {
console.error('前端认证失败:', error);
toast.error('认证失败请检查Token是否正确', {
description: (error as ApiError).message
});
}
}
async function saveConfig() {
if (!formData) {
toast.error('配置未加载');
return;
}
// 保存前根据输入内容判断类型
const trimmed = intervalInput.trim();
const asNumber = Number(trimmed);
if (!isNaN(asNumber) && trimmed !== '') {
// 纯数字,作为 Interval
formData.interval = asNumber;
} else {
// 非数字,作为 Cron 表达式
formData.interval = trimmed;
}
saving = true;
try {
let resp = await api.updateConfig(formData);
formData = resp.data;
config = { ...formData };
// 更新输入框显示
if (typeof formData.interval === 'number') {
intervalInput = String(formData.interval);
} else {
intervalInput = formData.interval;
}
toast.success('配置已保存');
} catch (error) {
console.error('保存配置失败:', error);
toast.error('保存配置失败', {
description: (error as ApiError).message
});
} finally {
saving = false;
}
}
function handleQrLoginSuccess(credential: Credential) {
if (!formData) return;
// 自动填充凭证到 formData
formData.credential = credential;
toast.success('扫码登录成功,已填充凭据');
// 自动保存配置
saveConfig();
// 关闭弹窗
showQrLoginDialog = false;
}
onMount(() => {
setBreadcrumb([{ label: '设置' }]);
frontendToken = api.getAuthToken() || '';
loadConfig();
});
</script>
<svelte:head>
<title>设置 - Bili Sync</title>
</svelte:head>
<div class="space-y-6">
<!-- 前端认证状态栏 -->
<div class="bg-card rounded-lg border p-4">
<div class="flex items-center justify-between">
<div class="space-y-1">
<h2 class="font-semibold">前端认证状态</h2>
<p class="text-muted-foreground text-sm">
{formData ? '已认证 - 可以正常加载数据' : '未认证 - 请输入 Token 进行鉴权'}
</p>
</div>
{#if !formData}
<div class="flex gap-3">
<PasswordInput bind:value={frontendToken} placeholder="输入认证Token" />
<Button onclick={authenticateFrontend} disabled={!frontendToken.trim()}>认证</Button>
</div>
{:else}
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-emerald-500"></div>
<span class="text-sm text-emerald-600">已认证</span>
</div>
<Button
variant="outline"
size="sm"
onclick={() => {
formData = null;
config = null;
api.clearAuthToken();
frontendToken = '';
}}
>
退出认证
</Button>
</div>
{/if}
</div>
</div>
<!-- 应用配置 -->
{#if loading}
<div class="flex items-center justify-center py-16">
<div class="space-y-2 text-center">
<div
class="border-primary mx-auto h-6 w-6 animate-spin rounded-full border-2 border-t-transparent"
></div>
<p class="text-muted-foreground">加载配置中...</p>
</div>
</div>
{:else if formData}
<div class="space-y-6">
<Tabs.Root value="basic" class="w-full">
<Tabs.List class="grid w-full grid-cols-6">
<Tabs.Trigger value="basic">基本设置</Tabs.Trigger>
<Tabs.Trigger value="auth">B站认证</Tabs.Trigger>
<Tabs.Trigger value="filter">视频处理</Tabs.Trigger>
<Tabs.Trigger value="danmaku">弹幕渲染</Tabs.Trigger>
<Tabs.Trigger value="notifiers">通知设置</Tabs.Trigger>
<Tabs.Trigger value="advanced">高级设置</Tabs.Trigger>
</Tabs.List>
<!-- 基本设置 -->
<Tabs.Content value="basic" class="mt-6 space-y-6">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="space-y-2">
<Label for="bind-address">绑定地址</Label>
<Input
id="bind-address"
placeholder="127.0.0.1:9999"
bind:value={formData.bind_address}
/>
</div>
<div class="space-y-2">
<div class="flex items-center gap-1">
<Label for="interval">任务触发条件</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<InfoIcon class="text-muted-foreground h-3.5 w-3.5" />
</Tooltip.Trigger>
<Tooltip.Content>
<p class="text-xs">
视频下载任务的触发条件,支持两种格式:<br />
1. 输入数字表示间隔秒数,例如 1200 表示每隔 20 分钟触发一次; <br />
2. 输入 Cron 表达式,格式为“秒 分 时 日 月 周”例如“0 0 2 * * *”表示每天凌晨2点触发一次。
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Input
id="interval"
type="text"
bind:value={intervalInput}
placeholder="1200 0 0 2 * * *"
/>
</div>
<div class="space-y-2">
<Label for="video-name">视频名称模板</Label>
<Input id="video-name" bind:value={formData.video_name} />
</div>
<div class="space-y-2">
<Label for="page-name">分页名称模板</Label>
<Input id="page-name" bind:value={formData.page_name} />
</div>
<div class="space-y-2">
<Label for="upper-path">UP主头像保存路径</Label>
<Input
id="upper-path"
placeholder="/path/to/upper/faces"
bind:value={formData.upper_path}
/>
</div>
<div class="space-y-2">
<Label for="time-format">时间格式</Label>
<Input
id="time-format"
placeholder="%Y-%m-%d %H:%M:%S"
bind:value={formData.time_format}
/>
</div>
</div>
<div class="space-y-4">
<div class="space-y-2">
<Label for="backend-auth-token">后端 API 认证Token</Label>
<PasswordInput
id="backend-auth-token"
placeholder="设置后端 API 认证Token"
bind:value={formData.auth_token}
/>
<p class="text-muted-foreground text-xs">
修改此Token后前端需要使用新Token重新认证才能访问API
</p>
</div>
</div>
<Separator />
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="space-y-2">
<Label for="favorite-default-path">收藏夹快捷订阅路径模板</Label>
<Input id="favorite-default-path" bind:value={formData.favorite_default_path} />
</div>
<div class="space-y-2">
<Label for="collection-default-path">合集快捷订阅路径模板</Label>
<Input id="collection-default-path" bind:value={formData.collection_default_path} />
</div>
<div class="space-y-2">
<Label for="submission-default-path">UP 主投稿快捷订阅路径模板</Label>
<Input id="submission-default-path" bind:value={formData.submission_default_path} />
</div>
</div>
<Separator />
<div class="space-y-4">
<div class="flex items-center space-x-2">
<Switch id="cdn-sorting" bind:checked={formData.cdn_sorting} />
<Label for="cdn-sorting">启用CDN排序</Label>
</div>
<div class="flex items-center space-x-2">
<Switch id="try-upower-anyway" bind:checked={formData.try_upower_anyway} />
<div class="flex items-center gap-1">
<Label for="try-upower-anyway">尝试下载未充电视频</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<InfoIcon class="text-muted-foreground h-3.5 w-3.5" />
</Tooltip.Trigger>
<Tooltip.Content>
<p class="text-xs">
当关闭该开关时,程序仅会下载已充电的视频,未充电的视频直接跳过;开启后不再检查充电状态,一律尝试下载。<br
/>
这可以帮助下载未充电视频的封面等元数据,也应该可以下载未充电视频的试看部分(如果存在的话)。
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
</div>
</Tabs.Content>
<!-- B站认证 -->
<Tabs.Content value="auth" class="mt-6 space-y-6">
<div class="flex items-center justify-between">
<div class="space-y-1">
<Label class="text-base font-semibold">快速登录</Label>
<p class="text-muted-foreground text-sm">使用哔哩哔哩 APP 扫码登录,自动填充凭据</p>
</div>
<Button
onclick={() => {
showQrLoginDialog = true;
tick().then(() => {
qrLoginComponent!.init();
});
}}
>
<QrCodeIcon class="mr-2 h-4 w-4" />
扫码登录
</Button>
</div>
<Separator />
<!-- 原有的手动输入 Cookie 表单 -->
<div class="space-y-4">
<div class="space-y-2">
<Label for="sessdata">SESSDATA</Label>
<PasswordInput
id="sessdata"
placeholder="请输入SESSDATA"
bind:value={formData.credential.sessdata}
/>
</div>
<div class="space-y-2">
<Label for="bili-jct">bili_jct</Label>
<PasswordInput
id="bili-jct"
placeholder="请输入bili_jct"
bind:value={formData.credential.bili_jct}
/>
</div>
<div class="space-y-2">
<Label for="buvid3">buvid3</Label>
<PasswordInput
id="buvid3"
placeholder="请输入buvid3"
bind:value={formData.credential.buvid3}
/>
</div>
<div class="space-y-2">
<Label for="dedeuserid">dedeuserid</Label>
<PasswordInput
id="dedeuserid"
placeholder="请输入dedeuserid"
bind:value={formData.credential.dedeuserid}
/>
</div>
<div class="space-y-2">
<Label for="ac-time-value">ac_time_value</Label>
<PasswordInput
id="ac-time-value"
placeholder="请输入ac_time_value"
bind:value={formData.credential.ac_time_value}
/>
</div>
</div>
</Tabs.Content>
<!-- 过滤规则 -->
<Tabs.Content value="filter" class="mt-6 space-y-6">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="space-y-2">
<Label for="video-max-quality">最高视频质量</Label>
<select
id="video-max-quality"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
bind:value={formData.filter_option.video_max_quality}
>
<option value="Quality360p">360p</option>
<option value="Quality480p">480p</option>
<option value="Quality720p">720p</option>
<option value="Quality1080p">1080p</option>
<option value="Quality1080pPLUS">1080p+</option>
<option value="Quality1080p60">1080p60</option>
<option value="Quality4k">4K</option>
<option value="QualityHdr">HDR</option>
<option value="QualityDolby">杜比视界</option>
<option value="Quality8k">8K</option>
</select>
</div>
<div class="space-y-2">
<Label for="video-min-quality">最低视频质量</Label>
<select
id="video-min-quality"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
bind:value={formData.filter_option.video_min_quality}
>
<option value="Quality360p">360p</option>
<option value="Quality480p">480p</option>
<option value="Quality720p">720p</option>
<option value="Quality1080p">1080p</option>
<option value="Quality1080pPLUS">1080p+</option>
<option value="Quality1080p60">1080p60</option>
<option value="Quality4k">4K</option>
<option value="QualityHdr">HDR</option>
<option value="QualityDolby">杜比视界</option>
<option value="Quality8k">8K</option>
</select>
</div>
<div class="space-y-2">
<Label for="audio-max-quality">最高音频质量</Label>
<select
id="audio-max-quality"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
bind:value={formData.filter_option.audio_max_quality}
>
<option value="Quality64k">64k</option>
<option value="Quality132k">132k</option>
<option value="Quality192k">192k</option>
<option value="QualityDolby">杜比全景声</option>
<option value="QualityHiRES">Hi-RES</option>
</select>
</div>
<div class="space-y-2">
<Label for="audio-min-quality">最低音频质量</Label>
<select
id="audio-min-quality"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
bind:value={formData.filter_option.audio_min_quality}
>
<option value="Quality64k">64k</option>
<option value="Quality132k">132k</option>
<option value="Quality192k">192k</option>
<option value="QualityDolby">杜比全景声</option>
<option value="QualityHiRES">Hi-RES</option>
</select>
</div>
</div>
<Separator />
<div class="space-y-4">
<Label>视频编码格式偏好(按优先级排序)</Label>
<p class="text-muted-foreground text-sm">排在前面的编码格式优先级更高</p>
<div class="space-y-2">
{#each formData.filter_option.codecs as codec, index (index)}
<div class="flex items-center space-x-2 rounded-lg border p-3">
<Badge variant="secondary">{index + 1}</Badge>
<span class="flex-1 font-medium">{codec}</span>
<div class="flex space-x-1">
<Button
type="button"
size="sm"
variant="outline"
disabled={index === 0}
onclick={() => {
if (formData && index > 0) {
const newCodecs = [...formData.filter_option.codecs];
[newCodecs[index - 1], newCodecs[index]] = [
newCodecs[index],
newCodecs[index - 1]
];
formData.filter_option.codecs = newCodecs;
}
}}
>
</Button>
<Button
type="button"
size="sm"
variant="outline"
disabled={index === formData.filter_option.codecs.length - 1}
onclick={() => {
if (formData && index < formData.filter_option.codecs.length - 1) {
const newCodecs = [...formData.filter_option.codecs];
[newCodecs[index], newCodecs[index + 1]] = [
newCodecs[index + 1],
newCodecs[index]
];
formData.filter_option.codecs = newCodecs;
}
}}
>
</Button>
<Button
type="button"
size="sm"
variant="destructive"
onclick={() => {
if (formData) {
formData.filter_option.codecs = formData.filter_option.codecs.filter(
(_, i) => i !== index
);
}
}}
>
×
</Button>
</div>
</div>
{/each}
{#if formData.filter_option.codecs.length < 3}
<div class="space-y-2">
<Label>添加编码格式</Label>
<div class="flex gap-2">
{#each ['AV1', 'HEV', 'AVC'] as codec (codec)}
{#if !formData.filter_option.codecs.includes(codec)}
<Button
type="button"
size="sm"
variant="outline"
onclick={() => {
if (formData) {
formData.filter_option.codecs = [
...formData.filter_option.codecs,
codec
];
}
}}
>
+ {codec}
</Button>
{/if}
{/each}
</div>
</div>
{/if}
</div>
</div>
<Separator />
<div class="space-y-4">
<Label>特殊流排除选项</Label>
<p class="text-muted-foreground text-sm">排除某些类型的特殊流</p>
<div class="flex items-center space-x-2">
<Switch id="no-dolby-video" bind:checked={formData.filter_option.no_dolby_video} />
<Label for="no-dolby-video">排除杜比视界视频</Label>
</div>
<div class="flex items-center space-x-2">
<Switch id="no-dolby-audio" bind:checked={formData.filter_option.no_dolby_audio} />
<Label for="no-dolby-audio">排除杜比全景声音频</Label>
</div>
<div class="flex items-center space-x-2">
<Switch id="no-hdr" bind:checked={formData.filter_option.no_hdr} />
<Label for="no-hdr">排除HDR视频</Label>
</div>
<div class="flex items-center space-x-2">
<Switch id="no-hires" bind:checked={formData.filter_option.no_hires} />
<Label for="no-hires">排除Hi-RES音频</Label>
</div>
</div>
<Separator />
<div class="space-y-4">
<Label>处理跳过选项</Label>
<p class="text-muted-foreground text-sm">在视频处理部分跳过某些执行环节</p>
<div class="flex items-center space-x-2">
<Switch id="skip-poster" bind:checked={formData.skip_option.no_poster} />
<Label for="skip-poster">跳过视频封面</Label>
</div>
<div class="flex items-center space-x-2">
<Switch id="skip-video-nfo" bind:checked={formData.skip_option.no_video_nfo} />
<Label for="skip-video-nfo">跳过视频 NFO</Label>
</div>
<div class="flex items-center space-x-2">
<Switch id="skip-upper-info" bind:checked={formData.skip_option.no_upper} />
<Label for="skip-upper-info">跳过 Up 主头像、信息</Label>
</div>
<div class="flex items-center space-x-2">
<Switch id="skip-danmaku" bind:checked={formData.skip_option.no_danmaku} />
<Label for="skip-danmaku">跳过弹幕</Label>
</div>
<div class="flex items-center space-x-2">
<Switch id="skip-subtitle" bind:checked={formData.skip_option.no_subtitle} />
<Label for="skip-subtitle">跳过字幕</Label>
</div>
</div>
</Tabs.Content>
<!-- 弹幕设置 -->
<Tabs.Content value="danmaku" class="mt-6 space-y-6">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="space-y-2">
<Label for="danmaku-duration">弹幕持续时间(秒)</Label>
<Input
id="danmaku-duration"
type="number"
min="1"
step="0.1"
bind:value={formData.danmaku_option.duration}
/>
</div>
<div class="space-y-2">
<Label for="danmaku-font">字体</Label>
<Input
id="danmaku-font"
placeholder="黑体"
bind:value={formData.danmaku_option.font}
/>
</div>
<div class="space-y-2">
<Label for="danmaku-font-size">字体大小</Label>
<Input
id="danmaku-font-size"
type="number"
min="8"
max="72"
bind:value={formData.danmaku_option.font_size}
/>
</div>
<div class="space-y-2">
<Label for="danmaku-width-ratio">宽度比例</Label>
<Input
id="danmaku-width-ratio"
type="number"
min="0.1"
max="3"
step="0.1"
bind:value={formData.danmaku_option.width_ratio}
/>
</div>
<div class="space-y-2">
<Label for="danmaku-horizontal-gap">水平间距</Label>
<Input
id="danmaku-horizontal-gap"
type="number"
min="0"
step="1"
bind:value={formData.danmaku_option.horizontal_gap}
/>
</div>
<div class="space-y-2">
<Label for="danmaku-lane-size">轨道大小</Label>
<Input
id="danmaku-lane-size"
type="number"
min="8"
max="128"
bind:value={formData.danmaku_option.lane_size}
/>
</div>
<div class="space-y-2">
<Label for="danmaku-float-percentage">滚动弹幕高度百分比</Label>
<Input
id="danmaku-float-percentage"
type="number"
min="0"
max="1"
step="0.01"
bind:value={formData.danmaku_option.float_percentage}
/>
</div>
<div class="space-y-2">
<Label for="danmaku-bottom-percentage">底部弹幕高度百分比</Label>
<Input
id="danmaku-bottom-percentage"
type="number"
min="0"
max="1"
step="0.01"
bind:value={formData.danmaku_option.bottom_percentage}
/>
</div>
<div class="space-y-2">
<Label for="danmaku-opacity">透明度 (0-255)</Label>
<Input
id="danmaku-opacity"
type="number"
min="0"
max="255"
bind:value={formData.danmaku_option.opacity}
/>
</div>
<div class="space-y-2">
<Label for="danmaku-outline">描边宽度</Label>
<Input
id="danmaku-outline"
type="number"
min="0"
max="5"
step="0.1"
bind:value={formData.danmaku_option.outline}
/>
</div>
<div class="space-y-2">
<Label for="danmaku-time-offset">时间偏移(秒)</Label>
<Input
id="danmaku-time-offset"
type="number"
step="0.1"
bind:value={formData.danmaku_option.time_offset}
/>
</div>
</div>
<Separator />
<div class="space-y-4">
<div class="flex items-center space-x-2">
<Switch id="danmaku-bold" bind:checked={formData.danmaku_option.bold} />
<Label for="danmaku-bold">粗体显示</Label>
</div>
</div>
</Tabs.Content>
<!-- 通知设置 -->
<Tabs.Content value="notifiers" class="mt-6 space-y-6">
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold">通知器管理</h3>
<p class="text-muted-foreground text-sm">
配置通知器,在下载任务出现错误时发送通知
</p>
</div>
<Button onclick={openAddNotifierDialog}>+ 添加通知器</Button>
</div>
{#if !formData.notifiers || formData.notifiers.length === 0}
<div class="rounded-lg border-2 border-dashed py-12 text-center">
<p class="text-muted-foreground">暂无通知器配置</p>
<Button class="mt-4" variant="outline" onclick={openAddNotifierDialog}>
添加第一个通知器
</Button>
</div>
{:else}
<div class="space-y-3">
{#each formData.notifiers as notifier, index (index)}
<div class="flex items-center justify-between rounded-lg border p-4">
<div class="flex-1">
{#if notifier.type === 'telegram'}
<div class="flex items-center gap-2">
<Badge variant="secondary">Telegram</Badge>
<span class="text-muted-foreground text-sm">
Chat ID: {notifier.chat_id}
</span>
</div>
{:else if notifier.type === 'webhook'}
<div class="flex items-center gap-2">
<Badge variant="secondary">Webhook</Badge>
<span class="text-muted-foreground text-sm">
{notifier.url}
</span>
</div>
{/if}
</div>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
onclick={() => openEditNotifierDialog(notifier, index)}
>
编辑
</Button>
<Button size="sm" variant="secondary" onclick={() => testNotifier(notifier)}>
测试
</Button>
<Button size="sm" variant="destructive" onclick={() => deleteNotifier(index)}>
删除
</Button>
</div>
</div>
{/each}
</div>
{/if}
</div>
</Tabs.Content>
<!-- 高级设置 -->
<Tabs.Content value="advanced" class="mt-6 space-y-6">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="space-y-2">
<Label for="video-concurrent">视频并发数</Label>
<Input
id="video-concurrent"
type="number"
min="1"
max="20"
bind:value={formData.concurrent_limit.video}
/>
</div>
<div class="space-y-2">
<Label for="page-concurrent">分页并发数</Label>
<Input
id="page-concurrent"
type="number"
min="1"
max="20"
bind:value={formData.concurrent_limit.page}
/>
</div>
<div class="space-y-2">
<Label for="nfo-time-type">NFO时间类型</Label>
<select
id="nfo-time-type"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
bind:value={formData.nfo_time_type}
>
<option value="favtime">收藏时间</option>
<option value="pubtime">发布时间</option>
</select>
</div>
</div>
<Separator />
<div class="space-y-4">
<div class="mb-4 flex items-center space-x-2">
<Switch
id="rate-limit-enable"
checked={formData.concurrent_limit.rate_limit !== null &&
formData.concurrent_limit.rate_limit !== undefined}
onCheckedChange={(checked) => {
if (checked) {
formData!.concurrent_limit.rate_limit = { limit: 4, duration: 250 };
} else {
formData!.concurrent_limit.rate_limit = undefined;
}
}}
/>
<Label for="rate-limit-enable">启用请求频率限制</Label>
</div>
{#if formData.concurrent_limit.rate_limit}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="space-y-2">
<Label for="rate-limit-duration">时间间隔(毫秒)</Label>
<Input
id="rate-limit-duration"
type="number"
min="100"
bind:value={formData.concurrent_limit.rate_limit.duration}
/>
<p class="text-muted-foreground text-xs">请求限制的时间窗口(毫秒)</p>
</div>
<div class="space-y-2">
<Label for="rate-limit-limit">限制请求数</Label>
<Input
id="rate-limit-limit"
type="number"
min="1"
bind:value={formData.concurrent_limit.rate_limit.limit}
/>
<p class="text-muted-foreground text-xs">每个时间间隔内允许的最大请求数</p>
</div>
</div>
{/if}
</div>
<Separator />
<div class="space-y-4">
<div class="mb-4 flex items-center space-x-2">
<Switch
id="download-enable"
bind:checked={formData.concurrent_limit.download.enable}
/>
<Label for="rate-limit-duration">启用单文件分块下载</Label>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="space-y-2">
<Label for="download-concurrency">下载分块数</Label>
<Input
id="download-concurrency"
type="number"
min="1"
max="16"
disabled={!formData.concurrent_limit.download.enable}
bind:value={formData.concurrent_limit.download.concurrency}
/>
<p class="text-muted-foreground text-xs">
单文件将分为若干大小相同的块并行下载,所有分块下载完毕后合并
</p>
</div>
<div class="space-y-2">
<Label for="download-threshold">启用分块下载的文件大小阈值(字节)</Label>
<Input
id="download-threshold"
type="number"
min="1048576"
disabled={!formData.concurrent_limit.download?.enable}
bind:value={formData.concurrent_limit.download.threshold}
/>
<p class="text-muted-foreground text-xs">
大于该阈值的文件才使用分块下载,文件过小时分块下载的拆分合并成本可能大于带来的增益
</p>
</div>
</div>
</div>
</Tabs.Content>
</Tabs.Root>
<div class="flex justify-end pt-6">
<Button onclick={saveConfig} disabled={saving} size="lg">
{saving ? '保存中...' : '保存配置'}
</Button>
</div>
</div>
{:else}
<div class="flex items-center justify-center py-16">
<div class="space-y-4 text-center">
<p class="text-muted-foreground">请先进行前端认证以加载配置</p>
</div>
</div>
{/if}
</div>
<Dialog.Root bind:open={showNotifierDialog}>
<Dialog.Portal>
<Dialog.Overlay class="bg-background/80 fixed inset-0 z-50 backdrop-blur-sm" />
<Dialog.Content
class="bg-background fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg"
>
<Dialog.Header>
<Dialog.Title>
{isEditing ? '编辑通知器' : '添加通知器'}
</Dialog.Title>
<Dialog.Description>配置通知器类型和参数</Dialog.Description>
</Dialog.Header>
{#if showNotifierDialog}
<NotifierDialog
notifier={editingNotifier}
onSave={(notifier) => {
if (isEditing && editingNotifierIndex !== null) {
updateNotifier(editingNotifierIndex, notifier);
} else {
addNotifier(notifier);
}
}}
onCancel={closeNotifierDialog}
/>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<!-- QR 登录弹窗 -->
<Dialog.Root bind:open={showQrLoginDialog}>
<Dialog.Portal>
<Dialog.Overlay class="bg-background/80 fixed inset-0 z-50 backdrop-blur-sm" />
<Dialog.Content
class="bg-background fixed top-[50%] left-[50%] z-50 grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg"
>
<Dialog.Header>
<Dialog.Title>扫码登录</Dialog.Title>
<Dialog.Description>使用哔哩哔哩 APP 扫描二维码登录</Dialog.Description>
</Dialog.Header>
<QrLogin bind:this={qrLoginComponent} onSuccess={handleQrLoginSuccess} />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>