feat: 支持根据筛选条件批量编辑视频的下载状态 (#558)
This commit is contained in:
@@ -1,49 +1,90 @@
|
||||
use std::borrow::Borrow;
|
||||
|
||||
use itertools::Itertools;
|
||||
use sea_orm::{ConnectionTrait, DatabaseTransaction};
|
||||
|
||||
use crate::api::response::{PageInfo, VideoInfo};
|
||||
use crate::api::response::{PageInfo, SimplePageInfo, SimpleVideoInfo, VideoInfo};
|
||||
|
||||
pub async fn update_video_download_status(
|
||||
pub trait VideoRecord {
|
||||
fn as_id_status_tuple(&self) -> (i32, u32);
|
||||
}
|
||||
|
||||
pub trait PageRecord {
|
||||
fn as_id_status_tuple(&self) -> (i32, u32);
|
||||
}
|
||||
|
||||
impl VideoRecord for VideoInfo {
|
||||
fn as_id_status_tuple(&self) -> (i32, u32) {
|
||||
(self.id, self.download_status)
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoRecord for SimpleVideoInfo {
|
||||
fn as_id_status_tuple(&self) -> (i32, u32) {
|
||||
(self.id, self.download_status)
|
||||
}
|
||||
}
|
||||
|
||||
impl PageRecord for PageInfo {
|
||||
fn as_id_status_tuple(&self) -> (i32, u32) {
|
||||
(self.id, self.download_status)
|
||||
}
|
||||
}
|
||||
|
||||
impl PageRecord for SimplePageInfo {
|
||||
fn as_id_status_tuple(&self) -> (i32, u32) {
|
||||
(self.id, self.download_status)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_video_download_status<T>(
|
||||
txn: &DatabaseTransaction,
|
||||
videos: &[impl Borrow<VideoInfo>],
|
||||
videos: &[impl Borrow<T>],
|
||||
batch_size: Option<usize>,
|
||||
) -> Result<(), sea_orm::DbErr> {
|
||||
) -> Result<(), sea_orm::DbErr>
|
||||
where
|
||||
T: VideoRecord,
|
||||
{
|
||||
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?;
|
||||
execute_video_update_batch(txn, chunk.iter().map(|v| v.borrow().as_id_status_tuple())).await?;
|
||||
}
|
||||
} else {
|
||||
execute_video_update_batch(txn, &videos).await?;
|
||||
execute_video_update_batch(txn, videos.iter().map(|v| v.borrow().as_id_status_tuple())).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_page_download_status(
|
||||
pub async fn update_page_download_status<T>(
|
||||
txn: &DatabaseTransaction,
|
||||
pages: &[impl Borrow<PageInfo>],
|
||||
pages: &[impl Borrow<T>],
|
||||
batch_size: Option<usize>,
|
||||
) -> Result<(), sea_orm::DbErr> {
|
||||
) -> Result<(), sea_orm::DbErr>
|
||||
where
|
||||
T: PageRecord,
|
||||
{
|
||||
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?;
|
||||
execute_page_update_batch(txn, chunk.iter().map(|v| v.borrow().as_id_status_tuple())).await?;
|
||||
}
|
||||
} else {
|
||||
execute_page_update_batch(txn, &pages).await?;
|
||||
execute_page_update_batch(txn, pages.iter().map(|v| v.borrow().as_id_status_tuple())).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_video_update_batch(txn: &DatabaseTransaction, videos: &[&VideoInfo]) -> Result<(), sea_orm::DbErr> {
|
||||
if videos.is_empty() {
|
||||
async fn execute_video_update_batch(
|
||||
txn: &DatabaseTransaction,
|
||||
videos: impl Iterator<Item = (i32, u32)>,
|
||||
) -> Result<(), sea_orm::DbErr> {
|
||||
let values = videos.map(|v| format!("({}, {})", v.0, v.1)).join(", ");
|
||||
if values.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let sql = format!(
|
||||
@@ -52,18 +93,21 @@ async fn execute_video_update_batch(txn: &DatabaseTransaction, videos: &[&VideoI
|
||||
SET download_status = tempdata.download_status \
|
||||
FROM tempdata \
|
||||
WHERE video.id = tempdata.id",
|
||||
videos
|
||||
.iter()
|
||||
.map(|v| format!("({}, {})", v.id, v.download_status))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
values
|
||||
);
|
||||
txn.execute_unprepared(&sql).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_page_update_batch(txn: &DatabaseTransaction, pages: &[&PageInfo]) -> Result<(), sea_orm::DbErr> {
|
||||
if pages.is_empty() {
|
||||
async fn execute_page_update_batch(
|
||||
txn: &DatabaseTransaction,
|
||||
pages: impl Iterator<Item = (i32, u32)>,
|
||||
) -> Result<(), sea_orm::DbErr> {
|
||||
let values = pages
|
||||
.map(|p| format!("({}, {})", p.0, p.1))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if values.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let sql = format!(
|
||||
@@ -72,11 +116,7 @@ async fn execute_page_update_batch(txn: &DatabaseTransaction, pages: &[&PageInfo
|
||||
SET download_status = tempdata.download_status \
|
||||
FROM tempdata \
|
||||
WHERE page.id = tempdata.id",
|
||||
pages
|
||||
.iter()
|
||||
.map(|p| format!("({}, {})", p.id, p.download_status))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
values
|
||||
);
|
||||
txn.execute_unprepared(&sql).await?;
|
||||
Ok(())
|
||||
|
||||
@@ -16,7 +16,18 @@ pub struct VideosRequest {
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetRequest {
|
||||
pub struct ResetVideoStatusRequest {
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetFilteredVideoStatusRequest {
|
||||
pub collection: Option<i32>,
|
||||
pub favorite: Option<i32>,
|
||||
pub submission: Option<i32>,
|
||||
pub watch_later: Option<i32>,
|
||||
pub query: Option<String>,
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
@@ -46,6 +57,21 @@ pub struct UpdateVideoStatusRequest {
|
||||
pub page_updates: Vec<PageStatusUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct UpdateFilteredVideoStatusRequest {
|
||||
pub collection: Option<i32>,
|
||||
pub favorite: Option<i32>,
|
||||
pub submission: Option<i32>,
|
||||
pub watch_later: Option<i32>,
|
||||
pub query: Option<String>,
|
||||
#[serde(default)]
|
||||
#[validate(nested)]
|
||||
pub video_updates: Vec<StatusUpdate>,
|
||||
#[serde(default)]
|
||||
#[validate(nested)]
|
||||
pub page_updates: Vec<StatusUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FollowedCollectionsRequest {
|
||||
pub page_num: Option<i32>,
|
||||
|
||||
@@ -33,7 +33,7 @@ pub struct ResetVideoResponse {
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ResetAllVideosResponse {
|
||||
pub struct ResetFilteredVideosResponse {
|
||||
pub resetted: bool,
|
||||
pub resetted_videos_count: usize,
|
||||
pub resetted_pages_count: usize,
|
||||
@@ -46,6 +46,13 @@ pub struct UpdateVideoStatusResponse {
|
||||
pub pages: Vec<PageInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UpdateFilteredVideoStatusResponse {
|
||||
pub success: bool,
|
||||
pub updated_videos_count: usize,
|
||||
pub updated_pages_count: usize,
|
||||
}
|
||||
|
||||
#[derive(FromQueryResult, Serialize)]
|
||||
pub struct VideoSource {
|
||||
pub id: i32,
|
||||
@@ -75,6 +82,21 @@ pub struct PageInfo {
|
||||
pub download_status: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, DerivePartialModel, FromQueryResult, Clone, Copy)]
|
||||
#[sea_orm(entity = "video::Entity")]
|
||||
pub struct SimpleVideoInfo {
|
||||
pub id: i32,
|
||||
pub download_status: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, DerivePartialModel, FromQueryResult, Clone, Copy)]
|
||||
#[sea_orm(entity = "page::Entity")]
|
||||
pub struct SimplePageInfo {
|
||||
pub id: i32,
|
||||
pub video_id: i32,
|
||||
pub download_status: u32,
|
||||
}
|
||||
|
||||
fn serde_video_download_status<S>(status: &u32, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
|
||||
@@ -11,10 +11,13 @@ use sea_orm::{
|
||||
|
||||
use crate::api::error::InnerApiError;
|
||||
use crate::api::helper::{update_page_download_status, update_video_download_status};
|
||||
use crate::api::request::{ResetRequest, UpdateVideoStatusRequest, VideosRequest};
|
||||
use crate::api::request::{
|
||||
ResetFilteredVideoStatusRequest, ResetVideoStatusRequest, UpdateFilteredVideoStatusRequest,
|
||||
UpdateVideoStatusRequest, VideosRequest,
|
||||
};
|
||||
use crate::api::response::{
|
||||
PageInfo, ResetAllVideosResponse, ResetVideoResponse, UpdateVideoStatusResponse, VideoInfo, VideoResponse,
|
||||
VideosResponse,
|
||||
PageInfo, ResetFilteredVideosResponse, ResetVideoResponse, SimplePageInfo, SimpleVideoInfo,
|
||||
UpdateFilteredVideoStatusResponse, UpdateVideoStatusResponse, VideoInfo, VideoResponse, VideosResponse,
|
||||
};
|
||||
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
||||
use crate::utils::status::{PageStatus, VideoStatus};
|
||||
@@ -23,9 +26,10 @@ pub(super) fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/videos", get(get_videos))
|
||||
.route("/videos/{id}", get(get_video))
|
||||
.route("/videos/{id}/reset", post(reset_video))
|
||||
.route("/videos/reset-all", post(reset_all_videos))
|
||||
.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))
|
||||
.route("/videos/update-status", post(update_filtered_video_status))
|
||||
}
|
||||
|
||||
/// 列出视频的基本信息,支持根据视频来源筛选、名称查找和分页
|
||||
@@ -89,10 +93,10 @@ pub async fn get_video(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn reset_video(
|
||||
pub async fn reset_video_status(
|
||||
Path(id): Path<i32>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Json(request): Json<ResetRequest>,
|
||||
Json(request): Json<ResetVideoStatusRequest>,
|
||||
) -> Result<ApiResponse<ResetVideoResponse>, ApiError> {
|
||||
let (video_info, pages_info) = tokio::try_join!(
|
||||
video::Entity::find_by_id(id).into_partial_model::<VideoInfo>().one(&db),
|
||||
@@ -134,7 +138,7 @@ pub async fn reset_video(
|
||||
let txn = db.begin().await?;
|
||||
if !resetted_videos_info.is_empty() {
|
||||
// 只可能有 1 个元素,所以不用 batch
|
||||
update_video_download_status(&txn, &resetted_videos_info, None).await?;
|
||||
update_video_download_status::<VideoInfo>(&txn, &resetted_videos_info, None).await?;
|
||||
}
|
||||
if !resetted_pages_info.is_empty() {
|
||||
update_page_download_status(&txn, &resetted_pages_info, Some(500)).await?;
|
||||
@@ -148,15 +152,34 @@ pub async fn reset_video(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn reset_all_videos(
|
||||
pub async fn reset_filtered_video_status(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
Json(request): Json<ResetRequest>,
|
||||
) -> Result<ApiResponse<ResetAllVideosResponse>, ApiError> {
|
||||
// 先查询所有视频和页面数据
|
||||
let (all_videos, all_pages) = tokio::try_join!(
|
||||
video::Entity::find().into_partial_model::<VideoInfo>().all(&db),
|
||||
page::Entity::find().into_partial_model::<PageInfo>().all(&db)
|
||||
)?;
|
||||
Json(request): Json<ResetFilteredVideoStatusRequest>,
|
||||
) -> Result<ApiResponse<ResetFilteredVideosResponse>, ApiError> {
|
||||
let mut query = video::Entity::find();
|
||||
for (field, column) in [
|
||||
(request.collection, video::Column::CollectionId),
|
||||
(request.favorite, video::Column::FavoriteId),
|
||||
(request.submission, video::Column::SubmissionId),
|
||||
(request.watch_later, video::Column::WatchLaterId),
|
||||
] {
|
||||
if let Some(id) = field {
|
||||
query = query.filter(column.eq(id));
|
||||
}
|
||||
}
|
||||
if let Some(query_word) = request.query {
|
||||
query = query.filter(
|
||||
video::Column::Name
|
||||
.contains(&query_word)
|
||||
.or(video::Column::Bvid.contains(query_word)),
|
||||
);
|
||||
}
|
||||
let all_videos = query.into_partial_model::<SimpleVideoInfo>().all(&db).await?;
|
||||
let all_pages = page::Entity::find()
|
||||
.filter(page::Column::VideoId.is_in(all_videos.iter().map(|v| v.id)))
|
||||
.into_partial_model::<SimplePageInfo>()
|
||||
.all(&db)
|
||||
.await?;
|
||||
let resetted_pages_info = all_pages
|
||||
.into_iter()
|
||||
.filter_map(|mut page_info| {
|
||||
@@ -200,7 +223,7 @@ pub async fn reset_all_videos(
|
||||
}
|
||||
txn.commit().await?;
|
||||
}
|
||||
Ok(ApiResponse::ok(ResetAllVideosResponse {
|
||||
Ok(ApiResponse::ok(ResetFilteredVideosResponse {
|
||||
resetted: has_video_updates || has_page_updates,
|
||||
resetted_videos_count: resetted_videos_info.len(),
|
||||
resetted_pages_count: resetted_pages_info.len(),
|
||||
@@ -248,10 +271,10 @@ pub async fn update_video_status(
|
||||
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?;
|
||||
update_video_download_status::<VideoInfo>(&txn, &[&video_info], None).await?;
|
||||
}
|
||||
if has_page_updates {
|
||||
update_page_download_status(&txn, &updated_pages_info, None).await?;
|
||||
update_page_download_status::<PageInfo>(&txn, &updated_pages_info, None).await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
}
|
||||
@@ -261,3 +284,64 @@ pub async fn update_video_status(
|
||||
pages: pages_info,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn update_filtered_video_status(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
ValidatedJson(request): ValidatedJson<UpdateFilteredVideoStatusRequest>,
|
||||
) -> Result<ApiResponse<UpdateFilteredVideoStatusResponse>, ApiError> {
|
||||
let mut query = video::Entity::find();
|
||||
for (field, column) in [
|
||||
(request.collection, video::Column::CollectionId),
|
||||
(request.favorite, video::Column::FavoriteId),
|
||||
(request.submission, video::Column::SubmissionId),
|
||||
(request.watch_later, video::Column::WatchLaterId),
|
||||
] {
|
||||
if let Some(id) = field {
|
||||
query = query.filter(column.eq(id));
|
||||
}
|
||||
}
|
||||
if let Some(query_word) = request.query {
|
||||
query = query.filter(
|
||||
video::Column::Name
|
||||
.contains(&query_word)
|
||||
.or(video::Column::Bvid.contains(query_word)),
|
||||
);
|
||||
}
|
||||
let mut all_videos = query.into_partial_model::<SimpleVideoInfo>().all(&db).await?;
|
||||
let mut all_pages = page::Entity::find()
|
||||
.filter(page::Column::VideoId.is_in(all_videos.iter().map(|v| v.id)))
|
||||
.into_partial_model::<SimplePageInfo>()
|
||||
.all(&db)
|
||||
.await?;
|
||||
for video_info in all_videos.iter_mut() {
|
||||
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();
|
||||
}
|
||||
for page_info in all_pages.iter_mut() {
|
||||
let mut page_status = PageStatus::from(page_info.download_status);
|
||||
for update in &request.page_updates {
|
||||
page_status.set(update.status_index, update.status_value);
|
||||
}
|
||||
page_info.download_status = page_status.into();
|
||||
}
|
||||
let has_video_updates = !all_videos.is_empty();
|
||||
let has_page_updates = !all_pages.is_empty();
|
||||
if has_video_updates || has_page_updates {
|
||||
let txn = db.begin().await?;
|
||||
if has_video_updates {
|
||||
update_video_download_status(&txn, &all_videos, Some(500)).await?;
|
||||
}
|
||||
if has_page_updates {
|
||||
update_page_download_status(&txn, &all_pages, Some(500)).await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
}
|
||||
Ok(ApiResponse::ok(UpdateFilteredVideoStatusResponse {
|
||||
success: has_video_updates || has_page_updates,
|
||||
updated_videos_count: all_videos.len(),
|
||||
updated_pages_count: all_pages.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
VideosResponse,
|
||||
VideoResponse,
|
||||
ResetVideoResponse,
|
||||
ResetAllVideosResponse,
|
||||
ResetFilteredVideosResponse,
|
||||
UpdateVideoStatusRequest,
|
||||
UpdateVideoStatusResponse,
|
||||
ApiError,
|
||||
@@ -21,8 +21,12 @@ import type {
|
||||
DashBoardResponse,
|
||||
SysInfo,
|
||||
TaskStatus,
|
||||
ResetRequest,
|
||||
UpdateVideoSourceResponse
|
||||
ResetVideoStatusRequest,
|
||||
UpdateVideoSourceResponse,
|
||||
Notifier,
|
||||
UpdateFilteredVideoStatusRequest,
|
||||
UpdateFilteredVideoStatusResponse,
|
||||
ResetFilteredVideoStatusRequest
|
||||
} from './types';
|
||||
import { wsManager } from './ws';
|
||||
|
||||
@@ -152,12 +156,17 @@ class ApiClient {
|
||||
return this.get<VideoResponse>(`/videos/${id}`);
|
||||
}
|
||||
|
||||
async resetVideo(id: number, request: ResetRequest): Promise<ApiResponse<ResetVideoResponse>> {
|
||||
return this.post<ResetVideoResponse>(`/videos/${id}/reset`, request);
|
||||
async resetVideoStatus(
|
||||
id: number,
|
||||
request: ResetVideoStatusRequest
|
||||
): Promise<ApiResponse<ResetVideoResponse>> {
|
||||
return this.post<ResetVideoResponse>(`/videos/${id}/reset-status`, request);
|
||||
}
|
||||
|
||||
async resetAllVideos(request: ResetRequest): Promise<ApiResponse<ResetAllVideosResponse>> {
|
||||
return this.post<ResetAllVideosResponse>('/videos/reset-all', request);
|
||||
async resetFilteredVideoStatus(
|
||||
request: ResetFilteredVideoStatusRequest
|
||||
): Promise<ApiResponse<ResetFilteredVideosResponse>> {
|
||||
return this.post<ResetFilteredVideosResponse>('/videos/reset-status', request);
|
||||
}
|
||||
|
||||
async updateVideoStatus(
|
||||
@@ -167,6 +176,12 @@ class ApiClient {
|
||||
return this.post<UpdateVideoStatusResponse>(`/videos/${id}/update-status`, request);
|
||||
}
|
||||
|
||||
async updateFilteredVideoStatus(
|
||||
request: UpdateFilteredVideoStatusRequest
|
||||
): Promise<ApiResponse<UpdateFilteredVideoStatusResponse>> {
|
||||
return this.post<UpdateFilteredVideoStatusResponse>('/videos/update-status', request);
|
||||
}
|
||||
|
||||
async getCreatedFavorites(): Promise<ApiResponse<FavoritesResponse>> {
|
||||
return this.get<FavoritesResponse>('/me/favorites');
|
||||
}
|
||||
@@ -268,10 +283,14 @@ const api = {
|
||||
getVideoSources: () => apiClient.getVideoSources(),
|
||||
getVideos: (params?: VideosRequest) => apiClient.getVideos(params),
|
||||
getVideo: (id: number) => apiClient.getVideo(id),
|
||||
resetVideo: (id: number, request: ResetRequest) => apiClient.resetVideo(id, request),
|
||||
resetAllVideos: (request: ResetRequest) => apiClient.resetAllVideos(request),
|
||||
resetVideoStatus: (id: number, request: ResetVideoStatusRequest) =>
|
||||
apiClient.resetVideoStatus(id, request),
|
||||
resetFilteredVideoStatus: (request: ResetFilteredVideoStatusRequest) =>
|
||||
apiClient.resetFilteredVideoStatus(request),
|
||||
updateVideoStatus: (id: number, request: UpdateVideoStatusRequest) =>
|
||||
apiClient.updateVideoStatus(id, request),
|
||||
updateFilteredVideoStatus: (request: UpdateFilteredVideoStatusRequest) =>
|
||||
apiClient.updateFilteredVideoStatus(request),
|
||||
getCreatedFavorites: () => apiClient.getCreatedFavorites(),
|
||||
getFollowedCollections: (pageNum?: number, pageSize?: number) =>
|
||||
apiClient.getFollowedCollections(pageNum, pageSize),
|
||||
|
||||
322
web/src/lib/components/filtered-status-editor.svelte
Normal file
322
web/src/lib/components/filtered-status-editor.svelte
Normal file
@@ -0,0 +1,322 @@
|
||||
<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 type { StatusUpdate, UpdateFilteredVideoStatusRequest } from '$lib/types';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
export let open = false;
|
||||
export let hasFilters = false;
|
||||
export let loading = false;
|
||||
export let filterDescriptionParts: string[] = [];
|
||||
export let onsubmit: (request: UpdateFilteredVideoStatusRequest) => void;
|
||||
|
||||
// 视频任务名称(与后端 VideoStatus 对应)
|
||||
const videoTaskNames = ['视频封面', '视频信息', 'UP 主头像', 'UP 主信息', '分页下载'];
|
||||
|
||||
// 分页任务名称(与后端 PageStatus 对应)
|
||||
const pageTaskNames = ['视频封面', '视频内容', '视频信息', '视频弹幕', '视频字幕'];
|
||||
|
||||
// 状态选项:null 表示未选择,0 表示未开始,7 表示已完成
|
||||
type StatusValue = null | 0 | 7;
|
||||
|
||||
// 视频任务状态,默认都是 null(未选择)
|
||||
let videoStatuses: StatusValue[] = Array(5).fill(null);
|
||||
|
||||
// 分页任务状态,默认都是 null(未选择)
|
||||
let pageStatuses: StatusValue[] = Array(5).fill(null);
|
||||
|
||||
function setVideoStatus(taskIndex: number, value: StatusValue) {
|
||||
videoStatuses[taskIndex] = value;
|
||||
videoStatuses = [...videoStatuses];
|
||||
}
|
||||
|
||||
function setPageStatus(taskIndex: number, value: StatusValue) {
|
||||
pageStatuses[taskIndex] = value;
|
||||
pageStatuses = [...pageStatuses];
|
||||
}
|
||||
|
||||
function resetVideoStatus(taskIndex: number) {
|
||||
videoStatuses[taskIndex] = null;
|
||||
videoStatuses = [...videoStatuses];
|
||||
}
|
||||
|
||||
function resetPageStatus(taskIndex: number) {
|
||||
pageStatuses[taskIndex] = null;
|
||||
pageStatuses = [...pageStatuses];
|
||||
}
|
||||
|
||||
function resetAllStatuses() {
|
||||
videoStatuses = Array(5).fill(null);
|
||||
pageStatuses = Array(5).fill(null);
|
||||
}
|
||||
|
||||
function hasAnyChanges(): boolean {
|
||||
return (
|
||||
videoStatuses.some((status) => status !== null) ||
|
||||
pageStatuses.some((status) => status !== null)
|
||||
);
|
||||
}
|
||||
|
||||
function buildRequest(): UpdateFilteredVideoStatusRequest {
|
||||
const request: UpdateFilteredVideoStatusRequest = {};
|
||||
|
||||
// 添加视频更新
|
||||
const videoUpdates: StatusUpdate[] = [];
|
||||
videoStatuses.forEach((status, index) => {
|
||||
if (status !== null) {
|
||||
videoUpdates.push({
|
||||
status_index: index,
|
||||
status_value: status
|
||||
});
|
||||
}
|
||||
});
|
||||
if (videoUpdates.length > 0) {
|
||||
request.video_updates = videoUpdates;
|
||||
}
|
||||
|
||||
// 添加分页更新
|
||||
const pageUpdates: StatusUpdate[] = [];
|
||||
pageStatuses.forEach((status, index) => {
|
||||
if (status !== null) {
|
||||
pageUpdates.push({
|
||||
status_index: index,
|
||||
status_value: status
|
||||
});
|
||||
}
|
||||
});
|
||||
if (pageUpdates.length > 0) {
|
||||
request.page_updates = pageUpdates;
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!hasAnyChanges()) {
|
||||
toast.info('请至少选择一个状态进行修改');
|
||||
return;
|
||||
}
|
||||
const request = buildRequest();
|
||||
onsubmit(request);
|
||||
}
|
||||
|
||||
// 当 Sheet 关闭时重置状态
|
||||
$: if (!open) {
|
||||
resetAllStatuses();
|
||||
}
|
||||
|
||||
function getStatusInfo(status: StatusValue) {
|
||||
if (status === 0) {
|
||||
return { label: '未开始', class: 'text-yellow-600', dotClass: 'bg-yellow-600' };
|
||||
}
|
||||
if (status === 7) {
|
||||
return { label: '已完成', class: 'text-emerald-600', dotClass: 'bg-emerald-600' };
|
||||
}
|
||||
return { label: '无修改', class: 'text-muted-foreground', dotClass: 'bg-muted-foreground' };
|
||||
}
|
||||
</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">{hasFilters ? '编辑筛选视频' : '编辑全部视频'}</SheetTitle>
|
||||
<SheetDescription class="text-muted-foreground space-y-2 text-sm"
|
||||
>批量编辑视频和分页的下载状态。可将任意子任务状态修改为“未开始”或“已完成”。<br />
|
||||
{#if hasFilters}
|
||||
正在编辑<strong>符合以下筛选条件</strong>的视频的下载状态:
|
||||
<div class="bg-muted my-2 rounded-md p-2 text-left">
|
||||
{#each filterDescriptionParts as part, index (index)}
|
||||
<div><strong>{part}</strong></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
正在编辑<strong>全部视频</strong>的下载状态。 <br />
|
||||
{/if}
|
||||
<div class="leading-relaxed text-orange-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)}
|
||||
{@const statusInfo = getStatusInfo(videoStatuses[index])}
|
||||
{@const isModified = videoStatuses[index] !== null}
|
||||
<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={() => resetVideoStatus(index)}
|
||||
disabled={loading}
|
||||
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs text-gray-600 hover:bg-gray-100"
|
||||
title="恢复到原始状态"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant={videoStatuses[index] === 0 ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => setVideoStatus(index, 0)}
|
||||
disabled={loading}
|
||||
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs {videoStatuses[index] ===
|
||||
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={videoStatuses[index] === 7 ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => setVideoStatus(index, 7)}
|
||||
disabled={loading}
|
||||
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs {videoStatuses[index] ===
|
||||
7
|
||||
? 'border-emerald-600 bg-emerald-600 font-medium text-white hover:bg-emerald-700'
|
||||
: 'hover:border-emerald-400 hover:bg-emerald-50 hover:text-emerald-700'}"
|
||||
>
|
||||
已完成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页状态编辑 -->
|
||||
<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 pageTaskNames as taskName, index (index)}
|
||||
{@const statusInfo = getStatusInfo(pageStatuses[index])}
|
||||
{@const isModified = pageStatuses[index] !== null}
|
||||
<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={() => resetPageStatus(index)}
|
||||
disabled={loading}
|
||||
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs text-gray-600 hover:bg-gray-100"
|
||||
title="恢复到原始状态"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant={pageStatuses[index] === 0 ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => setPageStatus(index, 0)}
|
||||
disabled={loading}
|
||||
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs {pageStatuses[index] === 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={pageStatuses[index] === 7 ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => setPageStatus(index, 7)}
|
||||
disabled={loading}
|
||||
class="h-7 min-w-[60px] cursor-pointer px-3 text-xs {pageStatuses[index] === 7
|
||||
? 'border-emerald-600 bg-emerald-600 font-medium text-white hover:bg-emerald-700'
|
||||
: 'hover:border-emerald-400 hover:bg-emerald-50 hover:text-emerald-700'}"
|
||||
>
|
||||
已完成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter class="bg-background flex gap-2 border-t px-6 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={resetAllStatuses}
|
||||
disabled={!hasAnyChanges() || loading}
|
||||
class="flex-1 cursor-pointer"
|
||||
>
|
||||
重置所有状态
|
||||
</Button>
|
||||
<Button
|
||||
onclick={handleSubmit}
|
||||
disabled={loading || !hasAnyChanges()}
|
||||
class="flex-1 cursor-pointer"
|
||||
>
|
||||
{loading ? '提交中...' : '提交更改'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -31,6 +31,41 @@ export const ToQuery = (state: AppState): string => {
|
||||
return queryString ? `videos?${queryString}` : 'videos';
|
||||
};
|
||||
|
||||
// 将 AppState 转换为请求体中的筛选参数
|
||||
export const ToFilterParams = (
|
||||
state: AppState
|
||||
): {
|
||||
query?: string;
|
||||
collection?: number;
|
||||
favorite?: number;
|
||||
submission?: number;
|
||||
watch_later?: number;
|
||||
} => {
|
||||
const params: {
|
||||
query?: string;
|
||||
collection?: number;
|
||||
favorite?: number;
|
||||
submission?: number;
|
||||
watch_later?: number;
|
||||
} = {};
|
||||
|
||||
if (state.query.trim()) {
|
||||
params.query = state.query;
|
||||
}
|
||||
|
||||
if (state.videoSource && state.videoSource.type && state.videoSource.id) {
|
||||
const { type, id } = state.videoSource;
|
||||
params[type as 'collection' | 'favorite' | 'submission' | 'watch_later'] = parseInt(id);
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
// 检查是否有活动的筛选条件
|
||||
export const hasActiveFilters = (state: AppState): boolean => {
|
||||
return !!(state.query.trim() || state.videoSource);
|
||||
};
|
||||
|
||||
export const setQuery = (query: string) => {
|
||||
appStateStore.update((state) => ({
|
||||
...state,
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
// API 响应包装器
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
status_code: number;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 请求参数类型
|
||||
export interface VideosRequest {
|
||||
collection?: number;
|
||||
favorite?: number;
|
||||
@@ -16,13 +13,11 @@ export interface VideosRequest {
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
// 视频来源类型
|
||||
export interface VideoSource {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 视频来源响应类型
|
||||
export interface VideoSourcesResponse {
|
||||
collection: VideoSource[];
|
||||
favorite: VideoSource[];
|
||||
@@ -30,7 +25,6 @@ export interface VideoSourcesResponse {
|
||||
watch_later: VideoSource[];
|
||||
}
|
||||
|
||||
// 视频信息类型
|
||||
export interface VideoInfo {
|
||||
id: number;
|
||||
bvid: string;
|
||||
@@ -40,13 +34,11 @@ export interface VideoInfo {
|
||||
download_status: [number, number, number, number, number];
|
||||
}
|
||||
|
||||
// 视频列表响应类型
|
||||
export interface VideosResponse {
|
||||
videos: VideoInfo[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
// 分页信息类型
|
||||
export interface PageInfo {
|
||||
id: number;
|
||||
pid: number;
|
||||
@@ -54,59 +46,75 @@ export interface PageInfo {
|
||||
download_status: [number, number, number, number, number];
|
||||
}
|
||||
|
||||
// 单个视频响应类型
|
||||
export interface VideoResponse {
|
||||
video: VideoInfo;
|
||||
pages: PageInfo[];
|
||||
}
|
||||
|
||||
// 重置视频响应类型
|
||||
export interface ResetVideoResponse {
|
||||
resetted: boolean;
|
||||
video: VideoInfo;
|
||||
pages: PageInfo[];
|
||||
}
|
||||
|
||||
// 重置所有视频响应类型
|
||||
export interface ResetAllVideosResponse {
|
||||
export interface ResetFilteredVideosResponse {
|
||||
resetted: boolean;
|
||||
resetted_videos_count: number;
|
||||
resetted_pages_count: number;
|
||||
}
|
||||
|
||||
// API 错误类型
|
||||
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 UpdateVideoStatusRequest {
|
||||
video_updates?: StatusUpdate[];
|
||||
page_updates?: PageStatusUpdate[];
|
||||
}
|
||||
|
||||
// 重置视频状态响应类型
|
||||
export interface UpdateVideoStatusResponse {
|
||||
success: boolean;
|
||||
video: VideoInfo;
|
||||
pages: PageInfo[];
|
||||
}
|
||||
|
||||
// 重置请求类型
|
||||
export interface ResetRequest {
|
||||
export interface UpdateFilteredVideoStatusResponse {
|
||||
success: boolean;
|
||||
updated_videos_count: number;
|
||||
updated_pages_count: number;
|
||||
}
|
||||
|
||||
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 UpdateVideoStatusRequest {
|
||||
video_updates?: StatusUpdate[];
|
||||
page_updates?: PageStatusUpdate[];
|
||||
}
|
||||
|
||||
export interface UpdateFilteredVideoStatusRequest {
|
||||
collection?: number;
|
||||
favorite?: number;
|
||||
submission?: number;
|
||||
watch_later?: number;
|
||||
query?: string;
|
||||
video_updates?: StatusUpdate[];
|
||||
page_updates?: StatusUpdate[];
|
||||
}
|
||||
|
||||
export interface ResetVideoStatusRequest {
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
export interface ResetFilteredVideoStatusRequest {
|
||||
collection?: number;
|
||||
favorite?: number;
|
||||
submission?: number;
|
||||
watch_later?: number;
|
||||
query?: string;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
@@ -170,7 +178,6 @@ export interface InsertSubmissionRequest {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// Rule 相关类型
|
||||
export interface Condition<T> {
|
||||
operator: string;
|
||||
value: T | T[];
|
||||
@@ -184,7 +191,6 @@ export interface RuleTarget<T> {
|
||||
export type AndGroup = RuleTarget<string | number | Date>[];
|
||||
export type Rule = AndGroup[];
|
||||
|
||||
// 视频源详细信息类型
|
||||
export interface VideoSourceDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -195,7 +201,6 @@ export interface VideoSourceDetail {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// 视频源详细信息响应类型
|
||||
export interface VideoSourcesDetailsResponse {
|
||||
collections: VideoSourceDetail[];
|
||||
favorites: VideoSourceDetail[];
|
||||
@@ -203,7 +208,6 @@ export interface VideoSourcesDetailsResponse {
|
||||
watch_later: VideoSourceDetail[];
|
||||
}
|
||||
|
||||
// 更新视频源请求类型
|
||||
export interface UpdateVideoSourceRequest {
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
@@ -211,7 +215,6 @@ export interface UpdateVideoSourceRequest {
|
||||
useDynamicApi?: boolean | null;
|
||||
}
|
||||
|
||||
// 配置相关类型
|
||||
export interface Credential {
|
||||
sessdata: string;
|
||||
bili_jct: string;
|
||||
@@ -273,7 +276,6 @@ export interface ConcurrentLimit {
|
||||
download: ConcurrentDownloadLimit;
|
||||
}
|
||||
|
||||
// Notifier 相关类型
|
||||
export interface TelegramNotifier {
|
||||
type: 'telegram';
|
||||
bot_token: string;
|
||||
@@ -312,13 +314,11 @@ export interface Config {
|
||||
version: number;
|
||||
}
|
||||
|
||||
// 日期计数对类型
|
||||
export interface DayCountPair {
|
||||
day: string;
|
||||
cnt: number;
|
||||
}
|
||||
|
||||
// 仪表盘响应类型
|
||||
export interface DashBoardResponse {
|
||||
enabled_favorites: number;
|
||||
enabled_collections: number;
|
||||
@@ -327,7 +327,6 @@ export interface DashBoardResponse {
|
||||
videos_by_day: DayCountPair[];
|
||||
}
|
||||
|
||||
// 系统信息响应类型
|
||||
export interface SysInfo {
|
||||
total_memory: number;
|
||||
used_memory: number;
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
bind:resetting
|
||||
onReset={async (forceReset: boolean) => {
|
||||
try {
|
||||
const result = await api.resetVideo(videoData!.video.id, { force: forceReset });
|
||||
const result = await api.resetVideoStatus(videoData!.video.id, { force: forceReset });
|
||||
const data = result.data;
|
||||
if (data.resetted) {
|
||||
videoData = {
|
||||
|
||||
@@ -3,11 +3,18 @@
|
||||
import Pagination from '$lib/components/pagination.svelte';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
import EditIcon from '@lucide/svelte/icons/edit';
|
||||
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
|
||||
import api from '$lib/api';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import type { VideosResponse, VideoSourcesResponse, ApiError, VideoSource } from '$lib/types';
|
||||
import type {
|
||||
VideosResponse,
|
||||
VideoSourcesResponse,
|
||||
ApiError,
|
||||
VideoSource,
|
||||
UpdateFilteredVideoStatusRequest
|
||||
} from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -19,11 +26,14 @@
|
||||
setAll,
|
||||
setCurrentPage,
|
||||
setQuery,
|
||||
ToQuery
|
||||
ToQuery,
|
||||
ToFilterParams,
|
||||
hasActiveFilters
|
||||
} 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';
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
@@ -37,6 +47,9 @@
|
||||
|
||||
let forceReset = false;
|
||||
|
||||
let updateAllDialogOpen = false;
|
||||
let updatingAll = false;
|
||||
|
||||
let videoSources: VideoSourcesResponse | null = null;
|
||||
let filters: Record<string, Filter> | null = null;
|
||||
|
||||
@@ -97,7 +110,7 @@
|
||||
|
||||
async function handleResetVideo(id: number, forceReset: boolean) {
|
||||
try {
|
||||
const result = await api.resetVideo(id, { force: forceReset });
|
||||
const result = await api.resetVideoStatus(id, { force: forceReset });
|
||||
const data = result.data;
|
||||
if (data.resetted) {
|
||||
toast.success('重置成功', {
|
||||
@@ -121,7 +134,12 @@
|
||||
async function handleResetAllVideos() {
|
||||
resettingAll = true;
|
||||
try {
|
||||
const result = await api.resetAllVideos({ force: forceReset });
|
||||
// 获取筛选参数
|
||||
const filterParams = ToFilterParams($appStateStore);
|
||||
const result = await api.resetFilteredVideoStatus({
|
||||
...filterParams,
|
||||
force: forceReset
|
||||
});
|
||||
const data = result.data;
|
||||
if (data.resetted) {
|
||||
toast.success('重置成功', {
|
||||
@@ -143,6 +161,59 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateAllVideos(request: UpdateFilteredVideoStatusRequest) {
|
||||
updatingAll = true;
|
||||
try {
|
||||
// 获取筛选参数并合并
|
||||
const filterParams = ToFilterParams($appStateStore);
|
||||
const fullRequest = {
|
||||
...filterParams,
|
||||
...request
|
||||
};
|
||||
const result = await api.updateFilteredVideoStatus(fullRequest);
|
||||
const data = result.data;
|
||||
if (data.success) {
|
||||
toast.success('更新成功', {
|
||||
description: `已更新 ${data.updated_videos_count} 个视频和 ${data.updated_pages_count} 个分页`
|
||||
});
|
||||
const { query, currentPage, videoSource } = $appStateStore;
|
||||
await loadVideos(query, currentPage, videoSource);
|
||||
} else {
|
||||
toast.info('没有视频被更新');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新失败:', error);
|
||||
toast.error('更新失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
} finally {
|
||||
updatingAll = false;
|
||||
updateAllDialogOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取筛选条件的显示数组
|
||||
function getFilterDescriptionParts(): string[] {
|
||||
const state = $appStateStore;
|
||||
const parts: string[] = [];
|
||||
if (state.query.trim()) {
|
||||
parts.push(`搜索词:"${state.query}"`);
|
||||
}
|
||||
if (state.videoSource && videoSources) {
|
||||
const sourceType = state.videoSource.type;
|
||||
const sourceId = parseInt(state.videoSource.id);
|
||||
const sourceConfig = Object.values(VIDEO_SOURCES).find((s) => s.type === sourceType);
|
||||
if (sourceConfig) {
|
||||
const sourceList = videoSources[sourceType as keyof VideoSourcesResponse] as VideoSource[];
|
||||
const source = sourceList.find((s) => s.id === sourceId);
|
||||
if (source) {
|
||||
parts.push(`${sourceConfig.title}:${source.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
$: if ($page.url.search !== lastSearch) {
|
||||
lastSearch = $page.url.search;
|
||||
handleSearchParamsChange($page.url.searchParams);
|
||||
@@ -177,6 +248,8 @@
|
||||
});
|
||||
|
||||
$: totalPages = videosData ? Math.ceil(videosData.total_count / pageSize) : 0;
|
||||
$: hasFilters = hasActiveFilters($appStateStore);
|
||||
$: filterDescriptionParts = videoSources && $appStateStore && getFilterDescriptionParts();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -221,6 +294,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="hover:bg-accent hover:text-accent-foreground h-8 cursor-pointer text-xs font-medium"
|
||||
onclick={() => (updateAllDialogOpen = true)}
|
||||
disabled={updatingAll || loading}
|
||||
>
|
||||
<EditIcon class="mr-1.5 h-3 w-3" />
|
||||
{hasFilters ? '编辑筛选' : '编辑全部'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -229,7 +312,7 @@
|
||||
disabled={resettingAll || loading}
|
||||
>
|
||||
<RotateCcwIcon class="mr-1.5 h-3 w-3 {resettingAll ? 'animate-spin' : ''}" />
|
||||
重置全部
|
||||
{hasFilters ? '重置筛选' : '重置全部'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,16 +354,25 @@
|
||||
<AlertDialog.Root bind:open={resetAllDialogOpen}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>重置全部视频</AlertDialog.Title>
|
||||
<AlertDialog.Title>{hasFilters ? '重置筛选视频' : '重置全部视频'}</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
确定要重置<strong>全部视频</strong>的下载状态吗?<br />
|
||||
{#if hasFilters}
|
||||
确定要重置<strong>符合以下筛选条件</strong>的视频的下载状态吗?<br />
|
||||
<div class="bg-muted my-2 rounded-md p-2 text-left">
|
||||
{#each filterDescriptionParts as part, index (index)}
|
||||
<div><strong>{part}</strong></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
确定要重置<strong>全部视频</strong>的下载状态吗?<br />
|
||||
{/if}
|
||||
此操作会将所有的失败状态重置为未开始,<span class="text-destructive font-medium"
|
||||
>无法撤销</span
|
||||
>。
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
|
||||
<div class="space-y-4 py-4">
|
||||
<div class="py-2">
|
||||
<div class="rounded-lg border border-orange-200 bg-orange-50 p-3">
|
||||
<div class="mb-2 flex items-center space-x-2">
|
||||
<Checkbox id="force-reset-all" bind:checked={forceReset} />
|
||||
@@ -317,3 +409,11 @@
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<FilteredStatusEditor
|
||||
bind:open={updateAllDialogOpen}
|
||||
{hasFilters}
|
||||
loading={updatingAll}
|
||||
filterDescriptionParts={filterDescriptionParts || []}
|
||||
onsubmit={handleUpdateAllVideos}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user