feat: 支持 webui 加载用户的订阅与收藏,一键点击订阅 (#357)
This commit is contained in:
@@ -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" />
|
||||
|
||||
263
web/src/lib/components/subscription-card.svelte
Normal file
263
web/src/lib/components/subscription-card.svelte
Normal 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} />
|
||||
242
web/src/lib/components/subscription-dialog.svelte
Normal file
242
web/src/lib/components/subscription-dialog.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user