873 lines
27 KiB
Svelte
873 lines
27 KiB
Svelte
<script lang="ts">
|
||
import { onMount } from 'svelte';
|
||
import { Button } from '$lib/components/ui/button/index.js';
|
||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||
import { Input } from '$lib/components/ui/input/index.js';
|
||
import { Label } from '$lib/components/ui/label/index.js';
|
||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||
import * as Table from '$lib/components/ui/table/index.js';
|
||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||
import {
|
||
SquarePenIcon,
|
||
FolderIcon,
|
||
HeartIcon,
|
||
UserIcon,
|
||
ClockIcon,
|
||
PlusIcon,
|
||
InfoIcon,
|
||
Trash2Icon,
|
||
CircleCheckBigIcon,
|
||
CircleXIcon
|
||
} from '@lucide/svelte/icons';
|
||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||
import { toast } from 'svelte-sonner';
|
||
import { setBreadcrumb } from '$lib/stores/breadcrumb';
|
||
import type { ApiError, VideoSourceDetail, VideoSourcesDetailsResponse, Rule } from '$lib/types';
|
||
import api from '$lib/api';
|
||
import RuleEditor from '$lib/components/rule-editor.svelte';
|
||
import ListRestartIcon from '@lucide/svelte/icons/list-restart';
|
||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||
|
||
let videoSourcesData: VideoSourcesDetailsResponse | null = null;
|
||
let loading = false;
|
||
let activeTab = 'favorites';
|
||
let batchSaving = false;
|
||
|
||
// 添加对话框状态
|
||
let showAddDialog = false;
|
||
let addDialogType: 'favorites' | 'collections' | 'submissions' = 'favorites';
|
||
let adding = false;
|
||
|
||
// 编辑对话框状态
|
||
let showEditDialog = false;
|
||
let editingSource: VideoSourceDetail | null = null;
|
||
let editingType = '';
|
||
let editingIdx: number = 0;
|
||
let saving = false;
|
||
|
||
// 规则评估对话框状态
|
||
let showEvaluateDialog = false;
|
||
let evaluateSource: VideoSourceDetail | null = null;
|
||
let evaluateType = '';
|
||
let evaluating = false;
|
||
|
||
// 删除对话框状态
|
||
let showRemoveDialog = false;
|
||
let removeSource: VideoSourceDetail | null = null;
|
||
let removeType = '';
|
||
let removeIdx: number = 0;
|
||
let removing = false;
|
||
|
||
// 编辑表单数据
|
||
let editForm = {
|
||
path: '',
|
||
enabled: false,
|
||
rule: null as Rule | null,
|
||
useDynamicApi: null as boolean | null
|
||
};
|
||
|
||
// 表单数据
|
||
let favoriteForm = { fid: '' };
|
||
let collectionForm = { sid: '', mid: '', collection_type: '2' }; // 默认为合集
|
||
let submissionForm = { upper_id: '' };
|
||
let selectedIds: Record<string, number[]> = {
|
||
favorites: [],
|
||
collections: [],
|
||
submissions: [],
|
||
watch_later: []
|
||
};
|
||
|
||
const TAB_CONFIG = {
|
||
favorites: { label: '收藏夹', icon: HeartIcon },
|
||
collections: { label: '合集 / 列表', icon: FolderIcon },
|
||
submissions: { label: '用户投稿', icon: UserIcon },
|
||
watch_later: { label: '稍后再看', icon: ClockIcon }
|
||
} as const;
|
||
|
||
// 数据加载
|
||
async function loadVideoSources() {
|
||
loading = true;
|
||
try {
|
||
const response = await api.getVideoSourcesDetails();
|
||
videoSourcesData = response.data;
|
||
selectedIds = { favorites: [], collections: [], submissions: [], watch_later: [] };
|
||
} catch (error) {
|
||
toast.error('加载视频源失败', {
|
||
description: (error as ApiError).message
|
||
});
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
// 打开编辑对话框
|
||
function openEditDialog(type: string, source: VideoSourceDetail, idx: number) {
|
||
editingSource = source;
|
||
editingType = type;
|
||
editingIdx = idx;
|
||
editForm = {
|
||
path: source.path,
|
||
enabled: source.enabled,
|
||
useDynamicApi: source.useDynamicApi,
|
||
rule: source.rule
|
||
};
|
||
showEditDialog = true;
|
||
}
|
||
|
||
function openEvaluateRules(type: string, source: VideoSourceDetail) {
|
||
evaluateSource = source;
|
||
evaluateType = type;
|
||
showEvaluateDialog = true;
|
||
}
|
||
|
||
function openRemoveDialog(type: string, source: VideoSourceDetail, idx: number) {
|
||
removeSource = source;
|
||
removeType = type;
|
||
removeIdx = idx;
|
||
showRemoveDialog = true;
|
||
}
|
||
|
||
// 保存编辑
|
||
async function saveEdit() {
|
||
if (!editingSource) return;
|
||
|
||
if (!editForm.path?.trim()) {
|
||
toast.error('路径不能为空');
|
||
return;
|
||
}
|
||
saving = true;
|
||
try {
|
||
let response = await api.updateVideoSource(editingType, editingSource.id, {
|
||
path: editForm.path,
|
||
enabled: editForm.enabled,
|
||
rule: editForm.rule,
|
||
useDynamicApi: editForm.useDynamicApi
|
||
});
|
||
// 更新本地数据
|
||
if (videoSourcesData && editingSource) {
|
||
const sources = videoSourcesData[
|
||
editingType as keyof VideoSourcesDetailsResponse
|
||
] as VideoSourceDetail[];
|
||
sources[editingIdx] = {
|
||
...sources[editingIdx],
|
||
path: editForm.path,
|
||
enabled: editForm.enabled,
|
||
rule: editForm.rule,
|
||
useDynamicApi: editForm.useDynamicApi,
|
||
ruleDisplay: response.data.ruleDisplay
|
||
};
|
||
videoSourcesData = { ...videoSourcesData };
|
||
}
|
||
showEditDialog = false;
|
||
toast.success('保存成功');
|
||
} catch (error) {
|
||
toast.error('保存失败', {
|
||
description: (error as ApiError).message
|
||
});
|
||
} finally {
|
||
saving = false;
|
||
}
|
||
}
|
||
|
||
async function evaluateRules() {
|
||
if (!evaluateSource) return;
|
||
evaluating = true;
|
||
try {
|
||
let response = await api.evaluateVideoSourceRules(evaluateType, evaluateSource.id);
|
||
if (response && response.data) {
|
||
showEvaluateDialog = false;
|
||
toast.success('重新评估规则成功');
|
||
} else {
|
||
toast.error('重新评估规则失败');
|
||
}
|
||
} catch (error) {
|
||
toast.error('重新评估规则失败', {
|
||
description: (error as ApiError).message
|
||
});
|
||
} finally {
|
||
evaluating = false;
|
||
}
|
||
}
|
||
|
||
async function removeVideoSource() {
|
||
if (!removeSource) return;
|
||
removing = true;
|
||
try {
|
||
let response = await api.removeVideoSource(removeType, removeSource.id);
|
||
if (response && response.data) {
|
||
if (videoSourcesData) {
|
||
const sources = videoSourcesData[
|
||
removeType as keyof VideoSourcesDetailsResponse
|
||
] as VideoSourceDetail[];
|
||
sources.splice(removeIdx, 1);
|
||
videoSourcesData = { ...videoSourcesData };
|
||
selectedIds = {
|
||
...selectedIds,
|
||
[removeType]: (selectedIds[removeType] ?? []).filter((id) => id !== removeSource.id)
|
||
};
|
||
}
|
||
showRemoveDialog = false;
|
||
toast.success('删除视频源成功');
|
||
} else {
|
||
toast.error('删除视频源失败');
|
||
}
|
||
} catch (error) {
|
||
toast.error('删除视频源失败', {
|
||
description: (error as ApiError).message
|
||
});
|
||
} finally {
|
||
removing = false;
|
||
}
|
||
}
|
||
|
||
function getSourcesForTab(tabValue: string): VideoSourceDetail[] {
|
||
if (!videoSourcesData) return [];
|
||
return videoSourcesData[tabValue as keyof VideoSourcesDetailsResponse] as VideoSourceDetail[];
|
||
}
|
||
|
||
function isSelected(tab: string, id: number): boolean {
|
||
return selectedIds[tab]?.includes(id) ?? false;
|
||
}
|
||
|
||
function toggleSelect(tab: string, id: number, checked: boolean) {
|
||
const current = selectedIds[tab] ?? [];
|
||
selectedIds = {
|
||
...selectedIds,
|
||
[tab]: checked ? [...new Set([...current, id])] : current.filter((item) => item !== id)
|
||
};
|
||
}
|
||
|
||
function toggleSelectAll(tab: string, sourceIds: number[], checked: boolean) {
|
||
selectedIds = {
|
||
...selectedIds,
|
||
[tab]: checked ? [...sourceIds] : []
|
||
};
|
||
}
|
||
|
||
async function batchUpdateSource(
|
||
tab: string,
|
||
changes: { enabled?: boolean; useDynamicApi?: boolean }
|
||
) {
|
||
if (!videoSourcesData) return;
|
||
const ids = selectedIds[tab] ?? [];
|
||
if (ids.length === 0) {
|
||
toast.error('请先勾选要操作的视频源');
|
||
return;
|
||
}
|
||
const sources = getSourcesForTab(tab).filter((source) => ids.includes(source.id));
|
||
if (sources.length === 0) {
|
||
toast.error('未找到可更新的视频源');
|
||
return;
|
||
}
|
||
|
||
batchSaving = true;
|
||
try {
|
||
const results = await Promise.allSettled(
|
||
sources.map((source) =>
|
||
api.updateVideoSource(tab, source.id, {
|
||
path: source.path,
|
||
enabled: changes.enabled ?? source.enabled,
|
||
rule: source.rule,
|
||
useDynamicApi:
|
||
changes.useDynamicApi === undefined
|
||
? source.useDynamicApi
|
||
: changes.useDynamicApi
|
||
})
|
||
)
|
||
);
|
||
let successCount = 0;
|
||
let failedCount = 0;
|
||
const ruleDisplayMap = new Map<number, string>();
|
||
results.forEach((result, index) => {
|
||
if (result.status === 'fulfilled') {
|
||
successCount += 1;
|
||
ruleDisplayMap.set(sources[index].id, result.value.data.ruleDisplay);
|
||
} else {
|
||
failedCount += 1;
|
||
}
|
||
});
|
||
|
||
if (successCount > 0) {
|
||
const list = videoSourcesData[tab as keyof VideoSourcesDetailsResponse] as VideoSourceDetail[];
|
||
videoSourcesData = {
|
||
...videoSourcesData,
|
||
[tab]: list.map((item) => {
|
||
if (!ids.includes(item.id)) return item;
|
||
return {
|
||
...item,
|
||
enabled: changes.enabled ?? item.enabled,
|
||
useDynamicApi:
|
||
changes.useDynamicApi === undefined ? item.useDynamicApi : changes.useDynamicApi,
|
||
ruleDisplay: ruleDisplayMap.get(item.id) ?? item.ruleDisplay
|
||
};
|
||
})
|
||
};
|
||
selectedIds = {
|
||
...selectedIds,
|
||
[tab]: []
|
||
};
|
||
}
|
||
|
||
if (failedCount > 0) {
|
||
toast.warning('批量更新部分失败', {
|
||
description: `成功 ${successCount} 条,失败 ${failedCount} 条`
|
||
});
|
||
} else {
|
||
toast.success(`批量更新成功,共 ${successCount} 条`);
|
||
}
|
||
} catch (error) {
|
||
toast.error('批量更新失败', {
|
||
description: (error as ApiError).message
|
||
});
|
||
} finally {
|
||
batchSaving = false;
|
||
}
|
||
}
|
||
|
||
// 打开添加对话框
|
||
function openAddDialog(type: 'favorites' | 'collections' | 'submissions') {
|
||
addDialogType = type;
|
||
// 重置表单
|
||
favoriteForm = { fid: '' };
|
||
collectionForm = { sid: '', mid: '', collection_type: '2' };
|
||
submissionForm = { upper_id: '' };
|
||
showAddDialog = true;
|
||
}
|
||
|
||
// 处理添加
|
||
async function handleAdd() {
|
||
adding = true;
|
||
try {
|
||
switch (addDialogType) {
|
||
case 'favorites':
|
||
if (!favoriteForm.fid) {
|
||
toast.error('请填写完整的收藏夹信息');
|
||
return;
|
||
}
|
||
await api.insertFavorite({
|
||
fid: parseInt(favoriteForm.fid)
|
||
});
|
||
break;
|
||
case 'collections':
|
||
if (!collectionForm.sid || !collectionForm.mid) {
|
||
toast.error('请填写完整的合集信息');
|
||
return;
|
||
}
|
||
await api.insertCollection({
|
||
sid: parseInt(collectionForm.sid),
|
||
mid: parseInt(collectionForm.mid),
|
||
collection_type: parseInt(collectionForm.collection_type)
|
||
});
|
||
break;
|
||
case 'submissions':
|
||
if (!submissionForm.upper_id) {
|
||
toast.error('请填写完整的用户投稿信息');
|
||
return;
|
||
}
|
||
await api.insertSubmission({
|
||
upper_id: parseInt(submissionForm.upper_id)
|
||
});
|
||
break;
|
||
}
|
||
|
||
toast.success('添加成功');
|
||
showAddDialog = false;
|
||
loadVideoSources(); // 重新加载数据
|
||
} catch (error) {
|
||
toast.error('添加失败', {
|
||
description: (error as ApiError).message
|
||
});
|
||
} finally {
|
||
adding = false;
|
||
}
|
||
}
|
||
|
||
// 初始化
|
||
onMount(() => {
|
||
setBreadcrumb([{ label: '视频源' }]);
|
||
loadVideoSources();
|
||
});
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>视频源管理 - Bili Sync</title>
|
||
</svelte:head>
|
||
|
||
<div class="space-y-6">
|
||
{#if loading}
|
||
<div class="flex items-center justify-center py-12">
|
||
<div class="text-muted-foreground">加载中...</div>
|
||
</div>
|
||
{:else if videoSourcesData}
|
||
<Tabs.Root bind:value={activeTab} class="w-full">
|
||
<Tabs.List class="grid w-full grid-cols-4">
|
||
{#each Object.entries(TAB_CONFIG) as [key, config] (key)}
|
||
<Tabs.Trigger value={key} class="relative">
|
||
{config.label}
|
||
</Tabs.Trigger>
|
||
{/each}
|
||
</Tabs.List>
|
||
{#each Object.entries(TAB_CONFIG) as [key, config] (key)}
|
||
{@const sources = getSourcesForTab(key)}
|
||
{@const sourceIds = sources.map((source) => source.id)}
|
||
{@const selectedCount = (selectedIds[key] ?? []).length}
|
||
<Tabs.Content value={key} class="mt-6">
|
||
<div class="mb-4 flex items-center justify-between">
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onclick={() => batchUpdateSource(key, { enabled: true })}
|
||
disabled={batchSaving || selectedCount === 0}
|
||
>
|
||
批量启用
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onclick={() => batchUpdateSource(key, { enabled: false })}
|
||
disabled={batchSaving || selectedCount === 0}
|
||
>
|
||
批量禁用
|
||
</Button>
|
||
{#if key === 'submissions'}
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onclick={() => batchUpdateSource(key, { useDynamicApi: true })}
|
||
disabled={batchSaving || selectedCount === 0}
|
||
>
|
||
批量开动态 API
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onclick={() => batchUpdateSource(key, { useDynamicApi: false })}
|
||
disabled={batchSaving || selectedCount === 0}
|
||
>
|
||
批量关动态 API
|
||
</Button>
|
||
{/if}
|
||
</div>
|
||
{#if key === 'favorites' || key === 'collections' || key === 'submissions'}
|
||
<Button size="sm" onclick={() => openAddDialog(key)} class="flex items-center gap-2">
|
||
<PlusIcon class="h-4 w-4" />
|
||
手动添加
|
||
</Button>
|
||
{/if}
|
||
</div>
|
||
{#if sources.length > 0}
|
||
<div class="overflow-x-auto">
|
||
<Table.Root>
|
||
<Table.Header>
|
||
<Table.Row>
|
||
<Table.Head class="w-[5%]">
|
||
<input
|
||
type="checkbox"
|
||
class="h-4 w-4 cursor-pointer"
|
||
checked={sources.length > 0 && selectedCount === sources.length}
|
||
onchange={(event) =>
|
||
toggleSelectAll(
|
||
key,
|
||
sourceIds,
|
||
(event.currentTarget as HTMLInputElement).checked
|
||
)}
|
||
/>
|
||
</Table.Head>
|
||
<Table.Head class="w-[20%]">名称</Table.Head>
|
||
<Table.Head class="w-[30%]">下载路径</Table.Head>
|
||
<Table.Head class="w-[15%]">过滤规则</Table.Head>
|
||
<Table.Head class="w-[15%]">启用状态</Table.Head>
|
||
<Table.Head class="w-[10%] text-right">操作</Table.Head>
|
||
</Table.Row>
|
||
</Table.Header>
|
||
<Table.Body>
|
||
{#each sources as source, index (index)}
|
||
<Table.Row>
|
||
<Table.Cell>
|
||
<input
|
||
type="checkbox"
|
||
class="h-4 w-4 cursor-pointer"
|
||
checked={isSelected(key, source.id)}
|
||
onchange={(event) =>
|
||
toggleSelect(
|
||
key,
|
||
source.id,
|
||
(event.currentTarget as HTMLInputElement).checked
|
||
)}
|
||
/>
|
||
</Table.Cell>
|
||
<Table.Cell class="font-medium">{source.name}</Table.Cell>
|
||
<Table.Cell>
|
||
<div
|
||
class="bg-secondary hover:bg-secondary/80 flex w-fit cursor-text items-center gap-2 rounded-md px-2.5 py-1.5 transition-colors"
|
||
>
|
||
<FolderIcon class="text-foreground/70 h-3.5 w-3.5 shrink-0" />
|
||
<span
|
||
class="text-foreground/70 font-mono text-xs font-medium select-text"
|
||
>
|
||
{source.path || '未设置'}
|
||
</span>
|
||
</div>
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
{#if source.rule && source.rule.length > 0}
|
||
<Tooltip.Root disableHoverableContent={true}>
|
||
<Tooltip.Trigger>
|
||
<Badge
|
||
variant="secondary"
|
||
class="flex w-fit cursor-help items-center gap-1.5"
|
||
>
|
||
{source.rule.length} 条规则
|
||
</Badge>
|
||
</Tooltip.Trigger>
|
||
<Tooltip.Content>
|
||
<p class="text-xs">{source.ruleDisplay}</p>
|
||
</Tooltip.Content>
|
||
</Tooltip.Root>
|
||
{:else}
|
||
<Badge variant="secondary" class="flex w-fit items-center gap-1.5">
|
||
-
|
||
</Badge>
|
||
{/if}
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
{#if source.enabled}
|
||
<Badge
|
||
class="flex w-fit items-center gap-1.5 bg-emerald-700 text-emerald-100"
|
||
>
|
||
<CircleCheckBigIcon class="h-3 w-3" />
|
||
已启用{#if key === 'submissions' && source.useDynamicApi !== null}{source.useDynamicApi
|
||
? '(动态 API)'
|
||
: ''}{/if}
|
||
</Badge>
|
||
{:else}
|
||
<Badge class="flex w-fit items-center gap-1.5 bg-rose-700 text-rose-100 ">
|
||
<CircleXIcon class="h-3 w-3" />
|
||
已禁用
|
||
</Badge>
|
||
{/if}
|
||
</Table.Cell>
|
||
|
||
<Table.Cell class="text-right">
|
||
<Tooltip.Root disableHoverableContent={true}>
|
||
<Tooltip.Trigger>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onclick={() => openEditDialog(key, source, index)}
|
||
class="h-8 w-8 p-0"
|
||
>
|
||
<SquarePenIcon class="h-3 w-3" />
|
||
</Button>
|
||
</Tooltip.Trigger>
|
||
<Tooltip.Content>
|
||
<p class="text-xs">编辑</p>
|
||
</Tooltip.Content>
|
||
</Tooltip.Root>
|
||
<Tooltip.Root disableHoverableContent={true}>
|
||
<Tooltip.Trigger>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onclick={() => openEvaluateRules(key, source)}
|
||
class="h-8 w-8 p-0"
|
||
>
|
||
<ListRestartIcon class="h-3 w-3" />
|
||
</Button>
|
||
</Tooltip.Trigger>
|
||
<Tooltip.Content>
|
||
<p class="text-xs">重新评估规则</p>
|
||
</Tooltip.Content>
|
||
</Tooltip.Root>
|
||
{#if activeTab !== 'watch_later'}
|
||
<Tooltip.Root disableHoverableContent={true}>
|
||
<Tooltip.Trigger>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onclick={() => openRemoveDialog(key, source, index)}
|
||
class="h-8 w-8 p-0"
|
||
>
|
||
<Trash2Icon class="h-3 w-3" />
|
||
</Button>
|
||
</Tooltip.Trigger>
|
||
<Tooltip.Content>
|
||
<p class="text-xs">删除</p>
|
||
</Tooltip.Content>
|
||
</Tooltip.Root>
|
||
{/if}
|
||
</Table.Cell>
|
||
</Table.Row>
|
||
{/each}
|
||
</Table.Body>
|
||
</Table.Root>
|
||
</div>
|
||
{:else}
|
||
<div class="flex flex-col items-center justify-center py-12">
|
||
<svelte:component this={config.icon} class="text-muted-foreground mb-4 h-12 w-12" />
|
||
<div class="text-muted-foreground mb-2 text-lg font-medium">暂无{config.label}</div>
|
||
<p class="text-muted-foreground mb-4 text-center text-sm">
|
||
{#if key === 'favorites'}
|
||
还没有添加任何收藏夹订阅
|
||
{:else if key === 'collections'}
|
||
还没有添加任何合集或列表订阅
|
||
{:else if key === 'submissions'}
|
||
还没有添加任何用户投稿订阅
|
||
{:else}
|
||
还没有添加稍后再看订阅
|
||
{/if}
|
||
</p>
|
||
{#if key === 'favorites' || key === 'collections' || key === 'submissions'}
|
||
<Button onclick={() => openAddDialog(key)} class="flex items-center gap-2">
|
||
<PlusIcon class="h-4 w-4" />
|
||
手动添加
|
||
</Button>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</Tabs.Content>
|
||
{/each}
|
||
</Tabs.Root>
|
||
{:else}
|
||
<div class="flex flex-col items-center justify-center py-12">
|
||
<div class="text-muted-foreground mb-2">加载失败</div>
|
||
<p class="text-muted-foreground text-sm">请刷新页面重试</p>
|
||
<Button class="mt-4" onclick={loadVideoSources}>重新加载</Button>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- 编辑对话框 -->
|
||
<Dialog.Root bind:open={showEditDialog}>
|
||
<Dialog.Content
|
||
class="no-scrollbar max-h-[85vh] max-w-[90vw]! overflow-y-auto lg:max-w-[70vw]!"
|
||
>
|
||
<Dialog.Title class="text-lg font-semibold">
|
||
编辑视频源: {editingSource?.name || ''}
|
||
</Dialog.Title>
|
||
<div class="mt-6 space-y-6">
|
||
<!-- 下载路径 -->
|
||
<div>
|
||
<Label for="edit-path" class="text-sm font-medium">下载路径</Label>
|
||
<Input
|
||
id="edit-path"
|
||
type="text"
|
||
bind:value={editForm.path}
|
||
placeholder="请输入下载路径,例如:/path/to/download"
|
||
disabled={editingType === 'watch_later'}
|
||
class="mt-2"
|
||
/>
|
||
{#if editingType === 'watch_later'}
|
||
<p class="text-muted-foreground mt-2 text-xs">
|
||
稍后再看固定使用“设置 - 基本设置 - 稍后再看默认下载路径”自动生成。
|
||
</p>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- 启用状态 -->
|
||
<div class="flex items-center space-x-2">
|
||
<Switch bind:checked={editForm.enabled} />
|
||
<Label class="text-sm font-medium">启用此视频源</Label>
|
||
</div>
|
||
|
||
{#if editingType === 'submissions' && editForm.useDynamicApi !== null}
|
||
<div class="flex items-center space-x-2">
|
||
<Switch bind:checked={editForm.useDynamicApi} />
|
||
<div class="flex items-center gap-1">
|
||
<Label class="text-sm font-medium">使用动态 API 获取视频</Label>
|
||
<Tooltip.Root>
|
||
<Tooltip.Trigger>
|
||
<InfoIcon class="text-muted-foreground h-3.5 w-3.5" />
|
||
</Tooltip.Trigger>
|
||
<Tooltip.Content>
|
||
<p class="text-xs">
|
||
只有使用动态 API 才能拉取到动态视频,但该接口不提供分页参数,每次请求只能拉取 12
|
||
条视频。<br />这会一定程度上增加请求次数,用户可根据实际情况酌情选择,推荐仅在
|
||
UP 主有较多动态视频时开启。
|
||
</p>
|
||
</Tooltip.Content>
|
||
</Tooltip.Root>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- 规则编辑器 -->
|
||
<div>
|
||
<RuleEditor rule={editForm.rule} onRuleChange={(rule) => (editForm.rule = rule)} />
|
||
</div>
|
||
</div>
|
||
<div class="mt-8 flex justify-end gap-3">
|
||
<Button variant="outline" onclick={() => (showEditDialog = false)} disabled={saving}>
|
||
取消
|
||
</Button>
|
||
<Button onclick={saveEdit} disabled={saving}>
|
||
{saving ? '保存中...' : '保存'}
|
||
</Button>
|
||
</div>
|
||
</Dialog.Content>
|
||
</Dialog.Root>
|
||
|
||
<AlertDialog.Root bind:open={showEvaluateDialog}>
|
||
<AlertDialog.Content>
|
||
<AlertDialog.Header>
|
||
<AlertDialog.Title>重新评估规则</AlertDialog.Title>
|
||
<AlertDialog.Description>
|
||
确定要重新评估视频源 <strong>"{evaluateSource?.name}"</strong> 的过滤规则吗?<br />
|
||
规则修改后默认仅对新视频生效,该操作可使用当前规则对数据库中已存在的历史视频进行重新评估,<span
|
||
class="text-destructive font-medium">无法撤销</span
|
||
>。<br />
|
||
</AlertDialog.Description>
|
||
</AlertDialog.Header>
|
||
<AlertDialog.Footer>
|
||
<AlertDialog.Cancel
|
||
disabled={evaluating}
|
||
onclick={() => {
|
||
showEvaluateDialog = false;
|
||
}}>取消</AlertDialog.Cancel
|
||
>
|
||
<AlertDialog.Action onclick={evaluateRules} disabled={evaluating}>
|
||
{evaluating ? '重新评估中' : '确认重新评估'}
|
||
</AlertDialog.Action>
|
||
</AlertDialog.Footer>
|
||
</AlertDialog.Content>
|
||
</AlertDialog.Root>
|
||
|
||
<AlertDialog.Root bind:open={showRemoveDialog}>
|
||
<AlertDialog.Content>
|
||
<AlertDialog.Header>
|
||
<AlertDialog.Title>删除视频源</AlertDialog.Title>
|
||
<AlertDialog.Description>
|
||
确定要删除视频源 <strong>"{removeSource?.name}"</strong> 吗?<br />
|
||
删除后该视频源相关的所有条目将从数据库中移除(不影响磁盘文件),该操作<span
|
||
class="text-destructive font-medium">无法撤销</span
|
||
>。<br />
|
||
</AlertDialog.Description>
|
||
</AlertDialog.Header>
|
||
<AlertDialog.Footer>
|
||
<AlertDialog.Cancel
|
||
disabled={removing}
|
||
onclick={() => {
|
||
showRemoveDialog = false;
|
||
}}>取消</AlertDialog.Cancel
|
||
>
|
||
<AlertDialog.Action onclick={removeVideoSource} disabled={removing}>
|
||
{removing ? '删除中' : '删除'}
|
||
</AlertDialog.Action>
|
||
</AlertDialog.Footer>
|
||
</AlertDialog.Content>
|
||
</AlertDialog.Root>
|
||
|
||
<!-- 添加对话框 -->
|
||
<Dialog.Root bind:open={showAddDialog}>
|
||
<Dialog.Content>
|
||
<Dialog.Title class="text-lg font-semibold">
|
||
{#if addDialogType === 'favorites'}
|
||
添加收藏夹
|
||
{:else if addDialogType === 'collections'}
|
||
添加合集
|
||
{:else}
|
||
添加用户投稿
|
||
{/if}
|
||
</Dialog.Title>
|
||
<div class="mt-4">
|
||
{#if addDialogType === 'favorites'}
|
||
<div class="space-y-4">
|
||
<div>
|
||
<Label for="fid" class="text-sm font-medium">收藏夹ID (fid)</Label>
|
||
<Input
|
||
id="fid"
|
||
type="number"
|
||
bind:value={favoriteForm.fid}
|
||
placeholder="请输入收藏夹ID"
|
||
class="mt-1"
|
||
/>
|
||
</div>
|
||
</div>
|
||
{:else if addDialogType === 'collections'}
|
||
<div class="space-y-4">
|
||
<div>
|
||
<Label for="collection-type" class="text-sm font-medium">合集类型</Label>
|
||
<select
|
||
id="collection-type"
|
||
bind:value={collectionForm.collection_type}
|
||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring mt-1 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"
|
||
>
|
||
<option value="1">列表 (Series)</option>
|
||
<option value="2">合集 (Season)</option>
|
||
</select>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<Label for="sid" class="text-sm font-medium">
|
||
{collectionForm.collection_type === '1'
|
||
? '列表ID (series_id)'
|
||
: '合集ID (season_id)'}
|
||
</Label>
|
||
<Input
|
||
id="sid"
|
||
type="number"
|
||
bind:value={collectionForm.sid}
|
||
placeholder={collectionForm.collection_type === '1'
|
||
? '请输入列表ID'
|
||
: '请输入合集ID'}
|
||
class="mt-1"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label for="mid" class="text-sm font-medium">用户ID (mid)</Label>
|
||
<Input
|
||
id="mid"
|
||
type="number"
|
||
bind:value={collectionForm.mid}
|
||
placeholder="请输入用户ID"
|
||
class="mt-1"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p class="text-muted-foreground text-xs">可从合集/列表页面URL中获取相应ID</p>
|
||
</div>
|
||
{:else}
|
||
<div class="space-y-4">
|
||
<div>
|
||
<Label for="upper_id" class="text-sm font-medium">UP主ID (mid)</Label>
|
||
<Input
|
||
id="upper_id"
|
||
type="number"
|
||
bind:value={submissionForm.upper_id}
|
||
placeholder="请输入UP主ID"
|
||
class="mt-1"
|
||
/>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
<div class="mt-4 space-y-1.5">
|
||
<Label class="text-sm font-medium">下载路径</Label>
|
||
<p class="text-muted-foreground text-xs">
|
||
{#if addDialogType === 'favorites'}
|
||
收藏夹会固定使用“设置 - 基本设置 - 收藏夹快捷订阅路径模板”自动生成。
|
||
{:else if addDialogType === 'collections'}
|
||
合集会固定使用“设置 - 基本设置 - 合集快捷订阅路径模板”自动生成。
|
||
{:else}
|
||
用户投稿会固定使用“设置 - 基本设置 - UP 主投稿默认下载路径”自动生成。
|
||
{/if}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div class="mt-6 flex justify-end gap-2">
|
||
<Button
|
||
variant="outline"
|
||
onclick={() => (showAddDialog = false)}
|
||
disabled={adding}
|
||
class="px-4"
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button onclick={handleAdd} disabled={adding} class="px-4">
|
||
{adding ? '添加中...' : '添加'}
|
||
</Button>
|
||
</div>
|
||
</Dialog.Content>
|
||
</Dialog.Root>
|
||
</div>
|