From 7f09a98d6c4a66144b05dc405b34383a0a99efbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=B4=80=E1=B4=8D=E1=B4=9B=E1=B4=8F=E1=B4=80=E1=B4=87?= =?UTF-8?q?=CA=80?= Date: Fri, 16 Jan 2026 15:10:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E4=BB=85=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E3=80=81=E4=BB=85=E6=88=90=E5=8A=9F=E3=80=81=E4=BB=85?= =?UTF-8?q?=E7=AD=89=E5=BE=85=E7=9A=84=E7=AD=9B=E9=80=89=20(#610)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/bili_sync/src/api/helper.rs | 15 ++- crates/bili_sync/src/api/request.rs | 17 ++- crates/bili_sync/src/api/routes/videos/mod.rs | 12 +- crates/bili_sync/src/utils/status.rs | 23 +++- web/src/lib/components/dropdown-filter.svelte | 2 +- web/src/lib/components/status-filter.svelte | 93 +++++++++++++++ web/src/lib/stores/filter.ts | 53 +++------ web/src/routes/videos/+page.svelte | 108 +++++++++++------- 8 files changed, 227 insertions(+), 96 deletions(-) create mode 100644 web/src/lib/components/status-filter.svelte diff --git a/crates/bili_sync/src/api/helper.rs b/crates/bili_sync/src/api/helper.rs index 9c535b3..48f7d1b 100644 --- a/crates/bili_sync/src/api/helper.rs +++ b/crates/bili_sync/src/api/helper.rs @@ -1,9 +1,22 @@ use std::borrow::Borrow; use itertools::Itertools; -use sea_orm::{ConnectionTrait, DatabaseTransaction}; +use sea_orm::{Condition, ConnectionTrait, DatabaseTransaction}; +use crate::api::request::StatusFilter; use crate::api::response::{PageInfo, SimplePageInfo, SimpleVideoInfo, VideoInfo}; +use crate::utils::status::VideoStatus; + +impl StatusFilter { + pub fn to_video_query(&self) -> Condition { + let query_builder = VideoStatus::query_builder(); + match self { + Self::Failed => query_builder.failed(), + Self::Succeeded => query_builder.succeeded(), + Self::Waiting => query_builder.waiting(), + } + } +} pub trait VideoRecord { fn as_id_status_tuple(&self) -> (i32, u32); diff --git a/crates/bili_sync/src/api/request.rs b/crates/bili_sync/src/api/request.rs index dc52a67..e4fcf69 100644 --- a/crates/bili_sync/src/api/request.rs +++ b/crates/bili_sync/src/api/request.rs @@ -4,6 +4,14 @@ use validator::Validate; use crate::bilibili::CollectionType; +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StatusFilter { + Failed, + Succeeded, + Waiting, +} + #[derive(Deserialize)] pub struct VideosRequest { pub collection: Option, @@ -11,8 +19,7 @@ pub struct VideosRequest { pub submission: Option, pub watch_later: Option, pub query: Option, - #[serde(default)] - pub failed_only: bool, + pub status_filter: Option, pub page: Option, pub page_size: Option, } @@ -30,8 +37,7 @@ pub struct ResetFilteredVideoStatusRequest { pub submission: Option, pub watch_later: Option, pub query: Option, - #[serde(default)] - pub failed_only: bool, + pub status_filter: Option, #[serde(default)] pub force: bool, } @@ -68,8 +74,7 @@ pub struct UpdateFilteredVideoStatusRequest { pub submission: Option, pub watch_later: Option, pub query: Option, - #[serde(default)] - pub failed_only: bool, + pub status_filter: Option, #[serde(default)] #[validate(nested)] pub video_updates: Vec, diff --git a/crates/bili_sync/src/api/routes/videos/mod.rs b/crates/bili_sync/src/api/routes/videos/mod.rs index 7c86d9b..0c1f9d3 100644 --- a/crates/bili_sync/src/api/routes/videos/mod.rs +++ b/crates/bili_sync/src/api/routes/videos/mod.rs @@ -62,8 +62,8 @@ pub async fn get_videos( .or(video::Column::Bvid.contains(query_word)), ); } - if params.failed_only { - query = query.filter(VideoStatus::query_builder().any_failed()) + if let Some(status_filter) = params.status_filter { + query = query.filter(status_filter.to_video_query()); } let total_count = query.clone().count(&db).await?; let (page, page_size) = if let (Some(page), Some(page_size)) = (params.page, params.page_size) { @@ -221,8 +221,8 @@ pub async fn reset_filtered_video_status( .or(video::Column::Bvid.contains(query_word)), ); } - if request.failed_only { - query = query.filter(VideoStatus::query_builder().any_failed()); + if let Some(status_filter) = request.status_filter { + query = query.filter(status_filter.to_video_query()); } let all_videos = query.into_partial_model::().all(&db).await?; let all_pages = page::Entity::find() @@ -357,8 +357,8 @@ pub async fn update_filtered_video_status( .or(video::Column::Bvid.contains(query_word)), ); } - if request.failed_only { - query = query.filter(VideoStatus::query_builder().any_failed()) + if let Some(status_filter) = request.status_filter { + query = query.filter(status_filter.to_video_query()); } let mut all_videos = query.into_partial_model::().all(&db).await?; let mut all_pages = page::Entity::find() diff --git a/crates/bili_sync/src/utils/status.rs b/crates/bili_sync/src/utils/status.rs index e17519c..355117f 100644 --- a/crates/bili_sync/src/utils/status.rs +++ b/crates/bili_sync/src/utils/status.rs @@ -1,7 +1,7 @@ use std::marker::PhantomData; use bili_sync_entity::{page, video}; -use bili_sync_migration::ExprTrait; +use bili_sync_migration::{ExprTrait, IntoCondition}; use sea_orm::sea_query::Expr; use sea_orm::{ColumnTrait, Condition}; @@ -213,7 +213,17 @@ impl StatusQueryBuilder { Self { column } } - pub fn any_failed(&self) -> Condition { + /// 完成状态:所有子任务的状态都是成功 + pub fn succeeded(&self) -> Condition { + let mut condition = Condition::all(); + for offset in 0..N as i32 { + condition = condition.add(Expr::col(self.column).right_shift(offset * 3).bit_and(7).eq(7)) + } + condition + } + + /// 失败状态:存在任何失败的子任务 + pub fn failed(&self) -> Condition { let mut condition = Condition::any(); for offset in 0..N as i32 { condition = condition.add( @@ -225,6 +235,15 @@ impl StatusQueryBuilder { } condition } + + /// 等待状态:所有子任务的状态都不是失败,且其中存在未开始 + pub fn waiting(&self) -> Condition { + let mut condition = Condition::any(); + for offset in 0..N as i32 { + condition = condition.add(Expr::col(self.column).right_shift(offset * 3).bit_and(7).eq(0)) + } + condition.and(self.failed().not()).into_condition() + } } #[cfg(test)] diff --git a/web/src/lib/components/dropdown-filter.svelte b/web/src/lib/components/dropdown-filter.svelte index 584bec5..fcb3ea2 100644 --- a/web/src/lib/components/dropdown-filter.svelte +++ b/web/src/lib/components/dropdown-filter.svelte @@ -62,7 +62,7 @@ {/snippet} - + {#if filters} {#each Object.entries(filters) as [key, filter] (key)} diff --git a/web/src/lib/components/status-filter.svelte b/web/src/lib/components/status-filter.svelte new file mode 100644 index 0000000..e9831e3 --- /dev/null +++ b/web/src/lib/components/status-filter.svelte @@ -0,0 +1,93 @@ + + +
+ + {currentOption ? currentOption.label : '未应用'} + + + + + {#snippet child({ props })} + + {/snippet} + + + + 视频状态 + {#each statusOptions as option (option.value)} + handleSelect(option.value)}> + + + {option.label} + + {#if value === option.value} + + {/if} + + {/each} + + { + closeAndFocusTrigger(); + onRemove?.(); + }} + > + + 移除筛选 + + + + +
diff --git a/web/src/lib/stores/filter.ts b/web/src/lib/stores/filter.ts index 09ff421..ff5eaa4 100644 --- a/web/src/lib/stores/filter.ts +++ b/web/src/lib/stores/filter.ts @@ -1,5 +1,7 @@ import { writable } from 'svelte/store'; +export type StatusFilterValue = 'failed' | 'succeeded' | 'waiting' | null; + export interface AppState { query: string; currentPage: number; @@ -7,18 +9,18 @@ export interface AppState { type: string; id: string; } | null; - failedOnly: boolean; + statusFilter: StatusFilterValue | null; } export const appStateStore = writable({ query: '', currentPage: 0, videoSource: null, - failedOnly: false + statusFilter: null }); export const ToQuery = (state: AppState): string => { - const { query, videoSource, currentPage, failedOnly } = state; + const { query, videoSource, currentPage, statusFilter } = state; const params = new URLSearchParams(); if (currentPage > 0) { params.set('page', String(currentPage)); @@ -29,8 +31,8 @@ export const ToQuery = (state: AppState): string => { if (videoSource && videoSource.type && videoSource.id) { params.set(videoSource.type, videoSource.id); } - if (failedOnly) { - params.set('failed_only', 'true'); + if (statusFilter) { + params.set('status_filter', statusFilter); } const queryString = params.toString(); return queryString ? `videos?${queryString}` : 'videos'; @@ -45,7 +47,7 @@ export const ToFilterParams = ( favorite?: number; submission?: number; watch_later?: number; - failed_only?: boolean; + status_filter?: Exclude; } => { const params: { query?: string; @@ -53,7 +55,7 @@ export const ToFilterParams = ( favorite?: number; submission?: number; watch_later?: number; - failed_only?: boolean; + status_filter?: Exclude; } = {}; if (state.query.trim()) { @@ -64,15 +66,15 @@ export const ToFilterParams = ( const { type, id } = state.videoSource; params[type as 'collection' | 'favorite' | 'submission' | 'watch_later'] = parseInt(id); } - if (state.failedOnly) { - params.failed_only = true; + if (state.statusFilter) { + params.status_filter = state.statusFilter; } return params; }; // 检查是否有活动的筛选条件 export const hasActiveFilters = (state: AppState): boolean => { - return !!(state.query.trim() || state.videoSource || state.failedOnly); + return !!(state.query.trim() || state.videoSource || state.statusFilter); }; export const setQuery = (query: string) => { @@ -82,20 +84,6 @@ export const setQuery = (query: string) => { })); }; -export const setVideoSourceFilter = (filter: { type: string; id: string }) => { - appStateStore.update((state) => ({ - ...state, - videoSource: filter - })); -}; - -export const clearVideoSourceFilter = () => { - appStateStore.update((state) => ({ - ...state, - videoSource: null - })); -}; - export const setCurrentPage = (page: number) => { appStateStore.update((state) => ({ ...state, @@ -103,10 +91,10 @@ export const setCurrentPage = (page: number) => { })); }; -export const setFailedOnly = (failedOnly: boolean) => { +export const setStatusFilter = (statusFilter: StatusFilterValue | null) => { appStateStore.update((state) => ({ ...state, - failedOnly + statusFilter })); }; @@ -121,21 +109,12 @@ export const setAll = ( query: string, currentPage: number, videoSource: { type: string; id: string } | null, - failedOnly: boolean + statusFilter: StatusFilterValue | null ) => { appStateStore.set({ query, currentPage, videoSource, - failedOnly - }); -}; - -export const clearAll = () => { - appStateStore.set({ - query: '', - currentPage: 0, - videoSource: null, - failedOnly: false + statusFilter }); }; diff --git a/web/src/routes/videos/+page.svelte b/web/src/routes/videos/+page.svelte index 9ea0984..1da56e3 100644 --- a/web/src/routes/videos/+page.svelte +++ b/web/src/routes/videos/+page.svelte @@ -26,15 +26,17 @@ setAll, setCurrentPage, setQuery, - setFailedOnly, + setStatusFilter, ToQuery, ToFilterParams, - hasActiveFilters + hasActiveFilters, + type StatusFilterValue } from '$lib/stores/filter'; import { toast } from 'svelte-sonner'; import DropdownFilter, { type Filter } from '$lib/components/dropdown-filter.svelte'; import SearchBar from '$lib/components/search-bar.svelte'; import FilteredStatusEditor from '$lib/components/filtered-status-editor.svelte'; + import StatusFilter from '$lib/components/status-filter.svelte'; const pageSize = 20; @@ -62,13 +64,18 @@ videoSource = { type: source.type, id: value }; } } - // 支持从 URL 里还原失败筛选 - const failedParam = searchParams.get('failed_only'); - const failedOnly = failedParam === 'true' || failedParam === '1'; + // 支持从 URL 里还原状态筛选 + const statusFilterParam = searchParams.get('status_filter'); + const statusFilter: StatusFilterValue | null = + statusFilterParam === 'failed' || + statusFilterParam === 'succeeded' || + statusFilterParam === 'waiting' + ? statusFilterParam + : null; return { query: searchParams.get('query') || '', videoSource, - failedOnly, + statusFilter, pageNum: parseInt(searchParams.get('page') || '0') }; } @@ -77,7 +84,7 @@ query: string, pageNum: number = 0, filter?: { type: string; id: string } | null, - failedOnly: boolean = false + statusFilter: StatusFilterValue | null = null ) { loading = true; try { @@ -91,7 +98,9 @@ if (filter) { params[filter.type] = parseInt(filter.id); } - params.failed_only = failedOnly; + if (statusFilter) { + params.status_filter = statusFilter; + } const result = await api.getVideos(params); videosData = result.data; } catch (error) { @@ -110,9 +119,9 @@ } async function handleSearchParamsChange(searchParams: URLSearchParams) { - const { query, videoSource, pageNum, failedOnly } = getApiParams(searchParams); - setAll(query, pageNum, videoSource, failedOnly); - loadVideos(query, pageNum, videoSource, failedOnly); + const { query, videoSource, pageNum, statusFilter } = getApiParams(searchParams); + setAll(query, pageNum, videoSource, statusFilter); + loadVideos(query, pageNum, videoSource, statusFilter); } async function handleResetVideo(id: number, forceReset: boolean) { @@ -123,8 +132,8 @@ toast.success('重置成功', { description: `视频「${data.video.name}」已重置` }); - const { query, currentPage, videoSource, failedOnly } = $appStateStore; - await loadVideos(query, currentPage, videoSource, failedOnly); + const { query, currentPage, videoSource, statusFilter } = $appStateStore; + await loadVideos(query, currentPage, videoSource, statusFilter); } else { toast.info('重置无效', { description: `视频「${data.video.name}」没有失败的状态,无需重置` @@ -151,8 +160,8 @@ description: `视频「${data.video.name}」已清空重置` }); } - const { query, currentPage, videoSource, failedOnly } = $appStateStore; - await loadVideos(query, currentPage, videoSource, failedOnly); + const { query, currentPage, videoSource, statusFilter } = $appStateStore; + await loadVideos(query, currentPage, videoSource, statusFilter); } catch (error) { console.error('清空重置失败:', error); toast.error('清空重置失败', { @@ -175,8 +184,8 @@ toast.success('重置成功', { description: `已重置 ${data.resetted_videos_count} 个视频和 ${data.resetted_pages_count} 个分页` }); - const { query, currentPage, videoSource, failedOnly } = $appStateStore; - await loadVideos(query, currentPage, videoSource, failedOnly); + const { query, currentPage, videoSource, statusFilter } = $appStateStore; + await loadVideos(query, currentPage, videoSource, statusFilter); } else { toast.info('没有需要重置的视频'); } @@ -206,8 +215,8 @@ toast.success('更新成功', { description: `已更新 ${data.updated_videos_count} 个视频和 ${data.updated_pages_count} 个分页` }); - const { query, currentPage, videoSource, failedOnly } = $appStateStore; - await loadVideos(query, currentPage, videoSource, failedOnly); + const { query, currentPage, videoSource, statusFilter } = $appStateStore; + await loadVideos(query, currentPage, videoSource, statusFilter); } else { toast.info('没有视频被更新'); } @@ -241,7 +250,14 @@ } } } - parts.push(`仅失败视频:${state.failedOnly}`); + if (state.statusFilter) { + const statusLabels = { + failed: '仅失败', + succeeded: '仅成功', + waiting: '仅等待' + }; + parts.push(`状态:${statusLabels[state.statusFilter]}`); + } return parts; } @@ -297,34 +313,40 @@ goto(`/${ToQuery($appStateStore)}`); }} > -
- 筛选: -
- { - setFailedOnly(value); +
+ +
+ 状态: + { + setStatusFilter(value); + resetCurrentPage(); + goto(`/${ToQuery($appStateStore)}`); + }} + onRemove={() => { + setStatusFilter(null); resetCurrentPage(); goto(`/${ToQuery($appStateStore)}`); }} /> -
- { - setAll('', 0, { type, id }, $appStateStore.failedOnly); - goto(`/${ToQuery($appStateStore)}`); - }} - onRemove={() => { - setAll('', 0, null, $appStateStore.failedOnly); - goto(`/${ToQuery($appStateStore)}`); - }} - /> + +
+ 来源: + { + setAll('', 0, { type, id }, $appStateStore.statusFilter); + goto(`/${ToQuery($appStateStore)}`); + }} + onRemove={() => { + setAll('', 0, null, $appStateStore.statusFilter); + goto(`/${ToQuery($appStateStore)}`); + }} + /> +