From 26514f717457bae2099ddd4345dce2d3704d1230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=B4=80=E1=B4=8D=E1=B4=9B=E1=B4=8F=E1=B4=80=E1=B4=87?= =?UTF-8?q?=CA=80?= Date: Sun, 11 Jan 2026 15:03:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=B8=85=E9=99=A4?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=EF=BC=8C=E6=96=B9=E4=BE=BF=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E5=88=B7=E6=96=B0=20(#596)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/bili_sync/src/api/response.rs | 6 ++ crates/bili_sync/src/api/routes/videos/mod.rs | 52 ++++++++++- web/src/lib/api.ts | 6 ++ web/src/lib/components/video-card.svelte | 65 ++++++++++++- web/src/lib/types.ts | 5 + web/src/routes/video/[id]/+page.svelte | 92 ++++++++++++++----- web/src/routes/videos/+page.svelte | 26 ++++++ 7 files changed, 219 insertions(+), 33 deletions(-) diff --git a/crates/bili_sync/src/api/response.rs b/crates/bili_sync/src/api/response.rs index ae3f885..eba227d 100644 --- a/crates/bili_sync/src/api/response.rs +++ b/crates/bili_sync/src/api/response.rs @@ -33,6 +33,12 @@ pub struct ResetVideoResponse { pub pages: Vec, } +#[derive(Serialize)] +pub struct ClearAndResetVideoStatusResponse { + pub warning: Option, + pub video: VideoInfo, +} + #[derive(Serialize)] pub struct ResetFilteredVideosResponse { pub resetted: bool, diff --git a/crates/bili_sync/src/api/routes/videos/mod.rs b/crates/bili_sync/src/api/routes/videos/mod.rs index 7e1e97b..d0f3ce8 100644 --- a/crates/bili_sync/src/api/routes/videos/mod.rs +++ b/crates/bili_sync/src/api/routes/videos/mod.rs @@ -1,12 +1,14 @@ use std::collections::HashSet; -use anyhow::Result; +use anyhow::{Context, Result}; use axum::extract::{Extension, Path, Query}; use axum::routing::{get, post}; use axum::{Json, Router}; use bili_sync_entity::*; +use sea_orm::ActiveValue::Set; use sea_orm::{ - ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, TransactionTrait, + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, + QueryOrder, TransactionTrait, TryIntoModel, }; use crate::api::error::InnerApiError; @@ -16,8 +18,9 @@ use crate::api::request::{ UpdateVideoStatusRequest, VideosRequest, }; use crate::api::response::{ - PageInfo, ResetFilteredVideosResponse, ResetVideoResponse, SimplePageInfo, SimpleVideoInfo, - UpdateFilteredVideoStatusResponse, UpdateVideoStatusResponse, VideoInfo, VideoResponse, VideosResponse, + ClearAndResetVideoStatusResponse, PageInfo, ResetFilteredVideosResponse, ResetVideoResponse, SimplePageInfo, + SimpleVideoInfo, UpdateFilteredVideoStatusResponse, UpdateVideoStatusResponse, VideoInfo, VideoResponse, + VideosResponse, }; use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson}; use crate::utils::status::{PageStatus, VideoStatus}; @@ -26,6 +29,10 @@ pub(super) fn router() -> Router { Router::new() .route("/videos", get(get_videos)) .route("/videos/{id}", get(get_video)) + .route( + "/videos/{id}/clear-and-reset-status", + post(clear_and_reset_video_status), + ) .route("/videos/{id}/reset-status", post(reset_video_status)) .route("/videos/{id}/update-status", post(update_video_status)) .route("/videos/reset-status", post(reset_filtered_video_status)) @@ -152,6 +159,43 @@ pub async fn reset_video_status( })) } +pub async fn clear_and_reset_video_status( + Path(id): Path, + Extension(db): Extension, +) -> Result, ApiError> { + let video_info = video::Entity::find_by_id(id).one(&db).await?; + let Some(video_info) = video_info else { + return Err(InnerApiError::NotFound(id).into()); + }; + let txn = db.begin().await?; + let mut video_info = video_info.into_active_model(); + video_info.single_page = Set(None); + video_info.download_status = Set(0); + let video_info = video_info.update(&txn).await?; + page::Entity::delete_many() + .filter(page::Column::VideoId.eq(id)) + .exec(&txn) + .await?; + txn.commit().await?; + let video_info = video_info.try_into_model()?; + let warning = tokio::fs::remove_dir_all(&video_info.path) + .await + .context(format!("删除本地路径「{}」失败", video_info.path)) + .err() + .map(|e| format!("{:#}", e)); + Ok(ApiResponse::ok(ClearAndResetVideoStatusResponse { + warning, + video: VideoInfo { + id: video_info.id, + bvid: video_info.bvid, + name: video_info.name, + upper_name: video_info.upper_name, + should_download: video_info.should_download, + download_status: video_info.download_status, + }, + })) +} + pub async fn reset_filtered_video_status( Extension(db): Extension, Json(request): Json, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 6806e2b..d4134d7 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -5,6 +5,7 @@ import type { VideosResponse, VideoResponse, ResetVideoResponse, + ClearAndResetVideoResponse, ResetFilteredVideosResponse, UpdateVideoStatusRequest, UpdateVideoStatusResponse, @@ -165,6 +166,10 @@ class ApiClient { return this.post(`/videos/${id}/reset-status`, request); } + async clearAndResetVideoStatus(id: number): Promise> { + return this.post(`/videos/${id}/clear-and-reset-status`); + } + async resetFilteredVideoStatus( request: ResetFilteredVideoStatusRequest ): Promise> { @@ -297,6 +302,7 @@ const api = { getVideo: (id: number) => apiClient.getVideo(id), resetVideoStatus: (id: number, request: ResetVideoStatusRequest) => apiClient.resetVideoStatus(id, request), + clearAndResetVideoStatus: (id: number) => apiClient.clearAndResetVideoStatus(id), resetFilteredVideoStatus: (request: ResetFilteredVideoStatusRequest) => apiClient.resetFilteredVideoStatus(request), updateVideoStatus: (id: number, request: UpdateVideoStatusRequest) => diff --git a/web/src/lib/components/video-card.svelte b/web/src/lib/components/video-card.svelte index c0701bf..f2bc32d 100644 --- a/web/src/lib/components/video-card.svelte +++ b/web/src/lib/components/video-card.svelte @@ -9,6 +9,7 @@ import type { VideoInfo } from '$lib/types'; import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'; import InfoIcon from '@lucide/svelte/icons/info'; + import BrushCleaningIcon from '@lucide/svelte/icons/brush-cleaning'; import UserIcon from '@lucide/svelte/icons/user'; import SquareArrowOutUpRightIcon from '@lucide/svelte/icons/square-arrow-out-up-right'; import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal'; @@ -24,8 +25,11 @@ export let taskNames: string[] = []; // 自定义任务名称 export let showProgress: boolean = true; // 是否显示进度信息 export let onReset: ((forceReset: boolean) => Promise) | null = null; // 自定义重置函数 + export let onClearAndReset: (() => Promise) | null = null; // 自定义清空重置函数 export let resetDialogOpen = false; // 导出对话框状态,让父组件可以控制 + export let clearAndResetDialogOpen = false; // 导出清空重置对话框状态 export let resetting = false; + export let clearAndResetting = false; let forceReset = false; @@ -98,6 +102,15 @@ forceReset = false; } + async function handleClearAndReset() { + clearAndResetting = true; + if (onClearAndReset) { + await onClearAndReset(); + } + clearAndResetting = false; + clearAndResetDialogOpen = false; + } + function handleViewDetail() { goto(`/video/${video.id}`); } @@ -112,7 +125,7 @@ - +
+ (resetDialogOpen = true)}> + + 重置 + + (clearAndResetDialogOpen = true)} + > + + 清空重置 + @@ -204,10 +228,6 @@ 在 B 站打开 - (resetDialogOpen = true)}> - - 重置下载状态 -
@@ -261,3 +281,38 @@ + + + + + + 清空重置视频 + + 确定要清空重置视频 "{displayTitle}" 吗? +
+
+ 此操作会: +
    +
  • 将视频状态重置为未开始
  • +
  • 删除所有分页信息
  • +
  • 删除视频对应的文件夹
  • +
+
+ 该功能可在多页视频变更后手动触发全量更新,执行后无法撤销。 +
+
+ + + 取消 + + {clearAndResetting ? '清空重置中...' : '确认清空重置'} + + +
+
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 242342a..3130a35 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -57,6 +57,11 @@ export interface ResetVideoResponse { pages: PageInfo[]; } +export interface ClearAndResetVideoResponse { + warning?: string; + video: VideoInfo; +} + export interface ResetFilteredVideosResponse { resetted: boolean; resetted_videos_count: number; diff --git a/web/src/routes/video/[id]/+page.svelte b/web/src/routes/video/[id]/+page.svelte index a5ecf57..45fff5f 100644 --- a/web/src/routes/video/[id]/+page.svelte +++ b/web/src/routes/video/[id]/+page.svelte @@ -8,6 +8,7 @@ import type { ApiError, VideoResponse, UpdateVideoStatusRequest } from '$lib/types'; import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'; import EditIcon from '@lucide/svelte/icons/edit'; + import BrushCleaningIcon from '@lucide/svelte/icons/brush-cleaning'; import { setBreadcrumb } from '$lib/stores/breadcrumb'; import { appStateStore, ToQuery } from '$lib/stores/filter'; import VideoCard from '$lib/components/video-card.svelte'; @@ -19,6 +20,8 @@ let error: string | null = null; let resetDialogOpen = false; let resetting = false; + let clearAndResetDialogOpen = false; + let clearAndResetting = false; let statusEditorOpen = false; let statusEditorLoading = false; @@ -87,6 +90,56 @@ statusEditorLoading = false; } } + + async function handleReset(forceReset: boolean) { + if (!videoData) return; + try { + const result = await api.resetVideoStatus(videoData.video.id, { force: forceReset }); + const data = result.data; + if (data.resetted) { + videoData = { + video: data.video, + pages: data.pages + }; + toast.success('重置成功'); + } else { + toast.info('重置无效', { + description: `视频「${data.video.name}」没有失败的状态,无需重置` + }); + } + } catch (error) { + console.error('重置失败:', error); + toast.error('重置失败', { + description: (error as ApiError).message + }); + } + } + + async function handleClearAndReset() { + if (!videoData) return; + try { + const result = await api.clearAndResetVideoStatus(videoData.video.id); + const data = result.data; + videoData = { + video: data.video, + pages: [] + }; + if (data.warning) { + toast.warning('清空重置成功', { + description: data.warning + }); + } else { + toast.success('清空重置成功', { + description: `视频「${data.video.name}」已清空重置` + }); + } + } catch (error) { + console.error('清空重置失败:', error); + toast.error('清空重置失败', { + description: (error as ApiError).message + }); + } + } @@ -130,11 +183,21 @@ variant="outline" class="shrink-0 cursor-pointer " onclick={() => (resetDialogOpen = true)} - disabled={resetting} + disabled={resetting || clearAndResetting} > 重置 +