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

@@ -223,14 +223,22 @@ class ApiClient {
return this.get<DashBoardResponse>('/dashboard');
}
// 获取系统信息流SSE
async getSysInfoStream(): Promise<EventSource> {
createLogStream(
onMessage: (data: string) => void,
onError?: (error: Event) => void
): EventSource {
const token = localStorage.getItem('authToken');
const url = `/api/dashboard/sysinfo${token ? `?token=${encodeURIComponent(token)}` : ''}`;
return new EventSource(url);
const url = `/api/logs${token ? `?token=${encodeURIComponent(token)}` : ''}`;
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
onMessage(event.data);
};
if (onError) {
eventSource.onerror = onError;
}
return eventSource;
}
// 创建系统信息流的便捷方法
createSysInfoStream(
onMessage: (data: SysInfoResponse) => void,
onError?: (error: Event) => void
@@ -238,7 +246,6 @@ class ApiClient {
const token = localStorage.getItem('authToken');
const url = `/api/dashboard/sysinfo${token ? `?token=${encodeURIComponent(token)}` : ''}`;
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as SysInfoResponse;
@@ -247,11 +254,9 @@ class ApiClient {
console.error('Failed to parse SSE data:', error);
}
};
if (onError) {
eventSource.onerror = onError;
}
return eventSource;
}
}
@@ -282,11 +287,12 @@ const api = {
getConfig: () => apiClient.getConfig(),
updateConfig: (config: Config) => apiClient.updateConfig(config),
getDashboard: () => apiClient.getDashboard(),
getSysInfoStream: () => apiClient.getSysInfoStream(),
createSysInfoStream: (
onMessage: (data: SysInfoResponse) => void,
onError?: (error: Event) => void
) => apiClient.createSysInfoStream(onMessage, onError),
createLogStream: (onMessage: (data: string) => void, onError?: (error: Event) => void) =>
apiClient.createLogStream(onMessage, onError),
setAuthToken: (token: string) => apiClient.setAuthToken(token),
clearAuthToken: () => apiClient.clearAuthToken()
};

View File

@@ -1,226 +1,144 @@
<script lang="ts">
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import SettingsIcon from '@lucide/svelte/icons/settings';
import UserIcon from '@lucide/svelte/icons/user';
import DatabaseIcon from '@lucide/svelte/icons/database';
import FileVideoIcon from '@lucide/svelte/icons/file-video';
import BotIcon from '@lucide/svelte/icons/bot';
import ChartPieIcon from '@lucide/svelte/icons/chart-pie';
import HeartIcon from '@lucide/svelte/icons/heart';
import FolderIcon from '@lucide/svelte/icons/folder';
import DatabaseIcon from '@lucide/svelte/icons/database';
import UserIcon from '@lucide/svelte/icons/user';
import Settings2Icon from '@lucide/svelte/icons/settings-2';
import SquareTerminalIcon from '@lucide/svelte/icons/square-terminal';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js';
import {
appStateStore,
setVideoSourceFilter,
clearAll,
ToQuery,
resetCurrentPage
} from '$lib/stores/filter';
import type { ComponentProps } from 'svelte';
import { type VideoSourcesResponse } from '$lib/types';
import { VIDEO_SOURCES } from '$lib/consts';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import { goto } from '$app/navigation';
import { videoSourceStore } from '$lib/stores/video-source';
const sidebar = useSidebar();
const items = Object.values(VIDEO_SOURCES);
function handleSourceClick(sourceType: string, sourceId: number) {
setVideoSourceFilter({
type: sourceType,
id: sourceId.toString()
});
resetCurrentPage();
goto(`/${ToQuery($appStateStore)}`);
if (sidebar.isMobile) {
sidebar.setOpenMobile(false);
}
}
function handleLogoClick() {
clearAll();
goto('/');
if (sidebar.isMobile) {
sidebar.setOpenMobile(false);
}
}
let { ref = $bindable(null), ...restProps }: ComponentProps<typeof Sidebar.Root> = $props();
const data = {
header: {
title: 'Bili Sync',
subtitle: '后台管理系统',
icon: BotIcon,
href: '/'
},
navMain: [
{
category: '总览',
items: [
{
title: '仪表盘',
icon: ChartPieIcon,
href: '/'
},
{
title: '日志',
icon: SquareTerminalIcon,
href: '/logs'
}
]
},
{
category: '内容管理',
items: [
{
title: '视频',
icon: FileVideoIcon,
href: '/videos'
},
{
title: '视频源',
icon: DatabaseIcon,
href: '/video-sources'
}
]
},
{
category: '快捷订阅',
items: [
{
title: '收藏夹',
icon: HeartIcon,
href: '/me/favorites'
},
{
title: '合集',
icon: FolderIcon,
href: '/me/collections'
},
{
title: 'up 主',
icon: UserIcon,
href: '/me/uppers'
}
]
}
],
footer: [
{
title: '设置',
icon: Settings2Icon,
href: '/settings'
}
]
};
</script>
<Sidebar.Root class="border-border bg-background border-r">
<Sidebar.Header class="border-border flex h-[73px] items-center border-b">
<a
href="/"
class="flex w-full items-center gap-3 px-4 py-3 hover:cursor-pointer"
onclick={handleLogoClick}
>
<div class="flex h-8 w-8 items-center justify-center overflow-hidden rounded-lg">
<img src="/favicon.png" alt="Bili Sync" class="h-6 w-6" />
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">Bili Sync</span>
<span class="text-muted-foreground truncate text-xs">视频管理系统</span>
</div>
</a>
<Sidebar.Root bind:ref variant="inset" {...restProps}>
<Sidebar.Header>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton size="lg">
{#snippet child({ props })}
<a href={data.header.href} {...props}>
<div
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
>
<data.header.icon class="size-4" />
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{data.header.title}</span>
<span class="truncate text-xs">{data.header.subtitle}</span>
</div>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Header>
<Sidebar.Content class="flex flex-col px-2 py-3">
<div class="flex-1">
<Sidebar.Group>
<Sidebar.GroupLabel
class="text-muted-foreground mb-2 px-2 text-xs font-medium tracking-wider uppercase"
>
视频筛选
</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu class="space-y-1">
{#each items as item (item.type)}
<Collapsible.Root class="group/collapsible">
<Sidebar.MenuItem>
<Collapsible.Trigger class="w-full">
{#snippet child({ props })}
<Sidebar.MenuButton
{...props}
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 font-medium transition-all duration-200"
>
<div class="flex flex-1 items-center gap-3">
<item.icon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">{item.title}</span>
</div>
<ChevronRightIcon
class="text-muted-foreground h-3 w-3 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</Sidebar.MenuButton>
{/snippet}
</Collapsible.Trigger>
<Collapsible.Content class="mt-1">
<div class="border-border ml-5 space-y-0.5 border-l pl-2">
{#if $videoSourceStore}
{#if $videoSourceStore[item.type as keyof VideoSourcesResponse]?.length > 0}
{#each $videoSourceStore[item.type as keyof VideoSourcesResponse] as source (source.id)}
<Sidebar.MenuItem>
<button
class="text-foreground hover:bg-accent/50 w-full cursor-pointer rounded-md px-3 py-2 text-left text-sm transition-all duration-200"
onclick={() => handleSourceClick(item.type, source.id)}
>
<span class="block truncate">{source.name}</span>
</button>
</Sidebar.MenuItem>
{/each}
{:else}
<div class="text-muted-foreground px-3 py-2 text-sm">无数据</div>
{/if}
{:else}
<div class="text-muted-foreground px-3 py-2 text-sm">加载中...</div>
{/if}
</div>
</Collapsible.Content>
</Sidebar.MenuItem>
</Collapsible.Root>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
<Sidebar.Group>
<Sidebar.GroupLabel
class="text-muted-foreground mb-2 px-2 text-xs font-medium tracking-wider uppercase"
>
快捷订阅
</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu class="space-y-1">
<Sidebar.Content>
<Sidebar.Group>
{#each data.navMain as group (group.category)}
<Sidebar.GroupLabel class="h-10">{group.category}</Sidebar.GroupLabel>
<Sidebar.Menu>
{#each group.items as item (item.title)}
<Sidebar.MenuItem>
<Sidebar.MenuButton>
<a
href="/me/favorites"
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200"
onclick={() => {
if (sidebar.isMobile) {
sidebar.setOpenMobile(false);
}
}}
>
<HeartIcon class="text-muted-foreground h-4 w-4" />
<span>创建的收藏夹</span>
</a>
<Sidebar.MenuButton class="h-8">
{#snippet child({ props })}
<a href={item.href} {...props}>
<item.icon class="size-4" />
<span class="text-sm">{item.title}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton>
<a
href="/me/collections"
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200"
onclick={() => {
if (sidebar.isMobile) {
sidebar.setOpenMobile(false);
}
}}
>
<FolderIcon class="text-muted-foreground h-4 w-4" />
<span>关注的合集</span>
</a>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton>
<a
href="/me/uppers"
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200"
onclick={() => {
if (sidebar.isMobile) {
sidebar.setOpenMobile(false);
}
}}
>
<UserIcon class="text-muted-foreground h-4 w-4" />
<span>关注的 UP 主</span>
</a>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
</div>
<!-- 固定在底部的菜单选项 -->
<div class="border-border mt-auto border-t pt-4">
<Sidebar.Menu class="space-y-1">
<Sidebar.MenuItem>
<Sidebar.MenuButton>
<a
href="/video-sources"
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 font-medium transition-all duration-200"
onclick={() => {
if (sidebar.isMobile) {
sidebar.setOpenMobile(false);
}
}}
>
<div class="flex flex-1 items-center gap-3">
<DatabaseIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">视频源管理</span>
</div>
</a>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton>
<a
href="/settings"
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 font-medium transition-all duration-200"
onclick={() => {
if (sidebar.isMobile) {
sidebar.setOpenMobile(false);
}
}}
>
<div class="flex flex-1 items-center gap-3">
<SettingsIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">设置</span>
</div>
</a>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</div>
{/each}
</Sidebar.Menu>
{/each}
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer>
<Sidebar.Separator />
<Sidebar.Menu>
{#each data.footer as item (item.title)}
<Sidebar.MenuItem>
<Sidebar.MenuButton class="h-8">
{#snippet child({ props })}
<a href={item.href} {...props}>
<item.icon class="size-4" />
<span class="text-sm">{item.title}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.Footer>
</Sidebar.Root>

View File

@@ -4,30 +4,21 @@
export let items: Array<{
href?: string;
label: string;
isActive?: boolean;
onClick?: () => void;
}> = [{ href: '/', label: '主页' }];
</script>
<Breadcrumb.Root>
<Breadcrumb.List>
{#each items as item, index (item.label)}
<Breadcrumb.Item>
{#if item.isActive || (!item.href && !item.onClick)}
<Breadcrumb.Page>{item.label}</Breadcrumb.Page>
{:else if item.onClick}
<button
class="hover:text-foreground cursor-pointer transition-colors"
onclick={item.onClick}
>
{item.label}
</button>
{:else}
<Breadcrumb.Item class="hidden md:block">
{#if item.href}
<Breadcrumb.Link href={item.href}>{item.label}</Breadcrumb.Link>
{:else}
<Breadcrumb.Page>{item.label}</Breadcrumb.Page>
{/if}
</Breadcrumb.Item>
{#if index < items.length - 1}
<Breadcrumb.Separator />
<Breadcrumb.Separator class="hidden md:block" />
{/if}
{/each}
</Breadcrumb.List>

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import TrashIcon from '@lucide/svelte/icons/trash';
import { tick } from 'svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Command from '$lib/components/ui/command/index.js';
import { Button } from '$lib/components/ui/button/index.js';
export interface Filter {
name: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon: any;
values: Record<string, string>;
}
interface SelectedLabel {
type: string;
id: string;
}
interface Props {
filters: Record<string, Filter> | null;
selectedLabel: SelectedLabel | null;
onSelect?: (type: string, id: string) => void;
onRemove?: () => void;
}
let { filters, selectedLabel = $bindable(), onSelect, onRemove }: Props = $props();
let open = $state(false);
let triggerRef = $state<HTMLButtonElement>(null!);
// We want to refocus the trigger button when the user selects
// an item from the list so users can continue navigating the
// rest of the form with the keyboard.
function closeAndFocusTrigger() {
open = false;
tick().then(() => {
triggerRef.focus();
});
}
</script>
<div class="inline-flex items-center gap-1">
{#if filters}
<span class="bg-secondary text-secondary-foreground rounded-lg px-2 py-1 text-xs font-medium">
{#if selectedLabel && selectedLabel.type && selectedLabel.id}
{filters[selectedLabel.type]?.name || ''} : {filters[selectedLabel.type]!.values[
selectedLabel.id
] || ''}
{:else}
未应用
{/if}
</span>
{/if}
<DropdownMenu.Root bind:open>
<DropdownMenu.Trigger bind:ref={triggerRef}>
{#snippet child({ props })}
<Button variant="ghost" size="sm" {...props} class="h-6 w-6 p-0">
<EllipsisIcon class="h-3 w-3" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-[200px]" align="end">
<DropdownMenu.Group>
{#if filters}
{#each Object.entries(filters) as [key, filter] (key)}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>
<filter.icon class="mr-2 size-3" />
<span class="text-xs font-medium">
{filter.name}
</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="p-0">
<Command.Root
value={selectedLabel && selectedLabel.type === key ? selectedLabel.id : ''}
>
<Command.Input
class="text-xs"
autofocus
placeholder="查找{filter.name.toLowerCase()}..."
/>
<Command.List>
<Command.Empty class="text-xs"
>未找到"{filter.name.toLowerCase()}"</Command.Empty
>
<Command.Group>
{#each Object.entries(filter.values) as [id, name] (id)}
<Command.Item
value={id}
class="text-xs"
onSelect={() => {
closeAndFocusTrigger();
onSelect?.(key, id);
}}
>
{name}
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{/each}
{/if}
<DropdownMenu.Separator />
<DropdownMenu.Item
onclick={() => {
closeAndFocusTrigger();
onRemove?.();
}}
>
<TrashIcon class="mr-2 size-3" />
<span class="text-xs font-medium"> 移除筛选 </span>
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>

View File

@@ -1,24 +0,0 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge/index.js';
import XIcon from '@lucide/svelte/icons/x';
export let filterTitle: string = '';
export let filterName: string = '';
export let onRemove: () => void = () => {};
</script>
{#if filterTitle && filterName}
<div class="mb-4 flex items-center gap-2">
<span class="text-muted-foreground text-sm">当前筛选:</span>
<Badge variant="secondary" class="flex items-center gap-2 pr-1">
<span>{filterTitle} {filterName}</span>
<button
class="hover:bg-muted-foreground/20 ml-1 cursor-pointer rounded-full p-0.5 transition-colors"
onclick={onRemove}
type="button"
>
<XIcon class="h-3 w-3" />
</button>
</Badge>
</div>
{/if}

View File

@@ -1,31 +1,28 @@
<script lang="ts">
import SearchIcon from '@lucide/svelte/icons/search';
import * as Input from '$lib/components/ui/input/index.js';
import { Button } from '$lib/components/ui/button/index.js';
export let placeholder: string = '搜索视频..';
export let value: string = '';
export let onSearch: ((query: string) => void) | undefined = undefined;
function handleSearch() {
if (onSearch) {
onSearch(value);
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
handleSearch();
}
onSearch?.(value);
}
</script>
<div class="flex w-full items-center space-x-2">
<div class="relative flex-1">
<SearchIcon class="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input.Root type="text" {placeholder} bind:value onkeydown={handleKeydown} class="h-11 pl-10" />
<div class="flex w-full max-w-48 items-center">
<div class="relative w-full">
<SearchIcon class="text-muted-foreground absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2" />
<Input.Root
type="text"
{placeholder}
bind:value
class="h-8 w-full border-0 pr-3 pl-7 text-sm shadow-none focus-visible:ring-0"
onkeydown={(e: KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
}}
/>
</div>
<Button onclick={handleSearch} size="default" class="h-11 flex-shrink-0 cursor-pointer px-8"
>搜索</Button
>
</div>

View File

@@ -39,21 +39,16 @@
pageStatuses = { ...pageStatuses };
}
// 编辑状态
let videoStatuses: number[] = [];
let pageStatuses: Record<number, number[]> = {};
// 原始状态备份
let originalVideoStatuses: number[] = [];
let originalPageStatuses: Record<number, number[]> = {};
// 响应式更新状态 - 当 video 或 pages props 变化时重新初始化
$: {
// 初始化视频状态
videoStatuses = [...video.download_status];
originalVideoStatuses = [...video.download_status];
// 初始化分页状态
if (pages.length > 0) {
pageStatuses = pages.reduce(
(acc, page) => {
@@ -77,20 +72,28 @@
function handleVideoStatusChange(taskIndex: number, newValue: number) {
videoStatuses[taskIndex] = newValue;
videoStatuses = [...videoStatuses];
}
function handlePageStatusChange(pageId: number, taskIndex: number, newValue: number) {
if (!pageStatuses[pageId]) {
pageStatuses[pageId] = [];
return;
}
pageStatuses[pageId][taskIndex] = newValue;
pageStatuses = { ...pageStatuses };
}
function resetAllStatuses() {
videoStatuses = [...originalVideoStatuses];
pageStatuses = { ...originalPageStatuses };
if (pages.length > 0) {
pageStatuses = pages.reduce(
(acc, page) => {
acc[page.id] = [...page.download_status];
return acc;
},
{} as Record<number, number[]>
);
} else {
pageStatuses = {};
}
}
function hasVideoChanges(): boolean {
@@ -112,44 +115,37 @@
function buildRequest(): UpdateVideoStatusRequest {
const request: UpdateVideoStatusRequest = {};
// 构建视频状态更新
if (hasVideoChanges()) {
request.video_updates = [];
videoStatuses.forEach((status, index) => {
if (status !== originalVideoStatuses[index]) {
request.video_updates!.push({
request.video_updates = [];
videoStatuses.forEach((status, index) => {
if (status !== originalVideoStatuses[index]) {
request.video_updates!.push({
status_index: index,
status_value: status
});
}
});
request.page_updates = [];
pages.forEach((page) => {
const currentStatuses = pageStatuses[page.id] || [];
const originalStatuses = originalPageStatuses[page.id] || [];
const updates: StatusUpdate[] = [];
currentStatuses.forEach((status, index) => {
if (status !== originalStatuses[index]) {
updates.push({
status_index: index,
status_value: status
});
}
});
}
// 构建分页状态更新
if (hasPageChanges()) {
request.page_updates = [];
pages.forEach((page) => {
const currentStatuses = pageStatuses[page.id] || [];
const originalStatuses = originalPageStatuses[page.id] || [];
const updates: StatusUpdate[] = [];
currentStatuses.forEach((status, index) => {
if (status !== originalStatuses[index]) {
updates.push({
status_index: index,
status_value: status
});
}
if (updates.length > 0) {
request.page_updates!.push({
page_id: page.id,
updates
});
if (updates.length > 0) {
request.page_updates!.push({
page_id: page.id,
updates
});
}
});
}
}
});
return request;
}
@@ -159,8 +155,11 @@
toast.info('没有状态变更需要提交');
return;
}
const request = buildRequest();
if (!request.video_updates?.length && !request.page_updates?.length) {
toast.info('没有状态变更需要提交');
return;
}
onsubmit(request);
}
</script>
@@ -179,7 +178,6 @@
<div class="flex-1 overflow-y-auto px-6">
<div class="space-y-6 py-2">
<!-- 视频状态编辑 -->
<div>
<h3 class="mb-4 text-base font-medium">视频状态</h3>
<div class="bg-card rounded-lg border p-4">

View File

@@ -67,11 +67,11 @@
function getSubtitle(): string {
switch (type) {
case 'favorite':
return `UP主ID: ${(item as FavoriteWithSubscriptionStatus).mid}`;
return `uid: ${(item as FavoriteWithSubscriptionStatus).mid}`;
case 'collection':
return `UP主ID: ${(item as CollectionWithSubscriptionStatus).mid}`;
return `uid: ${(item as CollectionWithSubscriptionStatus).mid}`;
case 'upper':
return ''; // UP主不需要副标题
return '';
default:
return '';
}
@@ -158,96 +158,105 @@
const disabledReason = getDisabledReason();
</script>
<Card class="group transition-shadow hover:shadow-md {disabled ? 'opacity-60 grayscale' : ''}">
<CardHeader class="pb-3">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 flex-1 items-start gap-3">
<!-- 头像或图标 -->
<div
class="bg-muted flex h-12 w-12 shrink-0 items-center justify-center rounded-lg {disabled
? 'opacity-50'
: ''}"
>
{#if avatarUrl && type === 'upper'}
<img
src={avatarUrl}
alt={title}
class="h-full w-full rounded-lg object-cover {disabled ? 'grayscale' : ''}"
loading="lazy"
/>
{:else}
<Icon class="text-muted-foreground h-6 w-6" />
{/if}
</div>
<Card
class="hover:shadow-primary/5 border-border/50 group flex h-full flex-col transition-all hover:shadow-lg {disabled
? 'opacity-60'
: ''}"
>
<CardHeader class="flex-shrink-0 pb-4">
<div class="flex items-start gap-3">
<!-- 头像或图标 - 简化设计 -->
<div
class="bg-accent/50 flex h-10 w-10 shrink-0 items-center justify-center rounded-full {disabled
? 'opacity-50'
: ''}"
>
{#if avatarUrl && type === 'upper'}
<img
src={avatarUrl}
alt={title}
class="h-full w-full rounded-full object-cover {disabled ? 'grayscale' : ''}"
loading="lazy"
/>
{:else}
<Icon class="text-muted-foreground h-5 w-5" />
{/if}
</div>
<!-- 标题和信息 -->
<div class="min-w-0 flex-1">
<!-- 内容区域 -->
<div class="min-w-0 flex-1 space-y-2">
<div class="flex items-start justify-between gap-2">
<CardTitle
class="line-clamp-2 text-base leading-tight {disabled
class="line-clamp-2 text-sm leading-relaxed font-medium {disabled
? 'text-muted-foreground line-through'
: ''}"
{title}
>
{title}
</CardTitle>
{#if subtitle}
<div class="text-muted-foreground mt-1 flex items-center gap-1.5 text-sm">
<UserIcon class="h-3 w-3 shrink-0" />
<span class="truncate" title={subtitle}>{subtitle}</span>
</div>
{/if}
{#if description}
<p class="text-muted-foreground mt-1 line-clamp-2 text-xs" title={description}>
{description}
</p>
<!-- 状态标记 -->
{#if disabled}
<Badge variant="destructive" class="shrink-0 text-xs">不可用</Badge>
{:else}
<Badge variant={subscribed ? 'outline' : 'secondary'} class="shrink-0 text-xs">
{subscribed ? '已订阅' : typeLabel}
</Badge>
{/if}
</div>
</div>
<!-- 状态标记 -->
<div class="flex shrink-0 flex-col items-end gap-2">
{#if disabled}
<Badge variant="destructive" class="text-xs">不可用</Badge>
<div class="text-muted-foreground text-xs">
{disabledReason}
<!-- 副标题和描述 -->
{#if subtitle && !disabled}
<div class="text-muted-foreground flex items-center gap-1 text-sm">
<UserIcon class="h-3 w-3 shrink-0" />
<span class="truncate" title={subtitle}>{subtitle}</span>
</div>
{/if}
{#if description && !disabled}
<p class="text-muted-foreground line-clamp-1 text-sm" title={description}>
{description}
</p>
{/if}
<!-- 计数信息 -->
{#if count !== null && !disabled}
<div class="text-muted-foreground text-sm">
{count}
{countLabel}
</div>
{:else}
<Badge variant={subscribed ? 'default' : 'outline'} class="text-xs">
{subscribed ? '已订阅' : typeLabel}
</Badge>
{#if count !== null}
<div class="text-muted-foreground text-xs">
{count}
{countLabel}
</div>
{/if}
{/if}
</div>
</div>
</CardHeader>
<CardContent class="pt-0">
<!-- 底部按钮区域 -->
<CardContent class="flex min-w-0 flex-1 flex-col justify-end pt-0 pb-4">
<div class="flex justify-end">
{#if disabled}
<Button size="sm" variant="outline" disabled class="cursor-not-allowed opacity-50">
<XIcon class="mr-2 h-4 w-4" />
不可用
<Button
size="sm"
variant="outline"
disabled
class="h-8 cursor-not-allowed text-xs opacity-50"
>
<XIcon class="mr-1 h-3 w-3" />
{disabledReason}
</Button>
{:else if subscribed}
<Button size="sm" variant="outline" disabled class="cursor-not-allowed">
<CheckIcon class="mr-2 h-4 w-4" />
<Button size="sm" variant="outline" disabled class="h-8 cursor-not-allowed text-xs">
<CheckIcon class="mr-1 h-3 w-3" />
已订阅
</Button>
{:else}
<Button
size="sm"
variant="default"
variant="outline"
onclick={handleSubscribe}
class="cursor-pointer"
{disabled}
class="h-8 cursor-pointer text-xs font-medium"
>
<PlusIcon class="mr-2 h-4 w-4" />
快捷订阅
<PlusIcon class="mr-1 h-3 w-3" />
订阅
</Button>
{/if}
</div>
@@ -256,3 +265,5 @@
<!-- 订阅对话框 -->
<SubscriptionDialog bind:open={dialogOpen} {item} {type} onSuccess={handleSubscriptionSuccess} />
<!-- 订阅对话框 -->
<SubscriptionDialog bind:open={dialogOpen} {item} {type} onSuccess={handleSubscriptionSuccess} />

View File

@@ -8,4 +8,4 @@
}: CollapsiblePrimitive.RootProps = $props();
</script>
<CollapsiblePrimitive.Root bind:ref data-slot="collapsible" {...restProps} />
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />

View File

@@ -1,8 +1,6 @@
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
const Root = CollapsiblePrimitive.Root;
const Trigger = CollapsiblePrimitive.Trigger;
const Content = CollapsiblePrimitive.Content;
import Root from './collapsible.svelte';
import Trigger from './collapsible-trigger.svelte';
import Content from './collapsible-content.svelte';
export {
Root,

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import type { Command as CommandPrimitive, Dialog as DialogPrimitive } from 'bits-ui';
import type { Snippet } from 'svelte';
import Command from './command.svelte';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import type { WithoutChildrenOrChild } from '$lib/utils.js';
let {
open = $bindable(false),
ref = $bindable(null),
value = $bindable(''),
title = 'Command Palette',
description = 'Search for a command to run',
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.RootProps> &
WithoutChildrenOrChild<CommandPrimitive.RootProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
title?: string;
description?: string;
} = $props();
</script>
<Dialog.Root bind:open {...restProps}>
<Dialog.Header class="sr-only">
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>{description}</Dialog.Description>
</Dialog.Header>
<Dialog.Content class="overflow-hidden p-0" {portalProps}>
<Command
class="**:data-[slot=command-input-wrapper]:h-12 [&_[data-command-group]]:px-2 [&_[data-command-group]:not([hidden])_~[data-command-group]]:pt-0 [&_[data-command-input-wrapper]_svg]:h-5 [&_[data-command-input-wrapper]_svg]:w-5 [&_[data-command-input]]:h-12 [&_[data-command-item]]:px-2 [&_[data-command-item]]:py-3 [&_[data-command-item]_svg]:h-5 [&_[data-command-item]_svg]:w-5"
{...restProps}
bind:value
bind:ref
{children}
/>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.EmptyProps = $props();
</script>
<CommandPrimitive.Empty
bind:ref
data-slot="command-empty"
class={cn('py-6 text-center text-sm', className)}
{...restProps}
/>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { Command as CommandPrimitive, useId } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
heading,
value,
...restProps
}: CommandPrimitive.GroupProps & {
heading?: string;
} = $props();
</script>
<CommandPrimitive.Group
bind:ref
data-slot="command-group"
class={cn('text-foreground overflow-hidden p-1', className)}
value={value ?? heading ?? `----${useId()}`}
{...restProps}
>
{#if heading}
<CommandPrimitive.GroupHeading class="text-muted-foreground px-2 py-1.5 text-xs font-medium">
{heading}
</CommandPrimitive.GroupHeading>
{/if}
<CommandPrimitive.GroupItems {children} />
</CommandPrimitive.Group>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import SearchIcon from '@lucide/svelte/icons/search';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
value = $bindable(''),
...restProps
}: CommandPrimitive.InputProps = $props();
</script>
<div class="flex h-9 items-center gap-2 border-b px-3" data-slot="command-input-wrapper">
<SearchIcon class="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
class={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className
)}
bind:ref
{...restProps}
bind:value
/>
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.ItemProps = $props();
</script>
<CommandPrimitive.Item
bind:ref
data-slot="command-item"
class={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.LinkItemProps = $props();
</script>
<CommandPrimitive.LinkItem
bind:ref
data-slot="command-item"
class={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.ListProps = $props();
</script>
<CommandPrimitive.List
bind:ref
data-slot="command-list"
class={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.SeparatorProps = $props();
</script>
<CommandPrimitive.Separator
bind:ref
data-slot="command-separator"
class={cn('bg-border -mx-1 h-px', className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="command-shortcut"
class={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...restProps}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
value = $bindable(''),
class: className,
...restProps
}: CommandPrimitive.RootProps = $props();
</script>
<CommandPrimitive.Root
bind:value
bind:ref
data-slot="command"
class={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,40 @@
import { Command as CommandPrimitive } from 'bits-ui';
import Root from './command.svelte';
import Dialog from './command-dialog.svelte';
import Empty from './command-empty.svelte';
import Group from './command-group.svelte';
import Item from './command-item.svelte';
import Input from './command-input.svelte';
import List from './command-list.svelte';
import Separator from './command-separator.svelte';
import Shortcut from './command-shortcut.svelte';
import LinkItem from './command-link-item.svelte';
const Loading = CommandPrimitive.Loading;
export {
Root,
Dialog,
Empty,
Group,
Item,
LinkItem,
Input,
List,
Separator,
Shortcut,
Loading,
//
Root as Command,
Dialog as CommandDialog,
Empty as CommandEmpty,
Group as CommandGroup,
Item as CommandItem,
LinkItem as CommandLinkItem,
Input as CommandInput,
List as CommandList,
Separator as CommandSeparator,
Shortcut as CommandShortcut,
Loading as CommandLoading
};

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CheckIcon from '@lucide/svelte/icons/check';
import MinusIcon from '@lucide/svelte/icons/minus';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import type { Snippet } from 'svelte';
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
{#if indeterminate}
<MinusIcon class="size-4" />
{:else}
<CheckIcon class={cn('size-4', !checked && 'text-transparent')} />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
sideOffset = 4,
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: DropdownMenuPrimitive.PortalProps;
} = $props();
</script>
<DropdownMenuPrimitive.Portal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
class={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className
)}
{...restProps}
/>
</DropdownMenuPrimitive.Portal>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
</script>
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
inset,
variant = 'default',
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: 'default' | 'destructive';
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dropdown-menu-label"
data-inset={inset}
class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props();
</script>
<DropdownMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="dropdown-menu-radio-group"
{...restProps}
/>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CircleIcon from '@lucide/svelte/icons/circle';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<DropdownMenuPrimitive.RadioItem
bind:ref
data-slot="dropdown-menu-radio-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
{#if checked}
<CircleIcon class="size-2 fill-current" />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</DropdownMenuPrimitive.RadioItem>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SeparatorProps = $props();
</script>
<DropdownMenuPrimitive.Separator
bind:ref
data-slot="dropdown-menu-separator"
class={cn('bg-border -mx-1 my-1 h-px', className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="dropdown-menu-shortcut"
class={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...restProps}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SubContentProps = $props();
</script>
<DropdownMenuPrimitive.SubContent
bind:ref
data-slot="dropdown-menu-sub-content"
class={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.SubTrigger
bind:ref
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
</script>
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />

View File

@@ -0,0 +1,49 @@
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CheckboxItem from './dropdown-menu-checkbox-item.svelte';
import Content from './dropdown-menu-content.svelte';
import Group from './dropdown-menu-group.svelte';
import Item from './dropdown-menu-item.svelte';
import Label from './dropdown-menu-label.svelte';
import RadioGroup from './dropdown-menu-radio-group.svelte';
import RadioItem from './dropdown-menu-radio-item.svelte';
import Separator from './dropdown-menu-separator.svelte';
import Shortcut from './dropdown-menu-shortcut.svelte';
import Trigger from './dropdown-menu-trigger.svelte';
import SubContent from './dropdown-menu-sub-content.svelte';
import SubTrigger from './dropdown-menu-sub-trigger.svelte';
import GroupHeading from './dropdown-menu-group-heading.svelte';
const Sub = DropdownMenuPrimitive.Sub;
const Root = DropdownMenuPrimitive.Root;
export {
CheckboxItem,
Content,
Root as DropdownMenu,
CheckboxItem as DropdownMenuCheckboxItem,
Content as DropdownMenuContent,
Group as DropdownMenuGroup,
Item as DropdownMenuItem,
Label as DropdownMenuLabel,
RadioGroup as DropdownMenuRadioGroup,
RadioItem as DropdownMenuRadioItem,
Separator as DropdownMenuSeparator,
Shortcut as DropdownMenuShortcut,
Sub as DropdownMenuSub,
SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger,
Trigger as DropdownMenuTrigger,
GroupHeading as DropdownMenuGroupHeading,
Group,
GroupHeading,
Item,
Label,
RadioGroup,
RadioItem,
Root,
Separator,
Shortcut,
Sub,
SubContent,
SubTrigger,
Trigger
};

View File

@@ -24,7 +24,7 @@
bind:this={ref}
data-slot="input"
class={cn(
'selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className

View File

@@ -11,7 +11,7 @@
<SeparatorPrimitive.Root
bind:ref
data-slot="separator-root"
data-slot="separator"
class={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className

View File

@@ -7,8 +7,6 @@
class: className,
...restProps
}: SheetPrimitive.OverlayProps = $props();
export { className as class };
</script>
<SheetPrimitive.Overlay

View File

@@ -22,7 +22,7 @@
onclick={sidebar.toggle}
title="Toggle Sidebar"
class={cn(
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-[calc(1/2*100%-1px)] after:w-[2px] sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',

View File

@@ -14,6 +14,6 @@
bind:ref
data-slot="sidebar-separator"
data-sidebar="separator"
class={cn('bg-sidebar-border mx-2 w-auto', className)}
class={cn('bg-sidebar-border', className)}
{...restProps}
/>

View File

@@ -70,7 +70,7 @@
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
)}
></div>

View File

@@ -34,9 +34,9 @@
class={cn(
'bg-primary z-50 size-2.5 rotate-45 rounded-[2px]',
'data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]',
'data-[side=bottom]:translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]',
'data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]',
'data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2',
'data-[side=left]:translate-y-[calc(50%_-_3px)]',
'data-[side=left]:-translate-y-[calc(50%_-_3px)]',
arrowClasses
)}
{...props}

View File

@@ -17,8 +17,6 @@
export let customSubtitle: string = ''; // 自定义副标题
export let taskNames: string[] = []; // 自定义任务名称
export let showProgress: boolean = true; // 是否显示进度信息
export let progressHeight: string = 'h-2'; // 进度条高度
export let gap: string = 'gap-1'; // 进度条间距
export let onReset: (() => Promise<void>) | null = null; // 自定义重置函数
export let resetDialogOpen = false; // 导出对话框状态,让父组件可以控制
export let resetting = false;
@@ -35,11 +33,11 @@
function getSegmentColor(status: number): string {
if (status === 7) {
return 'bg-green-500'; // 绿色 - 成功
return 'bg-emerald-500'; // 恢复更高对比度的绿色
} else if (status === 0) {
return 'bg-yellow-500'; // 色 - 未开始
return 'bg-slate-400'; // 恢复更清晰的灰色 - 未开始
} else {
return 'bg-red-500'; // 红色 - 失败
return 'bg-rose-500'; // 恢复更清晰的红色 - 失败
}
}
@@ -52,9 +50,9 @@
const failed = downloadStatus.filter((status) => status !== 7 && status !== 0).length;
if (completed === total) {
return { text: '全部完成', color: 'default' };
return { text: '完成', color: 'outline' }; // 更简洁的文案
} else if (failed > 0) {
return { text: '部分失败', color: 'destructive' };
return { text: '失败', color: 'destructive' };
} else {
return { text: '进行中', color: 'secondary' };
}
@@ -88,33 +86,30 @@
// 根据模式确定显示的标题和副标题
$: displayTitle = customTitle || video.name;
$: displaySubtitle = customSubtitle || video.upper_name;
$: showUserIcon = mode === 'default';
$: cardClasses =
mode === 'default'
? 'group flex h-full min-w-0 flex-col transition-shadow hover:shadow-md'
: 'transition-shadow hover:shadow-md';
? 'group flex h-full min-w-0 flex-col transition-all hover:shadow-lg hover:shadow-primary/5 border-border/50'
: 'transition-all hover:shadow-lg border-border/50';
</script>
<Card class={cardClasses}>
<CardHeader class={mode === 'default' ? 'flex-shrink-0 pb-3' : 'pb-3'}>
<div class="flex min-w-0 items-start justify-between gap-2">
<CardHeader class="flex-shrink-0 pb-3">
<div class="flex min-w-0 items-start justify-between gap-3">
<CardTitle
class="line-clamp-2 min-w-0 flex-1 cursor-default {mode === 'default'
? 'text-base'
: 'text-base'} leading-tight"
? 'text-sm'
: 'text-sm'} leading-relaxed font-medium"
title={displayTitle}
>
{displayTitle}
</CardTitle>
<Badge variant={overallStatus.color} class="shrink-0 text-xs">
<Badge variant={overallStatus.color} class="shrink-0 px-2 py-1 text-xs font-medium">
{overallStatus.text}
</Badge>
</div>
{#if displaySubtitle}
<div class="text-muted-foreground flex min-w-0 items-center gap-1 text-sm">
{#if showUserIcon}
<UserIcon class="h-3 w-3 shrink-0" />
{/if}
<div class="text-muted-foreground mt-1.5 flex min-w-0 items-center gap-1 text-sm">
<UserIcon class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 cursor-default truncate" title={displaySubtitle}>
{displaySubtitle}
</span>
@@ -122,34 +117,30 @@
{/if}
</CardHeader>
<CardContent
class={mode === 'default' ? 'flex min-w-0 flex-1 flex-col justify-end pt-0' : 'pt-0'}
class={mode === 'default' ? 'flex min-w-0 flex-1 flex-col justify-end pt-0 pb-3' : 'pt-0 pb-4'}
>
<div class="space-y-3">
<!-- 进度条区域 -->
{#if showProgress}
<div class="space-y-2">
<div
class="text-muted-foreground flex justify-between {mode === 'default'
? 'text-xs'
: 'text-xs'}"
>
<!-- 进度信息 -->
<div class="text-muted-foreground flex justify-between text-sm font-medium">
<span class="truncate">下载进度</span>
<span class="shrink-0">{completed}/{total}</span>
</div>
<!-- 进度条 -->
<div class="flex w-full {gap}">
<div class="flex w-full gap-0.5">
{#each video.download_status as status, index (index)}
<Tooltip.Root>
<Tooltip.Trigger class="flex-1">
<div
class="{progressHeight} w-full cursor-help rounded-sm transition-all {getSegmentColor(
class="h-1.5 w-full cursor-help rounded-full transition-all {getSegmentColor(
status
)}"
)} hover:opacity-80"
></div>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{getTaskName(index)}: {getStatusText(status)}</p>
<p class="text-sm">{getTaskName(index)}: {getStatusText(status)}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
@@ -157,13 +148,12 @@
</div>
{/if}
<!-- 操作按钮 -->
{#if showActions && mode === 'default'}
<div class="flex min-w-0 gap-1.5">
<div class="flex min-w-0 gap-1.5 pt-1">
<Button
size="sm"
variant="outline"
class="min-w-0 flex-1 cursor-pointer px-2 text-xs"
class="hover:bg-accent hover:text-accent-foreground h-8 min-w-0 flex-1 cursor-pointer px-2 text-xs font-medium"
onclick={handleViewDetail}
>
<InfoIcon class="mr-1 h-3 w-3 shrink-0" />
@@ -172,7 +162,7 @@
<Button
size="sm"
variant="outline"
class="shrink-0 cursor-pointer px-2"
class="hover:bg-accent hover:text-accent-foreground h-8 shrink-0 cursor-pointer px-2"
onclick={() => (resetDialogOpen = true)}
>
<RotateCcwIcon class="h-3 w-3" />

View File

@@ -1,9 +1,9 @@
import { MediaQuery } from 'svelte/reactivity';
const MOBILE_BREAKPOINT = 768;
const DEFAULT_MOBILE_BREAKPOINT = 768;
export class IsMobile extends MediaQuery {
constructor() {
super(`max-width: ${MOBILE_BREAKPOINT - 1}px`);
constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) {
super(`max-width: ${breakpoint - 1}px`);
}
}

View File

View File

@@ -3,8 +3,6 @@ import { writable } from 'svelte/store';
export interface BreadcrumbItem {
href?: string;
label: string;
isActive?: boolean;
onClick?: () => void;
}
export const breadcrumbStore = writable<BreadcrumbItem[]>([]);

View File

@@ -28,7 +28,7 @@ export const ToQuery = (state: AppState): string => {
params.set(videoSource.type, videoSource.id);
}
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
return queryString ? `videos?${queryString}` : 'videos';
};
export const setQuery = (query: string) => {

View File

@@ -1,13 +0,0 @@
import { writable } from 'svelte/store';
import { type VideoSourcesResponse } from '$lib/types';
export const videoSourceStore = writable<VideoSourcesResponse | undefined>(undefined);
// 便捷的设置和清除方法
export const setVideoSources = (sources: VideoSourcesResponse) => {
videoSourceStore.set(sources);
};
export const clearFilter = () => {
videoSourceStore.set(undefined);
};

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>