chore: 换用更美观、现代的前端页面 (#348)

This commit is contained in:
ᴀᴍᴛᴏᴀᴇʀ
2025-06-01 13:42:10 +08:00
committed by GitHub
parent a574d005c3
commit c07e475fe6
127 changed files with 3886 additions and 1041 deletions

View File

@@ -1,9 +1,77 @@
<script lang="ts">
import '../app.css';
import { Toaster } from '$lib/components/ui/sonner';
let { children } = $props();
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, 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);
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;
</script>
<Toaster />
{@render children()}
<Sidebar.Provider>
<div class="flex min-h-screen w-full">
<div data-sidebar="sidebar">
<AppSidebar />
</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.Provider>

View File

@@ -1,2 +1,2 @@
export const ssr = false;
export const prerender = true;
export const prerender = true;

View File

@@ -1,185 +1,178 @@
<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 api from '$lib/api';
import type { VideosResponse, VideoSourcesResponse, ApiError } from '$lib/types';
import { onMount } from 'svelte';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import VideoItem from '$lib/components/VideoItem.svelte';
import { listVideos, getVideoSources } from '$lib/api';
import type { VideoInfo, VideoSourcesResponse } from '$lib/types';
import Header from '$lib/components/Header.svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { setVideoSources, videoSourceStore } from '$lib/stores/video-source';
import { VIDEO_SOURCES } from '$lib/consts';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import {
appStateStore,
clearVideoSourceFilter,
setQuery,
setVideoSourceFilter,
ToQuery
} from '$lib/stores/filter';
import { toast } from 'svelte-sonner';
// API Token 管理
let apiToken: string = localStorage.getItem('auth_token') || '';
function updateToken() {
localStorage.setItem('auth_token', apiToken);
}
// 定义分类列表
const categories: (keyof VideoSourcesResponse)[] = [
'collection',
'favorite',
'submission',
'watch_later'
];
let activeCategory: keyof VideoSourcesResponse = 'collection';
let searchQuery = '';
let videos: VideoInfo[] = [];
let total = 0;
let videosData: VideosResponse | null = null;
let loading = false;
let currentPage = 0;
const pageSize = 10;
const pageSize = 20;
let currentFilter: { type: string; id: string } | null = null;
let lastSearch: string | null = null;
// 视频列表模型及全局选中模型(只全局允许选中一个)
let videoListModels: VideoSourcesResponse = {
collection: [],
favorite: [],
submission: [],
watch_later: []
};
// 移除 per 分类选中,新增全局 selectedModel
let selectedModel: { category: keyof VideoSourcesResponse; id: number } | null = null;
// 控制侧边栏各分类的折叠状态true 为折叠
let collapse: { [key in keyof VideoSourcesResponse]?: boolean } = {
collection: false,
favorite: false,
submission: false,
watch_later: false
};
// 新增:定义 collapse 信号,用于让每个 VideoItem 收起详情
let videoCollapseSignal = false;
// 加载视频列表模型
async function fetchVideoListModels() {
videoListModels = await getVideoSources();
// 默认选中第一个有数据的模型
for (const key of categories) {
if (videoListModels[key]?.length) {
selectedModel = { category: key, id: videoListModels[key][0].id };
break;
// 从URL参数获取筛选条件
function getFilterFromURL(searchParams: URLSearchParams) {
for (const source of Object.values(VIDEO_SOURCES)) {
const value = searchParams.get(source.type);
if (value) {
return { type: source.type, id: value };
}
}
// 默认使用 activeCategory 对应的选中 id 加载视频
fetchVideos();
return null;
}
// 加载视频列表,根据当前 activeCategory 对应的 selectedModel 发起请求
async function fetchVideos() {
const params: any = {};
if (selectedModel && selectedModel.category === activeCategory) {
params[`${activeCategory}`] = selectedModel.id.toString();
}
if (searchQuery) params.query = searchQuery;
params.page_size = pageSize;
params.page = currentPage;
const listRes = await listVideos(params);
videos = listRes.videos;
total = listRes.total_count;
// 获取筛选项名称
function getFilterName(type: string, id: string): string {
const videoSources = $videoSourceStore;
if (!videoSources || !type || !id) return '';
const sources = videoSources[type as keyof VideoSourcesResponse];
const source = sources?.find((s) => s.id.toString() === id);
return source?.name || '';
}
onMount(fetchVideoListModels);
$: activeCategory, currentPage, searchQuery, fetchVideos();
function onSearch() {
currentPage = 0;
fetchVideos();
// 获取筛选项标题
function getFilterTitle(type: string): string {
const sourceConfig = Object.values(VIDEO_SOURCES).find((s) => s.type === type);
return sourceConfig?.title || '';
}
function prevPage() {
if (currentPage > 0) {
currentPage -= 1;
videoCollapseSignal = !videoCollapseSignal;
fetchVideos();
// 平滑滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' });
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;
currentPage = pageNum;
} catch (error) {
console.error('加载视频失败:', error);
toast.error('加载视频失败', {
description: (error as ApiError).message
});
} finally {
loading = false;
}
}
function nextPage() {
if ((currentPage + 1) * pageSize < total) {
currentPage += 1;
videoCollapseSignal = !videoCollapseSignal;
fetchVideos();
// 平滑滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
// 点击侧边栏项时更新 activeCategory 和全局选中模型 id
function selectModel(category: keyof VideoSourcesResponse, id: number) {
// 如果当前已选中的模型和点击的一致,则取消筛选
if (selectedModel && selectedModel.category === category && selectedModel.id === id) {
selectedModel = null;
async function handlePageChange(pageNum: number) {
const query = ToQuery($appStateStore);
if (query) {
goto(`/${query}&page=${pageNum}`);
} else {
selectedModel = { category, id };
goto(`/?page=${pageNum}`);
}
activeCategory = category;
currentPage = 0;
videoCollapseSignal = !videoCollapseSignal;
fetchVideos();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function handleSearchParamsChange() {
const query = $page.url.searchParams.get('query');
currentFilter = getFilterFromURL($page.url.searchParams);
setQuery(query || '');
if (currentFilter) {
setVideoSourceFilter(currentFilter.type, currentFilter.id);
} else {
clearVideoSourceFilter();
}
loadVideos(query || '', parseInt($page.url.searchParams.get('page') || '0'), currentFilter);
}
function handleFilterRemove() {
clearVideoSourceFilter();
goto(`/${ToQuery($appStateStore)}`);
}
$: if ($page.url.search !== lastSearch) {
lastSearch = $page.url.search;
handleSearchParamsChange();
}
onMount(async () => {
setBreadcrumb([
{
label: '主页',
isActive: true
}
]);
});
$: totalPages = videosData ? Math.ceil(videosData.total_count / pageSize) : 0;
$: filterTitle = currentFilter ? getFilterTitle(currentFilter.type) : '';
$: filterName = currentFilter ? getFilterName(currentFilter.type, currentFilter.id) : '';
</script>
<svelte:head>
<title>bili-sync 管理页</title>
<title>主页 - Bili Sync</title>
</svelte:head>
<Header>
<div class="flex">
<!-- 左侧侧边栏 -->
<aside class="w-1/4 border-r p-4">
<h2 class="mb-4 text-xl font-bold">视频来源</h2>
{#each categories as cat}
<div class="mb-4">
<!-- 点击标题切换折叠状态 -->
<button
class="w-full text-left font-semibold"
on:click={() => (collapse[cat] = !collapse[cat])}
>
{cat}
{collapse[cat] ? '▶' : '▼'}
</button>
{#if !collapse[cat]}
{#if videoListModels[cat]?.length}
<ul class="ml-4">
{#each videoListModels[cat] as model}
<li class="mb-1">
<button
class="w-full rounded px-2 py-1 text-left hover:bg-gray-100 {selectedModel &&
selectedModel.category === cat &&
selectedModel.id === model.id
? 'bg-gray-200'
: ''}"
on:click={() => selectModel(cat, model.id)}
>
{model.name}
</button>
</li>
{/each}
</ul>
{:else}
<p class="ml-4 text-gray-500">无数据</p>
{/if}
{/if}
</div>
{/each}
</aside>
<FilterBadge {filterTitle} {filterName} onRemove={handleFilterRemove} />
<!-- 主内容区域 -->
<main class="flex-1 p-4">
<div class="mb-4">
<Input placeholder="搜索视频..." bind:value={searchQuery} on:change={onSearch} />
</div>
<div>
{#each videos as video}
<VideoItem {video} collapseSignal={videoCollapseSignal} />
{/each}
</div>
<div class="mt-4 flex items-center justify-between">
<Button onclick={prevPage} disabled={currentPage === 0}>上一页</Button>
<span>{currentPage + 1} 页,共 {Math.ceil(total / pageSize)}</span>
<Button onclick={nextPage} disabled={(currentPage + 1) * pageSize >= total}>下一页</Button>
</div>
</main>
<!-- 统计信息 -->
{#if videosData}
<div class="mb-6 flex items-center justify-between">
<div class="text-muted-foreground text-sm">
{videosData.total_count} 个视频
</div>
<div class="text-muted-foreground text-sm">
{totalPages}
</div>
</div>
</Header>
{/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} />
</div>
{/each}
</div>
<!-- 翻页组件 -->
<Pagination {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>
</div>
{/if}

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { onMount } 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 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';
let apiToken = '';
let saving = false;
async function saveApiToken() {
if (!apiToken.trim()) {
toast.error('请输入有效的API Token');
return;
}
saving = true;
try {
api.setAuthToken(apiToken.trim());
toast.success('API Token 已保存');
} catch (error) {
console.error('保存API Token失败:', error);
toast.error('保存失败,请重试');
} finally {
saving = false;
}
}
onMount(() => {
setBreadcrumb([
{
label: '主页',
onClick: () => {
goto(`/${ToQuery($appStateStore)}`);
}
},
{ label: '设置', isActive: true }
]);
const savedToken = localStorage.getItem('authToken');
if (savedToken) {
apiToken = savedToken;
}
});
</script>
<svelte:head>
<title>设置 - Bili Sync</title>
</svelte:head>
<div class="max-w-4xl">
<div class="space-y-8">
<!-- API Token 配置 -->
<div class="border-border border-b pb-6">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="lg:col-span-1">
<Label class="text-base font-semibold">API Token</Label>
<p class="text-muted-foreground mt-1 text-sm">用于身份验证的API令牌</p>
</div>
<div class="space-y-4 lg:col-span-2">
<div class="space-y-2">
<Input
id="api-token"
type="password"
placeholder="请输入API Token"
bind:value={apiToken}
class="max-w-lg"
/>
<p class="text-muted-foreground text-xs">请确保令牌的安全性,不要与他人分享</p>
</div>
<Button onclick={saveApiToken} disabled={saving} size="sm">
{saving ? '保存中...' : '保存'}
</Button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,172 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { Button } from '$lib/components/ui/button/index.js';
import api from '$lib/api';
import type { ApiError, VideoResponse } from '$lib/types';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import { appStateStore, ToQuery } from '$lib/stores/filter';
import VideoCard from '$lib/components/video-card.svelte';
import { toast } from 'svelte-sonner';
let videoData: VideoResponse | null = null;
let loading = false;
let error: string | null = null;
let resetDialogOpen = false;
let resetting = false;
async function loadVideoDetail() {
const videoId = parseInt($page.params.id);
if (isNaN(videoId)) {
error = '无效的视频ID';
toast.error('无效的视频ID');
return;
}
loading = true;
error = null;
try {
const result = await api.getVideo(videoId);
videoData = result.data;
} catch (error) {
console.error('加载视频详情失败:', error);
toast.error('加载视频详情失败', {
description: (error as ApiError).message
});
} finally {
loading = false;
}
}
onMount(() => {
setBreadcrumb([
{
label: '主页',
onClick: () => {
goto(`/${ToQuery($appStateStore)}`);
}
},
{ label: '视频详情', isActive: true }
]);
});
// 监听路由参数变化
$: if ($page.params.id) {
loadVideoDetail();
}
</script>
<svelte:head>
<title>{videoData?.video.name || '视频详情'} - Bili Sync</title>
</svelte:head>
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="text-muted-foreground">加载中...</div>
</div>
{:else if error}
<div class="flex items-center justify-center py-12">
<div class="space-y-2 text-center">
<p class="text-destructive">{error}</p>
<button
class="text-muted-foreground hover:text-foreground text-sm transition-colors"
onclick={() => goto('/')}
>
返回首页
</button>
</div>
</div>
{:else if videoData}
<!-- 视频信息区域 -->
<section>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold">视频信息</h2>
<Button
size="sm"
variant="outline"
class="shrink-0"
onclick={() => (resetDialogOpen = true)}
disabled={resetting}
>
<RotateCcwIcon class="mr-2 h-4 w-4 {resetting ? 'animate-spin' : ''}" />
重置
</Button>
</div>
<div style="margin-bottom: 1rem;">
<VideoCard
video={{
id: videoData.video.id,
name: videoData.video.name,
upper_name: videoData.video.upper_name,
download_status: videoData.video.download_status
}}
mode="detail"
showActions={false}
progressHeight="h-3"
gap="gap-2"
taskNames={['视频封面', '视频信息', 'UP主头像', 'UP主信息', '分P下载']}
bind:resetDialogOpen
bind:resetting
onReset={async () => {
try {
const result = await api.resetVideo((videoData as VideoResponse).video.id);
if (result.data.resetted) {
await loadVideoDetail();
toast.success('重置成功');
}
} catch (error) {
console.error('重置失败:', error);
toast.error('重置失败', {
description: (error as ApiError).message
});
}
}}
/>
</div>
</section>
<section>
{#if videoData.pages && videoData.pages.length > 0}
<div>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold">分页列表</h2>
<div class="text-muted-foreground text-sm">
{videoData.pages.length} 个分页
</div>
</div>
<div
class="grid gap-4"
style="grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));"
>
{#each videoData.pages as pageInfo (pageInfo.id)}
<VideoCard
video={{
id: pageInfo.id,
name: `P${pageInfo.pid}: ${pageInfo.name}`,
upper_name: '',
download_status: pageInfo.download_status
}}
mode="page"
showActions={false}
customTitle="P{pageInfo.pid}: {pageInfo.name}"
customSubtitle=""
taskNames={['视频封面', '视频内容', '视频信息', '视频弹幕', '视频字幕']}
/>
{/each}
</div>
</div>
{:else}
<div class="py-12 text-center">
<div class="space-y-2">
<p class="text-muted-foreground">暂无分P数据</p>
<p class="text-muted-foreground text-sm">该视频可能为单P视频</p>
</div>
</div>
{/if}
</section>
{/if}

View File

@@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = false;