feat: 筛选器支持根据创建时间筛选 (#743)

This commit is contained in:
ᴀᴍᴛᴏᴀᴇʀ
2026-08-23 10:13:50 +08:00
committed by GitHub
parent 0e5778b6cb
commit fb020451f3
6 changed files with 290 additions and 20 deletions
+7
View File
@@ -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<String>,
pub status_filter: Option<StatusFilter>,
pub validation_filter: Option<ValidationFilter>,
pub created_from: Option<NaiveDateTime>,
pub created_to: Option<NaiveDateTime>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
@@ -48,6 +51,8 @@ pub struct ResetFilteredVideoStatusRequest {
pub query: Option<String>,
pub status_filter: Option<StatusFilter>,
pub validation_filter: Option<ValidationFilter>,
pub created_from: Option<NaiveDateTime>,
pub created_to: Option<NaiveDateTime>,
#[serde(default)]
pub force: bool,
}
@@ -86,6 +91,8 @@ pub struct UpdateFilteredVideoStatusRequest {
pub query: Option<String>,
pub status_filter: Option<StatusFilter>,
pub validation_filter: Option<ValidationFilter>,
pub created_from: Option<NaiveDateTime>,
pub created_to: Option<NaiveDateTime>,
#[serde(default)]
#[validate(nested)]
pub video_updates: Vec<StatusUpdate>,
+26 -1
View File
@@ -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<video::Entity>,
created_from: Option<NaiveDateTime>,
created_to: Option<NaiveDateTime>,
) -> Select<video::Entity> {
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<DatabaseConnection>,
@@ -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::<SimpleVideoInfo>().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::<SimpleVideoInfo>().all(&db).await?;
let mut all_pages = page::Entity::find()
.filter(page::Column::VideoId.is_in(all_videos.iter().map(|v| v.id)))
@@ -0,0 +1,128 @@
<script lang="ts">
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import EraserIcon from '@lucide/svelte/icons/eraser';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import * as Popover from '$lib/components/ui/popover/index.js';
interface Props {
createdFrom: string | null;
createdTo: string | null;
onChange?: (createdFrom: string | null, createdTo: string | null) => void;
}
let { createdFrom, createdTo, onChange }: Props = $props();
const id = $props.id();
let open = $state(false);
let draftCreatedFrom = $state('');
let draftCreatedTo = $state('');
const invalidRange = $derived(
Boolean(draftCreatedFrom && draftCreatedTo && draftCreatedFrom > draftCreatedTo)
);
const displayValue = $derived.by(() => {
const from = createdFrom?.slice(0, 10);
const to = createdTo?.slice(0, 10);
if (from && to) return `${from} ${to}`;
if (from) return `${from} 起`;
if (to) return `截至 ${to}`;
return '未应用';
});
$effect(() => {
if (!open) {
draftCreatedFrom = createdFrom || '';
draftCreatedTo = createdTo || '';
}
});
function apply() {
if (invalidRange) return;
onChange?.(
draftCreatedFrom ? normalizeDateTime(draftCreatedFrom) : null,
draftCreatedTo ? normalizeDateTime(draftCreatedTo) : null
);
open = false;
}
function normalizeDateTime(value: string) {
return value.length === 16 ? `${value}:00` : value;
}
function clear() {
draftCreatedFrom = '';
draftCreatedTo = '';
}
</script>
<div class="inline-flex items-center gap-1">
<span
class="bg-secondary text-secondary-foreground max-w-52 truncate rounded-lg px-2 py-1 text-xs font-medium"
title={displayValue}
>
{displayValue}
</span>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button variant="ghost" size="sm" {...props} class="h-6 w-6 p-0">
<ChevronDownIcon class="h-3 w-3" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-88" align="end">
<div class="space-y-4">
<div>
<h4 class="text-sm leading-none font-medium">创建时间</h4>
<p class="text-muted-foreground mt-1.5 text-xs">可只设置一侧,时间按服务器时区解释</p>
</div>
<div class="space-y-3">
<div class="space-y-1.5">
<Label for={`${id}-created-from`} class="text-xs">开始时间</Label>
<Input
id={`${id}-created-from`}
type="datetime-local"
class="text-xs"
bind:value={draftCreatedFrom}
max={draftCreatedTo || undefined}
/>
</div>
<div class="space-y-1.5">
<Label for={`${id}-created-to`} class="text-xs">结束时间</Label>
<Input
id={`${id}-created-to`}
type="datetime-local"
class="text-xs"
bind:value={draftCreatedTo}
min={draftCreatedFrom || undefined}
/>
</div>
{#if invalidRange}
<p class="text-destructive text-xs">结束时间不能早于开始时间</p>
{/if}
</div>
<div class="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
class="text-muted-foreground px-2 text-xs"
disabled={!draftCreatedFrom && !draftCreatedTo}
onclick={clear}
>
<EraserIcon class="size-3" />
清空选择
</Button>
<div class="flex gap-2">
<Button variant="outline" size="sm" class="text-xs" onclick={() => (open = false)}>
取消
</Button>
<Button size="sm" class="text-xs" disabled={invalidRange} onclick={apply}>应用</Button>
</div>
</div>
</div>
</Popover.Content>
</Popover.Root>
</div>
+47 -5
View File
@@ -12,6 +12,8 @@ export interface AppState {
} | null;
statusFilter: StatusFilterValue | null;
validationFilter: ValidationFilterValue | null;
createdFrom: string | null;
createdTo: string | null;
}
export const appStateStore = writable<AppState>({
@@ -19,11 +21,21 @@ export const appStateStore = writable<AppState>({
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<StatusFilterValue, null>;
validation_filter?: Exclude<ValidationFilterValue, null>;
created_from?: string;
created_to?: string;
} => {
const params: {
query?: string;
@@ -64,6 +84,8 @@ export const ToFilterParams = (
watch_later?: number;
status_filter?: Exclude<StatusFilterValue, null>;
validation_filter?: Exclude<ValidationFilterValue, null>;
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
});
};
+6
View File
@@ -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;
}
+76 -14
View File
@@ -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 @@
}}
></SearchBar>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1">
<span class="text-muted-foreground text-xs">创建时间:</span>
<CreatedTimeFilter
createdFrom={$appStateStore.createdFrom}
createdTo={$appStateStore.createdTo}
onChange={(createdFrom, createdTo) => {
setCreatedTimeFilter(createdFrom, createdTo);
resetCurrentPage();
goto(`/${ToQuery($appStateStore)}`);
}}
/>
</div>
<div class="flex items-center gap-1">
<span class="text-muted-foreground text-xs">有效性:</span>
<ValidationFilter
@@ -410,11 +456,27 @@
{filters}
selectedLabel={$appStateStore.videoSource}
onSelect={(type, id) => {
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)}`);
}}
/>