chore: 换用更美观、现代的前端页面 (#348)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const ssr = false;
|
||||
export const prerender = true;
|
||||
export const prerender = true;
|
||||
|
||||
@@ -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}
|
||||
|
||||
81
web/src/routes/settings/+page.svelte
Normal file
81
web/src/routes/settings/+page.svelte
Normal 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>
|
||||
172
web/src/routes/video/[id]/+page.svelte
Normal file
172
web/src/routes/video/[id]/+page.svelte
Normal 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}
|
||||
2
web/src/routes/video/[id]/+page.ts
Normal file
2
web/src/routes/video/[id]/+page.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
Reference in New Issue
Block a user