feat: 前端支持根据 ID 手动添加订阅 (#374)

This commit is contained in:
ᴀᴍᴛᴏᴀᴇʀ
2025-07-06 22:49:17 +08:00
committed by GitHub
parent e50318870e
commit 7bb4e7bc44
28 changed files with 736 additions and 106 deletions

View File

@@ -12,9 +12,9 @@ import type {
FavoritesResponse,
CollectionsResponse,
UppersResponse,
UpsertFavoriteRequest,
UpsertCollectionRequest,
UpsertSubmissionRequest,
InsertFavoriteRequest,
InsertCollectionRequest,
InsertSubmissionRequest,
VideoSourcesDetailsResponse,
UpdateVideoSourceRequest,
Config
@@ -185,15 +185,15 @@ class ApiClient {
return this.get<UppersResponse>('/me/uppers', params as Record<string, unknown>);
}
async upsertFavorite(request: UpsertFavoriteRequest): Promise<ApiResponse<boolean>> {
async insertFavorite(request: InsertFavoriteRequest): Promise<ApiResponse<boolean>> {
return this.post<boolean>('/video-sources/favorites', request);
}
async upsertCollection(request: UpsertCollectionRequest): Promise<ApiResponse<boolean>> {
async insertCollection(request: InsertCollectionRequest): Promise<ApiResponse<boolean>> {
return this.post<boolean>('/video-sources/collections', request);
}
async upsertSubmission(request: UpsertSubmissionRequest): Promise<ApiResponse<boolean>> {
async insertSubmission(request: InsertSubmissionRequest): Promise<ApiResponse<boolean>> {
return this.post<boolean>('/video-sources/submissions', request);
}
@@ -235,9 +235,9 @@ const api = {
apiClient.getFollowedCollections(pageNum, pageSize),
getFollowedUppers: (pageNum?: number, pageSize?: number) =>
apiClient.getFollowedUppers(pageNum, pageSize),
upsertFavorite: (request: UpsertFavoriteRequest) => apiClient.upsertFavorite(request),
upsertCollection: (request: UpsertCollectionRequest) => apiClient.upsertCollection(request),
upsertSubmission: (request: UpsertSubmissionRequest) => apiClient.upsertSubmission(request),
insertFavorite: (request: InsertFavoriteRequest) => apiClient.insertFavorite(request),
insertCollection: (request: InsertCollectionRequest) => apiClient.insertCollection(request),
insertSubmission: (request: InsertSubmissionRequest) => apiClient.insertSubmission(request),
getVideoSourcesDetails: () => apiClient.getVideoSourcesDetails(),
updateVideoSource: (type: string, id: number, request: UpdateVideoSourceRequest) =>
apiClient.updateVideoSource(type, id, request),

View File

@@ -16,9 +16,9 @@
FavoriteWithSubscriptionStatus,
CollectionWithSubscriptionStatus,
UpperWithSubscriptionStatus,
UpsertFavoriteRequest,
UpsertCollectionRequest,
UpsertSubmissionRequest,
InsertFavoriteRequest,
InsertCollectionRequest,
InsertSubmissionRequest,
ApiError
} from '$lib/types';
@@ -94,30 +94,30 @@
switch (type) {
case 'favorite': {
const favorite = item as FavoriteWithSubscriptionStatus;
const request: UpsertFavoriteRequest = {
const request: InsertFavoriteRequest = {
fid: favorite.fid,
path: customPath.trim()
};
response = await api.upsertFavorite(request);
response = await api.insertFavorite(request);
break;
}
case 'collection': {
const collection = item as CollectionWithSubscriptionStatus;
const request: UpsertCollectionRequest = {
const request: InsertCollectionRequest = {
sid: collection.sid,
mid: collection.mid,
path: customPath.trim()
};
response = await api.upsertCollection(request);
response = await api.insertCollection(request);
break;
}
case 'upper': {
const upper = item as UpperWithSubscriptionStatus;
const request: UpsertSubmissionRequest = {
const request: InsertSubmissionRequest = {
upper_id: upper.mid,
path: customPath.trim()
};
response = await api.upsertSubmission(request);
response = await api.insertSubmission(request);
break;
}
}

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import XIcon from '@lucide/svelte/icons/x';
import type { Snippet } from 'svelte';
import * as Dialog from './index.js';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<Dialog.Portal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
'bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</Dialog.Portal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn('text-muted-foreground text-sm', 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<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...restProps}
>
{@render children?.()}
</div>

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<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn('text-lg leading-none font-semibold', className)}
{...restProps}
/>

View File

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

View File

@@ -0,0 +1,37 @@
import { Dialog as DialogPrimitive } from 'bits-ui';
import Title from './dialog-title.svelte';
import Footer from './dialog-footer.svelte';
import Header from './dialog-header.svelte';
import Overlay from './dialog-overlay.svelte';
import Content from './dialog-content.svelte';
import Description from './dialog-description.svelte';
import Trigger from './dialog-trigger.svelte';
import Close from './dialog-close.svelte';
const Root = DialogPrimitive.Root;
const Portal = DialogPrimitive.Portal;
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose
};

View File

@@ -0,0 +1,37 @@
import { Select as SelectPrimitive } from 'bits-ui';
import Group from './select-group.svelte';
import Label from './select-label.svelte';
import Item from './select-item.svelte';
import Content from './select-content.svelte';
import Trigger from './select-trigger.svelte';
import Separator from './select-separator.svelte';
import ScrollDownButton from './select-scroll-down-button.svelte';
import ScrollUpButton from './select-scroll-up-button.svelte';
import GroupHeading from './select-group-heading.svelte';
const Root = SelectPrimitive.Root;
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading
};

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { Select as SelectPrimitive } from 'bits-ui';
import SelectScrollUpButton from './select-scroll-up-button.svelte';
import SelectScrollDownButton from './select-scroll-down-button.svelte';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: SelectPrimitive.PortalProps;
} = $props();
</script>
<SelectPrimitive.Portal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
data-slot="select-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 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
'h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1'
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...restProps}
>
{@render children?.()}
</SelectPrimitive.GroupHeading>

View File

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

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import CheckIcon from '@lucide/svelte/icons/check';
import { Select as SelectPrimitive } from 'bits-ui';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 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 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute right-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>

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<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { Select as SelectPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn('flex cursor-default items-center justify-center py-1', className)}
{...restProps}
>
<ChevronDownIcon class="size-4" />
</SelectPrimitive.ScrollDownButton>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
import { Select as SelectPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn('flex cursor-default items-center justify-center py-1', className)}
{...restProps}
>
<ChevronUpIcon class="size-4" />
</SelectPrimitive.ScrollUpButton>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from 'bits-ui';
import { Separator } from '$lib/components/ui/separator/index.js';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...restProps}
/>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { Select as SelectPrimitive } from 'bits-ui';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
size = 'default',
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: 'sm' | 'default';
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
</SelectPrimitive.Trigger>

View File

@@ -145,19 +145,19 @@ export interface UppersResponse {
total: number;
}
export interface UpsertFavoriteRequest {
export interface InsertFavoriteRequest {
fid: number;
path: string;
}
export interface UpsertCollectionRequest {
export interface InsertCollectionRequest {
sid: number;
mid: number;
collection_type?: number;
path: string;
}
export interface UpsertSubmissionRequest {
export interface InsertSubmissionRequest {
upper_id: number;
path: string;
}

View File

@@ -623,8 +623,8 @@
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
bind:value={formData.nfo_time_type}
>
<option value="FavTime">收藏时间</option>
<option value="PubTime">发布时间</option>
<option value="favtime">收藏时间</option>
<option value="pubtime">发布时间</option>
</select>
</div>
</div>

View File

@@ -2,8 +2,11 @@
import { onMount } from 'svelte';
import { Button } from '$lib/components/ui/button/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import EditIcon from '@lucide/svelte/icons/edit';
import SaveIcon from '@lucide/svelte/icons/save';
import XIcon from '@lucide/svelte/icons/x';
@@ -11,6 +14,7 @@
import HeartIcon from '@lucide/svelte/icons/heart';
import UserIcon from '@lucide/svelte/icons/user';
import ClockIcon from '@lucide/svelte/icons/clock';
import PlusIcon from '@lucide/svelte/icons/plus';
import { toast } from 'svelte-sonner';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import { goto } from '$app/navigation';
@@ -22,6 +26,16 @@
let loading = false;
let activeTab = 'favorites';
// 添加对话框状态
let showAddDialog = false;
let addDialogType: 'favorites' | 'collections' | 'submissions' = 'favorites';
let adding = false;
// 表单数据
let favoriteForm = { fid: '', path: '' };
let collectionForm = { sid: '', mid: '', collection_type: '2', path: '' }; // 默认为合集
let submissionForm = { upper_id: '', path: '' };
type ExtendedVideoSource = VideoSourceDetail & {
type: string;
originalIndex: number;
@@ -31,10 +45,10 @@
};
const TAB_CONFIG = {
favorites: { label: '收藏夹', icon: HeartIcon, color: 'bg-red-500' },
collections: { label: '合集 / 列表', icon: FolderIcon, color: 'bg-blue-500' },
submissions: { label: '用户投稿', icon: UserIcon, color: 'bg-green-500' },
watch_later: { label: '稍后再看', icon: ClockIcon, color: 'bg-yellow-500' }
favorites: { label: '收藏夹', icon: HeartIcon },
collections: { label: '合集 / 列表', icon: FolderIcon },
submissions: { label: '用户投稿', icon: UserIcon },
watch_later: { label: '稍后再看', icon: ClockIcon }
} as const;
// 数据加载
@@ -123,6 +137,67 @@
});
}
// 打开添加对话框
function openAddDialog(type: 'favorites' | 'collections' | 'submissions') {
addDialogType = type;
// 重置表单
favoriteForm = { fid: '', path: '' };
collectionForm = { sid: '', mid: '', collection_type: '2', path: '' };
submissionForm = { upper_id: '', path: '' };
showAddDialog = true;
}
// 处理添加
async function handleAdd() {
adding = true;
try {
switch (addDialogType) {
case 'favorites':
if (!favoriteForm.fid || !favoriteForm.path.trim()) {
toast.error('请填写完整的收藏夹信息');
return;
}
await api.insertFavorite({
fid: parseInt(favoriteForm.fid),
path: favoriteForm.path
});
break;
case 'collections':
if (!collectionForm.sid || !collectionForm.mid || !collectionForm.path.trim()) {
toast.error('请填写完整的合集信息');
return;
}
await api.insertCollection({
sid: parseInt(collectionForm.sid),
mid: parseInt(collectionForm.mid),
collection_type: parseInt(collectionForm.collection_type),
path: collectionForm.path
});
break;
case 'submissions':
if (!submissionForm.upper_id || !submissionForm.path.trim()) {
toast.error('请填写完整的用户投稿信息');
return;
}
await api.insertSubmission({
upper_id: parseInt(submissionForm.upper_id),
path: submissionForm.path
});
break;
}
toast.success('添加成功');
showAddDialog = false;
loadVideoSources(); // 重新加载数据
} catch (error) {
toast.error('添加失败', {
description: (error as ApiError).message
});
} finally {
adding = false;
}
}
// 初始化
onMount(() => {
setBreadcrumb([
@@ -142,36 +217,33 @@
<title>视频源管理 - Bili Sync</title>
</svelte:head>
<div class="max-w-6xl">
<div class="space-y-6">
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="text-muted-foreground">加载中...</div>
</div>
{:else if videoSourcesData}
<Tabs.Root bind:value={activeTab} class="w-full">
<Tabs.List class="grid h-12 w-full grid-cols-4 bg-transparent p-0">
<Tabs.List class="grid w-full grid-cols-4">
{#each Object.entries(TAB_CONFIG) as [key, config] (key)}
{@const sources = getSourcesForTab(key)}
<Tabs.Trigger
value={key}
class="data-[state=active]:bg-muted/50 data-[state=active]:text-foreground text-muted-foreground hover:bg-muted/30 hover:text-foreground mx-1 flex min-w-0 items-center justify-center gap-2 rounded-lg bg-transparent px-2 py-3 text-sm font-medium transition-all sm:px-4"
>
<div
class="flex h-5 w-5 items-center justify-center rounded-full {config.color} flex-shrink-0"
>
<svelte:component this={config.icon} class="h-3 w-3 text-white" />
</div>
<span class="hidden truncate sm:inline">{config.label}</span>
<span
class="bg-background/50 flex-shrink-0 rounded-full px-2 py-0.5 text-xs font-medium"
>{sources.length}</span
>
<Tabs.Trigger value={key} class="relative">
{config.label}{sources.length}
</Tabs.Trigger>
{/each}
</Tabs.List>
{#each Object.entries(TAB_CONFIG) as [key, config] (key)}
{@const sources = getSourcesForTab(key)}
<Tabs.Content value={key} class="mt-6">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-medium">{config.label}管理</h3>
{#if key === 'favorites' || key === 'collections' || key === 'submissions'}
<Button size="sm" onclick={() => openAddDialog(key)} class="flex items-center gap-2">
<PlusIcon class="h-4 w-4" />
手动添加
</Button>
{/if}
</div>
{#if sources.length > 0}
<div class="overflow-x-auto">
<Table.Root>
@@ -259,15 +331,25 @@
</div>
{:else}
<div class="flex flex-col items-center justify-center py-12">
<div
class="flex h-12 w-12 items-center justify-center rounded-full {config.color} mb-4"
>
<svelte:component this={config.icon} class="h-6 w-6 text-white" />
</div>
<div class="text-muted-foreground mb-2">暂无{config.label}</div>
<p class="text-muted-foreground text-sm">
请先添加{config.label}订阅
<svelte:component this={config.icon} class="text-muted-foreground mb-4 h-12 w-12" />
<div class="text-muted-foreground mb-2 text-lg font-medium">暂无{config.label}</div>
<p class="text-muted-foreground mb-4 text-center text-sm">
{#if key === 'favorites'}
还没有添加任何收藏夹订阅
{:else if key === 'collections'}
还没有添加任何合集或列表订阅
{:else if key === 'submissions'}
还没有添加任何用户投稿订阅
{:else}
还没有添加稍后再看订阅
{/if}
</p>
{#if key === 'favorites' || key === 'collections' || key === 'submissions'}
<Button onclick={() => openAddDialog(key)} class="flex items-center gap-2">
<PlusIcon class="h-4 w-4" />
手动添加
</Button>
{/if}
</div>
{/if}
</Tabs.Content>
@@ -280,4 +362,134 @@
<Button class="mt-4" onclick={loadVideoSources}>重新加载</Button>
</div>
{/if}
<Dialog.Root bind:open={showAddDialog}>
<Dialog.Overlay class="data-[state=open]:animate-overlay-show fixed inset-0 bg-black/30" />
<Dialog.Content
class="data-[state=open]:animate-content-show bg-background fixed top-1/2 left-1/2 z-50 max-h-[85vh] w-full max-w-3xl -translate-x-1/2 -translate-y-1/2 rounded-lg border p-6 shadow-md outline-none"
>
<Dialog.Title class="text-lg font-semibold">
{#if addDialogType === 'favorites'}
添加收藏夹
{:else if addDialogType === 'collections'}
添加合集
{:else}
添加用户投稿
{/if}
</Dialog.Title>
<div class="mt-4">
{#if addDialogType === 'favorites'}
<div class="space-y-4">
<div>
<Label for="fid" class="text-sm font-medium">收藏夹ID (fid)</Label>
<Input
id="fid"
type="number"
bind:value={favoriteForm.fid}
placeholder="请输入收藏夹ID"
class="mt-1"
/>
</div>
</div>
{:else if addDialogType === 'collections'}
<div class="space-y-4">
<div>
<Label for="collection-type" class="text-sm font-medium">合集类型</Label>
<select
id="collection-type"
bind:value={collectionForm.collection_type}
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring mt-1 flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="1">列表 (Series)</option>
<option value="2">合集 (Season)</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<Label for="sid" class="text-sm font-medium">
{collectionForm.collection_type === '1'
? '列表ID (series_id)'
: '合集ID (season_id)'}
</Label>
<Input
id="sid"
type="number"
bind:value={collectionForm.sid}
placeholder={collectionForm.collection_type === '1'
? '请输入列表ID'
: '请输入合集ID'}
class="mt-1"
/>
</div>
<div>
<Label for="mid" class="text-sm font-medium">用户ID (mid)</Label>
<Input
id="mid"
type="number"
bind:value={collectionForm.mid}
placeholder="请输入用户ID"
class="mt-1"
/>
</div>
</div>
<p class="text-muted-foreground text-xs">可从合集/列表页面URL中获取相应ID</p>
</div>
{:else}
<div class="space-y-4">
<div>
<Label for="upper_id" class="text-sm font-medium">UP主ID (mid)</Label>
<Input
id="upper_id"
type="number"
bind:value={submissionForm.upper_id}
placeholder="请输入UP主ID"
class="mt-1"
/>
</div>
</div>
{/if}
<div class="mt-4">
<Label for="path" class="text-sm font-medium">下载路径</Label>
{#if addDialogType === 'favorites'}
<Input
id="path"
type="text"
bind:value={favoriteForm.path}
placeholder="请输入下载路径,例如:/path/to/download"
class="mt-1"
/>
{:else if addDialogType === 'collections'}
<Input
id="path"
type="text"
bind:value={collectionForm.path}
placeholder="请输入下载路径,例如:/path/to/download"
class="mt-1"
/>
{:else}
<Input
id="path"
type="text"
bind:value={submissionForm.path}
placeholder="请输入下载路径,例如:/path/to/download"
class="mt-1"
/>
{/if}
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<Button
variant="outline"
onclick={() => (showAddDialog = false)}
disabled={adding}
class="px-4"
>
取消
</Button>
<Button onclick={handleAdd} disabled={adding} class="px-4">
{adding ? '添加中...' : '添加'}
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
</div>