feat: 支持 webui 加载用户的订阅与收藏,一键点击订阅 (#357)

This commit is contained in:
ᴀᴍᴛᴏᴀᴇʀ
2025-06-09 11:16:33 +08:00
committed by GitHub
parent 586d5ec4ee
commit a98e49347b
23 changed files with 1513 additions and 38 deletions

View File

@@ -4,6 +4,10 @@
@custom-variant dark (&:is(.dark *));
html {
scroll-behavior: smooth !important;
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);

View File

@@ -8,7 +8,13 @@ import type {
ResetAllVideosResponse,
UpdateVideoStatusRequest,
UpdateVideoStatusResponse,
ApiError
ApiError,
FavoritesResponse,
CollectionsResponse,
UppersResponse,
UpsertFavoriteRequest,
UpsertCollectionRequest,
UpsertSubmissionRequest
} from './types';
// API 基础配置
@@ -168,6 +174,67 @@ class ApiClient {
): Promise<ApiResponse<UpdateVideoStatusResponse>> {
return this.post<UpdateVideoStatusResponse>(`/videos/${id}/update-status`, request);
}
/**
* 获取我的收藏夹
*/
async getCreatedFavorites(): Promise<ApiResponse<FavoritesResponse>> {
return this.get<FavoritesResponse>('/me/favorites');
}
/**
* 获取关注的合集
* @param page 页码
*/
async getFollowedCollections(
pageNum?: number,
pageSize?: number
): Promise<ApiResponse<CollectionsResponse>> {
const params = {
page_num: pageNum,
page_size: pageSize
};
return this.get<CollectionsResponse>('/me/collections', params);
}
/**
* 获取关注的UP主
* @param page 页码
*/
async getFollowedUppers(
pageNum?: number,
pageSize?: number
): Promise<ApiResponse<UppersResponse>> {
const params = {
page_num: pageNum,
page_size: pageSize
};
return this.get<UppersResponse>('/me/uppers', params);
}
/**
* 订阅收藏夹
* @param request 订阅请求参数
*/
async upsertFavorite(request: UpsertFavoriteRequest): Promise<ApiResponse<boolean>> {
return this.post<boolean>('/video-sources/favorites', request);
}
/**
* 订阅合集
* @param request 订阅请求参数
*/
async upsertCollection(request: UpsertCollectionRequest): Promise<ApiResponse<boolean>> {
return this.post<boolean>('/video-sources/collections', request);
}
/**
* 订阅UP主投稿
* @param request 订阅请求参数
*/
async upsertSubmission(request: UpsertSubmissionRequest): Promise<ApiResponse<boolean>> {
return this.post<boolean>('/video-sources/submissions', request);
}
}
// 创建默认的 API 客户端实例
@@ -206,6 +273,38 @@ export const api = {
updateVideoStatus: (id: number, request: UpdateVideoStatusRequest) =>
apiClient.updateVideoStatus(id, request),
/**
* 获取我的收藏夹
*/
getCreatedFavorites: () => apiClient.getCreatedFavorites(),
/**
* 获取关注的合集
*/
getFollowedCollections: (pageNum?: number, pageSize?: number) =>
apiClient.getFollowedCollections(pageNum, pageSize),
/**
* 获取关注的UP主
*/
getFollowedUppers: (pageNum?: number, pageSize?: number) =>
apiClient.getFollowedUppers(pageNum, pageSize),
/**
* 订阅收藏夹
*/
upsertFavorite: (request: UpsertFavoriteRequest) => apiClient.upsertFavorite(request),
/**
* 订阅合集
*/
upsertCollection: (request: UpsertCollectionRequest) => apiClient.upsertCollection(request),
/**
* 订阅UP主投稿
*/
upsertSubmission: (request: UpsertSubmissionRequest) => apiClient.upsertSubmission(request),
/**
* 设置认证 token
*/

View File

@@ -1,6 +1,9 @@
<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 HeartIcon from '@lucide/svelte/icons/heart';
import FolderIcon from '@lucide/svelte/icons/folder';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js';
import {
@@ -64,7 +67,7 @@
<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">
@@ -115,9 +118,69 @@
</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.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>
</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>
@@ -125,6 +188,11 @@
<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" />

View File

@@ -0,0 +1,263 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import SubscriptionDialog from './subscription-dialog.svelte';
import UserIcon from '@lucide/svelte/icons/user';
import VideoIcon from '@lucide/svelte/icons/video';
import FolderIcon from '@lucide/svelte/icons/folder';
import HeartIcon from '@lucide/svelte/icons/heart';
import CheckIcon from '@lucide/svelte/icons/check';
import PlusIcon from '@lucide/svelte/icons/plus';
import XIcon from '@lucide/svelte/icons/x';
import type {
FavoriteWithSubscriptionStatus,
CollectionWithSubscriptionStatus,
UpperWithSubscriptionStatus
} from '$lib/types';
export let item:
| FavoriteWithSubscriptionStatus
| CollectionWithSubscriptionStatus
| UpperWithSubscriptionStatus;
export let type: 'favorite' | 'collection' | 'upper' = 'favorite';
export let onSubscriptionSuccess: (() => void) | null = null;
let dialogOpen = false;
function getIcon() {
switch (type) {
case 'favorite':
return HeartIcon;
case 'collection':
return FolderIcon;
case 'upper':
return UserIcon;
default:
return VideoIcon;
}
}
function getTypeLabel() {
switch (type) {
case 'favorite':
return '收藏夹';
case 'collection':
return '合集';
case 'upper':
return 'UP主';
default:
return '';
}
}
function getTitle(): string {
switch (type) {
case 'favorite':
return (item as FavoriteWithSubscriptionStatus).title;
case 'collection':
return (item as CollectionWithSubscriptionStatus).title;
case 'upper':
return (item as UpperWithSubscriptionStatus).uname;
default:
return '';
}
}
function getSubtitle(): string {
switch (type) {
case 'favorite':
return `UP主ID: ${(item as FavoriteWithSubscriptionStatus).mid}`;
case 'collection':
return `UP主ID: ${(item as CollectionWithSubscriptionStatus).mid}`;
case 'upper':
return ''; // UP主不需要副标题
default:
return '';
}
}
function getDescription(): string {
switch (type) {
case 'upper':
return (item as UpperWithSubscriptionStatus).sign || '';
default:
return '';
}
}
function isDisabled(): boolean {
switch (type) {
case 'collection':
return (item as CollectionWithSubscriptionStatus).state === 1;
case 'upper': {
const upper = item as UpperWithSubscriptionStatus;
// 没看到有 status 标记,这样判断应该没什么大问题
return (
upper.uname === '账号已注销' &&
upper.face === 'https://i0.hdslb.com/bfs/face/member/noface.jpg'
);
}
default:
return false;
}
}
function getDisabledReason(): string {
switch (type) {
case 'collection':
return '已失效';
case 'upper':
return '账号已注销';
default:
return '';
}
}
function getCount(): number | null {
switch (type) {
case 'favorite':
return (item as FavoriteWithSubscriptionStatus).media_count;
default:
return null;
}
}
function getCountLabel(): string {
return '个视频';
}
function getAvatarUrl(): string {
switch (type) {
case 'upper':
return `/image-proxy?url=${(item as UpperWithSubscriptionStatus).face}`;
default:
return '';
}
}
function handleSubscribe() {
if (!disabled) {
dialogOpen = true;
}
}
function handleSubscriptionSuccess() {
// 更新本地状态
item.subscribed = true;
if (onSubscriptionSuccess) {
onSubscriptionSuccess();
}
}
const Icon = getIcon();
const typeLabel = getTypeLabel();
const title = getTitle();
const subtitle = getSubtitle();
const description = getDescription();
const count = getCount();
const countLabel = getCountLabel();
const avatarUrl = getAvatarUrl();
const subscribed = item.subscribed;
const disabled = isDisabled();
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>
<!-- 标题和信息 -->
<div class="min-w-0 flex-1">
<CardTitle
class="line-clamp-2 text-base leading-tight {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}
</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}
</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">
<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>
{:else if subscribed}
<Button size="sm" variant="outline" disabled class="cursor-not-allowed">
<CheckIcon class="mr-2 h-4 w-4" />
已订阅
</Button>
{:else}
<Button
size="sm"
variant="default"
onclick={handleSubscribe}
class="cursor-pointer"
{disabled}
>
<PlusIcon class="mr-2 h-4 w-4" />
快捷订阅
</Button>
{/if}
</div>
</CardContent>
</Card>
<!-- 订阅对话框 -->
<SubscriptionDialog bind:open={dialogOpen} {item} {type} onSuccess={handleSubscriptionSuccess} />

View File

@@ -0,0 +1,242 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle
} from '$lib/components/ui/sheet/index.js';
import { toast } from 'svelte-sonner';
import api from '$lib/api';
import type {
FavoriteWithSubscriptionStatus,
CollectionWithSubscriptionStatus,
UpperWithSubscriptionStatus,
UpsertFavoriteRequest,
UpsertCollectionRequest,
UpsertSubmissionRequest,
ApiError
} from '$lib/types';
export let open = false;
export let item:
| FavoriteWithSubscriptionStatus
| CollectionWithSubscriptionStatus
| UpperWithSubscriptionStatus
| null = null;
export let type: 'favorite' | 'collection' | 'upper' = 'favorite';
export let onSuccess: (() => void) | null = null;
let customPath = '';
let loading = false;
// 根据类型和item生成默认路径
function generateDefaultPath(): string {
if (!item) return '';
switch (type) {
case 'favorite': {
const favorite = item as FavoriteWithSubscriptionStatus;
return `收藏夹/${favorite.title}`;
}
case 'collection': {
const collection = item as CollectionWithSubscriptionStatus;
return `合集/${collection.title}`;
}
case 'upper': {
const upper = item as UpperWithSubscriptionStatus;
return `UP主/${upper.uname}`;
}
default:
return '';
}
}
function getTypeLabel(): string {
switch (type) {
case 'favorite':
return '收藏夹';
case 'collection':
return '合集';
case 'upper':
return 'UP主';
default:
return '';
}
}
function getItemTitle(): string {
if (!item) return '';
switch (type) {
case 'favorite':
return (item as FavoriteWithSubscriptionStatus).title;
case 'collection':
return (item as CollectionWithSubscriptionStatus).title;
case 'upper':
return (item as UpperWithSubscriptionStatus).uname;
default:
return '';
}
}
async function handleSubscribe() {
if (!item || !customPath.trim()) return;
loading = true;
try {
let response;
switch (type) {
case 'favorite': {
const favorite = item as FavoriteWithSubscriptionStatus;
const request: UpsertFavoriteRequest = {
// 数据库中保存的 fid 实际上是 favorite.id
fid: favorite.id,
name: favorite.title,
path: customPath.trim()
};
response = await api.upsertFavorite(request);
break;
}
case 'collection': {
const collection = item as CollectionWithSubscriptionStatus;
const request: UpsertCollectionRequest = {
id: collection.id,
mid: collection.mid,
path: customPath.trim()
};
response = await api.upsertCollection(request);
break;
}
case 'upper': {
const upper = item as UpperWithSubscriptionStatus;
const request: UpsertSubmissionRequest = {
upper_id: upper.mid,
path: customPath.trim()
};
response = await api.upsertSubmission(request);
break;
}
}
if (response && response.data) {
toast.success('订阅成功', {
description: `已订阅${getTypeLabel()}${getItemTitle()}」到路径「${customPath.trim()}」`
});
open = false;
if (onSuccess) {
onSuccess();
}
}
} catch (error) {
console.error(`订阅${getTypeLabel()}失败:`, error);
toast.error('订阅失败', {
description: (error as ApiError).message
});
} finally {
loading = false;
}
}
function handleCancel() {
open = false;
}
// 当对话框打开时重置path
$: if (open && item) {
customPath = generateDefaultPath();
}
const typeLabel = getTypeLabel();
const itemTitle = getItemTitle();
</script>
<Sheet bind:open>
<SheetContent side="right" class="flex w-full flex-col sm:max-w-md">
<SheetHeader class="px-6 pb-2">
<SheetTitle class="text-lg">订阅{typeLabel}</SheetTitle>
<SheetDescription class="text-muted-foreground space-y-1 text-sm">
<div>即将订阅{typeLabel}{itemTitle}</div>
<div>请手动编辑本地保存路径:</div>
</SheetDescription>
</SheetHeader>
<div class="flex-1 overflow-y-auto px-6">
<div class="space-y-4 py-4">
<!-- 项目信息 -->
<div class="bg-muted/30 rounded-lg border p-4">
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm font-medium">{typeLabel}名称:</span>
<span class="text-sm">{itemTitle}</span>
</div>
{#if type === 'favorite'}
{@const favorite = item as FavoriteWithSubscriptionStatus}
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm font-medium">视频数量:</span>
<span class="text-sm">{favorite.media_count}</span>
</div>
{/if}
{#if type === 'upper'}
{@const upper = item as UpperWithSubscriptionStatus}
{#if upper.sign}
<div class="flex items-start gap-2">
<span class="text-muted-foreground text-sm font-medium">个人简介:</span>
<span class="text-muted-foreground text-sm">{upper.sign}</span>
</div>
{/if}
{/if}
</div>
</div>
<!-- 路径输入 -->
<div class="space-y-3">
<Label for="custom-path" class="text-sm font-medium">
本地保存路径 <span class="text-destructive">*</span>
</Label>
<Input
id="custom-path"
type="text"
placeholder="请输入保存路径,例如:/home/我的收藏"
bind:value={customPath}
disabled={loading}
class="w-full"
/>
<div class="text-muted-foreground space-y-3 text-xs">
<p>路径将作为文件夹名称,用于存放下载的视频文件。</p>
<div>
<p class="mb-2 font-medium">路径示例:</p>
<div class="space-y-1 pl-4">
<div class="font-mono text-xs">Mac/Linux: /home/downloads/我的收藏</div>
<div class="font-mono text-xs">Windows: C:\Downloads\我的收藏</div>
</div>
</div>
</div>
</div>
</div>
</div>
<SheetFooter class="bg-background flex gap-2 border-t px-6 pt-4">
<Button
variant="outline"
onclick={handleCancel}
disabled={loading}
class="flex-1 cursor-pointer"
>
取消
</Button>
<Button
onclick={handleSubscribe}
disabled={loading || !customPath.trim()}
class="flex-1 cursor-pointer"
>
{loading ? '订阅中...' : '确认订阅'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>

View File

@@ -102,3 +102,62 @@ export interface UpdateVideoStatusResponse {
video: VideoInfo;
pages: PageInfo[];
}
// 收藏夹相关类型
export interface FavoriteWithSubscriptionStatus {
title: string;
media_count: number;
id: number;
fid: number;
mid: number;
subscribed: boolean;
}
export interface FavoritesResponse {
favorites: FavoriteWithSubscriptionStatus[];
}
// 合集相关类型
export interface CollectionWithSubscriptionStatus {
id: number;
mid: number;
state: number;
title: string;
subscribed: boolean;
}
export interface CollectionsResponse {
collections: CollectionWithSubscriptionStatus[];
total: number;
}
// UP主相关类型
export interface UpperWithSubscriptionStatus {
mid: number;
uname: string;
face: string;
sign: string;
subscribed: boolean;
}
export interface UppersResponse {
uppers: UpperWithSubscriptionStatus[];
total: number;
}
export interface UpsertFavoriteRequest {
fid: number;
name: string;
path: string;
}
export interface UpsertCollectionRequest {
id: number;
mid: number;
path: string;
}
export interface UpsertSubmissionRequest {
upper_id: number;
path: string;
}

View File

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

View File

@@ -0,0 +1,108 @@
<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';
let collections: CollectionWithSubscriptionStatus[] = [];
let totalCount = 0;
let currentPage = 0;
let loading = false;
const pageSize = 50;
async function loadCollections(page: number = 0) {
loading = true;
try {
const response = await api.getFollowedCollections(page + 1, pageSize); // API使用1基索引
collections = response.data.collections;
totalCount = response.data.total;
} catch (error) {
console.error('加载合集失败:', error);
toast.error('加载合集失败', {
description: (error as ApiError).message
});
} finally {
loading = false;
}
}
function handleSubscriptionSuccess() {
// 重新加载数据以获取最新状态
loadCollections(currentPage);
}
async function handlePageChange(page: number) {
currentPage = page;
await loadCollections(page);
}
onMount(async () => {
setBreadcrumb([
{
label: '主页',
onClick: () => {
goto(`/${ToQuery($appStateStore)}`);
}
},
{
label: '关注的合集',
isActive: true
}
]);
await loadCollections();
});
$: totalPages = Math.ceil(totalCount / pageSize);
</script>
<svelte:head>
<title>关注的合集 - Bili Sync</title>
</svelte:head>
<div class="max-w-6xl">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">关注的合集</h1>
<p class="text-muted-foreground mt-1">管理您在B站关注的合集订阅</p>
</div>
<div class="text-muted-foreground text-sm">
{#if !loading}
{totalCount} 个合集
{/if}
</div>
</div>
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="text-muted-foreground">加载中...</div>
</div>
{:else if collections.length > 0}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each collections as collection (collection.id)}
<SubscriptionCard
item={collection}
type="collection"
onSubscriptionSuccess={handleSubscriptionSuccess}
/>
{/each}
</div>
<!-- 分页组件 -->
{#if totalPages > 1}
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
{/if}
{: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">请先在B站关注一些合集或检查账号配置</p>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,88 @@
<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';
let favorites: FavoriteWithSubscriptionStatus[] = [];
let loading = false;
async function loadFavorites() {
loading = true;
try {
const response = await api.getCreatedFavorites();
favorites = response.data.favorites;
} catch (error) {
console.error('加载收藏夹失败:', error);
toast.error('加载收藏夹失败', {
description: (error as ApiError).message
});
} finally {
loading = false;
}
}
function handleSubscriptionSuccess() {
// 重新加载数据以获取最新状态
loadFavorites();
}
onMount(async () => {
setBreadcrumb([
{
label: '主页',
onClick: () => {
goto(`/${ToQuery($appStateStore)}`);
}
},
{ label: '我的收藏夹', isActive: true }
]);
await loadFavorites();
});
</script>
<svelte:head>
<title>我的收藏夹 - Bili Sync</title>
</svelte:head>
<div class="max-w-6xl">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">我的收藏夹</h1>
<p class="text-muted-foreground mt-1">管理您在B站创建的收藏夹订阅</p>
</div>
<div class="text-muted-foreground text-sm">
{#if !loading}
{favorites.length} 个收藏夹
{/if}
</div>
</div>
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="text-muted-foreground">加载中...</div>
</div>
{:else if favorites.length > 0}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each favorites as favorite (favorite.fid)}
<SubscriptionCard
item={favorite}
type="favorite"
onSubscriptionSuccess={handleSubscriptionSuccess}
/>
{/each}
</div>
{: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">请先在B站创建收藏夹或检查账号配置</p>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,106 @@
<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';
let uppers: UpperWithSubscriptionStatus[] = [];
let totalCount = 0;
let currentPage = 0;
let loading = false;
const pageSize = 50;
async function loadUppers(page: number = 0) {
loading = true;
try {
const response = await api.getFollowedUppers(page + 1, pageSize); // API使用1基索引
uppers = response.data.uppers;
totalCount = response.data.total;
} catch (error) {
console.error('加载UP主失败:', error);
toast.error('加载UP主失败', {
description: (error as ApiError).message
});
} finally {
loading = false;
}
}
function handleSubscriptionSuccess() {
// 重新加载数据以获取最新状态
loadUppers(currentPage);
}
async function handlePageChange(page: number) {
currentPage = page;
await loadUppers(page);
}
onMount(async () => {
setBreadcrumb([
{
label: '主页',
onClick: () => {
goto(`/${ToQuery($appStateStore)}`);
}
},
{ label: '关注的UP主', isActive: true }
]);
await loadUppers();
});
$: totalPages = Math.ceil(totalCount / pageSize);
</script>
<svelte:head>
<title>关注的UP主 - Bili Sync</title>
</svelte:head>
<div class="max-w-6xl">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">关注的UP主</h1>
<p class="text-muted-foreground mt-1">管理您在B站关注的UP主投稿订阅</p>
</div>
<div class="text-muted-foreground text-sm">
{#if !loading}
{totalCount} 个UP主
{/if}
</div>
</div>
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="text-muted-foreground">加载中...</div>
</div>
{:else if uppers.length > 0}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each uppers as upper (upper.mid)}
<SubscriptionCard
item={upper}
type="upper"
onSubscriptionSuccess={handleSubscriptionSuccess}
/>
{/each}
</div>
<!-- 分页组件 -->
{#if totalPages > 1}
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
{/if}
{:else}
<div class="flex items-center justify-center py-12">
<div class="space-y-2 text-center">
<p class="text-muted-foreground">暂无UP主数据</p>
<p class="text-muted-foreground text-sm">请先在B站关注一些UP主或检查账号配置</p>
</div>
</div>
{/if}
</div>

View File

@@ -156,7 +156,7 @@
bind:resetting
onReset={async () => {
try {
const result = await api.resetVideo((videoData as VideoResponse).video.id);
const result = await api.resetVideo(videoData!.video.id);
const data = result.data;
if (data.resetted) {
videoData = {

View File

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