feat: 支持手动编辑某个视频、分页状态,优化部分代码 (#355)
This commit is contained in:
53
Cargo.lock
generated
53
Cargo.lock
generated
@@ -501,6 +501,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"utoipa",
|
||||
"utoipa-swagger-ui",
|
||||
"validator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2332,6 +2333,28 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr2"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.96",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.93"
|
||||
@@ -3981,6 +4004,36 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "validator"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa"
|
||||
dependencies = [
|
||||
"idna 1.0.3",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"url",
|
||||
"validator_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "validator_derive"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"once_cell",
|
||||
"proc-macro-error2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.96",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -73,6 +73,7 @@ tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["chrono"] }
|
||||
utoipa = { version = "5.3.1", features = ["axum_extras"] }
|
||||
utoipa-swagger-ui = { version = "9.0.0", features = ["axum", "vendored"] }
|
||||
validator = { version = "0.20.0", features = ["derive"] }
|
||||
|
||||
[workspace.metadata.release]
|
||||
release = false
|
||||
|
||||
@@ -52,6 +52,7 @@ tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
utoipa = { workspace = true }
|
||||
utoipa-swagger-ui = { workspace = true }
|
||||
validator = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_matches = { workspace = true }
|
||||
|
||||
@@ -4,4 +4,6 @@ use thiserror::Error;
|
||||
pub enum InnerApiError {
|
||||
#[error("Primary key not found: {0}")]
|
||||
NotFound(i32),
|
||||
#[error("Bad request: {0}")]
|
||||
BadRequest(String),
|
||||
}
|
||||
|
||||
@@ -14,17 +14,17 @@ use utoipa::OpenApi;
|
||||
use crate::api::auth::OpenAPIAuth;
|
||||
use crate::api::error::InnerApiError;
|
||||
use crate::api::helper::{update_page_download_status, update_video_download_status};
|
||||
use crate::api::request::VideosRequest;
|
||||
use crate::api::request::{ResetVideoStatusRequest, VideosRequest};
|
||||
use crate::api::response::{
|
||||
PageInfo, ResetAllVideosResponse, ResetVideoResponse, VideoInfo, VideoResponse, VideoSource, VideoSourcesResponse,
|
||||
VideosResponse,
|
||||
PageInfo, ResetAllVideosResponse, ResetVideoResponse, ResetVideoStatusResponse, VideoInfo, VideoResponse,
|
||||
VideoSource, VideoSourcesResponse, VideosResponse,
|
||||
};
|
||||
use crate::api::wrapper::{ApiError, ApiResponse};
|
||||
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
||||
use crate::utils::status::{PageStatus, VideoStatus};
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(get_video_sources, get_videos, get_video, reset_video, reset_all_videos),
|
||||
paths(get_video_sources, get_videos, get_video, reset_video, reset_all_videos, reset_video_status),
|
||||
modifiers(&OpenAPIAuth),
|
||||
security(
|
||||
("Token" = []),
|
||||
@@ -197,7 +197,7 @@ pub async fn reset_video(
|
||||
}
|
||||
let resetted_videos_info = if video_resetted {
|
||||
video_info.download_status = video_status.into();
|
||||
vec![video_info.clone()]
|
||||
vec![&video_info]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
@@ -283,3 +283,69 @@ pub async fn reset_all_videos(
|
||||
resetted_pages_count: resetted_pages_info.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 重置指定视频及其分页的指定状态位
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/videos/{id}/reset-status",
|
||||
request_body = ResetVideoStatusRequest,
|
||||
responses(
|
||||
(status = 200, body = ApiResponse<ResetVideoStatusResponse>),
|
||||
)
|
||||
)]
|
||||
pub async fn reset_video_status(
|
||||
Path(id): Path<i32>,
|
||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||
ValidatedJson(request): ValidatedJson<ResetVideoStatusRequest>,
|
||||
) -> Result<ApiResponse<ResetVideoStatusResponse>, ApiError> {
|
||||
let (video_info, mut pages_info) = tokio::try_join!(
|
||||
video::Entity::find_by_id(id)
|
||||
.into_partial_model::<VideoInfo>()
|
||||
.one(db.as_ref()),
|
||||
page::Entity::find()
|
||||
.filter(page::Column::VideoId.eq(id))
|
||||
.order_by_asc(page::Column::Cid)
|
||||
.into_partial_model::<PageInfo>()
|
||||
.all(db.as_ref())
|
||||
)?;
|
||||
let Some(mut video_info) = video_info else {
|
||||
return Err(InnerApiError::NotFound(id).into());
|
||||
};
|
||||
let mut video_status = VideoStatus::from(video_info.download_status);
|
||||
for update in &request.video_updates {
|
||||
video_status.set(update.status_index, update.status_value);
|
||||
}
|
||||
video_info.download_status = video_status.into();
|
||||
let mut updated_pages_info = Vec::new();
|
||||
let mut page_id_map = pages_info
|
||||
.iter_mut()
|
||||
.map(|page| (page.id, page))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
for page_update in &request.page_updates {
|
||||
if let Some(page_info) = page_id_map.remove(&page_update.page_id) {
|
||||
let mut page_status = PageStatus::from(page_info.download_status);
|
||||
for update in &page_update.updates {
|
||||
page_status.set(update.status_index, update.status_value);
|
||||
}
|
||||
page_info.download_status = page_status.into();
|
||||
updated_pages_info.push(page_info);
|
||||
}
|
||||
}
|
||||
let has_video_updates = !request.video_updates.is_empty();
|
||||
let has_page_updates = !updated_pages_info.is_empty();
|
||||
if has_video_updates || has_page_updates {
|
||||
let txn = db.begin().await?;
|
||||
if has_video_updates {
|
||||
update_video_download_status(&txn, &[&video_info], None).await?;
|
||||
}
|
||||
if has_page_updates {
|
||||
update_page_download_status(&txn, &updated_pages_info, None).await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
}
|
||||
Ok(ApiResponse::ok(ResetVideoStatusResponse {
|
||||
success: has_video_updates || has_page_updates,
|
||||
video: video_info,
|
||||
pages: pages_info,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,44 +1,48 @@
|
||||
use std::borrow::Borrow;
|
||||
|
||||
use sea_orm::{ConnectionTrait, DatabaseTransaction};
|
||||
|
||||
use crate::api::response::{PageInfo, VideoInfo};
|
||||
|
||||
pub async fn update_video_download_status(
|
||||
txn: &DatabaseTransaction,
|
||||
videos: &[VideoInfo],
|
||||
videos: &[impl Borrow<VideoInfo>],
|
||||
batch_size: Option<usize>,
|
||||
) -> Result<(), sea_orm::DbErr> {
|
||||
if videos.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let videos = videos.iter().map(|v| v.borrow()).collect::<Vec<_>>();
|
||||
if let Some(size) = batch_size {
|
||||
for chunk in videos.chunks(size) {
|
||||
execute_video_update_batch(txn, chunk).await?;
|
||||
}
|
||||
} else {
|
||||
execute_video_update_batch(txn, videos).await?;
|
||||
execute_video_update_batch(txn, &videos).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_page_download_status(
|
||||
txn: &DatabaseTransaction,
|
||||
pages: &[PageInfo],
|
||||
pages: &[impl Borrow<PageInfo>],
|
||||
batch_size: Option<usize>,
|
||||
) -> Result<(), sea_orm::DbErr> {
|
||||
if pages.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let pages = pages.iter().map(|v| v.borrow()).collect::<Vec<_>>();
|
||||
if let Some(size) = batch_size {
|
||||
for chunk in pages.chunks(size) {
|
||||
execute_page_update_batch(txn, chunk).await?;
|
||||
}
|
||||
} else {
|
||||
execute_page_update_batch(txn, pages).await?;
|
||||
execute_page_update_batch(txn, &pages).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_video_update_batch(txn: &DatabaseTransaction, videos: &[VideoInfo]) -> Result<(), sea_orm::DbErr> {
|
||||
async fn execute_video_update_batch(txn: &DatabaseTransaction, videos: &[&VideoInfo]) -> Result<(), sea_orm::DbErr> {
|
||||
if videos.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -58,7 +62,7 @@ async fn execute_video_update_batch(txn: &DatabaseTransaction, videos: &[VideoIn
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_page_update_batch(txn: &DatabaseTransaction, pages: &[PageInfo]) -> Result<(), sea_orm::DbErr> {
|
||||
async fn execute_page_update_batch(txn: &DatabaseTransaction, pages: &[&PageInfo]) -> Result<(), sea_orm::DbErr> {
|
||||
if pages.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use validator::Validate;
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct VideosRequest {
|
||||
pub collection: Option<i32>,
|
||||
@@ -11,3 +11,28 @@ pub struct VideosRequest {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, ToSchema)]
|
||||
pub struct StatusUpdate {
|
||||
#[validate(range(min = 0, max = 4))]
|
||||
pub status_index: usize,
|
||||
#[validate(custom(function = "crate::utils::validation::validate_status_value"))]
|
||||
pub status_value: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema, Validate)]
|
||||
pub struct PageStatusUpdate {
|
||||
pub page_id: i32,
|
||||
#[validate(nested)]
|
||||
pub updates: Vec<StatusUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema, Validate)]
|
||||
pub struct ResetVideoStatusRequest {
|
||||
#[serde(default)]
|
||||
#[validate(nested)]
|
||||
pub video_updates: Vec<StatusUpdate>,
|
||||
#[serde(default)]
|
||||
#[validate(nested)]
|
||||
pub page_updates: Vec<PageStatusUpdate>,
|
||||
}
|
||||
|
||||
@@ -39,13 +39,20 @@ pub struct ResetAllVideosResponse {
|
||||
pub resetted_pages_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ResetVideoStatusResponse {
|
||||
pub success: bool,
|
||||
pub video: VideoInfo,
|
||||
pub pages: Vec<PageInfo>,
|
||||
}
|
||||
|
||||
#[derive(FromQueryResult, Serialize, ToSchema)]
|
||||
pub struct VideoSource {
|
||||
id: i32,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema, DerivePartialModel, FromQueryResult, Clone)]
|
||||
#[derive(Serialize, ToSchema, DerivePartialModel, FromQueryResult)]
|
||||
#[sea_orm(entity = "video::Entity")]
|
||||
pub struct VideoInfo {
|
||||
pub id: i32,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
use anyhow::Error;
|
||||
use axum::Json;
|
||||
use axum::extract::rejection::JsonRejection;
|
||||
use axum::extract::{FromRequest, Request};
|
||||
use axum::response::IntoResponse;
|
||||
use reqwest::StatusCode;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use utoipa::ToSchema;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::api::error::InnerApiError;
|
||||
|
||||
@@ -18,6 +22,10 @@ impl<T: Serialize> ApiResponse<T> {
|
||||
Self { status_code: 200, data }
|
||||
}
|
||||
|
||||
pub fn bad_request(data: T) -> Self {
|
||||
Self { status_code: 400, data }
|
||||
}
|
||||
|
||||
pub fn unauthorized(data: T) -> Self {
|
||||
Self { status_code: 401, data }
|
||||
}
|
||||
@@ -57,8 +65,31 @@ impl IntoResponse for ApiError {
|
||||
if let Some(inner_error) = self.0.downcast_ref::<InnerApiError>() {
|
||||
match inner_error {
|
||||
InnerApiError::NotFound(_) => return ApiResponse::not_found(self.0.to_string()).into_response(),
|
||||
InnerApiError::BadRequest(_) => {
|
||||
return ApiResponse::bad_request(self.0.to_string()).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
ApiResponse::internal_server_error(self.0.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct ValidatedJson<T>(pub T);
|
||||
|
||||
impl<T, S> FromRequest<S> for ValidatedJson<T>
|
||||
where
|
||||
T: DeserializeOwned + Validate,
|
||||
S: Send + Sync,
|
||||
Json<T>: FromRequest<S, Rejection = JsonRejection>,
|
||||
{
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let Json(value) = Json::<T>::from_request(req, state).await?;
|
||||
value
|
||||
.validate()
|
||||
.map_err(|e| ApiError::from(InnerApiError::BadRequest(e.to_string())))?;
|
||||
Ok(ValidatedJson(value))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ use utoipa::OpenApi;
|
||||
use utoipa_swagger_ui::{Config, SwaggerUi};
|
||||
|
||||
use crate::api::auth;
|
||||
use crate::api::handler::{ApiDoc, get_video, get_video_sources, get_videos, reset_all_videos, reset_video};
|
||||
use crate::api::handler::{
|
||||
ApiDoc, get_video, get_video_sources, get_videos, reset_all_videos, reset_video, reset_video_status,
|
||||
};
|
||||
use crate::config::CONFIG;
|
||||
|
||||
#[derive(Embed)]
|
||||
@@ -26,6 +28,7 @@ pub async fn http_server(database_connection: Arc<DatabaseConnection>) -> Result
|
||||
.route("/api/videos", get(get_videos))
|
||||
.route("/api/videos/{id}", get(get_video))
|
||||
.route("/api/videos/{id}/reset", post(reset_video))
|
||||
.route("/api/videos/{id}/reset-status", post(reset_video_status))
|
||||
.route("/api/videos/reset-all", post(reset_all_videos))
|
||||
.merge(
|
||||
SwaggerUi::new("/swagger-ui/")
|
||||
|
||||
@@ -5,7 +5,7 @@ pub mod model;
|
||||
pub mod nfo;
|
||||
pub mod signal;
|
||||
pub mod status;
|
||||
|
||||
pub mod validation;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
pub fn init_logger(log_level: &str) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::error::ExecutionStatus;
|
||||
|
||||
pub static STATUS_NOT_STARTED: u32 = 0b000;
|
||||
pub(super) static STATUS_MAX_RETRY: u32 = 0b100;
|
||||
pub static STATUS_OK: u32 = 0b111;
|
||||
pub static STATUS_COMPLETED: u32 = 1 << 31;
|
||||
@@ -34,7 +35,7 @@ impl<const N: usize> Status<N> {
|
||||
for i in 0..N {
|
||||
let status = self.get_status(i);
|
||||
if !(status < STATUS_MAX_RETRY || status == STATUS_OK) {
|
||||
self.set_status(i, 0);
|
||||
self.set_status(i, STATUS_NOT_STARTED);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
13
crates/bili_sync/src/utils/validation.rs
Normal file
13
crates/bili_sync/src/utils/validation.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use validator::ValidationError;
|
||||
|
||||
use crate::utils::status::{STATUS_NOT_STARTED, STATUS_OK};
|
||||
|
||||
pub fn validate_status_value(value: u32) -> Result<(), ValidationError> {
|
||||
if value == STATUS_OK || value == STATUS_NOT_STARTED {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ValidationError::new(
|
||||
"status_value must be either STATUS_OK or STATUS_NOT_STARTED",
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
250
web/src/lib/components/status-editor.svelte
Normal file
250
web/src/lib/components/status-editor.svelte
Normal 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>
|
||||
80
web/src/lib/components/status-task-card.svelte
Normal file
80
web/src/lib/components/status-task-card.svelte
Normal 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>
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
class="cursor-pointer text-xs"
|
||||
onclick={() => (resetAllDialogOpen = true)}
|
||||
disabled={resettingAll || loading}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -7,6 +7,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:12345'
|
||||
}
|
||||
},
|
||||
host: true
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user