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);
};