feat: 支持手动编辑某个视频、分页状态,优化部分代码 (#355)

This commit is contained in:
ᴀᴍᴛᴏᴀᴇʀ
2025-06-06 07:39:17 +08:00
committed by GitHub
parent c0ed37750f
commit 65a047b0fa
20 changed files with 670 additions and 31 deletions

View File

@@ -6,6 +6,8 @@ import type {
VideoResponse,
ResetVideoResponse,
ResetAllVideosResponse,
ResetVideoStatusRequest,
ResetVideoStatusResponse,
ApiError
} from './types';
@@ -154,6 +156,18 @@ class ApiClient {
async resetAllVideos(): Promise<ApiResponse<ResetAllVideosResponse>> {
return this.post<ResetAllVideosResponse>('/videos/reset-all');
}
/**
* 重置视频状态位
* @param id 视频 ID
* @param request 重置请求参数
*/
async resetVideoStatus(
id: number,
request: ResetVideoStatusRequest
): Promise<ApiResponse<ResetVideoStatusResponse>> {
return this.post<ResetVideoStatusResponse>(`/videos/${id}/reset-status`, request);
}
}
// 创建默认的 API 客户端实例
@@ -186,6 +200,12 @@ export const api = {
*/
resetAllVideos: () => apiClient.resetAllVideos(),
/**
* 重置视频状态位
*/
resetVideoStatus: (id: number, request: ResetVideoStatusRequest) =>
apiClient.resetVideoStatus(id, request),
/**
* 设置认证 token
*/

View File

@@ -0,0 +1,250 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle
} from '$lib/components/ui/sheet/index.js';
import StatusTaskCard from './status-task-card.svelte';
import type { VideoInfo, PageInfo, StatusUpdate, ResetVideoStatusRequest } from '$lib/types';
import { toast } from 'svelte-sonner';
export let open = false;
export let video: VideoInfo;
export let pages: PageInfo[] = [];
export let loading = false;
export let onsubmit: (request: ResetVideoStatusRequest) => void;
// 视频任务名称(与后端 VideoStatus 对应)
const videoTaskNames = ['视频封面', '视频信息', 'UP主头像', 'UP主信息', '分P下载'];
// 分页任务名称(与后端 PageStatus 对应)
const pageTaskNames = ['视频封面', '视频内容', '视频信息', '视频弹幕', '视频字幕'];
// 重置单个视频任务到原始状态
function resetVideoTask(taskIndex: number) {
videoStatuses[taskIndex] = originalVideoStatuses[taskIndex];
videoStatuses = [...videoStatuses];
}
// 重置单个分页任务到原始状态
function resetPageTask(pageId: number, taskIndex: number) {
if (!pageStatuses[pageId]) {
pageStatuses[pageId] = [];
}
pageStatuses[pageId][taskIndex] = originalPageStatuses[pageId]?.[taskIndex] ?? 0;
pageStatuses = { ...pageStatuses };
}
// 编辑状态
let videoStatuses: number[] = [];
let pageStatuses: Record<number, number[]> = {};
// 原始状态备份
let originalVideoStatuses: number[] = [];
let originalPageStatuses: Record<number, number[]> = {};
// 响应式更新状态 - 当 video 或 pages props 变化时重新初始化
$: {
// 初始化视频状态
videoStatuses = [...video.download_status];
originalVideoStatuses = [...video.download_status];
// 初始化分页状态
if (pages.length > 0) {
pageStatuses = pages.reduce(
(acc, page) => {
acc[page.id] = [...page.download_status];
return acc;
},
{} as Record<number, number[]>
);
originalPageStatuses = pages.reduce(
(acc, page) => {
acc[page.id] = [...page.download_status];
return acc;
},
{} as Record<number, number[]>
);
} else {
pageStatuses = {};
originalPageStatuses = {};
}
}
function handleVideoStatusChange(taskIndex: number, newValue: number) {
videoStatuses[taskIndex] = newValue;
videoStatuses = [...videoStatuses];
}
function handlePageStatusChange(pageId: number, taskIndex: number, newValue: number) {
if (!pageStatuses[pageId]) {
pageStatuses[pageId] = [];
}
pageStatuses[pageId][taskIndex] = newValue;
pageStatuses = { ...pageStatuses };
}
function resetAllStatuses() {
videoStatuses = [...originalVideoStatuses];
pageStatuses = { ...originalPageStatuses };
}
function hasVideoChanges(): boolean {
return !videoStatuses.every((status, index) => status === originalVideoStatuses[index]);
}
function hasPageChanges(): boolean {
return pages.some((page) => {
const currentStatuses = pageStatuses[page.id] || [];
const originalStatuses = originalPageStatuses[page.id] || [];
return !currentStatuses.every((status, index) => status === originalStatuses[index]);
});
}
function hasAnyChanges(): boolean {
return hasVideoChanges() || hasPageChanges();
}
function buildRequest(): ResetVideoStatusRequest {
const request: ResetVideoStatusRequest = {};
// 构建视频状态更新
if (hasVideoChanges()) {
request.video_updates = [];
videoStatuses.forEach((status, index) => {
if (status !== originalVideoStatuses[index]) {
request.video_updates!.push({
status_index: index,
status_value: status
});
}
});
}
// 构建分页状态更新
if (hasPageChanges()) {
request.page_updates = [];
pages.forEach((page) => {
const currentStatuses = pageStatuses[page.id] || [];
const originalStatuses = originalPageStatuses[page.id] || [];
const updates: StatusUpdate[] = [];
currentStatuses.forEach((status, index) => {
if (status !== originalStatuses[index]) {
updates.push({
status_index: index,
status_value: status
});
}
});
if (updates.length > 0) {
request.page_updates!.push({
page_id: page.id,
updates
});
}
});
}
return request;
}
function handleSubmit() {
if (!hasAnyChanges()) {
toast.info('没有状态变更需要提交');
return;
}
const request = buildRequest();
onsubmit(request);
}
</script>
<Sheet bind:open>
<SheetContent side="right" class="flex w-full flex-col sm:max-w-3xl">
<SheetHeader class="px-6 pb-2">
<SheetTitle class="text-lg">编辑状态</SheetTitle>
<SheetDescription class="text-muted-foreground space-y-1 text-sm">
<div>修改视频和分页的下载状态。可以将任务重置为未开始状态,或者标记为已完成。</div>
<div class="font-medium text-red-600">
⚠️ 已完成任务被重置为未开始,任务重新执行时会覆盖现存文件。
</div>
</SheetDescription>
</SheetHeader>
<div class="flex-1 overflow-y-auto px-6">
<div class="space-y-6 py-2">
<!-- 视频状态编辑 -->
<div>
<h3 class="mb-4 text-base font-medium">视频状态</h3>
<div class="bg-card rounded-lg border p-4">
<div class="space-y-3">
{#each videoTaskNames as taskName, index (index)}
<StatusTaskCard
{taskName}
currentStatus={videoStatuses[index] ?? 0}
originalStatus={originalVideoStatuses[index] ?? 0}
onStatusChange={(newStatus) => handleVideoStatusChange(index, newStatus)}
onReset={() => resetVideoTask(index)}
disabled={loading}
/>
{/each}
</div>
</div>
</div>
<!-- 分页状态编辑 -->
{#if pages.length > 0}
<div>
<h3 class="mb-4 text-base font-medium">分页状态</h3>
<div class="space-y-4">
{#each pages as page (page.id)}
<div class="bg-card rounded-lg border">
<div class="bg-muted/30 border-b px-4 py-3">
<h4 class="text-sm font-medium">P{page.pid}: {page.name}</h4>
</div>
<div class="space-y-3 p-4">
{#each pageTaskNames as taskName, index (index)}
<StatusTaskCard
{taskName}
currentStatus={(pageStatuses[page.id] || page.download_status)[index] ?? 0}
originalStatus={originalPageStatuses[page.id]?.[index] ?? 0}
onStatusChange={(newStatus) =>
handlePageStatusChange(page.id, index, newStatus)}
onReset={() => resetPageTask(page.id, index)}
disabled={loading}
/>
{/each}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
<SheetFooter class="bg-background flex gap-2 border-t px-6 pt-4">
<Button
variant="outline"
onclick={resetAllStatuses}
disabled={!hasAnyChanges()}
class="flex-1 cursor-pointer"
>
重置所有状态
</Button>
<Button
onclick={handleSubmit}
disabled={loading || !hasAnyChanges()}
class="flex-1 cursor-pointer"
>
{loading ? '提交中...' : '提交更改'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>

View File

@@ -0,0 +1,80 @@
<!-- 可复用的状态任务卡片组件 -->
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
export let taskName: string;
export let currentStatus: number;
export let originalStatus: number;
export let onStatusChange: (newStatus: number) => void;
export let onReset: () => void;
export let disabled: boolean = false;
// 获取状态显示信息
function getStatusInfo(value: number) {
if (value === 7) return { label: '已完成', class: 'text-green-600', dotClass: 'bg-green-500' };
if (value >= 1 && value <= 4)
return { label: `失败${value}次`, class: 'text-red-600', dotClass: 'bg-red-500' };
return { label: '未开始', class: 'text-yellow-600', dotClass: 'bg-yellow-500' };
}
$: statusInfo = getStatusInfo(currentStatus);
$: isModified = currentStatus !== originalStatus;
</script>
<div
class="bg-background hover:bg-muted/30 flex items-center justify-between rounded-md border p-3 transition-colors {isModified
? 'border-blue-200 ring-2 ring-blue-500/20'
: ''}"
>
<div class="flex items-center gap-3">
<div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{taskName}</span>
{#if isModified}
<span class="hidden text-xs font-medium text-blue-600 sm:inline">已修改</span>
<div class="h-2 w-2 rounded-full bg-blue-500 sm:hidden" title="已修改"></div>
{/if}
</div>
<div class="mt-0.5 flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full {statusInfo.dotClass}"></div>
<span class="text-xs {statusInfo.class}">{statusInfo.label}</span>
</div>
</div>
</div>
<div class="flex gap-1.5">
{#if isModified}
<Button
variant="ghost"
size="sm"
onclick={onReset}
{disabled}
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs text-gray-600 hover:bg-gray-100"
title="恢复到原始状态"
>
重置
</Button>
{/if}
<Button
variant={currentStatus === 0 ? 'default' : 'outline'}
size="sm"
onclick={() => onStatusChange(0)}
{disabled}
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs {currentStatus === 0
? 'border-yellow-600 bg-yellow-600 font-medium text-white hover:bg-yellow-700'
: 'hover:border-yellow-400 hover:bg-yellow-50 hover:text-yellow-700'}"
>
未开始
</Button>
<Button
variant={currentStatus === 7 ? 'default' : 'outline'}
size="sm"
onclick={() => onStatusChange(7)}
{disabled}
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs {currentStatus === 7
? 'border-green-600 bg-green-600 font-medium text-white hover:bg-green-700'
: 'hover:border-green-400 hover:bg-green-50 hover:text-green-700'}"
>
已完成
</Button>
</div>
</div>

View File

@@ -77,3 +77,28 @@ export interface ApiError {
message: string;
status?: number;
}
// 状态更新类型
export interface StatusUpdate {
status_index: number;
status_value: number;
}
// 页面状态更新类型
export interface PageStatusUpdate {
page_id: number;
updates: StatusUpdate[];
}
// 重置视频状态请求类型
export interface ResetVideoStatusRequest {
video_updates?: StatusUpdate[];
page_updates?: PageStatusUpdate[];
}
// 重置视频状态响应类型
export interface ResetVideoStatusResponse {
success: boolean;
video: VideoInfo;
pages: PageInfo[];
}

View File

@@ -201,7 +201,7 @@
<Button
size="sm"
variant="outline"
class="text-xs"
class="cursor-pointer text-xs"
onclick={() => (resetAllDialogOpen = true)}
disabled={resettingAll || loading}
>

View File

@@ -4,11 +4,13 @@
import { onMount } from 'svelte';
import { Button } from '$lib/components/ui/button/index.js';
import api from '$lib/api';
import type { ApiError, VideoResponse } from '$lib/types';
import type { ApiError, VideoResponse, ResetVideoStatusRequest } from '$lib/types';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import EditIcon from '@lucide/svelte/icons/edit';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import { appStateStore, ToQuery } from '$lib/stores/filter';
import VideoCard from '$lib/components/video-card.svelte';
import StatusEditor from '$lib/components/status-editor.svelte';
import { toast } from 'svelte-sonner';
let videoData: VideoResponse | null = null;
@@ -16,6 +18,8 @@
let error: string | null = null;
let resetDialogOpen = false;
let resetting = false;
let statusEditorOpen = false;
let statusEditorLoading = false;
async function loadVideoDetail() {
const videoId = parseInt($page.params.id);
@@ -55,6 +59,35 @@
$: if ($page.params.id) {
loadVideoDetail();
}
async function handleStatusEditorSubmit(request: ResetVideoStatusRequest) {
if (!videoData) return;
statusEditorLoading = true;
try {
const result = await api.resetVideoStatus(videoData.video.id, request);
const data = result.data;
if (data.success) {
// 更新本地数据
videoData = {
video: data.video,
pages: data.pages
};
statusEditorOpen = false;
toast.success('状态更新成功');
} else {
toast.error('状态更新失败');
}
} catch (error) {
console.error('状态更新失败:', error);
toast.error('状态更新失败', {
description: (error as ApiError).message
});
} finally {
statusEditorLoading = false;
}
}
</script>
<svelte:head>
@@ -82,16 +115,28 @@
<section>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold">视频信息</h2>
<Button
size="sm"
variant="outline"
class="shrink-0"
onclick={() => (resetDialogOpen = true)}
disabled={resetting}
>
<RotateCcwIcon class="mr-2 h-4 w-4 {resetting ? 'animate-spin' : ''}" />
重置
</Button>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
class="shrink-0 cursor-pointer "
onclick={() => (statusEditorOpen = true)}
disabled={statusEditorLoading}
>
<EditIcon class="mr-2 h-4 w-4" />
编辑状态
</Button>
<Button
size="sm"
variant="outline"
class="shrink-0 cursor-pointer "
onclick={() => (resetDialogOpen = true)}
disabled={resetting}
>
<RotateCcwIcon class="mr-2 h-4 w-4 {resetting ? 'animate-spin' : ''}" />
重置
</Button>
</div>
</div>
<div style="margin-bottom: 1rem;">
@@ -175,4 +220,15 @@
</div>
{/if}
</section>
<!-- 状态编辑器 -->
{#if videoData}
<StatusEditor
bind:open={statusEditorOpen}
video={videoData.video}
pages={videoData.pages}
loading={statusEditorLoading}
onsubmit={handleStatusEditorSubmit}
/>
{/if}
{/if}