feat: 支持筛选视频的有效性 (#673)
This commit is contained in:
95
web/src/lib/components/validation-filter.svelte
Normal file
95
web/src/lib/components/validation-filter.svelte
Normal file
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
CircleCheckBigIcon,
|
||||
TriangleAlertIcon,
|
||||
SkipForwardIcon,
|
||||
ChevronDownIcon,
|
||||
TrashIcon
|
||||
} from '@lucide/svelte/icons';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { type ValidationFilterValue } from '$lib/stores/filter';
|
||||
|
||||
interface Props {
|
||||
value: ValidationFilterValue;
|
||||
onSelect?: (value: ValidationFilterValue) => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
let { value = $bindable('normal'), onSelect, onRemove }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let triggerRef = $state<HTMLButtonElement>(null!);
|
||||
|
||||
function closeAndFocusTrigger() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
const validationOptions = [
|
||||
{
|
||||
value: 'normal' as const,
|
||||
label: '有效',
|
||||
icon: CircleCheckBigIcon
|
||||
},
|
||||
{
|
||||
value: 'skipped' as const,
|
||||
label: '跳过',
|
||||
icon: SkipForwardIcon
|
||||
},
|
||||
{
|
||||
value: 'invalid' as const,
|
||||
label: '失效',
|
||||
icon: TriangleAlertIcon
|
||||
}
|
||||
];
|
||||
|
||||
function handleSelect(selectedValue: ValidationFilterValue) {
|
||||
value = selectedValue;
|
||||
onSelect?.(selectedValue);
|
||||
closeAndFocusTrigger();
|
||||
}
|
||||
|
||||
const currentOption = $derived(validationOptions.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 validationOptions 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}
|
||||
<CircleCheckBigIcon 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>
|
||||
@@ -65,7 +65,7 @@
|
||||
} {
|
||||
if (!valid) {
|
||||
// 视频属性表明已失效,或由于各种条件判断(充电视频等)判定为无效的情况
|
||||
return { text: '无效', style: 'bg-gray-100 text-gray-700' };
|
||||
return { text: '失效', style: 'bg-gray-100 text-gray-700' };
|
||||
}
|
||||
if (!shouldDownload) {
|
||||
// 被过滤规则排除,显示为“跳过”
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export type StatusFilterValue = 'failed' | 'succeeded' | 'waiting' | null;
|
||||
export type ValidationFilterValue = 'skipped' | 'invalid' | 'normal' | null;
|
||||
|
||||
export interface AppState {
|
||||
query: string;
|
||||
@@ -10,17 +11,19 @@ export interface AppState {
|
||||
id: string;
|
||||
} | null;
|
||||
statusFilter: StatusFilterValue | null;
|
||||
validationFilter: ValidationFilterValue | null;
|
||||
}
|
||||
|
||||
export const appStateStore = writable<AppState>({
|
||||
query: '',
|
||||
currentPage: 0,
|
||||
videoSource: null,
|
||||
statusFilter: null
|
||||
statusFilter: null,
|
||||
validationFilter: 'normal'
|
||||
});
|
||||
|
||||
export const ToQuery = (state: AppState): string => {
|
||||
const { query, videoSource, currentPage, statusFilter } = state;
|
||||
const { query, videoSource, currentPage, statusFilter, validationFilter } = state;
|
||||
const params = new URLSearchParams();
|
||||
if (currentPage > 0) {
|
||||
params.set('page', String(currentPage));
|
||||
@@ -34,6 +37,9 @@ export const ToQuery = (state: AppState): string => {
|
||||
if (statusFilter) {
|
||||
params.set('status_filter', statusFilter);
|
||||
}
|
||||
if (validationFilter) {
|
||||
params.set('validation_filter', validationFilter);
|
||||
}
|
||||
const queryString = params.toString();
|
||||
return queryString ? `videos?${queryString}` : 'videos';
|
||||
};
|
||||
@@ -48,6 +54,7 @@ export const ToFilterParams = (
|
||||
submission?: number;
|
||||
watch_later?: number;
|
||||
status_filter?: Exclude<StatusFilterValue, null>;
|
||||
validation_filter?: Exclude<ValidationFilterValue, null>;
|
||||
} => {
|
||||
const params: {
|
||||
query?: string;
|
||||
@@ -56,6 +63,7 @@ export const ToFilterParams = (
|
||||
submission?: number;
|
||||
watch_later?: number;
|
||||
status_filter?: Exclude<StatusFilterValue, null>;
|
||||
validation_filter?: Exclude<ValidationFilterValue, null>;
|
||||
} = {};
|
||||
|
||||
if (state.query.trim()) {
|
||||
@@ -69,12 +77,20 @@ export const ToFilterParams = (
|
||||
if (state.statusFilter) {
|
||||
params.status_filter = state.statusFilter;
|
||||
}
|
||||
if (state.validationFilter) {
|
||||
params.validation_filter = state.validationFilter;
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
// 检查是否有活动的筛选条件
|
||||
export const hasActiveFilters = (state: AppState): boolean => {
|
||||
return !!(state.query.trim() || state.videoSource || state.statusFilter);
|
||||
return !!(
|
||||
state.query.trim() ||
|
||||
state.videoSource ||
|
||||
state.statusFilter ||
|
||||
state.validationFilter
|
||||
);
|
||||
};
|
||||
|
||||
export const setQuery = (query: string) => {
|
||||
@@ -98,6 +114,13 @@ export const setStatusFilter = (statusFilter: StatusFilterValue | null) => {
|
||||
}));
|
||||
};
|
||||
|
||||
export const setValidationFilter = (validationFilter: ValidationFilterValue | null) => {
|
||||
appStateStore.update((state) => ({
|
||||
...state,
|
||||
validationFilter
|
||||
}));
|
||||
};
|
||||
|
||||
export const resetCurrentPage = () => {
|
||||
appStateStore.update((state) => ({
|
||||
...state,
|
||||
@@ -109,12 +132,14 @@ export const setAll = (
|
||||
query: string,
|
||||
currentPage: number,
|
||||
videoSource: { type: string; id: string } | null,
|
||||
statusFilter: StatusFilterValue | null
|
||||
statusFilter: StatusFilterValue | null,
|
||||
validationFilter: ValidationFilterValue | null = 'normal'
|
||||
) => {
|
||||
appStateStore.set({
|
||||
query,
|
||||
currentPage,
|
||||
videoSource,
|
||||
statusFilter
|
||||
statusFilter,
|
||||
validationFilter
|
||||
});
|
||||
};
|
||||
|
||||
@@ -9,7 +9,8 @@ export interface VideosRequest {
|
||||
submission?: number;
|
||||
watch_later?: number;
|
||||
query?: string;
|
||||
failed_only?: boolean;
|
||||
status_filter?: 'failed' | 'succeeded' | 'waiting';
|
||||
validation_filter?: 'skipped' | 'invalid' | 'normal';
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
@@ -108,8 +109,8 @@ export interface UpdateFilteredVideoStatusRequest {
|
||||
submission?: number;
|
||||
watch_later?: number;
|
||||
query?: string;
|
||||
// 仅更新下载失败
|
||||
failed_only?: boolean;
|
||||
status_filter?: 'failed' | 'succeeded' | 'waiting';
|
||||
validation_filter?: 'skipped' | 'invalid' | 'normal';
|
||||
video_updates?: StatusUpdate[];
|
||||
page_updates?: StatusUpdate[];
|
||||
}
|
||||
@@ -124,8 +125,8 @@ export interface ResetFilteredVideoStatusRequest {
|
||||
submission?: number;
|
||||
watch_later?: number;
|
||||
query?: string;
|
||||
// 仅重置下载失败
|
||||
failed_only?: boolean;
|
||||
status_filter?: 'failed' | 'succeeded' | 'waiting';
|
||||
validation_filter?: 'skipped' | 'invalid' | 'normal';
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,16 +26,19 @@
|
||||
setCurrentPage,
|
||||
setQuery,
|
||||
setStatusFilter,
|
||||
setValidationFilter,
|
||||
ToQuery,
|
||||
ToFilterParams,
|
||||
hasActiveFilters,
|
||||
type StatusFilterValue
|
||||
type StatusFilterValue,
|
||||
type ValidationFilterValue
|
||||
} 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';
|
||||
import ValidationFilter from '$lib/components/validation-filter.svelte';
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
@@ -71,10 +74,18 @@
|
||||
statusFilterParam === 'waiting'
|
||||
? statusFilterParam
|
||||
: null;
|
||||
const validationFilterParam = searchParams.get('validation_filter');
|
||||
const validationFilter: ValidationFilterValue =
|
||||
validationFilterParam === 'skipped' ||
|
||||
validationFilterParam === 'invalid' ||
|
||||
validationFilterParam === 'normal'
|
||||
? validationFilterParam
|
||||
: null;
|
||||
return {
|
||||
query: searchParams.get('query') || '',
|
||||
videoSource,
|
||||
statusFilter,
|
||||
validationFilter,
|
||||
pageNum: parseInt(searchParams.get('page') || '0')
|
||||
};
|
||||
}
|
||||
@@ -83,7 +94,8 @@
|
||||
query: string,
|
||||
pageNum: number = 0,
|
||||
filter?: { type: string; id: string } | null,
|
||||
statusFilter: StatusFilterValue | null = null
|
||||
statusFilter: StatusFilterValue | null = null,
|
||||
validationFilter: ValidationFilterValue | null = null
|
||||
) {
|
||||
loading = true;
|
||||
try {
|
||||
@@ -100,6 +112,9 @@
|
||||
if (statusFilter) {
|
||||
params.status_filter = statusFilter;
|
||||
}
|
||||
if (validationFilter) {
|
||||
params.validation_filter = validationFilter;
|
||||
}
|
||||
const result = await api.getVideos(params);
|
||||
videosData = result.data;
|
||||
} catch (error) {
|
||||
@@ -118,9 +133,10 @@
|
||||
}
|
||||
|
||||
async function handleSearchParamsChange(searchParams: URLSearchParams) {
|
||||
const { query, videoSource, pageNum, statusFilter } = getApiParams(searchParams);
|
||||
setAll(query, pageNum, videoSource, statusFilter);
|
||||
loadVideos(query, pageNum, videoSource, statusFilter);
|
||||
const { query, videoSource, pageNum, statusFilter, validationFilter } =
|
||||
getApiParams(searchParams);
|
||||
setAll(query, pageNum, videoSource, statusFilter, validationFilter);
|
||||
loadVideos(query, pageNum, videoSource, statusFilter, validationFilter);
|
||||
}
|
||||
|
||||
async function handleResetVideo(id: number, forceReset: boolean) {
|
||||
@@ -131,8 +147,8 @@
|
||||
toast.success('重置成功', {
|
||||
description: `视频「${data.video.name}」已重置`
|
||||
});
|
||||
const { query, currentPage, videoSource, statusFilter } = $appStateStore;
|
||||
await loadVideos(query, currentPage, videoSource, statusFilter);
|
||||
const { query, currentPage, videoSource, statusFilter, validationFilter } = $appStateStore;
|
||||
await loadVideos(query, currentPage, videoSource, statusFilter, validationFilter);
|
||||
} else {
|
||||
toast.info('重置无效', {
|
||||
description: `视频「${data.video.name}」没有失败的状态,无需重置`
|
||||
@@ -159,8 +175,8 @@
|
||||
description: `视频「${data.video.name}」已清空重置`
|
||||
});
|
||||
}
|
||||
const { query, currentPage, videoSource, statusFilter } = $appStateStore;
|
||||
await loadVideos(query, currentPage, videoSource, statusFilter);
|
||||
const { query, currentPage, videoSource, statusFilter, validationFilter } = $appStateStore;
|
||||
await loadVideos(query, currentPage, videoSource, statusFilter, validationFilter);
|
||||
} catch (error) {
|
||||
console.error('清空重置失败:', error);
|
||||
toast.error('清空重置失败', {
|
||||
@@ -183,8 +199,8 @@
|
||||
toast.success('重置成功', {
|
||||
description: `已重置 ${data.resetted_videos_count} 个视频和 ${data.resetted_pages_count} 个分页`
|
||||
});
|
||||
const { query, currentPage, videoSource, statusFilter } = $appStateStore;
|
||||
await loadVideos(query, currentPage, videoSource, statusFilter);
|
||||
const { query, currentPage, videoSource, statusFilter, validationFilter } = $appStateStore;
|
||||
await loadVideos(query, currentPage, videoSource, statusFilter, validationFilter);
|
||||
} else {
|
||||
toast.info('没有需要重置的视频');
|
||||
}
|
||||
@@ -214,8 +230,8 @@
|
||||
toast.success('更新成功', {
|
||||
description: `已更新 ${data.updated_videos_count} 个视频和 ${data.updated_pages_count} 个分页`
|
||||
});
|
||||
const { query, currentPage, videoSource, statusFilter } = $appStateStore;
|
||||
await loadVideos(query, currentPage, videoSource, statusFilter);
|
||||
const { query, currentPage, videoSource, statusFilter, validationFilter } = $appStateStore;
|
||||
await loadVideos(query, currentPage, videoSource, statusFilter, validationFilter);
|
||||
} else {
|
||||
toast.info('没有视频被更新');
|
||||
}
|
||||
@@ -257,6 +273,14 @@
|
||||
};
|
||||
parts.push(`状态:${statusLabels[state.statusFilter]}`);
|
||||
}
|
||||
if (state.validationFilter && state.validationFilter !== 'normal') {
|
||||
const validationLabels = {
|
||||
skipped: '跳过',
|
||||
invalid: '失效',
|
||||
normal: '有效'
|
||||
};
|
||||
parts.push(`有效性:${validationLabels[state.validationFilter]}`);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
@@ -330,6 +354,22 @@
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-muted-foreground text-xs">有效性:</span>
|
||||
<ValidationFilter
|
||||
value={$appStateStore.validationFilter}
|
||||
onSelect={(value) => {
|
||||
setValidationFilter(value);
|
||||
resetCurrentPage();
|
||||
goto(`/${ToQuery($appStateStore)}`);
|
||||
}}
|
||||
onRemove={() => {
|
||||
setValidationFilter(null);
|
||||
resetCurrentPage();
|
||||
goto(`/${ToQuery($appStateStore)}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<!-- 视频源筛选 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-muted-foreground text-xs">来源:</span>
|
||||
@@ -337,11 +377,11 @@
|
||||
{filters}
|
||||
selectedLabel={$appStateStore.videoSource}
|
||||
onSelect={(type, id) => {
|
||||
setAll('', 0, { type, id }, $appStateStore.statusFilter);
|
||||
setAll('', 0, { type, id }, $appStateStore.statusFilter, $appStateStore.validationFilter);
|
||||
goto(`/${ToQuery($appStateStore)}`);
|
||||
}}
|
||||
onRemove={() => {
|
||||
setAll('', 0, null, $appStateStore.statusFilter);
|
||||
setAll('', 0, null, $appStateStore.statusFilter, $appStateStore.validationFilter);
|
||||
goto(`/${ToQuery($appStateStore)}`);
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user