feat: 添加视频源管理页,支持修改路径与启用状态 (#369)
This commit is contained in:
@@ -14,7 +14,9 @@ import type {
|
||||
UppersResponse,
|
||||
UpsertFavoriteRequest,
|
||||
UpsertCollectionRequest,
|
||||
UpsertSubmissionRequest
|
||||
UpsertSubmissionRequest,
|
||||
VideoSourcesDetailsResponse,
|
||||
UpdateVideoSourceRequest
|
||||
} from './types';
|
||||
|
||||
// API 基础配置
|
||||
@@ -235,6 +237,27 @@ class ApiClient {
|
||||
async upsertSubmission(request: UpsertSubmissionRequest): Promise<ApiResponse<boolean>> {
|
||||
return this.post<boolean>('/video-sources/submissions', request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有视频源的详细信息
|
||||
*/
|
||||
async getVideoSourcesDetails(): Promise<ApiResponse<VideoSourcesDetailsResponse>> {
|
||||
return this.get<VideoSourcesDetailsResponse>('/video-sources/details');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新视频源
|
||||
* @param type 视频源类型
|
||||
* @param id 视频源 ID
|
||||
* @param request 更新请求
|
||||
*/
|
||||
async updateVideoSource(
|
||||
type: string,
|
||||
id: number,
|
||||
request: UpdateVideoSourceRequest
|
||||
): Promise<ApiResponse<boolean>> {
|
||||
return this.put<boolean>(`/video-sources/${type}/${id}`, request);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建默认的 API 客户端实例
|
||||
@@ -305,6 +328,17 @@ export const api = {
|
||||
*/
|
||||
upsertSubmission: (request: UpsertSubmissionRequest) => apiClient.upsertSubmission(request),
|
||||
|
||||
/**
|
||||
* 获取所有视频源的详细信息
|
||||
*/
|
||||
getVideoSourcesDetails: () => apiClient.getVideoSourcesDetails(),
|
||||
|
||||
/**
|
||||
* 更新视频源
|
||||
*/
|
||||
updateVideoSource: (type: string, id: number, request: UpdateVideoSourceRequest) =>
|
||||
apiClient.updateVideoSource(type, id, request),
|
||||
|
||||
/**
|
||||
* 设置认证 token
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import HeartIcon from '@lucide/svelte/icons/heart';
|
||||
import FolderIcon from '@lucide/svelte/icons/folder';
|
||||
import DatabaseIcon from '@lucide/svelte/icons/database';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js';
|
||||
import {
|
||||
@@ -183,6 +184,24 @@
|
||||
<!-- 固定在底部的菜单选项 -->
|
||||
<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
|
||||
|
||||
7
web/src/lib/components/ui/switch/index.ts
Normal file
7
web/src/lib/components/ui/switch/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from './switch.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Switch
|
||||
};
|
||||
29
web/src/lib/components/ui/switch/switch.svelte
Normal file
29
web/src/lib/components/ui/switch/switch.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { Switch as SwitchPrimitive } from 'bits-ui';
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
checked = $bindable(false),
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<SwitchPrimitive.Root
|
||||
bind:ref
|
||||
bind:checked
|
||||
data-slot="switch"
|
||||
class={cn(
|
||||
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
class={cn(
|
||||
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
28
web/src/lib/components/ui/table/index.ts
Normal file
28
web/src/lib/components/ui/table/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import Root from './table.svelte';
|
||||
import Body from './table-body.svelte';
|
||||
import Caption from './table-caption.svelte';
|
||||
import Cell from './table-cell.svelte';
|
||||
import Footer from './table-footer.svelte';
|
||||
import Head from './table-head.svelte';
|
||||
import Header from './table-header.svelte';
|
||||
import Row from './table-row.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Body,
|
||||
Caption,
|
||||
Cell,
|
||||
Footer,
|
||||
Head,
|
||||
Header,
|
||||
Row,
|
||||
//
|
||||
Root as Table,
|
||||
Body as TableBody,
|
||||
Caption as TableCaption,
|
||||
Cell as TableCell,
|
||||
Footer as TableFooter,
|
||||
Head as TableHead,
|
||||
Header as TableHeader,
|
||||
Row as TableRow
|
||||
};
|
||||
20
web/src/lib/components/ui/table/table-body.svelte
Normal file
20
web/src/lib/components/ui/table/table-body.svelte
Normal 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<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tbody
|
||||
bind:this={ref}
|
||||
data-slot="table-body"
|
||||
class={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</tbody>
|
||||
20
web/src/lib/components/ui/table/table-caption.svelte
Normal file
20
web/src/lib/components/ui/table/table-caption.svelte
Normal 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<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<caption
|
||||
bind:this={ref}
|
||||
data-slot="table-caption"
|
||||
class={cn('text-muted-foreground mt-4 text-sm', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</caption>
|
||||
20
web/src/lib/components/ui/table/table-cell.svelte
Normal file
20
web/src/lib/components/ui/table/table-cell.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLTdAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLTdAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<td
|
||||
bind:this={ref}
|
||||
data-slot="table-cell"
|
||||
class={cn('p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</td>
|
||||
20
web/src/lib/components/ui/table/table-footer.svelte
Normal file
20
web/src/lib/components/ui/table/table-footer.svelte
Normal 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<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tfoot
|
||||
bind:this={ref}
|
||||
data-slot="table-footer"
|
||||
class={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</tfoot>
|
||||
23
web/src/lib/components/ui/table/table-head.svelte
Normal file
23
web/src/lib/components/ui/table/table-head.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLThAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLThAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<th
|
||||
bind:this={ref}
|
||||
data-slot="table-head"
|
||||
class={cn(
|
||||
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</th>
|
||||
20
web/src/lib/components/ui/table/table-header.svelte
Normal file
20
web/src/lib/components/ui/table/table-header.svelte
Normal 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<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<thead
|
||||
bind:this={ref}
|
||||
data-slot="table-header"
|
||||
class={cn('[&_tr]:border-b', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</thead>
|
||||
23
web/src/lib/components/ui/table/table-row.svelte
Normal file
23
web/src/lib/components/ui/table/table-row.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<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<HTMLTableRowElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tr
|
||||
bind:this={ref}
|
||||
data-slot="table-row"
|
||||
class={cn(
|
||||
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</tr>
|
||||
22
web/src/lib/components/ui/table/table.svelte
Normal file
22
web/src/lib/components/ui/table/table.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLTableAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLTableAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<div data-slot="table-container" class="relative w-full overflow-x-auto">
|
||||
<table
|
||||
bind:this={ref}
|
||||
data-slot="table"
|
||||
class={cn('w-full caption-bottom text-sm', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</table>
|
||||
</div>
|
||||
@@ -160,3 +160,25 @@ export interface UpsertSubmissionRequest {
|
||||
upper_id: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// 视频源详细信息类型
|
||||
export interface VideoSourceDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// 视频源详细信息响应类型
|
||||
export interface VideoSourcesDetailsResponse {
|
||||
collections: VideoSourceDetail[];
|
||||
favorites: VideoSourceDetail[];
|
||||
submissions: VideoSourceDetail[];
|
||||
watch_later: VideoSourceDetail[];
|
||||
}
|
||||
|
||||
// 更新视频源请求类型
|
||||
export interface UpdateVideoSourceRequest {
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
283
web/src/routes/video-sources/+page.svelte
Normal file
283
web/src/routes/video-sources/+page.svelte
Normal file
@@ -0,0 +1,283 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import EditIcon from '@lucide/svelte/icons/edit';
|
||||
import SaveIcon from '@lucide/svelte/icons/save';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import FolderIcon from '@lucide/svelte/icons/folder';
|
||||
import HeartIcon from '@lucide/svelte/icons/heart';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { setBreadcrumb } from '$lib/stores/breadcrumb';
|
||||
import { goto } from '$app/navigation';
|
||||
import { appStateStore, ToQuery } from '$lib/stores/filter';
|
||||
import type { ApiError, VideoSourceDetail, VideoSourcesDetailsResponse } from '$lib/types';
|
||||
import api from '$lib/api';
|
||||
|
||||
let videoSourcesData: VideoSourcesDetailsResponse | null = null;
|
||||
let loading = false;
|
||||
let activeTab = 'favorites';
|
||||
|
||||
type ExtendedVideoSource = VideoSourceDetail & {
|
||||
type: string;
|
||||
originalIndex: number;
|
||||
editing?: boolean;
|
||||
editingPath?: string;
|
||||
editingEnabled?: boolean;
|
||||
};
|
||||
|
||||
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' }
|
||||
} as const;
|
||||
|
||||
// 数据加载
|
||||
async function loadVideoSources() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await api.getVideoSourcesDetails();
|
||||
videoSourcesData = response.data;
|
||||
} catch (error) {
|
||||
toast.error('加载视频源失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(type: string, index: number) {
|
||||
if (!videoSourcesData) return;
|
||||
const sources = videoSourcesData[type as keyof VideoSourcesDetailsResponse];
|
||||
if (!sources?.[index]) return;
|
||||
|
||||
const source = sources[index] as ExtendedVideoSource;
|
||||
source.editing = true;
|
||||
source.editingPath = source.path;
|
||||
source.editingEnabled = source.enabled;
|
||||
videoSourcesData = { ...videoSourcesData };
|
||||
}
|
||||
|
||||
function cancelEdit(type: string, index: number) {
|
||||
if (!videoSourcesData) return;
|
||||
const sources = videoSourcesData[type as keyof VideoSourcesDetailsResponse];
|
||||
if (!sources?.[index]) return;
|
||||
|
||||
const source = sources[index] as ExtendedVideoSource;
|
||||
source.editing = false;
|
||||
source.editingPath = undefined;
|
||||
source.editingEnabled = undefined;
|
||||
videoSourcesData = { ...videoSourcesData };
|
||||
}
|
||||
|
||||
async function saveEdit(type: string, index: number) {
|
||||
if (!videoSourcesData) return;
|
||||
const sources = videoSourcesData[type as keyof VideoSourcesDetailsResponse];
|
||||
if (!sources?.[index]) return;
|
||||
|
||||
const source = sources[index] as ExtendedVideoSource;
|
||||
if (!source.editingPath?.trim()) {
|
||||
toast.error('路径不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.updateVideoSource(type, source.id, {
|
||||
path: source.editingPath,
|
||||
enabled: source.editingEnabled ?? false
|
||||
});
|
||||
|
||||
source.path = source.editingPath;
|
||||
source.enabled = source.editingEnabled ?? false;
|
||||
source.editing = false;
|
||||
source.editingPath = undefined;
|
||||
source.editingEnabled = undefined;
|
||||
videoSourcesData = { ...videoSourcesData };
|
||||
|
||||
toast.success('保存成功');
|
||||
} catch (error) {
|
||||
toast.error('保存失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getSourcesForTab(tabValue: string): ExtendedVideoSource[] {
|
||||
if (!videoSourcesData) return [];
|
||||
const sources = videoSourcesData[
|
||||
tabValue as keyof VideoSourcesDetailsResponse
|
||||
] as VideoSourceDetail[];
|
||||
// 直接返回原始数据的引用,只添加必要的属性
|
||||
return sources.map((source, originalIndex) => {
|
||||
// 使用类型断言来扩展 VideoSourceDetail
|
||||
const extendedSource = source as ExtendedVideoSource;
|
||||
extendedSource.type = tabValue;
|
||||
extendedSource.originalIndex = originalIndex;
|
||||
return extendedSource;
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMount(() => {
|
||||
setBreadcrumb([
|
||||
{
|
||||
label: '主页',
|
||||
onClick: () => {
|
||||
goto(`/${ToQuery($appStateStore)}`);
|
||||
}
|
||||
},
|
||||
{ label: '视频源管理', isActive: true }
|
||||
]);
|
||||
loadVideoSources();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>视频源管理 - Bili Sync</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-6xl">
|
||||
{#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">
|
||||
{#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>
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
{#each Object.entries(TAB_CONFIG) as [key, config] (key)}
|
||||
{@const sources = getSourcesForTab(key)}
|
||||
<Tabs.Content value={key} class="mt-6">
|
||||
{#if sources.length > 0}
|
||||
<div class="overflow-x-auto">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[30%] md:w-[25%]">名称</Table.Head>
|
||||
<Table.Head class="w-[30%] md:w-[40%]">下载路径</Table.Head>
|
||||
<Table.Head class="w-[25%] md:w-[20%]">状态</Table.Head>
|
||||
<Table.Head class="w-[15%] text-right sm:w-[12%]">操作</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each sources as source, index (index)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="w-[30%] font-medium md:w-[25%]">{source.name}</Table.Cell>
|
||||
<Table.Cell class="w-[30%] md:w-[40%]">
|
||||
{#if source.editing}
|
||||
<input
|
||||
bind:value={source.editingPath}
|
||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-8 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"
|
||||
placeholder="输入下载路径"
|
||||
/>
|
||||
{:else}
|
||||
<code
|
||||
class="bg-muted text-muted-foreground inline-flex h-8 items-center rounded px-3 py-1 text-sm"
|
||||
>
|
||||
{source.path || '未设置'}
|
||||
</code>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="w-[25%] md:w-[20%]">
|
||||
{#if source.editing}
|
||||
<div class="flex h-8 items-center">
|
||||
<Switch bind:checked={source.editingEnabled} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-8 items-center gap-2">
|
||||
<Switch checked={source.enabled} disabled />
|
||||
<span class="text-muted-foreground text-sm whitespace-nowrap">
|
||||
{source.enabled ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="w-[15%] text-right sm:w-[12%]">
|
||||
{#if source.editing}
|
||||
<div
|
||||
class="flex flex-col items-end justify-end gap-1 sm:flex-row sm:items-center"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => saveEdit(key, source.originalIndex)}
|
||||
class="h-7 w-7 p-0 sm:h-8 sm:w-8"
|
||||
title="保存"
|
||||
>
|
||||
<SaveIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => cancelEdit(key, source.originalIndex)}
|
||||
class="h-7 w-7 p-0 sm:h-8 sm:w-8"
|
||||
title="取消"
|
||||
>
|
||||
<XIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => startEdit(key, source.originalIndex)}
|
||||
class="h-7 w-7 p-0 sm:h-8 sm:w-8"
|
||||
title="编辑"
|
||||
>
|
||||
<EditIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</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}订阅
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
{/each}
|
||||
</Tabs.Root>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-12">
|
||||
<div class="text-muted-foreground mb-2">加载失败</div>
|
||||
<p class="text-muted-foreground text-sm">请刷新页面重试</p>
|
||||
<Button class="mt-4" onclick={loadVideoSources}>重新加载</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user