feat: 添加视频源管理页,支持修改路径与启用状态 (#369)

This commit is contained in:
ᴀᴍᴛᴏᴀᴇʀ
2025-06-17 18:55:45 +08:00
committed by GitHub
parent f47ce92a51
commit 28971c3ff3
21 changed files with 769 additions and 16 deletions

View File

@@ -6,7 +6,7 @@ use axum::Router;
use axum::body::Body;
use axum::extract::{Extension, Path, Query};
use axum::response::Response;
use axum::routing::{get, post};
use axum::routing::{get, post, put};
use bili_sync_entity::*;
use bili_sync_migration::{Expr, OnConflict};
use reqwest::{Method, StatusCode, header};
@@ -18,17 +18,19 @@ use sea_orm::{
use utoipa::OpenApi;
use super::request::ImageProxyParams;
use crate::adapter::_ActiveModel;
use crate::api::auth::OpenAPIAuth;
use crate::api::error::InnerApiError;
use crate::api::helper::{update_page_download_status, update_video_download_status};
use crate::api::request::{
FollowedCollectionsRequest, FollowedUppersRequest, UpdateVideoStatusRequest, UpsertCollectionRequest,
UpsertFavoriteRequest, UpsertSubmissionRequest, VideosRequest,
FollowedCollectionsRequest, FollowedUppersRequest, UpdateVideoSourceRequest, UpdateVideoStatusRequest,
UpsertCollectionRequest, UpsertFavoriteRequest, UpsertSubmissionRequest, VideosRequest,
};
use crate::api::response::{
CollectionWithSubscriptionStatus, CollectionsResponse, FavoriteWithSubscriptionStatus, FavoritesResponse, PageInfo,
ResetAllVideosResponse, ResetVideoResponse, UpdateVideoStatusResponse, UpperWithSubscriptionStatus, UppersResponse,
VideoInfo, VideoResponse, VideoSource, VideoSourcesResponse, VideosResponse,
VideoInfo, VideoResponse, VideoSource, VideoSourceDetail, VideoSourcesDetailsResponse, VideoSourcesResponse,
VideosResponse,
};
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
use crate::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Me, Submission};
@@ -37,7 +39,7 @@ use crate::utils::status::{PageStatus, VideoStatus};
#[derive(OpenApi)]
#[openapi(
paths(
get_video_sources, get_videos, get_video, reset_video, reset_all_videos, update_video_status,
get_video_sources, get_video_sources_details, update_video_source, get_videos, get_video, reset_video, reset_all_videos, update_video_status,
get_created_favorites, get_followed_collections, get_followed_uppers,
upsert_favorite, upsert_collection, upsert_submission
),
@@ -51,6 +53,8 @@ pub struct ApiDoc;
pub fn api_router() -> Router {
Router::new()
.route("/api/video-sources", get(get_video_sources))
.route("/api/video-sources/details", get(get_video_sources_details))
.route("/api/video-sources/{type}/{id}", put(update_video_source))
.route("/api/video-sources/collections", post(upsert_collection))
.route("/api/video-sources/favorites", post(upsert_favorite))
.route("/api/video-sources/submissions", post(upsert_submission))
@@ -76,7 +80,7 @@ pub fn api_router() -> Router {
pub async fn get_video_sources(
Extension(db): Extension<Arc<DatabaseConnection>>,
) -> Result<ApiResponse<VideoSourcesResponse>, ApiError> {
let (collection, favorite, submission, watch_later) = tokio::try_join!(
let (collection, favorite, submission, mut watch_later) = tokio::try_join!(
collection::Entity::find()
.select_only()
.columns([collection::Column::Id, collection::Column::Name])
@@ -100,6 +104,13 @@ pub async fn get_video_sources(
.into_model::<VideoSource>()
.all(db.as_ref())
)?;
// watch_later 是一个特殊的视频来源,如果不存在则添加一个默认项
if watch_later.is_empty() {
watch_later.push(VideoSource {
id: 1,
name: "稍后再看".to_string(),
});
}
Ok(ApiResponse::ok(VideoSourcesResponse {
collection,
favorite,
@@ -643,6 +654,135 @@ pub async fn upsert_submission(
Ok(ApiResponse::ok(true))
}
/// 获取所有视频源的详细信息,包括 path 和 enabled 状态
#[utoipa::path(
get,
path = "/api/video-sources/details",
responses(
(status = 200, body = ApiResponse<VideoSourcesDetailsResponse>),
)
)]
pub async fn get_video_sources_details(
Extension(db): Extension<Arc<DatabaseConnection>>,
) -> Result<ApiResponse<VideoSourcesDetailsResponse>, ApiError> {
let (collections, favorites, submissions, mut watch_later) = tokio::try_join!(
collection::Entity::find()
.select_only()
.columns([
collection::Column::Id,
collection::Column::Name,
collection::Column::Path,
collection::Column::Enabled
])
.into_model::<VideoSourceDetail>()
.all(db.as_ref()),
favorite::Entity::find()
.select_only()
.columns([
favorite::Column::Id,
favorite::Column::Name,
favorite::Column::Path,
favorite::Column::Enabled
])
.into_model::<VideoSourceDetail>()
.all(db.as_ref()),
submission::Entity::find()
.select_only()
.column(submission::Column::Id)
.column_as(submission::Column::UpperName, "name")
.columns([submission::Column::Path, submission::Column::Enabled])
.into_model::<VideoSourceDetail>()
.all(db.as_ref()),
watch_later::Entity::find()
.select_only()
.column(watch_later::Column::Id)
.column_as(Expr::value("稍后再看"), "name")
.columns([watch_later::Column::Path, watch_later::Column::Enabled])
.into_model::<VideoSourceDetail>()
.all(db.as_ref())
)?;
if watch_later.is_empty() {
watch_later.push(VideoSourceDetail {
id: 1,
name: "稍后再看".to_string(),
path: String::new(),
enabled: false,
})
}
Ok(ApiResponse::ok(VideoSourcesDetailsResponse {
collections,
favorites,
submissions,
watch_later,
}))
}
/// 更新视频源的 path 和 enabled 状态
#[utoipa::path(
put,
path = "/api/video-sources/{type}/{id}",
request_body = UpdateVideoSourceRequest,
responses(
(status = 200, body = ApiResponse<bool>),
)
)]
pub async fn update_video_source(
Path((source_type, id)): Path<(String, i32)>,
Extension(db): Extension<Arc<DatabaseConnection>>,
ValidatedJson(request): ValidatedJson<UpdateVideoSourceRequest>,
) -> Result<ApiResponse<bool>, ApiError> {
let active_model = match source_type.as_str() {
"collections" => collection::Entity::find_by_id(id).one(db.as_ref()).await?.map(|model| {
let mut active_model: collection::ActiveModel = model.into();
active_model.path = Set(request.path);
active_model.enabled = Set(request.enabled);
_ActiveModel::Collection(active_model)
}),
"favorites" => favorite::Entity::find_by_id(id).one(db.as_ref()).await?.map(|model| {
let mut active_model: favorite::ActiveModel = model.into();
active_model.path = Set(request.path);
active_model.enabled = Set(request.enabled);
_ActiveModel::Favorite(active_model)
}),
"submissions" => submission::Entity::find_by_id(id).one(db.as_ref()).await?.map(|model| {
let mut active_model: submission::ActiveModel = model.into();
active_model.path = Set(request.path);
active_model.enabled = Set(request.enabled);
_ActiveModel::Submission(active_model)
}),
"watch_later" => match watch_later::Entity::find_by_id(id).one(db.as_ref()).await? {
// 稍后再看需要做特殊处理get 时如果稍后再看不存在返回的是 id 为 1 的假记录
// 因此此处可能是更新也可能是插入,做个额外的处理
Some(model) => {
// 如果有记录,使用 id 对应的记录更新
let mut active_model: watch_later::ActiveModel = model.into();
active_model.path = Set(request.path);
active_model.enabled = Set(request.enabled);
Some(_ActiveModel::WatchLater(active_model))
}
None => {
if id != 1 {
None
} else {
// 如果没有记录且 id 为 1插入一个新的稍后再看记录
Some(_ActiveModel::WatchLater(watch_later::ActiveModel {
id: Set(1),
path: Set(request.path),
enabled: Set(request.enabled),
..Default::default()
}))
}
}
},
_ => return Err(InnerApiError::BadRequest("Invalid video source type".to_string()).into()),
};
let Some(active_model) = active_model else {
return Err(InnerApiError::NotFound(id).into());
};
active_model.save(db.as_ref()).await?;
Ok(ApiResponse::ok(true))
}
/// B 站的图片会检查 referer需要做个转发伪造一下否则直接返回 403
pub async fn image_proxy(
Extension(bili_client): Extension<Arc<BiliClient>>,

View File

@@ -80,3 +80,10 @@ pub struct UpsertSubmissionRequest {
pub struct ImageProxyParams {
pub url: String,
}
#[derive(Deserialize, ToSchema, Validate)]
pub struct UpdateVideoSourceRequest {
#[validate(custom(function = "crate::utils::validation::validate_path"))]
pub path: String,
pub enabled: bool,
}

View File

@@ -48,8 +48,8 @@ pub struct UpdateVideoStatusResponse {
#[derive(FromQueryResult, Serialize, ToSchema)]
pub struct VideoSource {
id: i32,
name: String,
pub id: i32,
pub name: String,
}
#[derive(Serialize, ToSchema, DerivePartialModel, FromQueryResult)]
@@ -135,3 +135,19 @@ pub struct UppersResponse {
pub uppers: Vec<UpperWithSubscriptionStatus>,
pub total: i64,
}
#[derive(Serialize, ToSchema)]
pub struct VideoSourcesDetailsResponse {
pub collections: Vec<VideoSourceDetail>,
pub favorites: Vec<VideoSourceDetail>,
pub submissions: Vec<VideoSourceDetail>,
pub watch_later: Vec<VideoSourceDetail>,
}
#[derive(Serialize, ToSchema, FromQueryResult)]
pub struct VideoSourceDetail {
pub id: i32,
pub name: String,
pub path: String,
pub enabled: bool,
}

View File

@@ -6,7 +6,7 @@
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@internationalized/date": "^3.5.6",
"@internationalized/date": "^3.8.1",
"@lucide/svelte": "^0.482.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0",
@@ -14,7 +14,7 @@
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"bits-ui": "^2.1.0",
"bits-ui": "^2.7.0",
"clsx": "^2.1.1",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
@@ -276,7 +276,7 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"bits-ui": ["bits-ui@2.3.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/dom": "^1.7.0", "css.escape": "^1.5.1", "esm-env": "^1.1.2", "runed": "^0.28.0", "svelte-toolbelt": "^0.9.1", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-PgDcs49PdHGRC7T1tTNqWhMrTHgOVE0q/q03K7n64V5MxeB2aMrbAqoW9Wg+43P4u2vMzUn8ifhLBPSfZDNceg=="],
"bits-ui": ["bits-ui@2.8.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/dom": "^1.7.0", "css.escape": "^1.5.1", "esm-env": "^1.1.2", "runed": "^0.28.0", "svelte-toolbelt": "^0.9.1", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-WiTZcCbYLm4Cx6/67NqXVSD0BkfNmdX8Abs84HpIaplX/wRRbg8tkMtJYlLw7mepgGvwGR3enLi6tFkcHU3JXA=="],
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://next.shadcn-svelte.com/schema.json",
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "slate"
@@ -12,5 +12,5 @@
"lib": "$lib"
},
"typescript": true,
"registry": "https://next.shadcn-svelte.com/registry"
"registry": "https://shadcn-svelte.com/registry"
}

View File

@@ -16,7 +16,7 @@
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@internationalized/date": "^3.5.6",
"@internationalized/date": "^3.8.1",
"@lucide/svelte": "^0.482.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0",
@@ -24,7 +24,7 @@
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"bits-ui": "^2.1.0",
"bits-ui": "^2.7.0",
"clsx": "^2.1.1",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",

View File

@@ -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
*/

View File

@@ -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

View File

@@ -0,0 +1,7 @@
import Root from './switch.svelte';
export {
Root,
//
Root as Switch
};

View 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>

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

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<HTMLTableSectionElement>> = $props();
</script>
<tbody
bind:this={ref}
data-slot="table-body"
class={cn('[&_tr:last-child]:border-0', className)}
{...restProps}
>
{@render children?.()}
</tbody>

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<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>

View 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>

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<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>

View 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>

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<HTMLTableSectionElement>> = $props();
</script>
<thead
bind:this={ref}
data-slot="table-header"
class={cn('[&_tr]:border-b', className)}
{...restProps}
>
{@render children?.()}
</thead>

View 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>

View 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>

View File

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

View 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>