diff --git a/crates/bili_sync/src/api/request.rs b/crates/bili_sync/src/api/request.rs index df9295d..fca8ba2 100644 --- a/crates/bili_sync/src/api/request.rs +++ b/crates/bili_sync/src/api/request.rs @@ -1,4 +1,5 @@ use bili_sync_entity::rule::Rule; +use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; use validator::Validate; @@ -29,6 +30,8 @@ pub struct VideosRequest { pub query: Option, pub status_filter: Option, pub validation_filter: Option, + pub created_from: Option, + pub created_to: Option, pub page: Option, pub page_size: Option, } @@ -48,6 +51,8 @@ pub struct ResetFilteredVideoStatusRequest { pub query: Option, pub status_filter: Option, pub validation_filter: Option, + pub created_from: Option, + pub created_to: Option, #[serde(default)] pub force: bool, } @@ -86,6 +91,8 @@ pub struct UpdateFilteredVideoStatusRequest { pub query: Option, pub status_filter: Option, pub validation_filter: Option, + pub created_from: Option, + pub created_to: 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 5602be6..f779f71 100644 --- a/crates/bili_sync/src/api/routes/videos/mod.rs +++ b/crates/bili_sync/src/api/routes/videos/mod.rs @@ -5,10 +5,12 @@ use axum::extract::{Extension, Path, Query}; use axum::routing::{get, post}; use axum::{Json, Router}; use bili_sync_entity::*; +use chrono::NaiveDateTime; use sea_orm::ActiveValue::Set; +use sea_orm::sea_query::{Expr, ExprTrait}; use sea_orm::{ ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, - QueryOrder, TransactionTrait, TryIntoModel, + QueryOrder, Select, TransactionTrait, TryIntoModel, }; use crate::api::error::InnerApiError; @@ -39,6 +41,26 @@ pub(super) fn router() -> Router { .route("/videos/update-status", post(update_filtered_video_status)) } +fn apply_created_at_filter( + mut query: Select, + created_from: Option, + created_to: Option, +) -> Select { + if let Some(created_from) = created_from { + query = query.filter( + Expr::cust_with_expr("datetime(?, 'localtime')", Expr::col(video::Column::CreatedAt)) + .gte(created_from.format("%Y-%m-%d %H:%M:%S").to_string()), + ); + } + if let Some(created_to) = created_to { + query = query.filter( + Expr::cust_with_expr("datetime(?, 'localtime')", Expr::col(video::Column::CreatedAt)) + .lte(created_to.format("%Y-%m-%d %H:%M:%S").to_string()), + ); + } + query +} + /// 列出视频的基本信息,支持根据视频来源筛选、名称查找和分页 pub async fn get_videos( Extension(db): Extension, @@ -68,6 +90,7 @@ pub async fn get_videos( if let Some(validation_filter) = params.validation_filter { query = query.filter(validation_filter.to_video_query()); } + query = apply_created_at_filter(query, params.created_from, params.created_to); let total_count = query.clone().count(&db).await?; let (page, page_size) = if let (Some(page), Some(page_size)) = (params.page, params.page_size) { (page, page_size) @@ -240,6 +263,7 @@ pub async fn reset_filtered_video_status( if let Some(validation_filter) = request.validation_filter { query = query.filter(validation_filter.to_video_query()); } + query = apply_created_at_filter(query, request.created_from, request.created_to); let all_videos = query.into_partial_model::().all(&db).await?; let all_pages = page::Entity::find() .filter(page::Column::VideoId.is_in(all_videos.iter().map(|v| v.id))) @@ -379,6 +403,7 @@ pub async fn update_filtered_video_status( if let Some(validation_filter) = request.validation_filter { query = query.filter(validation_filter.to_video_query()); } + query = apply_created_at_filter(query, request.created_from, request.created_to); let mut all_videos = query.into_partial_model::().all(&db).await?; let mut all_pages = page::Entity::find() .filter(page::Column::VideoId.is_in(all_videos.iter().map(|v| v.id))) diff --git a/web/src/lib/components/created-time-filter.svelte b/web/src/lib/components/created-time-filter.svelte new file mode 100644 index 0000000..69b7491 --- /dev/null +++ b/web/src/lib/components/created-time-filter.svelte @@ -0,0 +1,128 @@ + + +
+ + {displayValue} + + + + + {#snippet child({ props })} + + {/snippet} + + +
+
+

创建时间

+

可只设置一侧,时间按服务器时区解释

+
+
+
+ + +
+
+ + +
+ {#if invalidRange} +

结束时间不能早于开始时间

+ {/if} +
+
+ +
+ + +
+
+
+
+
+
diff --git a/web/src/lib/stores/filter.ts b/web/src/lib/stores/filter.ts index 7033123..144d3ca 100644 --- a/web/src/lib/stores/filter.ts +++ b/web/src/lib/stores/filter.ts @@ -12,6 +12,8 @@ export interface AppState { } | null; statusFilter: StatusFilterValue | null; validationFilter: ValidationFilterValue | null; + createdFrom: string | null; + createdTo: string | null; } export const appStateStore = writable({ @@ -19,11 +21,21 @@ export const appStateStore = writable({ currentPage: 0, videoSource: null, statusFilter: null, - validationFilter: 'normal' + validationFilter: 'normal', + createdFrom: null, + createdTo: null }); export const ToQuery = (state: AppState): string => { - const { query, videoSource, currentPage, statusFilter, validationFilter } = state; + const { + query, + videoSource, + currentPage, + statusFilter, + validationFilter, + createdFrom, + createdTo + } = state; const params = new URLSearchParams(); if (currentPage > 0) { params.set('page', String(currentPage)); @@ -40,6 +52,12 @@ export const ToQuery = (state: AppState): string => { if (validationFilter) { params.set('validation_filter', validationFilter); } + if (createdFrom) { + params.set('created_from', createdFrom); + } + if (createdTo) { + params.set('created_to', createdTo); + } const queryString = params.toString(); return queryString ? `videos?${queryString}` : 'videos'; }; @@ -55,6 +73,8 @@ export const ToFilterParams = ( watch_later?: number; status_filter?: Exclude; validation_filter?: Exclude; + created_from?: string; + created_to?: string; } => { const params: { query?: string; @@ -64,6 +84,8 @@ export const ToFilterParams = ( watch_later?: number; status_filter?: Exclude; validation_filter?: Exclude; + created_from?: string; + created_to?: string; } = {}; if (state.query.trim()) { @@ -80,6 +102,12 @@ export const ToFilterParams = ( if (state.validationFilter) { params.validation_filter = state.validationFilter; } + if (state.createdFrom) { + params.created_from = state.createdFrom; + } + if (state.createdTo) { + params.created_to = state.createdTo; + } return params; }; @@ -89,7 +117,9 @@ export const hasActiveFilters = (state: AppState): boolean => { state.query.trim() || state.videoSource || state.statusFilter || - state.validationFilter + state.validationFilter || + state.createdFrom || + state.createdTo ); }; @@ -121,6 +151,14 @@ export const setValidationFilter = (validationFilter: ValidationFilterValue | nu })); }; +export const setCreatedTimeFilter = (createdFrom: string | null, createdTo: string | null) => { + appStateStore.update((state) => ({ + ...state, + createdFrom, + createdTo + })); +}; + export const resetCurrentPage = () => { appStateStore.update((state) => ({ ...state, @@ -133,13 +171,17 @@ export const setAll = ( currentPage: number, videoSource: { type: string; id: string } | null, statusFilter: StatusFilterValue | null, - validationFilter: ValidationFilterValue | null = 'normal' + validationFilter: ValidationFilterValue | null = 'normal', + createdFrom: string | null = null, + createdTo: string | null = null ) => { appStateStore.set({ query, currentPage, videoSource, statusFilter, - validationFilter + validationFilter, + createdFrom, + createdTo }); }; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index c6ad4cb..aacfea0 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -11,6 +11,8 @@ export interface VideosRequest { query?: string; status_filter?: 'failed' | 'succeeded' | 'waiting'; validation_filter?: 'skipped' | 'invalid' | 'normal'; + created_from?: string; + created_to?: string; page?: number; page_size?: number; } @@ -124,6 +126,8 @@ export interface UpdateFilteredVideoStatusRequest { query?: string; status_filter?: 'failed' | 'succeeded' | 'waiting'; validation_filter?: 'skipped' | 'invalid' | 'normal'; + created_from?: string; + created_to?: string; video_updates?: StatusUpdate[]; page_updates?: StatusUpdate[]; } @@ -140,6 +144,8 @@ export interface ResetFilteredVideoStatusRequest { query?: string; status_filter?: 'failed' | 'succeeded' | 'waiting'; validation_filter?: 'skipped' | 'invalid' | 'normal'; + created_from?: string; + created_to?: string; force: boolean; } diff --git a/web/src/routes/videos/+page.svelte b/web/src/routes/videos/+page.svelte index a409853..ec98bde 100644 --- a/web/src/routes/videos/+page.svelte +++ b/web/src/routes/videos/+page.svelte @@ -26,6 +26,7 @@ resetCurrentPage, setAll, setCurrentPage, + setCreatedTimeFilter, setQuery, setStatusFilter, setValidationFilter, @@ -41,6 +42,7 @@ import FilteredStatusEditor from '$lib/components/filtered-status-editor.svelte'; import StatusFilter from '$lib/components/status-filter.svelte'; import ValidationFilter from '$lib/components/validation-filter.svelte'; + import CreatedTimeFilter from '$lib/components/created-time-filter.svelte'; import { SvelteMap } from 'svelte/reactivity'; const pageSize = 20; @@ -91,6 +93,8 @@ videoSource, statusFilter, validationFilter, + createdFrom: searchParams.get('created_from'), + createdTo: searchParams.get('created_to'), pageNum: parseInt(searchParams.get('page') || '0') }; } @@ -100,7 +104,9 @@ pageNum: number = 0, filter?: { type: string; id: string } | null, statusFilter: StatusFilterValue | null = null, - validationFilter: ValidationFilterValue | null = null + validationFilter: ValidationFilterValue | null = null, + createdFrom: string | null = null, + createdTo: string | null = null ) { loading = true; try { @@ -120,6 +126,12 @@ if (validationFilter) { params.validation_filter = validationFilter; } + if (createdFrom) { + params.created_from = createdFrom; + } + if (createdTo) { + params.created_to = createdTo; + } const result = await api.getVideos(params); videosData = result.data; } catch (error) { @@ -132,16 +144,37 @@ } } + async function reloadVideos() { + const { + query, + currentPage, + videoSource, + statusFilter, + validationFilter, + createdFrom, + createdTo + } = $appStateStore; + await loadVideos( + query, + currentPage, + videoSource, + statusFilter, + validationFilter, + createdFrom, + createdTo + ); + } + async function handlePageChange(pageNum: number) { setCurrentPage(pageNum); goto(`/${ToQuery($appStateStore)}`); } async function handleSearchParamsChange(searchParams: URLSearchParams) { - const { query, videoSource, pageNum, statusFilter, validationFilter } = + const { query, videoSource, pageNum, statusFilter, validationFilter, createdFrom, createdTo } = getApiParams(searchParams); - setAll(query, pageNum, videoSource, statusFilter, validationFilter); - loadVideos(query, pageNum, videoSource, statusFilter, validationFilter); + setAll(query, pageNum, videoSource, statusFilter, validationFilter, createdFrom, createdTo); + loadVideos(query, pageNum, videoSource, statusFilter, validationFilter, createdFrom, createdTo); } async function handleResetVideo(id: number, forceReset: boolean) { @@ -152,8 +185,7 @@ toast.success('重置成功', { description: `视频「${data.video.name}」已重置` }); - const { query, currentPage, videoSource, statusFilter, validationFilter } = $appStateStore; - await loadVideos(query, currentPage, videoSource, statusFilter, validationFilter); + await reloadVideos(); } else { toast.info('重置无效', { description: `视频「${data.video.name}」没有失败的状态,无需重置` @@ -180,8 +212,7 @@ description: `视频「${data.video.name}」已清空重置` }); } - const { query, currentPage, videoSource, statusFilter, validationFilter } = $appStateStore; - await loadVideos(query, currentPage, videoSource, statusFilter, validationFilter); + await reloadVideos(); } catch (error) { console.error('清空重置失败:', error); toast.error('清空重置失败', { @@ -204,8 +235,7 @@ toast.success('重置成功', { description: `已重置 ${data.resetted_videos_count} 个视频和 ${data.resetted_pages_count} 个分页` }); - const { query, currentPage, videoSource, statusFilter, validationFilter } = $appStateStore; - await loadVideos(query, currentPage, videoSource, statusFilter, validationFilter); + await reloadVideos(); } else { toast.info('没有需要重置的视频'); } @@ -235,8 +265,7 @@ toast.success('更新成功', { description: `已更新 ${data.updated_videos_count} 个视频和 ${data.updated_pages_count} 个分页` }); - const { query, currentPage, videoSource, statusFilter, validationFilter } = $appStateStore; - await loadVideos(query, currentPage, videoSource, statusFilter, validationFilter); + await reloadVideos(); } else { toast.info('没有视频被更新'); } @@ -302,6 +331,11 @@ }; parts.push(`有效性:${validationLabels[state.validationFilter]}`); } + if (state.createdFrom || state.createdTo) { + const createdFrom = state.createdFrom?.replace('T', ' ') || '不限'; + const createdTo = state.createdTo?.replace('T', ' ') || '不限'; + parts.push(`创建时间:${createdFrom} 至 ${createdTo}`); + } return parts; } @@ -370,6 +404,18 @@ }} >
+
+ 创建时间: + { + setCreatedTimeFilter(createdFrom, createdTo); + resetCurrentPage(); + goto(`/${ToQuery($appStateStore)}`); + }} + /> +
有效性: { - setAll('', 0, { type, id }, $appStateStore.statusFilter, $appStateStore.validationFilter); + setAll( + '', + 0, + { type, id }, + $appStateStore.statusFilter, + $appStateStore.validationFilter, + $appStateStore.createdFrom, + $appStateStore.createdTo + ); goto(`/${ToQuery($appStateStore)}`); }} onRemove={() => { - setAll('', 0, null, $appStateStore.statusFilter, $appStateStore.validationFilter); + setAll( + '', + 0, + null, + $appStateStore.statusFilter, + $appStateStore.validationFilter, + $appStateStore.createdFrom, + $appStateStore.createdTo + ); goto(`/${ToQuery($appStateStore)}`); }} />