feat: 修改交互逻辑,支持前端查看日志 (#378)

This commit is contained in:
ᴀᴍᴛᴏᴀᴇʀ
2025-07-08 12:48:51 +08:00
committed by GitHub
parent 7c73a2f01a
commit 1affe4d594
71 changed files with 2049 additions and 1301 deletions

View File

@@ -1,78 +1,26 @@
<script lang="ts">
import '../app.css';
import AppSidebar from '$lib/components/app-sidebar.svelte';
import SearchBar from '$lib/components/search-bar.svelte';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { goto } from '$app/navigation';
import { appStateStore, resetCurrentPage, setQuery, ToQuery } from '$lib/stores/filter';
import { Toaster } from '$lib/components/ui/sonner/index.js';
import { breadcrumbStore } from '$lib/stores/breadcrumb';
import BreadCrumb from '$lib/components/bread-crumb.svelte';
import { videoSourceStore, setVideoSources } from '$lib/stores/video-source';
import { onMount } from 'svelte';
import api from '$lib/api';
import { toast } from 'svelte-sonner';
import type { ApiError } from '$lib/types';
let dataLoaded = false;
async function handleSearch(query: string) {
setQuery(query);
resetCurrentPage();
goto(`/${ToQuery($appStateStore)}`);
}
// 初始化共用数据
onMount(async () => {
// 初始化视频源数据,所有组件都会用到
if (!$videoSourceStore) {
try {
const response = await api.getVideoSources();
setVideoSources(response.data);
} catch (error) {
console.error('加载视频来源失败:', error);
toast.error('加载视频来源失败', {
description: (error as ApiError).message
});
}
}
dataLoaded = true;
});
// 从全局状态获取当前查询值
$: searchValue = $appStateStore.query;
import { Separator } from '$lib/components/ui/separator/index.js';
import { breadcrumbStore } from '$lib/stores/breadcrumb';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { Toaster } from '$lib/components/ui/sonner/index.js';
</script>
<Toaster />
<Sidebar.Provider>
<div class="flex min-h-screen w-full">
<div data-sidebar="sidebar">
<AppSidebar />
<AppSidebar />
<Sidebar.Inset class="flex flex-col" style="height: calc(100vh - 1rem)">
<header class="flex h-16 shrink-0 items-center gap-2">
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1" />
<Separator orientation="vertical" class="mr-2 data-[orientation=vertical]:h-4" />
<BreadCrumb items={$breadcrumbStore} />
</div>
</header>
<div class="w-full overflow-y-auto px-6 py-2" style="scrollbar-width: thin;">
<slot />
</div>
<Sidebar.Inset class="min-h-screen flex-1">
<div
class="bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-[73px] w-full items-center border-b backdrop-blur"
>
<div class="flex w-full items-center gap-4 px-6">
<Sidebar.Trigger class="shrink-0" data-sidebar="trigger" />
<div class="flex-1">
<SearchBar onSearch={handleSearch} value={searchValue} />
</div>
</div>
</div>
<div class="bg-background min-h-screen w-full">
<div class="w-full px-6 py-6">
{#if $breadcrumbStore.length > 0}
<div class="mb-6">
<BreadCrumb items={$breadcrumbStore} />
</div>
{/if}
{#if dataLoaded}
<slot />
{/if}
</div>
</div>
</Sidebar.Inset>
</div>
</Sidebar.Inset>
</Sidebar.Provider>

View File

@@ -1,89 +1,51 @@
<script lang="ts">
import VideoCard from '$lib/components/video-card.svelte';
import FilterBadge from '$lib/components/filter-badge.svelte';
import Pagination from '$lib/components/pagination.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import api from '$lib/api';
import type { VideosResponse, VideoSourcesResponse, ApiError } from '$lib/types';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { videoSourceStore } from '$lib/stores/video-source';
import { VIDEO_SOURCES } from '$lib/consts';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { Progress } from '$lib/components/ui/progress/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Chart from '$lib/components/ui/chart/index.js';
import MyChartTooltip from '$lib/components/custom/my-chart-tooltip.svelte';
import { curveNatural } from 'd3-shape';
import { BarChart, AreaChart } from 'layerchart';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import {
appStateStore,
clearVideoSourceFilter,
resetCurrentPage,
setAll,
setCurrentPage,
ToQuery
} from '$lib/stores/filter';
import { toast } from 'svelte-sonner';
import api from '$lib/api';
import type { DashBoardResponse, SysInfoResponse, ApiError } from '$lib/types';
import DatabaseIcon from '@lucide/svelte/icons/database';
import HeartIcon from '@lucide/svelte/icons/heart';
import FolderIcon from '@lucide/svelte/icons/folder';
import UserIcon from '@lucide/svelte/icons/user';
import ClockIcon from '@lucide/svelte/icons/clock';
import VideoIcon from '@lucide/svelte/icons/video';
import HardDriveIcon from '@lucide/svelte/icons/hard-drive';
import CpuIcon from '@lucide/svelte/icons/cpu';
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
const pageSize = 20;
let videosData: VideosResponse | null = null;
let dashboardData: DashBoardResponse | null = null;
let sysInfo: SysInfoResponse | null = null;
let loading = false;
let sysInfoEventSource: EventSource | null = null;
let lastSearch: string | null = null;
let resetAllDialogOpen = false;
let resettingAll = false;
function getApiParams(searchParams: URLSearchParams) {
let videoSource = null;
for (const source of Object.values(VIDEO_SOURCES)) {
const value = searchParams.get(source.type);
if (value) {
videoSource = { type: source.type, id: value };
}
}
return {
query: searchParams.get('query') || '',
videoSource,
pageNum: parseInt(searchParams.get('page') || '0')
};
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function getFilterContent(type: string, id: string) {
const filterTitle = Object.values(VIDEO_SOURCES).find((s) => s.type === type)?.title || '';
let filterName = '';
const videoSources = $videoSourceStore;
if (videoSources && type && id) {
const sources = videoSources[type as keyof VideoSourcesResponse];
filterName = sources?.find((s) => s.id.toString() === id)?.name || '';
}
return {
title: filterTitle,
name: filterName
};
function formatCpu(cpu: number): string {
return `${cpu.toFixed(1)}%`;
}
async function loadVideos(
query: string,
pageNum: number = 0,
filter?: { type: string; id: string } | null
) {
async function loadDashboard() {
loading = true;
try {
const params: Record<string, string | number> = {
page: pageNum,
page_size: pageSize
};
if (query) {
params.query = query;
}
if (filter) {
params[filter.type] = parseInt(filter.id);
}
const result = await api.getVideos(params);
videosData = result.data;
const response = await api.getDashboard();
dashboardData = response.data;
} catch (error) {
console.error('加载视频失败:', error);
toast.error('加载视频失败', {
console.error('加载仪表盘数据失败:', error);
toast.error('加载仪表盘数据失败', {
description: (error as ApiError).message
});
} finally {
@@ -91,188 +53,372 @@
}
}
async function handlePageChange(pageNum: number) {
setCurrentPage(pageNum);
goto(`/${ToQuery($appStateStore)}`);
}
async function handleSearchParamsChange(searchParams: URLSearchParams) {
const { query, videoSource, pageNum } = getApiParams(searchParams);
setAll(query, pageNum, videoSource);
loadVideos(query, pageNum, videoSource);
}
function handleFilterRemove() {
clearVideoSourceFilter();
resetCurrentPage();
goto(`/${ToQuery($appStateStore)}`);
}
async function handleResetVideo(id: number) {
try {
const result = await api.resetVideo(id);
const data = result.data;
if (data.resetted) {
toast.success('重置成功', {
description: `视频「${data.video.name}」已重置`
});
const { query, currentPage, videoSource } = $appStateStore;
await loadVideos(query, currentPage, videoSource);
} else {
toast.info('重置无效', {
description: `视频「${data.video.name}」没有失败的状态,无需重置`
});
// 启动系统信息流
function startSysInfoStream() {
sysInfoEventSource = api.createSysInfoStream(
(data) => {
sysInfo = data;
},
(error) => {
console.error('系统信息流错误:', error);
toast.error('系统信息流出现错误,请稍后重试');
}
} catch (error) {
console.error('重置失败:', error);
toast.error('重置失败', {
description: (error as ApiError).message
});
);
}
// 停止系统信息流
function stopSysInfoStream() {
if (sysInfoEventSource) {
sysInfoEventSource.close();
sysInfoEventSource = null;
}
}
async function handleResetAllVideos() {
resettingAll = true;
try {
const result = await api.resetAllVideos();
const data = result.data;
if (data.resetted) {
toast.success('重置成功', {
description: `已重置 ${data.resetted_videos_count} 个视频和 ${data.resetted_pages_count} 个分页`
});
const { query, currentPage, videoSource } = $appStateStore;
await loadVideos(query, currentPage, videoSource);
} else {
toast.info('没有需要重置的视频');
}
} catch (error) {
console.error('重置失败:', error);
toast.error('重置失败', {
description: (error as ApiError).message
});
} finally {
resettingAll = false;
resetAllDialogOpen = false;
}
}
$: if ($page.url.search !== lastSearch) {
lastSearch = $page.url.search;
handleSearchParamsChange($page.url.searchParams);
}
onMount(async () => {
setBreadcrumb([
{
label: '主页',
isActive: true
}
]);
onMount(() => {
setBreadcrumb([{ label: '仪表盘' }]);
loadDashboard();
startSysInfoStream();
return () => {
stopSysInfoStream();
};
});
$: totalPages = videosData ? Math.ceil(videosData.total_count / pageSize) : 0;
$: filterContent = $appStateStore.videoSource
? getFilterContent($appStateStore.videoSource.type, $appStateStore.videoSource.id)
: { title: '', name: '' };
// 图表配置
const videoChartConfig = {
videos: {
label: '视频数量',
color: 'var(--chart-1)'
}
} satisfies Chart.ChartConfig;
const memoryChartConfig = {
used: {
label: '整体占用',
color: 'var(--chart-1)'
},
process: {
label: '程序占用',
color: 'var(--chart-2)'
}
} satisfies Chart.ChartConfig;
const cpuChartConfig = {
used: {
label: '整体占用',
color: 'var(--chart-1)'
},
process: {
label: '程序占用',
color: 'var(--chart-2)'
}
} satisfies Chart.ChartConfig;
let memoryHistory: Array<{ time: Date; used: number; process: number }> = [];
let cpuHistory: Array<{ time: Date; used: number; process: number }> = [];
$: if (sysInfo) {
memoryHistory = [
...memoryHistory.slice(-19),
{
time: new Date(),
used: sysInfo.used_memory,
process: sysInfo.process_memory
}
];
cpuHistory = [
...cpuHistory.slice(-19),
{
time: new Date(),
used: sysInfo.used_cpu,
process: sysInfo.process_cpu
}
];
}
// 计算磁盘使用率
$: diskUsagePercent = sysInfo
? ((sysInfo.total_disk - sysInfo.available_disk) / sysInfo.total_disk) * 100
: 0;
</script>
<svelte:head>
<title>主页 - Bili Sync</title>
<title>仪表盘 - Bili Sync</title>
<style>
body {
/* 避免最右侧 tooltip 溢出导致的无限抖动 */
overflow-x: hidden;
}
</style>
</svelte:head>
<FilterBadge
filterTitle={filterContent.title}
filterName={filterContent.name}
onRemove={handleFilterRemove}
/>
<!-- 统计信息 -->
{#if videosData}
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="text-muted-foreground text-sm">
{videosData.total_count} 个视频
</div>
<div class="text-muted-foreground text-sm">
{totalPages}
</div>
<div class="space-y-6">
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="text-muted-foreground">加载中...</div>
</div>
<div class="flex items-center gap-2">
<Button
size="sm"
variant="outline"
class="cursor-pointer text-xs"
onclick={() => (resetAllDialogOpen = true)}
disabled={resettingAll || loading}
>
<RotateCcwIcon class="mr-1.5 h-3 w-3 {resettingAll ? 'animate-spin' : ''}" />
重置所有视频
</Button>
{:else}
<div class="grid gap-4 md:grid-cols-3">
<Card class="md:col-span-1">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">存储空间</CardTitle>
<HardDriveIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if sysInfo}
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="text-2xl font-bold">{formatBytes(sysInfo.available_disk)} 可用</div>
<div class="text-muted-foreground text-sm">
{formatBytes(sysInfo.total_disk)}
</div>
</div>
<Progress value={diskUsagePercent} class="h-2" />
<div class="text-muted-foreground text-xs">
已使用 {diskUsagePercent.toFixed(1)}% 的存储空间
</div>
</div>
{:else}
<div class="text-muted-foreground text-sm">加载中...</div>
{/if}
</CardContent>
</Card>
<Card class="md:col-span-2">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">当前监听</CardTitle>
<DatabaseIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if dashboardData}
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<HeartIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">收藏夹</span>
</div>
<Badge variant="outline">{dashboardData.enabled_favorites}</Badge>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<FolderIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">合集</span>
</div>
<Badge variant="outline">{dashboardData.enabled_collections}</Badge>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UserIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">投稿</span>
</div>
<Badge variant="outline">{dashboardData.enabled_submissions}</Badge>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<ClockIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">稍后再看</span>
</div>
<Badge variant="outline">
{dashboardData.enable_watch_later ? '启用' : '禁用'}
</Badge>
</div>
</div>
{:else}
<div class="text-muted-foreground text-sm">加载中...</div>
{/if}
</CardContent>
</Card>
</div>
</div>
{/if}
<!-- 视频卡片网格 -->
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="text-muted-foreground">加载中...</div>
</div>
{:else if videosData?.videos.length}
<div
style="display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; width: 100%; max-width: none; justify-items: start;"
>
{#each videosData.videos as video (video.id)}
<div style="max-width: 400px; width: 100%;">
<VideoCard
{video}
onReset={async () => {
await handleResetVideo(video.id);
}}
/>
</div>
{/each}
</div>
<!-- 翻页组件 -->
<Pagination
currentPage={$appStateStore.currentPage}
{totalPages}
onPageChange={handlePageChange}
/>
{:else}
<div class="flex items-center justify-center py-12">
<div class="space-y-2 text-center">
<p class="text-muted-foreground">暂无视频数据</p>
<p class="text-muted-foreground text-sm">尝试搜索或检查视频来源配置</p>
<div class="grid grid-cols-1 gap-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">最近入库</CardTitle>
<VideoIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if dashboardData && dashboardData.videos_by_day.length > 0}
<div class="mb-4 space-y-2">
<div class="flex items-center justify-between text-sm">
<span>近七日共新增视频</span>
<span class="font-medium"
>{dashboardData.videos_by_day.reduce((sum, v) => sum + v.cnt, 0)}</span
>
</div>
</div>
<Chart.Container config={videoChartConfig} class="h-[200px] w-full">
<BarChart
data={dashboardData.videos_by_day}
x="day"
axis="x"
series={[
{
key: 'cnt',
label: '新增视频',
color: videoChartConfig.videos.color
}
]}
props={{
bars: {
stroke: 'none',
rounded: 'all',
radius: 8,
initialHeight: 0
},
highlight: { area: { fill: 'none' } },
xAxis: { format: () => '' }
}}
>
{#snippet tooltip()}
<MyChartTooltip indicator="line" />
{/snippet}
</BarChart>
</Chart.Container>
{:else}
<div class="text-muted-foreground flex h-[300px] items-center justify-center text-sm">
暂无视频统计数据
</div>
{/if}</CardContent
>
</Card>
</div>
</div>
{/if}
<!-- 重置所有视频确认对话框 -->
<AlertDialog.Root bind:open={resetAllDialogOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>重置所有视频</AlertDialog.Title>
<AlertDialog.Description>
此操作将重置所有视频和分页的失败状态为未下载状态,使它们在下次下载任务中重新尝试。
<br />
<strong class="text-destructive">此操作不可撤销,确定要继续吗?</strong>
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel disabled={resettingAll}>取消</AlertDialog.Cancel>
<AlertDialog.Action
onclick={handleResetAllVideos}
disabled={resettingAll}
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{#if resettingAll}
<RotateCcwIcon class="mr-2 h-4 w-4 animate-spin" />
重置中...
{:else}
确认重置
{/if}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- 第三行:系统监控 -->
<div class="grid gap-4 md:grid-cols-2">
<!-- 内存使用情况 -->
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">内存使用情况</CardTitle>
<MemoryStickIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if sysInfo}
<div class="mb-4 space-y-2">
<div class="flex items-center justify-between text-sm">
<span>当前内存使用</span>
<span class="font-medium"
>{formatBytes(sysInfo.used_memory)} / {formatBytes(sysInfo.total_memory)}</span
>
</div>
</div>
{/if}
{#if memoryHistory.length > 0}
<Chart.Container config={memoryChartConfig} class="h-[150px] w-full">
<AreaChart
data={memoryHistory}
x="time"
axis="x"
series={[
{
key: 'used',
label: memoryChartConfig.used.label,
color: memoryChartConfig.used.color
},
{
key: 'process',
label: memoryChartConfig.process.label,
color: memoryChartConfig.process.color
}
]}
props={{
area: {
curve: curveNatural,
line: { class: 'stroke-1' },
'fill-opacity': 0.4
},
xAxis: {
format: () => ''
}
}}
>
{#snippet tooltip()}
<MyChartTooltip
labelFormatter={(v: Date) => {
return new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
}).format(v);
}}
valueFormatter={(v: number) => formatBytes(v)}
indicator="line"
/>
{/snippet}
</AreaChart>
</Chart.Container>
{:else}
<div class="text-muted-foreground flex h-[200px] items-center justify-center text-sm">
等待数据...
</div>
{/if}
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">CPU 使用情况</CardTitle>
<CpuIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if sysInfo}
<div class="mb-4 space-y-2">
<div class="flex items-center justify-between text-sm">
<span>当前 CPU 使用率</span>
<span class="font-medium">{formatCpu(sysInfo.used_cpu)}</span>
</div>
</div>
{/if}
{#if cpuHistory.length > 0}
<Chart.Container config={cpuChartConfig} class="h-[150px] w-full">
<AreaChart
data={cpuHistory}
x="time"
axis="x"
series={[
{
key: 'used',
label: cpuChartConfig.used.label,
color: cpuChartConfig.used.color
},
{
key: 'process',
label: cpuChartConfig.process.label,
color: cpuChartConfig.process.color
}
]}
props={{
area: {
curve: curveNatural,
line: { class: 'stroke-1' },
'fill-opacity': 0.4
},
xAxis: {
format: () => ''
}
}}
>
{#snippet tooltip()}
<MyChartTooltip
labelFormatter={(v: Date) => {
return new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
}).format(v);
}}
valueFormatter={(v: number) => formatCpu(v)}
indicator="line"
/>
{/snippet}
</AreaChart>
</Chart.Container>
{:else}
<div class="text-muted-foreground flex h-[150px] items-center justify-center text-sm">
等待数据...
</div>
{/if}
</CardContent>
</Card>
</div>
{/if}
</div>

View File

@@ -1,425 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { Progress } from '$lib/components/ui/progress/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Chart from '$lib/components/ui/chart/index.js';
import MyChartTooltip from '$lib/components/custom/my-chart-tooltip.svelte';
import { curveNatural } from 'd3-shape';
import { BarChart, AreaChart } from 'layerchart';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import { toast } from 'svelte-sonner';
import api from '$lib/api';
import type { DashBoardResponse, SysInfoResponse, ApiError } from '$lib/types';
import DatabaseIcon from '@lucide/svelte/icons/database';
import HeartIcon from '@lucide/svelte/icons/heart';
import FolderIcon from '@lucide/svelte/icons/folder';
import UserIcon from '@lucide/svelte/icons/user';
import ClockIcon from '@lucide/svelte/icons/clock';
import VideoIcon from '@lucide/svelte/icons/video';
import HardDriveIcon from '@lucide/svelte/icons/hard-drive';
import CpuIcon from '@lucide/svelte/icons/cpu';
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
let dashboardData: DashBoardResponse | null = null;
let sysInfo: SysInfoResponse | null = null;
let loading = false;
let sysInfoEventSource: EventSource | null = null;
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatCpu(cpu: number): string {
return `${cpu.toFixed(1)}%`;
}
async function loadDashboard() {
loading = true;
try {
const response = await api.getDashboard();
dashboardData = response.data;
} catch (error) {
console.error('加载仪表盘数据失败:', error);
toast.error('加载仪表盘数据失败', {
description: (error as ApiError).message
});
} finally {
loading = false;
}
}
// 启动系统信息流
function startSysInfoStream() {
try {
sysInfoEventSource = api.createSysInfoStream(
(data) => {
sysInfo = data;
},
(_error) => {
toast.error('系统信息流异常中断');
}
);
} catch (error) {
console.error('启动系统信息流失败:', error);
}
}
// 停止系统信息流
function stopSysInfoStream() {
if (sysInfoEventSource) {
sysInfoEventSource.close();
sysInfoEventSource = null;
}
}
onMount(() => {
setBreadcrumb([{ label: '仪表盘', isActive: true }]);
loadDashboard();
startSysInfoStream();
});
onDestroy(() => {
stopSysInfoStream();
});
// 图表配置
const videoChartConfig = {
videos: {
label: '视频数量',
color: 'var(--chart-1)'
}
} satisfies Chart.ChartConfig;
const memoryChartConfig = {
used: {
label: '整体占用',
color: 'var(--chart-1)'
},
process: {
label: '程序占用',
color: 'var(--chart-2)'
}
} satisfies Chart.ChartConfig;
const cpuChartConfig = {
used: {
label: '整体占用',
color: 'var(--chart-1)'
},
process: {
label: '程序占用',
color: 'var(--chart-2)'
}
} satisfies Chart.ChartConfig;
// 内存和 CPU 数据历史记录
let memoryHistory: Array<{ time: Date; used: number; process: number }> = [];
let cpuHistory: Array<{ time: Date; used: number; process: number }> = [];
// 更新历史数据
$: if (sysInfo) {
memoryHistory = [
...memoryHistory.slice(-19),
{
time: new Date(),
used: sysInfo.used_memory,
process: sysInfo.process_memory
}
];
cpuHistory = [
...cpuHistory.slice(-19),
{
time: new Date(),
used: sysInfo.used_cpu,
process: sysInfo.process_cpu
}
];
}
// 计算磁盘使用率
$: diskUsagePercent = sysInfo
? ((sysInfo.total_disk - sysInfo.available_disk) / sysInfo.total_disk) * 100
: 0;
</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}
<div class="grid gap-4 md:grid-cols-3">
<!-- 存储空间卡片 -->
<Card class="md:col-span-1">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">存储空间</CardTitle>
<HardDriveIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if sysInfo}
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="text-2xl font-bold">{formatBytes(sysInfo.available_disk)} 可用</div>
<div class="text-muted-foreground text-sm">
{formatBytes(sysInfo.total_disk)}
</div>
</div>
<Progress value={diskUsagePercent} class="h-2" />
<div class="text-muted-foreground text-xs">
已使用 {diskUsagePercent.toFixed(1)}% 的存储空间
</div>
</div>
{:else}
<div class="text-muted-foreground text-sm">加载中...</div>
{/if}
</CardContent>
</Card>
<Card class="md:col-span-2">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">当前监听</CardTitle>
<DatabaseIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if dashboardData}
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<HeartIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">收藏夹</span>
</div>
<Badge variant="outline">{dashboardData.enabled_favorites}</Badge>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<FolderIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">合集</span>
</div>
<Badge variant="outline">{dashboardData.enabled_collections}</Badge>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UserIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">投稿</span>
</div>
<Badge variant="outline">{dashboardData.enabled_submissions}</Badge>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<ClockIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">稍后再看</span>
</div>
<Badge variant="outline">
{dashboardData.enable_watch_later ? '启用' : '禁用'}
</Badge>
</div>
</div>
{:else}
<div class="text-muted-foreground text-sm">加载中...</div>
{/if}
</CardContent>
</Card>
</div>
<div class="grid grid-cols-1 gap-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">最近入库</CardTitle>
<VideoIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if dashboardData && dashboardData.videos_by_day.length > 0}
<div class="mb-4 space-y-2">
<div class="flex items-center justify-between text-sm">
<span>近七日共新增视频</span>
<span class="font-medium"
>{dashboardData.videos_by_day.reduce((sum, v) => sum + v.cnt, 0)}</span
>
</div>
</div>
<Chart.Container config={videoChartConfig} class="h-[200px] w-full">
<BarChart
data={dashboardData.videos_by_day}
x="day"
axis="x"
series={[
{
key: 'cnt',
label: '新增视频',
color: videoChartConfig.videos.color
}
]}
props={{
bars: {
stroke: 'none',
rounded: 'all',
radius: 8,
initialHeight: 0
},
highlight: { area: { fill: 'none' } },
xAxis: { format: () => '' }
}}
>
{#snippet tooltip()}
<MyChartTooltip indicator="line" />
{/snippet}
</BarChart>
</Chart.Container>
{:else}
<div class="text-muted-foreground flex h-[300px] items-center justify-center text-sm">
暂无视频统计数据
</div>
{/if}</CardContent
>
</Card>
</div>
<!-- 第三行:系统监控 -->
<div class="grid gap-4 md:grid-cols-2">
<!-- 内存使用情况 -->
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">内存使用情况</CardTitle>
<MemoryStickIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if sysInfo}
<div class="mb-4 space-y-2">
<div class="flex items-center justify-between text-sm">
<span>当前内存使用</span>
<span class="font-medium"
>{formatBytes(sysInfo.used_memory)} / {formatBytes(sysInfo.total_memory)}</span
>
</div>
</div>
{/if}
{#if memoryHistory.length > 0}
<Chart.Container config={memoryChartConfig} class="h-[150px] w-full">
<AreaChart
data={memoryHistory}
x="time"
axis="x"
series={[
{
key: 'used',
label: memoryChartConfig.used.label,
color: memoryChartConfig.used.color
},
{
key: 'process',
label: memoryChartConfig.process.label,
color: memoryChartConfig.process.color
}
]}
props={{
area: {
curve: curveNatural,
line: { class: 'stroke-1' },
'fill-opacity': 0.4
},
xAxis: {
format: () => ''
}
}}
>
{#snippet tooltip()}
<MyChartTooltip
labelFormatter={(v: Date) => {
return new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
}).format(v);
}}
valueFormatter={(v: number) => formatBytes(v)}
indicator="line"
/>
{/snippet}
</AreaChart>
</Chart.Container>
{:else}
<div class="text-muted-foreground flex h-[200px] items-center justify-center text-sm">
等待数据...
</div>
{/if}
</CardContent>
</Card>
<!-- CPU 使用情况 -->
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">CPU 使用情况</CardTitle>
<CpuIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if sysInfo}
<div class="mb-4 space-y-2">
<div class="flex items-center justify-between text-sm">
<span>当前 CPU 使用率</span>
<span class="font-medium">{formatCpu(sysInfo.used_cpu)}</span>
</div>
</div>
{/if}
{#if cpuHistory.length > 0}
<Chart.Container config={cpuChartConfig} class="h-[150px] w-full">
<AreaChart
data={cpuHistory}
x="time"
axis="x"
series={[
{
key: 'used',
label: cpuChartConfig.used.label,
color: cpuChartConfig.used.color
},
{
key: 'process',
label: cpuChartConfig.process.label,
color: cpuChartConfig.process.color
}
]}
props={{
area: {
curve: curveNatural,
line: { class: 'stroke-1' },
'fill-opacity': 0.4
},
xAxis: {
format: () => ''
}
}}
>
{#snippet tooltip()}
<MyChartTooltip
labelFormatter={(v: Date) => {
return new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
}).format(v);
}}
valueFormatter={(v: number) => formatCpu(v)}
indicator="line"
/>
{/snippet}
</AreaChart>
</Chart.Container>
{:else}
<div class="text-muted-foreground flex h-[150px] items-center justify-center text-sm">
等待数据...
</div>
{/if}
</CardContent>
</Card>
</div>
{/if}
</div>

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import api from '$lib/api';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import { onMount } from 'svelte';
import { Badge } from '$lib/components/ui/badge';
import { toast } from 'svelte-sonner';
let logEventSource: EventSource | null = null;
let logs: Array<{ timestamp: string; level: string; message: string }> = [];
let shouldAutoScroll = true;
function checkScrollPosition() {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
shouldAutoScroll = scrollTop + clientHeight >= scrollHeight - 5;
}
function scrollToBottom() {
if (shouldAutoScroll) {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' });
}
}
function startLogStream() {
if (logEventSource) {
logEventSource.close();
}
logEventSource = api.createLogStream(
(data: string) => {
logs = [...logs.slice(-200), JSON.parse(data)];
setTimeout(scrollToBottom, 0);
},
(error: Event) => {
console.error('日志流错误:', error);
toast.error('日志流出现错误,请稍后重试');
}
);
}
function stopLogStream() {
if (logEventSource) {
logEventSource.close();
logEventSource = null;
}
}
onMount(() => {
setBreadcrumb([{ label: '日志' }]);
window.addEventListener('scroll', checkScrollPosition);
startLogStream();
return () => {
stopLogStream();
window.removeEventListener('scroll', checkScrollPosition);
};
});
function getLevelColor(level: string) {
switch (level) {
case 'ERROR':
return 'text-red-600';
case 'WARN':
return 'text-yellow-600';
case 'INFO':
default:
return 'text-green-600';
}
}
</script>
<svelte:head>
<title>日志 - Bili Sync</title>
</svelte:head>
<div class="space-y-1">
{#each logs as log, index (index)}
<div
class="flex items-center gap-3 rounded-md p-1 font-mono text-xs {index % 2 === 0
? 'bg-muted/50'
: 'bg-background'}"
>
<span class="text-muted-foreground w-32 shrink-0">
{log.timestamp}
</span>
<Badge
class="w-16 shrink-0 justify-center {getLevelColor(log.level)} bg-primary/90 font-semibold"
>
{log.level}
</Badge>
<span class="flex-1 break-all">
{log.message}
</span>
</div>
{/each}
{#if logs.length === 0}
<div class="text-muted-foreground py-8 text-center">暂无日志记录</div>
{/if}
</div>

View File

@@ -1,11 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import SubscriptionCard from '$lib/components/subscription-card.svelte';
import Pagination from '$lib/components/pagination.svelte';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import { appStateStore, ToQuery } from '$lib/stores/filter';
import api from '$lib/api';
import type { CollectionWithSubscriptionStatus, ApiError } from '$lib/types';
@@ -45,14 +43,7 @@
onMount(async () => {
setBreadcrumb([
{
label: '主页',
onClick: () => {
goto(`/${ToQuery($appStateStore)}`);
}
},
{
label: '关注的合集',
isActive: true
label: '我关注的合集'
}
]);
await loadCollections();
@@ -67,7 +58,7 @@
<div>
<div class="mb-6 flex items-center justify-between">
<div class="text-muted-foreground text-sm">
<div class=" text-sm">
{#if !loading}
{totalCount} 个合集
{/if}

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import SubscriptionCard from '$lib/components/subscription-card.svelte';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import { appStateStore, ToQuery } from '$lib/stores/filter';
import api from '$lib/api';
import type { FavoriteWithSubscriptionStatus, ApiError } from '$lib/types';
@@ -32,15 +32,7 @@
}
onMount(async () => {
setBreadcrumb([
{
label: '主页',
onClick: () => {
goto(`/${ToQuery($appStateStore)}`);
}
},
{ label: '我的收藏夹', isActive: true }
]);
setBreadcrumb([{ label: '我创建的收藏夹' }]);
await loadFavorites();
});
@@ -52,7 +44,7 @@
<div>
<div class="mb-6 flex items-center justify-between">
<div class="text-muted-foreground text-sm">
<div class="text-sm">
{#if !loading}
{favorites.length} 个收藏夹
{/if}

View File

@@ -1,11 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import SubscriptionCard from '$lib/components/subscription-card.svelte';
import Pagination from '$lib/components/pagination.svelte';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import { appStateStore, ToQuery } from '$lib/stores/filter';
import api from '$lib/api';
import type { UpperWithSubscriptionStatus, ApiError } from '$lib/types';
@@ -43,16 +41,7 @@
}
onMount(async () => {
setBreadcrumb([
{
label: '主页',
onClick: () => {
goto(`/${ToQuery($appStateStore)}`);
}
},
{ label: '关注的UP主', isActive: true }
]);
setBreadcrumb([{ label: '我关注的 UP 主' }]);
await loadUppers();
});
@@ -65,9 +54,9 @@
<div>
<div class="mb-6 flex items-center justify-between">
<div class="text-muted-foreground text-sm">
<div class=" text-sm">
{#if !loading}
{totalCount} 个UP主
{totalCount} UP
{/if}
</div>
</div>

View File

@@ -10,8 +10,6 @@
import api from '$lib/api';
import { toast } from 'svelte-sonner';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import { goto } from '$app/navigation';
import { appStateStore, ToQuery } from '$lib/stores/filter';
import type { Config, ApiError } from '$lib/types';
let frontendToken = ''; // 前端认证token
@@ -45,8 +43,8 @@
try {
api.setAuthToken(frontendToken.trim());
localStorage.setItem('authToken', frontendToken.trim());
loadConfig();
toast.success('前端认证成功');
loadConfig(); // 认证成功后加载配置
} catch (error) {
console.error('前端认证失败:', error);
toast.error('认证失败请检查Token是否正确');
@@ -75,15 +73,7 @@
}
onMount(() => {
setBreadcrumb([
{
label: '主页',
onClick: () => {
goto(`/${ToQuery($appStateStore)}`);
}
},
{ label: '设置', isActive: true }
]);
setBreadcrumb([{ label: '设置' }]);
const savedToken = localStorage.getItem('authToken');
if (savedToken) {

View File

@@ -17,8 +17,6 @@
import PlusIcon from '@lucide/svelte/icons/plus';
import { toast } from 'svelte-sonner';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import { goto } from '$app/navigation';
import { appStateStore, ToQuery } from '$lib/stores/filter';
import type { ApiError, VideoSourceDetail, VideoSourcesDetailsResponse } from '$lib/types';
import api from '$lib/api';
@@ -200,15 +198,7 @@
// 初始化
onMount(() => {
setBreadcrumb([
{
label: '主页',
onClick: () => {
goto(`/${ToQuery($appStateStore)}`);
}
},
{ label: '视频源管理', isActive: true }
]);
setBreadcrumb([{ label: '视频源' }]);
loadVideoSources();
});
</script>
@@ -236,7 +226,7 @@
{@const sources = getSourcesForTab(key)}
<Tabs.Content value={key} class="mt-6">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-medium">{config.label}管理</h3>
<div></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" />

View File

@@ -46,12 +46,10 @@
onMount(() => {
setBreadcrumb([
{
label: '主页',
onClick: () => {
goto(`/${ToQuery($appStateStore)}`);
}
label: '视频',
href: `/${ToQuery($appStateStore)}`
},
{ label: '视频详情', isActive: true }
{ label: '视频详情' }
]);
});
@@ -149,8 +147,6 @@
}}
mode="detail"
showActions={false}
progressHeight="h-3"
gap="gap-2"
taskNames={['视频封面', '视频信息', 'UP主头像', 'UP主信息', '分P下载']}
bind:resetDialogOpen
bind:resetting

View File

@@ -0,0 +1,294 @@
<script lang="ts">
import VideoCard from '$lib/components/video-card.svelte';
import Pagination from '$lib/components/pagination.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import api from '$lib/api';
import type { VideosResponse, VideoSourcesResponse, ApiError, VideoSource } from '$lib/types';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { VIDEO_SOURCES } from '$lib/consts';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import {
appStateStore,
resetCurrentPage,
setAll,
setCurrentPage,
setQuery,
ToQuery
} from '$lib/stores/filter';
import { toast } from 'svelte-sonner';
import DropdownFilter, { type Filter } from '$lib/components/dropdown-filter.svelte';
import SearchBar from '$lib/components/search-bar.svelte';
const pageSize = 20;
let videosData: VideosResponse | null = null;
let loading = false;
let lastSearch: string | null = null;
let resetAllDialogOpen = false;
let resettingAll = false;
let videoSources: VideoSourcesResponse | null = null;
let filters: Record<string, Filter> | null = null;
function getApiParams(searchParams: URLSearchParams) {
let videoSource = null;
for (const source of Object.values(VIDEO_SOURCES)) {
const value = searchParams.get(source.type);
if (value) {
videoSource = { type: source.type, id: value };
}
}
return {
query: searchParams.get('query') || '',
videoSource,
pageNum: parseInt(searchParams.get('page') || '0')
};
}
async function loadVideos(
query: string,
pageNum: number = 0,
filter?: { type: string; id: string } | null
) {
loading = true;
try {
const params: Record<string, string | number> = {
page: pageNum,
page_size: pageSize
};
if (query) {
params.query = query;
}
if (filter) {
params[filter.type] = parseInt(filter.id);
}
const result = await api.getVideos(params);
videosData = result.data;
} catch (error) {
console.error('加载视频失败:', error);
toast.error('加载视频失败', {
description: (error as ApiError).message
});
} finally {
loading = false;
}
}
async function handlePageChange(pageNum: number) {
setCurrentPage(pageNum);
goto(`/${ToQuery($appStateStore)}`);
}
async function handleSearchParamsChange(searchParams: URLSearchParams) {
const { query, videoSource, pageNum } = getApiParams(searchParams);
setAll(query, pageNum, videoSource);
loadVideos(query, pageNum, videoSource);
}
async function handleResetVideo(id: number) {
try {
const result = await api.resetVideo(id);
const data = result.data;
if (data.resetted) {
toast.success('重置成功', {
description: `视频「${data.video.name}」已重置`
});
const { query, currentPage, videoSource } = $appStateStore;
await loadVideos(query, currentPage, videoSource);
} else {
toast.info('重置无效', {
description: `视频「${data.video.name}」没有失败的状态,无需重置`
});
}
} catch (error) {
console.error('重置失败:', error);
toast.error('重置失败', {
description: (error as ApiError).message
});
}
}
async function handleResetAllVideos() {
resettingAll = true;
try {
const result = await api.resetAllVideos();
const data = result.data;
if (data.resetted) {
toast.success('重置成功', {
description: `已重置 ${data.resetted_videos_count} 个视频和 ${data.resetted_pages_count} 个分页`
});
const { query, currentPage, videoSource } = $appStateStore;
await loadVideos(query, currentPage, videoSource);
} else {
toast.info('没有需要重置的视频');
}
} catch (error) {
console.error('重置失败:', error);
toast.error('重置失败', {
description: (error as ApiError).message
});
} finally {
resettingAll = false;
resetAllDialogOpen = false;
}
}
$: if ($page.url.search !== lastSearch) {
lastSearch = $page.url.search;
handleSearchParamsChange($page.url.searchParams);
}
$: if (videoSources) {
filters = Object.fromEntries(
Object.values(VIDEO_SOURCES).map((source) => [
source.type,
{
name: source.title,
icon: source.icon,
values: Object.fromEntries(
(videoSources![source.type as keyof VideoSourcesResponse] as VideoSource[]).map(
(item) => [item.id, item.name]
)
)
}
])
);
} else {
filters = null;
}
onMount(async () => {
setBreadcrumb([
{
label: '视频'
}
]);
videoSources = (await api.getVideoSources()).data;
});
$: totalPages = videosData ? Math.ceil(videosData.total_count / pageSize) : 0;
</script>
<svelte:head>
<title>主页 - Bili Sync</title>
</svelte:head>
<div class="mb-4 flex items-center justify-between">
<SearchBar
placeholder="搜索标题.."
value={$appStateStore.query}
onSearch={(value) => {
setQuery(value);
resetCurrentPage();
goto(`/${ToQuery($appStateStore)}`);
}}
></SearchBar>
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">筛选视频源:</span>
<DropdownFilter
{filters}
selectedLabel={$appStateStore.videoSource}
onSelect={(type, id) => {
setAll('', 0, { type, id });
goto(`/${ToQuery($appStateStore)}`);
}}
onRemove={() => {
setAll('', 0, null);
goto(`/${ToQuery($appStateStore)}`);
}}
/>
</div>
</div>
{#if videosData}
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-6">
<div class=" text-sm font-medium">
{videosData.total_count} 个视频
</div>
<div class=" text-sm font-medium">
{totalPages}
</div>
</div>
<div class="flex items-center gap-2">
<Button
size="sm"
variant="outline"
class="hover:bg-accent hover:text-accent-foreground h-8 cursor-pointer text-xs font-medium"
onclick={() => (resetAllDialogOpen = true)}
disabled={resettingAll || loading}
>
<RotateCcwIcon class="mr-1.5 h-3 w-3 {resettingAll ? 'animate-spin' : ''}" />
重置所有
</Button>
</div>
</div>
{/if}
{#if loading}
<div class="flex items-center justify-center py-16">
<div class="text-muted-foreground/70 text-sm">加载中...</div>
</div>
{:else if videosData?.videos.length}
<div
class="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
{#each videosData.videos as video (video.id)}
<VideoCard
{video}
onReset={async () => {
await handleResetVideo(video.id);
}}
/>
{/each}
</div>
<!-- 翻页组件 -->
<Pagination
currentPage={$appStateStore.currentPage}
{totalPages}
onPageChange={handlePageChange}
/>
{:else}
<div class="flex items-center justify-center py-16">
<div class="space-y-3 text-center">
<p class="text-muted-foreground text-sm">暂无视频数据</p>
<p class="text-muted-foreground/70 text-xs">尝试搜索或检查视频来源配置</p>
</div>
</div>
{/if}
<!-- 重置所有视频确认对话框 -->
<AlertDialog.Root bind:open={resetAllDialogOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>重置所有视频</AlertDialog.Title>
<AlertDialog.Description>
此操作将重置所有视频和分页的失败状态为未下载状态,使它们在下次下载任务中重新尝试。
<br />
<strong class="text-destructive">此操作不可撤销,确定要继续吗?</strong>
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel disabled={resettingAll}>取消</AlertDialog.Cancel>
<AlertDialog.Action
onclick={handleResetAllVideos}
disabled={resettingAll}
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{#if resettingAll}
<RotateCcwIcon class="mr-2 h-4 w-4 animate-spin" />
重置中...
{:else}
确认重置
{/if}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>