feat: 实现仅失败、仅成功、仅等待的筛选 (#610)

This commit is contained in:
ᴀᴍᴛᴏᴀᴇʀ
2026-01-16 15:10:39 +08:00
committed by GitHub
parent 269647ac22
commit 7f09a98d6c
8 changed files with 227 additions and 96 deletions
+14 -1
View File
@@ -1,9 +1,22 @@
use std::borrow::Borrow; use std::borrow::Borrow;
use itertools::Itertools; 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::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 { pub trait VideoRecord {
fn as_id_status_tuple(&self) -> (i32, u32); fn as_id_status_tuple(&self) -> (i32, u32);
+11 -6
View File
@@ -4,6 +4,14 @@ use validator::Validate;
use crate::bilibili::CollectionType; use crate::bilibili::CollectionType;
#[derive(Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StatusFilter {
Failed,
Succeeded,
Waiting,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct VideosRequest { pub struct VideosRequest {
pub collection: Option<i32>, pub collection: Option<i32>,
@@ -11,8 +19,7 @@ pub struct VideosRequest {
pub submission: Option<i32>, pub submission: Option<i32>,
pub watch_later: Option<i32>, pub watch_later: Option<i32>,
pub query: Option<String>, pub query: Option<String>,
#[serde(default)] pub status_filter: Option<StatusFilter>,
pub failed_only: bool,
pub page: Option<u64>, pub page: Option<u64>,
pub page_size: Option<u64>, pub page_size: Option<u64>,
} }
@@ -30,8 +37,7 @@ pub struct ResetFilteredVideoStatusRequest {
pub submission: Option<i32>, pub submission: Option<i32>,
pub watch_later: Option<i32>, pub watch_later: Option<i32>,
pub query: Option<String>, pub query: Option<String>,
#[serde(default)] pub status_filter: Option<StatusFilter>,
pub failed_only: bool,
#[serde(default)] #[serde(default)]
pub force: bool, pub force: bool,
} }
@@ -68,8 +74,7 @@ pub struct UpdateFilteredVideoStatusRequest {
pub submission: Option<i32>, pub submission: Option<i32>,
pub watch_later: Option<i32>, pub watch_later: Option<i32>,
pub query: Option<String>, pub query: Option<String>,
#[serde(default)] pub status_filter: Option<StatusFilter>,
pub failed_only: bool,
#[serde(default)] #[serde(default)]
#[validate(nested)] #[validate(nested)]
pub video_updates: Vec<StatusUpdate>, pub video_updates: Vec<StatusUpdate>,
@@ -62,8 +62,8 @@ pub async fn get_videos(
.or(video::Column::Bvid.contains(query_word)), .or(video::Column::Bvid.contains(query_word)),
); );
} }
if params.failed_only { if let Some(status_filter) = params.status_filter {
query = query.filter(VideoStatus::query_builder().any_failed()) query = query.filter(status_filter.to_video_query());
} }
let total_count = query.clone().count(&db).await?; let total_count = query.clone().count(&db).await?;
let (page, page_size) = if let (Some(page), Some(page_size)) = (params.page, params.page_size) { 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)), .or(video::Column::Bvid.contains(query_word)),
); );
} }
if request.failed_only { if let Some(status_filter) = request.status_filter {
query = query.filter(VideoStatus::query_builder().any_failed()); query = query.filter(status_filter.to_video_query());
} }
let all_videos = query.into_partial_model::<SimpleVideoInfo>().all(&db).await?; let all_videos = query.into_partial_model::<SimpleVideoInfo>().all(&db).await?;
let all_pages = page::Entity::find() let all_pages = page::Entity::find()
@@ -357,8 +357,8 @@ pub async fn update_filtered_video_status(
.or(video::Column::Bvid.contains(query_word)), .or(video::Column::Bvid.contains(query_word)),
); );
} }
if request.failed_only { if let Some(status_filter) = request.status_filter {
query = query.filter(VideoStatus::query_builder().any_failed()) query = query.filter(status_filter.to_video_query());
} }
let mut all_videos = query.into_partial_model::<SimpleVideoInfo>().all(&db).await?; let mut all_videos = query.into_partial_model::<SimpleVideoInfo>().all(&db).await?;
let mut all_pages = page::Entity::find() let mut all_pages = page::Entity::find()
+21 -2
View File
@@ -1,7 +1,7 @@
use std::marker::PhantomData; use std::marker::PhantomData;
use bili_sync_entity::{page, video}; 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::sea_query::Expr;
use sea_orm::{ColumnTrait, Condition}; use sea_orm::{ColumnTrait, Condition};
@@ -213,7 +213,17 @@ impl<const N: usize, C: ColumnTrait> StatusQueryBuilder<N, C> {
Self { column } 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(); let mut condition = Condition::any();
for offset in 0..N as i32 { for offset in 0..N as i32 {
condition = condition.add( condition = condition.add(
@@ -225,6 +235,15 @@ impl<const N: usize, C: ColumnTrait> StatusQueryBuilder<N, C> {
} }
condition 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)] #[cfg(test)]
@@ -62,7 +62,7 @@
</Button> </Button>
{/snippet} {/snippet}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content class="w-[200px]" align="end"> <DropdownMenu.Content class="w-50" align="end">
<DropdownMenu.Group> <DropdownMenu.Group>
{#if filters} {#if filters}
{#each Object.entries(filters) as [key, filter] (key)} {#each Object.entries(filters) as [key, filter] (key)}
@@ -0,0 +1,93 @@
<script lang="ts">
import CheckCircleIcon from '@lucide/svelte/icons/check-circle';
import XCircleIcon from '@lucide/svelte/icons/x-circle';
import ClockIcon from '@lucide/svelte/icons/clock';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import TrashIcon from '@lucide/svelte/icons/trash';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { type StatusFilterValue } from '$lib/stores/filter';
interface Props {
value: StatusFilterValue | null;
onSelect?: (value: StatusFilterValue) => void;
onRemove?: () => void;
}
let { value = $bindable(null), onSelect, onRemove }: Props = $props();
let open = $state(false);
let triggerRef = $state<HTMLButtonElement>(null!);
function closeAndFocusTrigger() {
open = false;
}
const statusOptions = [
{
value: 'failed' as const,
label: '仅失败',
icon: XCircleIcon
},
{
value: 'succeeded' as const,
label: '仅成功',
icon: CheckCircleIcon
},
{
value: 'waiting' as const,
label: '仅等待',
icon: ClockIcon
}
];
function handleSelect(selectedValue: StatusFilterValue) {
value = selectedValue;
onSelect?.(selectedValue);
closeAndFocusTrigger();
}
const currentOption = $derived(statusOptions.find((opt) => opt.value === value));
</script>
<div class="inline-flex items-center gap-1">
<span class="bg-secondary text-secondary-foreground rounded-lg px-2 py-1 text-xs font-medium">
{currentOption ? currentOption.label : '未应用'}
</span>
<DropdownMenu.Root bind:open>
<DropdownMenu.Trigger bind:ref={triggerRef}>
{#snippet child({ props })}
<Button variant="ghost" size="sm" {...props} class="h-6 w-6 p-0">
<ChevronDownIcon class="h-3 w-3" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-50" align="end">
<DropdownMenu.Group>
<DropdownMenu.Label class="text-xs">视频状态</DropdownMenu.Label>
{#each statusOptions as option (option.value)}
<DropdownMenu.Item class="text-xs" onclick={() => handleSelect(option.value)}>
<option.icon class="mr-2 size-3" />
<span class:font-semibold={value === option.value}>
{option.label}
</span>
{#if value === option.value}
<CheckCircleIcon class="ml-auto size-3" />
{/if}
</DropdownMenu.Item>
{/each}
<DropdownMenu.Separator />
<DropdownMenu.Item
onclick={() => {
closeAndFocusTrigger();
onRemove?.();
}}
>
<TrashIcon class="mr-2 size-3" />
<span class="text-xs font-medium">移除筛选</span>
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
+16 -37
View File
@@ -1,5 +1,7 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
export type StatusFilterValue = 'failed' | 'succeeded' | 'waiting' | null;
export interface AppState { export interface AppState {
query: string; query: string;
currentPage: number; currentPage: number;
@@ -7,18 +9,18 @@ export interface AppState {
type: string; type: string;
id: string; id: string;
} | null; } | null;
failedOnly: boolean; statusFilter: StatusFilterValue | null;
} }
export const appStateStore = writable<AppState>({ export const appStateStore = writable<AppState>({
query: '', query: '',
currentPage: 0, currentPage: 0,
videoSource: null, videoSource: null,
failedOnly: false statusFilter: null
}); });
export const ToQuery = (state: AppState): string => { export const ToQuery = (state: AppState): string => {
const { query, videoSource, currentPage, failedOnly } = state; const { query, videoSource, currentPage, statusFilter } = state;
const params = new URLSearchParams(); const params = new URLSearchParams();
if (currentPage > 0) { if (currentPage > 0) {
params.set('page', String(currentPage)); params.set('page', String(currentPage));
@@ -29,8 +31,8 @@ export const ToQuery = (state: AppState): string => {
if (videoSource && videoSource.type && videoSource.id) { if (videoSource && videoSource.type && videoSource.id) {
params.set(videoSource.type, videoSource.id); params.set(videoSource.type, videoSource.id);
} }
if (failedOnly) { if (statusFilter) {
params.set('failed_only', 'true'); params.set('status_filter', statusFilter);
} }
const queryString = params.toString(); const queryString = params.toString();
return queryString ? `videos?${queryString}` : 'videos'; return queryString ? `videos?${queryString}` : 'videos';
@@ -45,7 +47,7 @@ export const ToFilterParams = (
favorite?: number; favorite?: number;
submission?: number; submission?: number;
watch_later?: number; watch_later?: number;
failed_only?: boolean; status_filter?: Exclude<StatusFilterValue, null>;
} => { } => {
const params: { const params: {
query?: string; query?: string;
@@ -53,7 +55,7 @@ export const ToFilterParams = (
favorite?: number; favorite?: number;
submission?: number; submission?: number;
watch_later?: number; watch_later?: number;
failed_only?: boolean; status_filter?: Exclude<StatusFilterValue, null>;
} = {}; } = {};
if (state.query.trim()) { if (state.query.trim()) {
@@ -64,15 +66,15 @@ export const ToFilterParams = (
const { type, id } = state.videoSource; const { type, id } = state.videoSource;
params[type as 'collection' | 'favorite' | 'submission' | 'watch_later'] = parseInt(id); params[type as 'collection' | 'favorite' | 'submission' | 'watch_later'] = parseInt(id);
} }
if (state.failedOnly) { if (state.statusFilter) {
params.failed_only = true; params.status_filter = state.statusFilter;
} }
return params; return params;
}; };
// 检查是否有活动的筛选条件 // 检查是否有活动的筛选条件
export const hasActiveFilters = (state: AppState): boolean => { 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) => { 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) => { export const setCurrentPage = (page: number) => {
appStateStore.update((state) => ({ appStateStore.update((state) => ({
...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) => ({ appStateStore.update((state) => ({
...state, ...state,
failedOnly statusFilter
})); }));
}; };
@@ -121,21 +109,12 @@ export const setAll = (
query: string, query: string,
currentPage: number, currentPage: number,
videoSource: { type: string; id: string } | null, videoSource: { type: string; id: string } | null,
failedOnly: boolean statusFilter: StatusFilterValue | null
) => { ) => {
appStateStore.set({ appStateStore.set({
query, query,
currentPage, currentPage,
videoSource, videoSource,
failedOnly statusFilter
});
};
export const clearAll = () => {
appStateStore.set({
query: '',
currentPage: 0,
videoSource: null,
failedOnly: false
}); });
}; };
+65 -43
View File
@@ -26,15 +26,17 @@
setAll, setAll,
setCurrentPage, setCurrentPage,
setQuery, setQuery,
setFailedOnly, setStatusFilter,
ToQuery, ToQuery,
ToFilterParams, ToFilterParams,
hasActiveFilters hasActiveFilters,
type StatusFilterValue
} from '$lib/stores/filter'; } from '$lib/stores/filter';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import DropdownFilter, { type Filter } from '$lib/components/dropdown-filter.svelte'; import DropdownFilter, { type Filter } from '$lib/components/dropdown-filter.svelte';
import SearchBar from '$lib/components/search-bar.svelte'; import SearchBar from '$lib/components/search-bar.svelte';
import FilteredStatusEditor from '$lib/components/filtered-status-editor.svelte'; import FilteredStatusEditor from '$lib/components/filtered-status-editor.svelte';
import StatusFilter from '$lib/components/status-filter.svelte';
const pageSize = 20; const pageSize = 20;
@@ -62,13 +64,18 @@
videoSource = { type: source.type, id: value }; videoSource = { type: source.type, id: value };
} }
} }
// 支持从 URL 里还原失败筛选 // 支持从 URL 里还原状态筛选
const failedParam = searchParams.get('failed_only'); const statusFilterParam = searchParams.get('status_filter');
const failedOnly = failedParam === 'true' || failedParam === '1'; const statusFilter: StatusFilterValue | null =
statusFilterParam === 'failed' ||
statusFilterParam === 'succeeded' ||
statusFilterParam === 'waiting'
? statusFilterParam
: null;
return { return {
query: searchParams.get('query') || '', query: searchParams.get('query') || '',
videoSource, videoSource,
failedOnly, statusFilter,
pageNum: parseInt(searchParams.get('page') || '0') pageNum: parseInt(searchParams.get('page') || '0')
}; };
} }
@@ -77,7 +84,7 @@
query: string, query: string,
pageNum: number = 0, pageNum: number = 0,
filter?: { type: string; id: string } | null, filter?: { type: string; id: string } | null,
failedOnly: boolean = false statusFilter: StatusFilterValue | null = null
) { ) {
loading = true; loading = true;
try { try {
@@ -91,7 +98,9 @@
if (filter) { if (filter) {
params[filter.type] = parseInt(filter.id); params[filter.type] = parseInt(filter.id);
} }
params.failed_only = failedOnly; if (statusFilter) {
params.status_filter = statusFilter;
}
const result = await api.getVideos(params); const result = await api.getVideos(params);
videosData = result.data; videosData = result.data;
} catch (error) { } catch (error) {
@@ -110,9 +119,9 @@
} }
async function handleSearchParamsChange(searchParams: URLSearchParams) { async function handleSearchParamsChange(searchParams: URLSearchParams) {
const { query, videoSource, pageNum, failedOnly } = getApiParams(searchParams); const { query, videoSource, pageNum, statusFilter } = getApiParams(searchParams);
setAll(query, pageNum, videoSource, failedOnly); setAll(query, pageNum, videoSource, statusFilter);
loadVideos(query, pageNum, videoSource, failedOnly); loadVideos(query, pageNum, videoSource, statusFilter);
} }
async function handleResetVideo(id: number, forceReset: boolean) { async function handleResetVideo(id: number, forceReset: boolean) {
@@ -123,8 +132,8 @@
toast.success('重置成功', { toast.success('重置成功', {
description: `视频「${data.video.name}」已重置` description: `视频「${data.video.name}」已重置`
}); });
const { query, currentPage, videoSource, failedOnly } = $appStateStore; const { query, currentPage, videoSource, statusFilter } = $appStateStore;
await loadVideos(query, currentPage, videoSource, failedOnly); await loadVideos(query, currentPage, videoSource, statusFilter);
} else { } else {
toast.info('重置无效', { toast.info('重置无效', {
description: `视频「${data.video.name}」没有失败的状态,无需重置` description: `视频「${data.video.name}」没有失败的状态,无需重置`
@@ -151,8 +160,8 @@
description: `视频「${data.video.name}」已清空重置` description: `视频「${data.video.name}」已清空重置`
}); });
} }
const { query, currentPage, videoSource, failedOnly } = $appStateStore; const { query, currentPage, videoSource, statusFilter } = $appStateStore;
await loadVideos(query, currentPage, videoSource, failedOnly); await loadVideos(query, currentPage, videoSource, statusFilter);
} catch (error) { } catch (error) {
console.error('清空重置失败:', error); console.error('清空重置失败:', error);
toast.error('清空重置失败', { toast.error('清空重置失败', {
@@ -175,8 +184,8 @@
toast.success('重置成功', { toast.success('重置成功', {
description: `已重置 ${data.resetted_videos_count} 个视频和 ${data.resetted_pages_count} 个分页` description: `已重置 ${data.resetted_videos_count} 个视频和 ${data.resetted_pages_count} 个分页`
}); });
const { query, currentPage, videoSource, failedOnly } = $appStateStore; const { query, currentPage, videoSource, statusFilter } = $appStateStore;
await loadVideos(query, currentPage, videoSource, failedOnly); await loadVideos(query, currentPage, videoSource, statusFilter);
} else { } else {
toast.info('没有需要重置的视频'); toast.info('没有需要重置的视频');
} }
@@ -206,8 +215,8 @@
toast.success('更新成功', { toast.success('更新成功', {
description: `已更新 ${data.updated_videos_count} 个视频和 ${data.updated_pages_count} 个分页` description: `已更新 ${data.updated_videos_count} 个视频和 ${data.updated_pages_count} 个分页`
}); });
const { query, currentPage, videoSource, failedOnly } = $appStateStore; const { query, currentPage, videoSource, statusFilter } = $appStateStore;
await loadVideos(query, currentPage, videoSource, failedOnly); await loadVideos(query, currentPage, videoSource, statusFilter);
} else { } else {
toast.info('没有视频被更新'); 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; return parts;
} }
@@ -297,34 +313,40 @@
goto(`/${ToQuery($appStateStore)}`); goto(`/${ToQuery($appStateStore)}`);
}} }}
></SearchBar> ></SearchBar>
<div class="flex items-center gap-2"> <div class="flex items-center gap-3">
<span class="text-muted-foreground text-sm">筛选:</span> <!-- 状态筛选 -->
<div <div class="flex items-center gap-1">
class="bg-secondary text-secondary-foreground inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-medium" <span class="text-muted-foreground text-xs">状态:</span>
> <StatusFilter
<Checkbox value={$appStateStore.statusFilter}
id="failed-only" onSelect={(value) => {
checked={$appStateStore.failedOnly} setStatusFilter(value);
onCheckedChange={(value) => { resetCurrentPage();
setFailedOnly(value); goto(`/${ToQuery($appStateStore)}`);
}}
onRemove={() => {
setStatusFilter(null);
resetCurrentPage(); resetCurrentPage();
goto(`/${ToQuery($appStateStore)}`); goto(`/${ToQuery($appStateStore)}`);
}} }}
/> />
<Label for="failed-only" class="text-xs">仅失败视频</Label>
</div> </div>
<DropdownFilter <!-- 视频源筛选 -->
{filters} <div class="flex items-center gap-1">
selectedLabel={$appStateStore.videoSource} <span class="text-muted-foreground text-xs">来源:</span>
onSelect={(type, id) => { <DropdownFilter
setAll('', 0, { type, id }, $appStateStore.failedOnly); {filters}
goto(`/${ToQuery($appStateStore)}`); selectedLabel={$appStateStore.videoSource}
}} onSelect={(type, id) => {
onRemove={() => { setAll('', 0, { type, id }, $appStateStore.statusFilter);
setAll('', 0, null, $appStateStore.failedOnly); goto(`/${ToQuery($appStateStore)}`);
goto(`/${ToQuery($appStateStore)}`); }}
}} onRemove={() => {
/> setAll('', 0, null, $appStateStore.statusFilter);
goto(`/${ToQuery($appStateStore)}`);
}}
/>
</div>
</div> </div>
</div> </div>