mirror of
https://github.com/amtoaer/bili-sync.git
synced 2026-09-07 00:17:24 +08:00
feat: 实现视频的筛选规则 (#457)
This commit is contained in:
Generated
+14
@@ -533,7 +533,10 @@ dependencies = [
|
|||||||
name = "bili_sync_entity"
|
name = "bili_sync_entity"
|
||||||
version = "2.6.3"
|
version = "2.6.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"derivative",
|
||||||
|
"regex",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1034,6 +1037,17 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derivative"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 1.0.109",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_builder"
|
name = "derive_builder"
|
||||||
version = "0.20.2"
|
version = "0.20.2"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ clap = { version = "4.5.41", features = ["env", "string"] }
|
|||||||
cookie = "0.18.1"
|
cookie = "0.18.1"
|
||||||
cow-utils = "0.1.3"
|
cow-utils = "0.1.3"
|
||||||
dashmap = "6.1.0"
|
dashmap = "6.1.0"
|
||||||
|
derivative = "2.2.0"
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
enum_dispatch = "0.3.13"
|
enum_dispatch = "0.3.13"
|
||||||
float-ord = "0.3.2"
|
float-ord = "0.3.2"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::path::Path;
|
|||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
|
||||||
use anyhow::{Context, Result, ensure};
|
use anyhow::{Context, Result, ensure};
|
||||||
|
use bili_sync_entity::rule::Rule;
|
||||||
use bili_sync_entity::*;
|
use bili_sync_entity::*;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use futures::Stream;
|
use futures::Stream;
|
||||||
@@ -63,6 +64,10 @@ impl VideoSource for collection::Model {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rule(&self) -> Option<&Rule> {
|
||||||
|
self.rule.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
async fn refresh<'a>(
|
async fn refresh<'a>(
|
||||||
self,
|
self,
|
||||||
bili_client: &'a BiliClient,
|
bili_client: &'a BiliClient,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::path::Path;
|
|||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
|
||||||
use anyhow::{Context, Result, ensure};
|
use anyhow::{Context, Result, ensure};
|
||||||
|
use bili_sync_entity::rule::Rule;
|
||||||
use bili_sync_entity::*;
|
use bili_sync_entity::*;
|
||||||
use futures::Stream;
|
use futures::Stream;
|
||||||
use sea_orm::ActiveValue::Set;
|
use sea_orm::ActiveValue::Set;
|
||||||
@@ -42,6 +43,10 @@ impl VideoSource for favorite::Model {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rule(&self) -> Option<&Rule> {
|
||||||
|
self.rule.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
async fn refresh<'a>(
|
async fn refresh<'a>(
|
||||||
self,
|
self,
|
||||||
bili_client: &'a BiliClient,
|
bili_client: &'a BiliClient,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use sea_orm::sea_query::SimpleExpr;
|
|||||||
#[rustfmt::skip]
|
#[rustfmt::skip]
|
||||||
use bili_sync_entity::collection::Model as Collection;
|
use bili_sync_entity::collection::Model as Collection;
|
||||||
use bili_sync_entity::favorite::Model as Favorite;
|
use bili_sync_entity::favorite::Model as Favorite;
|
||||||
|
use bili_sync_entity::rule::Rule;
|
||||||
use bili_sync_entity::submission::Model as Submission;
|
use bili_sync_entity::submission::Model as Submission;
|
||||||
use bili_sync_entity::watch_later::Model as WatchLater;
|
use bili_sync_entity::watch_later::Model as WatchLater;
|
||||||
|
|
||||||
@@ -68,6 +69,8 @@ pub trait VideoSource {
|
|||||||
video_info.ok()
|
video_info.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rule(&self) -> Option<&Rule>;
|
||||||
|
|
||||||
fn log_refresh_video_start(&self) {
|
fn log_refresh_video_start(&self) {
|
||||||
info!("开始扫描{}..", self.display_name());
|
info!("开始扫描{}..", self.display_name());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use std::path::Path;
|
|||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
|
||||||
use anyhow::{Context, Result, ensure};
|
use anyhow::{Context, Result, ensure};
|
||||||
|
use bili_sync_entity::rule::Rule;
|
||||||
use bili_sync_entity::*;
|
use bili_sync_entity::*;
|
||||||
use futures::Stream;
|
use futures::Stream;
|
||||||
use sea_orm::ActiveValue::Set;
|
use sea_orm::ActiveValue::Set;
|
||||||
@@ -41,6 +42,10 @@ impl VideoSource for submission::Model {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rule(&self) -> Option<&Rule> {
|
||||||
|
self.rule.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
async fn refresh<'a>(
|
async fn refresh<'a>(
|
||||||
self,
|
self,
|
||||||
bili_client: &'a BiliClient,
|
bili_client: &'a BiliClient,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use std::path::Path;
|
|||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use bili_sync_entity::rule::Rule;
|
||||||
use bili_sync_entity::*;
|
use bili_sync_entity::*;
|
||||||
use futures::Stream;
|
use futures::Stream;
|
||||||
use sea_orm::ActiveValue::Set;
|
use sea_orm::ActiveValue::Set;
|
||||||
@@ -41,6 +42,10 @@ impl VideoSource for watch_later::Model {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rule(&self) -> Option<&Rule> {
|
||||||
|
self.rule.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
async fn refresh<'a>(
|
async fn refresh<'a>(
|
||||||
self,
|
self,
|
||||||
bili_client: &'a BiliClient,
|
bili_client: &'a BiliClient,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use bili_sync_entity::rule::Rule;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
@@ -86,4 +87,5 @@ pub struct UpdateVideoSourceRequest {
|
|||||||
#[validate(custom(function = "crate::utils::validation::validate_path"))]
|
#[validate(custom(function = "crate::utils::validation::validate_path"))]
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
pub rule: Option<Rule>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use bili_sync_entity::rule::Rule;
|
||||||
use bili_sync_entity::*;
|
use bili_sync_entity::*;
|
||||||
use sea_orm::{DerivePartialModel, FromQueryResult};
|
use sea_orm::{DerivePartialModel, FromQueryResult};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -169,9 +170,19 @@ pub struct SysInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, FromQueryResult)]
|
#[derive(Serialize, FromQueryResult)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct VideoSourceDetail {
|
pub struct VideoSourceDetail {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub path: String,
|
pub path: String,
|
||||||
|
pub rule: Option<Rule>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rule_display: Option<String>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateVideoSourceResponse {
|
||||||
|
pub rule_display: Option<String>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ use crate::api::error::InnerApiError;
|
|||||||
use crate::api::request::{
|
use crate::api::request::{
|
||||||
InsertCollectionRequest, InsertFavoriteRequest, InsertSubmissionRequest, UpdateVideoSourceRequest,
|
InsertCollectionRequest, InsertFavoriteRequest, InsertSubmissionRequest, UpdateVideoSourceRequest,
|
||||||
};
|
};
|
||||||
use crate::api::response::{VideoSource, VideoSourceDetail, VideoSourcesDetailsResponse, VideoSourcesResponse};
|
use crate::api::response::{
|
||||||
|
UpdateVideoSourceResponse, VideoSource, VideoSourceDetail, VideoSourcesDetailsResponse, VideoSourcesResponse,
|
||||||
|
};
|
||||||
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
||||||
use crate::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Submission};
|
use crate::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Submission};
|
||||||
|
|
||||||
@@ -75,13 +77,14 @@ pub async fn get_video_sources(
|
|||||||
pub async fn get_video_sources_details(
|
pub async fn get_video_sources_details(
|
||||||
Extension(db): Extension<DatabaseConnection>,
|
Extension(db): Extension<DatabaseConnection>,
|
||||||
) -> Result<ApiResponse<VideoSourcesDetailsResponse>, ApiError> {
|
) -> Result<ApiResponse<VideoSourcesDetailsResponse>, ApiError> {
|
||||||
let (collections, favorites, submissions, mut watch_later) = tokio::try_join!(
|
let (mut collections, mut favorites, mut submissions, mut watch_later) = tokio::try_join!(
|
||||||
collection::Entity::find()
|
collection::Entity::find()
|
||||||
.select_only()
|
.select_only()
|
||||||
.columns([
|
.columns([
|
||||||
collection::Column::Id,
|
collection::Column::Id,
|
||||||
collection::Column::Name,
|
collection::Column::Name,
|
||||||
collection::Column::Path,
|
collection::Column::Path,
|
||||||
|
collection::Column::Rule,
|
||||||
collection::Column::Enabled
|
collection::Column::Enabled
|
||||||
])
|
])
|
||||||
.into_model::<VideoSourceDetail>()
|
.into_model::<VideoSourceDetail>()
|
||||||
@@ -92,22 +95,31 @@ pub async fn get_video_sources_details(
|
|||||||
favorite::Column::Id,
|
favorite::Column::Id,
|
||||||
favorite::Column::Name,
|
favorite::Column::Name,
|
||||||
favorite::Column::Path,
|
favorite::Column::Path,
|
||||||
|
favorite::Column::Rule,
|
||||||
favorite::Column::Enabled
|
favorite::Column::Enabled
|
||||||
])
|
])
|
||||||
.into_model::<VideoSourceDetail>()
|
.into_model::<VideoSourceDetail>()
|
||||||
.all(&db),
|
.all(&db),
|
||||||
submission::Entity::find()
|
submission::Entity::find()
|
||||||
.select_only()
|
.select_only()
|
||||||
.column(submission::Column::Id)
|
|
||||||
.column_as(submission::Column::UpperName, "name")
|
.column_as(submission::Column::UpperName, "name")
|
||||||
.columns([submission::Column::Path, submission::Column::Enabled])
|
.columns([
|
||||||
|
submission::Column::Id,
|
||||||
|
submission::Column::Path,
|
||||||
|
submission::Column::Enabled,
|
||||||
|
submission::Column::Rule
|
||||||
|
])
|
||||||
.into_model::<VideoSourceDetail>()
|
.into_model::<VideoSourceDetail>()
|
||||||
.all(&db),
|
.all(&db),
|
||||||
watch_later::Entity::find()
|
watch_later::Entity::find()
|
||||||
.select_only()
|
.select_only()
|
||||||
.column(watch_later::Column::Id)
|
|
||||||
.column_as(Expr::value("稍后再看"), "name")
|
.column_as(Expr::value("稍后再看"), "name")
|
||||||
.columns([watch_later::Column::Path, watch_later::Column::Enabled])
|
.columns([
|
||||||
|
watch_later::Column::Id,
|
||||||
|
watch_later::Column::Path,
|
||||||
|
watch_later::Column::Enabled,
|
||||||
|
watch_later::Column::Rule
|
||||||
|
])
|
||||||
.into_model::<VideoSourceDetail>()
|
.into_model::<VideoSourceDetail>()
|
||||||
.all(&db)
|
.all(&db)
|
||||||
)?;
|
)?;
|
||||||
@@ -116,9 +128,18 @@ pub async fn get_video_sources_details(
|
|||||||
id: 1,
|
id: 1,
|
||||||
name: "稍后再看".to_string(),
|
name: "稍后再看".to_string(),
|
||||||
path: String::new(),
|
path: String::new(),
|
||||||
|
rule: None,
|
||||||
|
rule_display: None,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
for sources in [&mut collections, &mut favorites, &mut submissions, &mut watch_later] {
|
||||||
|
sources.iter_mut().for_each(|item| {
|
||||||
|
if let Some(rule) = &item.rule {
|
||||||
|
item.rule_display = Some(rule.to_string());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Ok(ApiResponse::ok(VideoSourcesDetailsResponse {
|
Ok(ApiResponse::ok(VideoSourcesDetailsResponse {
|
||||||
collections,
|
collections,
|
||||||
favorites,
|
favorites,
|
||||||
@@ -132,24 +153,28 @@ pub async fn update_video_source(
|
|||||||
Path((source_type, id)): Path<(String, i32)>,
|
Path((source_type, id)): Path<(String, i32)>,
|
||||||
Extension(db): Extension<DatabaseConnection>,
|
Extension(db): Extension<DatabaseConnection>,
|
||||||
ValidatedJson(request): ValidatedJson<UpdateVideoSourceRequest>,
|
ValidatedJson(request): ValidatedJson<UpdateVideoSourceRequest>,
|
||||||
) -> Result<ApiResponse<bool>, ApiError> {
|
) -> Result<ApiResponse<UpdateVideoSourceResponse>, ApiError> {
|
||||||
|
let rule_display = request.rule.as_ref().map(|rule| rule.to_string());
|
||||||
let active_model = match source_type.as_str() {
|
let active_model = match source_type.as_str() {
|
||||||
"collections" => collection::Entity::find_by_id(id).one(&db).await?.map(|model| {
|
"collections" => collection::Entity::find_by_id(id).one(&db).await?.map(|model| {
|
||||||
let mut active_model: collection::ActiveModel = model.into();
|
let mut active_model: collection::ActiveModel = model.into();
|
||||||
active_model.path = Set(request.path);
|
active_model.path = Set(request.path);
|
||||||
active_model.enabled = Set(request.enabled);
|
active_model.enabled = Set(request.enabled);
|
||||||
|
active_model.rule = Set(request.rule);
|
||||||
_ActiveModel::Collection(active_model)
|
_ActiveModel::Collection(active_model)
|
||||||
}),
|
}),
|
||||||
"favorites" => favorite::Entity::find_by_id(id).one(&db).await?.map(|model| {
|
"favorites" => favorite::Entity::find_by_id(id).one(&db).await?.map(|model| {
|
||||||
let mut active_model: favorite::ActiveModel = model.into();
|
let mut active_model: favorite::ActiveModel = model.into();
|
||||||
active_model.path = Set(request.path);
|
active_model.path = Set(request.path);
|
||||||
active_model.enabled = Set(request.enabled);
|
active_model.enabled = Set(request.enabled);
|
||||||
|
active_model.rule = Set(request.rule);
|
||||||
_ActiveModel::Favorite(active_model)
|
_ActiveModel::Favorite(active_model)
|
||||||
}),
|
}),
|
||||||
"submissions" => submission::Entity::find_by_id(id).one(&db).await?.map(|model| {
|
"submissions" => submission::Entity::find_by_id(id).one(&db).await?.map(|model| {
|
||||||
let mut active_model: submission::ActiveModel = model.into();
|
let mut active_model: submission::ActiveModel = model.into();
|
||||||
active_model.path = Set(request.path);
|
active_model.path = Set(request.path);
|
||||||
active_model.enabled = Set(request.enabled);
|
active_model.enabled = Set(request.enabled);
|
||||||
|
active_model.rule = Set(request.rule);
|
||||||
_ActiveModel::Submission(active_model)
|
_ActiveModel::Submission(active_model)
|
||||||
}),
|
}),
|
||||||
"watch_later" => match watch_later::Entity::find_by_id(id).one(&db).await? {
|
"watch_later" => match watch_later::Entity::find_by_id(id).one(&db).await? {
|
||||||
@@ -160,6 +185,7 @@ pub async fn update_video_source(
|
|||||||
let mut active_model: watch_later::ActiveModel = model.into();
|
let mut active_model: watch_later::ActiveModel = model.into();
|
||||||
active_model.path = Set(request.path);
|
active_model.path = Set(request.path);
|
||||||
active_model.enabled = Set(request.enabled);
|
active_model.enabled = Set(request.enabled);
|
||||||
|
active_model.rule = Set(request.rule);
|
||||||
Some(_ActiveModel::WatchLater(active_model))
|
Some(_ActiveModel::WatchLater(active_model))
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -170,6 +196,7 @@ pub async fn update_video_source(
|
|||||||
Some(_ActiveModel::WatchLater(watch_later::ActiveModel {
|
Some(_ActiveModel::WatchLater(watch_later::ActiveModel {
|
||||||
path: Set(request.path),
|
path: Set(request.path),
|
||||||
enabled: Set(request.enabled),
|
enabled: Set(request.enabled),
|
||||||
|
rule: Set(request.rule),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -181,7 +208,7 @@ pub async fn update_video_source(
|
|||||||
return Err(InnerApiError::NotFound(id).into());
|
return Err(InnerApiError::NotFound(id).into());
|
||||||
};
|
};
|
||||||
active_model.save(&db).await?;
|
active_model.save(&db).await?;
|
||||||
Ok(ApiResponse::ok(true))
|
Ok(ApiResponse::ok(UpdateVideoSourceResponse { rule_display }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 新增收藏夹订阅
|
/// 新增收藏夹订阅
|
||||||
@@ -196,7 +223,7 @@ pub async fn insert_favorite(
|
|||||||
f_id: Set(favorite_info.id),
|
f_id: Set(favorite_info.id),
|
||||||
name: Set(favorite_info.title.clone()),
|
name: Set(favorite_info.title.clone()),
|
||||||
path: Set(request.path),
|
path: Set(request.path),
|
||||||
enabled: Set(true),
|
enabled: Set(false),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.exec(&db)
|
.exec(&db)
|
||||||
@@ -225,7 +252,7 @@ pub async fn insert_collection(
|
|||||||
r#type: Set(collection_info.collection_type.into()),
|
r#type: Set(collection_info.collection_type.into()),
|
||||||
name: Set(collection_info.name.clone()),
|
name: Set(collection_info.name.clone()),
|
||||||
path: Set(request.path),
|
path: Set(request.path),
|
||||||
enabled: Set(true),
|
enabled: Set(false),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.exec(&db)
|
.exec(&db)
|
||||||
@@ -246,7 +273,7 @@ pub async fn insert_submission(
|
|||||||
upper_id: Set(upper.mid.parse()?),
|
upper_id: Set(upper.mid.parse()?),
|
||||||
upper_name: Set(upper.name),
|
upper_name: Set(upper.name),
|
||||||
path: Set(request.path),
|
path: Set(request.path),
|
||||||
enabled: Set(true),
|
enabled: Set(false),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.exec(&db)
|
.exec(&db)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use anyhow::{Result, ensure};
|
use anyhow::{Context, Result, ensure};
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
use futures::stream::FuturesUnordered;
|
use futures::stream::FuturesUnordered;
|
||||||
use prost::Message;
|
use prost::Message;
|
||||||
@@ -16,19 +16,6 @@ pub struct Video<'a> {
|
|||||||
pub bvid: String,
|
pub bvid: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
pub struct Tag {
|
|
||||||
pub tag_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl serde::Serialize for Tag {
|
|
||||||
fn serialize<S>(&self, serializer: S) -> core::result::Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: serde::Serializer,
|
|
||||||
{
|
|
||||||
serializer.serialize_str(&self.tag_name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[derive(Debug, serde::Deserialize, Default)]
|
#[derive(Debug, serde::Deserialize, Default)]
|
||||||
pub struct PageInfo {
|
pub struct PageInfo {
|
||||||
pub cid: i64,
|
pub cid: i64,
|
||||||
@@ -84,8 +71,8 @@ impl<'a> Video<'a> {
|
|||||||
Ok(serde_json::from_value(res["data"].take())?)
|
Ok(serde_json::from_value(res["data"].take())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_tags(&self) -> Result<Vec<Tag>> {
|
pub async fn get_tags(&self) -> Result<Vec<String>> {
|
||||||
let mut res = self
|
let res = self
|
||||||
.client
|
.client
|
||||||
.request(Method::GET, "https://api.bilibili.com/x/web-interface/view/detail/tag")
|
.request(Method::GET, "https://api.bilibili.com/x/web-interface/view/detail/tag")
|
||||||
.await
|
.await
|
||||||
@@ -96,7 +83,12 @@ impl<'a> Video<'a> {
|
|||||||
.json::<serde_json::Value>()
|
.json::<serde_json::Value>()
|
||||||
.await?
|
.await?
|
||||||
.validate()?;
|
.validate()?;
|
||||||
Ok(serde_json::from_value(res["data"].take())?)
|
Ok(res["data"]
|
||||||
|
.as_array()
|
||||||
|
.context("tags is not an array")?
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v["tag_name"].as_str().map(String::from))
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_danmaku_writer(&self, page: &'a PageInfo) -> Result<DanmakuWriter> {
|
pub async fn get_danmaku_writer(&self, page: &'a PageInfo) -> Result<DanmakuWriter> {
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ impl VideoInfo {
|
|||||||
ctime: Set(ctime.naive_utc()),
|
ctime: Set(ctime.naive_utc()),
|
||||||
pubtime: Set(pubtime.naive_utc()),
|
pubtime: Set(pubtime.naive_utc()),
|
||||||
favtime: if base_model.favtime != NaiveDateTime::default() {
|
favtime: if base_model.favtime != NaiveDateTime::default() {
|
||||||
NotSet // 之前设置了 favtime,不覆盖
|
Set(base_model.favtime) // 之前设置了 favtime,使用之前的值(等价于 unset,但设置上以支持后续的规则匹配)
|
||||||
} else {
|
} else {
|
||||||
Set(pubtime.naive_utc()) // 未设置过 favtime,使用 pubtime 填充
|
Set(pubtime.naive_utc()) // 未设置过 favtime,使用 pubtime 填充
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod filenamify;
|
|||||||
pub mod format_arg;
|
pub mod format_arg;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod nfo;
|
pub mod nfo;
|
||||||
|
pub mod rule;
|
||||||
pub mod signal;
|
pub mod signal;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
pub mod task_notifier;
|
pub mod task_notifier;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ pub async fn filter_unhandled_video_pages(
|
|||||||
.and(video::Column::DownloadStatus.lt(STATUS_COMPLETED))
|
.and(video::Column::DownloadStatus.lt(STATUS_COMPLETED))
|
||||||
.and(video::Column::Category.eq(2))
|
.and(video::Column::Category.eq(2))
|
||||||
.and(video::Column::SinglePage.is_not_null())
|
.and(video::Column::SinglePage.is_not_null())
|
||||||
|
.and(video::Column::ShouldDownload.eq(true))
|
||||||
.and(additional_expr),
|
.and(additional_expr),
|
||||||
)
|
)
|
||||||
.find_with_related(page::Entity)
|
.find_with_related(page::Entity)
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ mod tests {
|
|||||||
chrono::NaiveTime::from_hms_opt(3, 3, 3).unwrap(),
|
chrono::NaiveTime::from_hms_opt(3, 3, 3).unwrap(),
|
||||||
),
|
),
|
||||||
bvid: "BV1nWcSeeEkV".to_string(),
|
bvid: "BV1nWcSeeEkV".to_string(),
|
||||||
tags: Some(serde_json::json!(["tag1", "tag2"])),
|
tags: Some(vec!["tag1".to_owned(), "tag2".to_owned()].into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -343,10 +343,7 @@ impl<'a> From<&'a video::Model> for Movie<'a> {
|
|||||||
NFOTimeType::FavTime => video.favtime,
|
NFOTimeType::FavTime => video.favtime,
|
||||||
NFOTimeType::PubTime => video.pubtime,
|
NFOTimeType::PubTime => video.pubtime,
|
||||||
},
|
},
|
||||||
tags: video
|
tags: video.tags.as_ref().map(|tags| tags.clone().into()),
|
||||||
.tags
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|tags| serde_json::from_value(tags.clone()).ok()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -363,10 +360,7 @@ impl<'a> From<&'a video::Model> for TVShow<'a> {
|
|||||||
NFOTimeType::FavTime => video.favtime,
|
NFOTimeType::FavTime => video.favtime,
|
||||||
NFOTimeType::PubTime => video.pubtime,
|
NFOTimeType::PubTime => video.pubtime,
|
||||||
},
|
},
|
||||||
tags: video
|
tags: video.tags.as_ref().map(|tags| tags.clone().into()),
|
||||||
.tags
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|tags| serde_json::from_value(tags.clone()).ok()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
use bili_sync_entity::rule::{AndGroup, Condition, Rule, RuleTarget};
|
||||||
|
use bili_sync_entity::{page, video};
|
||||||
|
use chrono::{Local, NaiveDateTime};
|
||||||
|
|
||||||
|
pub(crate) trait Evaluatable<T> {
|
||||||
|
fn evaluate(&self, value: T) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) trait FieldEvaluatable {
|
||||||
|
fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Evaluatable<&str> for Condition<String> {
|
||||||
|
fn evaluate(&self, value: &str) -> bool {
|
||||||
|
match self {
|
||||||
|
Condition::Equals(expected) => expected == value,
|
||||||
|
Condition::Contains(substring) => value.contains(substring),
|
||||||
|
Condition::Prefix(prefix) => value.starts_with(prefix),
|
||||||
|
Condition::Suffix(suffix) => value.ends_with(suffix),
|
||||||
|
Condition::MatchesRegex(_, regex) => regex.is_match(value),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Evaluatable<usize> for Condition<usize> {
|
||||||
|
fn evaluate(&self, value: usize) -> bool {
|
||||||
|
match self {
|
||||||
|
Condition::Equals(expected) => *expected == value,
|
||||||
|
Condition::GreaterThan(threshold) => value > *threshold,
|
||||||
|
Condition::LessThan(threshold) => value < *threshold,
|
||||||
|
Condition::Between(start, end) => value > *start && value < *end,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Evaluatable<&NaiveDateTime> for Condition<NaiveDateTime> {
|
||||||
|
fn evaluate(&self, value: &NaiveDateTime) -> bool {
|
||||||
|
match self {
|
||||||
|
Condition::Equals(expected) => expected == value,
|
||||||
|
Condition::GreaterThan(threshold) => value > threshold,
|
||||||
|
Condition::LessThan(threshold) => value < threshold,
|
||||||
|
Condition::Between(start, end) => value > start && value < end,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FieldEvaluatable for RuleTarget {
|
||||||
|
fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool {
|
||||||
|
match self {
|
||||||
|
RuleTarget::Title(cond) => video.name.try_as_ref().is_some_and(|title| cond.evaluate(&title)),
|
||||||
|
// 目前的所有条件都是分别针对全体标签进行 any 评估的,例如 Prefix("a") && Suffix("b") 意味着 any(tag.Prefix("a")) && any(tag.Suffix("b")) 而非 any(tag.Prefix("a") && tag.Suffix("b"))
|
||||||
|
// 这可能不满足用户预期,但应该问题不大,如果真有很多人用复杂标签筛选再单独改
|
||||||
|
RuleTarget::Tags(cond) => video
|
||||||
|
.tags
|
||||||
|
.try_as_ref()
|
||||||
|
.and_then(|t| t.as_ref())
|
||||||
|
.is_some_and(|tags| tags.0.iter().any(|tag| cond.evaluate(&tag))),
|
||||||
|
RuleTarget::FavTime(cond) => video
|
||||||
|
.favtime
|
||||||
|
.try_as_ref()
|
||||||
|
.map(|fav_time| fav_time.and_utc().with_timezone(&Local).naive_local()) // 数据库中保存的一律是 utc 时间,转换为 local 时间再比较
|
||||||
|
.is_some_and(|fav_time| cond.evaluate(&fav_time)),
|
||||||
|
RuleTarget::PubTime(cond) => video
|
||||||
|
.pubtime
|
||||||
|
.try_as_ref()
|
||||||
|
.map(|pub_time| pub_time.and_utc().with_timezone(&Local).naive_local())
|
||||||
|
.is_some_and(|pub_time| cond.evaluate(&pub_time)),
|
||||||
|
RuleTarget::PageCount(cond) => cond.evaluate(pages.len()),
|
||||||
|
RuleTarget::Not(inner) => !inner.evaluate(video, pages),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FieldEvaluatable for AndGroup {
|
||||||
|
fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool {
|
||||||
|
self.iter().all(|target| target.evaluate(video, pages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FieldEvaluatable for Rule {
|
||||||
|
fn evaluate(&self, video: &video::ActiveModel, pages: &[page::ActiveModel]) -> bool {
|
||||||
|
if self.0.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.0.iter().any(|group| group.evaluate(video, pages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use bili_sync_entity::page;
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use sea_orm::ActiveValue::Set;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_display() {
|
||||||
|
let test_cases = vec![
|
||||||
|
(
|
||||||
|
Rule(vec![vec![RuleTarget::Title(Condition::Contains("唐氏".to_string()))]]),
|
||||||
|
"「(标题包含“唐氏”)」",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Rule(vec![vec![
|
||||||
|
RuleTarget::Title(Condition::Prefix("街霸".to_string())),
|
||||||
|
RuleTarget::Tags(Condition::Contains("套路".to_string())),
|
||||||
|
]]),
|
||||||
|
"「(标题以“街霸”开头)且(标签包含“套路”)」",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Rule(vec![
|
||||||
|
vec![
|
||||||
|
RuleTarget::Title(Condition::Contains("Rust".to_string())),
|
||||||
|
RuleTarget::PageCount(Condition::GreaterThan(5)),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
RuleTarget::Tags(Condition::Suffix("入门".to_string())),
|
||||||
|
RuleTarget::PubTime(Condition::GreaterThan(
|
||||||
|
NaiveDate::from_ymd_opt(2023, 1, 1)
|
||||||
|
.unwrap()
|
||||||
|
.and_hms_opt(0, 0, 0)
|
||||||
|
.unwrap(),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
"「(标题包含“Rust”)且(视频分页数量大于“5”)」或「(标签以“入门”结尾)且(发布时间大于“2023-01-01 00:00:00”)」",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Rule(vec![vec![
|
||||||
|
RuleTarget::Not(Box::new(RuleTarget::Title(Condition::Contains("广告".to_string())))),
|
||||||
|
RuleTarget::PageCount(Condition::LessThan(10)),
|
||||||
|
]]),
|
||||||
|
"「(标题不包含“广告”)且(视频分页数量小于“10”)」",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Rule(vec![vec![
|
||||||
|
RuleTarget::FavTime(Condition::Between(
|
||||||
|
NaiveDate::from_ymd_opt(2023, 6, 1)
|
||||||
|
.unwrap()
|
||||||
|
.and_hms_opt(0, 0, 0)
|
||||||
|
.unwrap(),
|
||||||
|
NaiveDate::from_ymd_opt(2023, 12, 31)
|
||||||
|
.unwrap()
|
||||||
|
.and_hms_opt(23, 59, 59)
|
||||||
|
.unwrap(),
|
||||||
|
)),
|
||||||
|
RuleTarget::Tags(Condition::MatchesRegex(
|
||||||
|
"技术|教程".to_string(),
|
||||||
|
regex::Regex::new("技术|教程").unwrap(),
|
||||||
|
)),
|
||||||
|
]]),
|
||||||
|
"「(收藏时间在“2023-06-01 00:00:00”和“2023-12-31 23:59:59”之间)且(标签匹配“技术|教程”)」",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (rule, expected) in test_cases {
|
||||||
|
assert_eq!(rule.to_string(), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate() {
|
||||||
|
let test_cases = vec![
|
||||||
|
(
|
||||||
|
(
|
||||||
|
video::ActiveModel {
|
||||||
|
name: Set("骂谁唐氏呢!!!".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
vec![],
|
||||||
|
),
|
||||||
|
Rule(vec![vec![RuleTarget::Title(Condition::Contains("唐氏".to_string()))]]),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
(
|
||||||
|
video::ActiveModel::default(),
|
||||||
|
vec![page::ActiveModel::default(); 2],
|
||||||
|
),
|
||||||
|
Rule(vec![vec![RuleTarget::PageCount(Condition::Equals(1))]]),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
(
|
||||||
|
video::ActiveModel{
|
||||||
|
tags: Set(Some(vec!["原神".to_owned(),"永雏塔菲".to_owned(),"虚拟主播".to_owned()].into())),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
vec![],
|
||||||
|
),
|
||||||
|
Rule (vec![vec![RuleTarget::Not(Box::new(RuleTarget::Tags(Condition::Equals(
|
||||||
|
"原神".to_string(),
|
||||||
|
))))]],
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
(
|
||||||
|
video::ActiveModel {
|
||||||
|
name: Set(
|
||||||
|
"万字怒扒网易《归唐》底裤!中国首款大厂买断制单机,靠谱吗?——全网最全!官方非独家幕后!关于《归唐》PV的所有秘密~都在这里了~".to_owned(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
vec![],
|
||||||
|
),
|
||||||
|
Rule(vec![vec![RuleTarget::Not(Box::new(RuleTarget::Title(Condition::MatchesRegex(
|
||||||
|
r"^\S+字(解析|怒扒|拆解)".to_owned(),
|
||||||
|
regex::Regex::new(r"^\S+字(解析|怒扒)").unwrap(),
|
||||||
|
))))]],
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for ((video, pages), rule, expected) in test_cases {
|
||||||
|
assert_eq!(rule.evaluate(&video, &pages), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,7 +176,7 @@ pub type VideoStatus = Status<5>;
|
|||||||
pub type PageStatus = Status<5>;
|
pub type PageStatus = Status<5>;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod tests {
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use crate::utils::model::{
|
|||||||
update_videos_model,
|
update_videos_model,
|
||||||
};
|
};
|
||||||
use crate::utils::nfo::NFO;
|
use crate::utils::nfo::NFO;
|
||||||
|
use crate::utils::rule::FieldEvaluatable;
|
||||||
use crate::utils::status::{PageStatus, STATUS_OK, VideoStatus};
|
use crate::utils::status::{PageStatus, STATUS_OK, VideoStatus};
|
||||||
|
|
||||||
/// 完整地处理某个视频来源
|
/// 完整地处理某个视频来源
|
||||||
@@ -136,7 +137,10 @@ pub async fn fetch_video_details(
|
|||||||
let mut video_active_model = view_info.into_detail_model(video_model);
|
let mut video_active_model = view_info.into_detail_model(video_model);
|
||||||
video_source.set_relation_id(&mut video_active_model);
|
video_source.set_relation_id(&mut video_active_model);
|
||||||
video_active_model.single_page = Set(Some(pages.len() == 1));
|
video_active_model.single_page = Set(Some(pages.len() == 1));
|
||||||
video_active_model.tags = Set(Some(serde_json::to_value(tags)?));
|
video_active_model.tags = Set(Some(tags.into()));
|
||||||
|
video_active_model.should_download = Set(video_source
|
||||||
|
.rule()
|
||||||
|
.is_none_or(|r| r.evaluate(&video_active_model, &pages)));
|
||||||
let txn = connection.begin().await?;
|
let txn = connection.begin().await?;
|
||||||
create_pages(pages, &txn).await?;
|
create_pages(pages, &txn).await?;
|
||||||
video_active_model.save(&txn).await?;
|
video_active_model.save(&txn).await?;
|
||||||
|
|||||||
@@ -5,5 +5,8 @@ edition = { workspace = true }
|
|||||||
publish = { workspace = true }
|
publish = { workspace = true }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
derivative = { workspace = true }
|
||||||
sea-orm = { workspace = true }
|
sea-orm = { workspace = true }
|
||||||
|
regex = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod rule;
|
||||||
|
pub mod string_vec;
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
use std::fmt::Display;
|
||||||
|
|
||||||
|
use derivative::Derivative;
|
||||||
|
use sea_orm::FromJsonQueryResult;
|
||||||
|
use sea_orm::prelude::DateTime;
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, Derivative)]
|
||||||
|
#[derivative(PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase", tag = "operator", content = "value")]
|
||||||
|
pub enum Condition<T: Serialize + Display> {
|
||||||
|
Equals(T),
|
||||||
|
Contains(T),
|
||||||
|
#[serde(deserialize_with = "deserialize_regex", serialize_with = "serialize_regex")]
|
||||||
|
MatchesRegex(String, #[derivative(PartialEq = "ignore")] regex::Regex),
|
||||||
|
Prefix(T),
|
||||||
|
Suffix(T),
|
||||||
|
GreaterThan(T),
|
||||||
|
LessThan(T),
|
||||||
|
Between(T, T),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)]
|
||||||
|
#[serde(rename_all = "camelCase", tag = "field", content = "rule")]
|
||||||
|
pub enum RuleTarget {
|
||||||
|
Title(Condition<String>),
|
||||||
|
Tags(Condition<String>),
|
||||||
|
FavTime(Condition<DateTime>),
|
||||||
|
PubTime(Condition<DateTime>),
|
||||||
|
PageCount(Condition<usize>),
|
||||||
|
Not(Box<RuleTarget>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type AndGroup = Vec<RuleTarget>;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)]
|
||||||
|
pub struct Rule(pub Vec<AndGroup>);
|
||||||
|
|
||||||
|
impl<T: Serialize + Display> Display for Condition<T> {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Condition::Equals(v) => write!(f, "等于“{}”", v),
|
||||||
|
Condition::Contains(v) => write!(f, "包含“{}”", v),
|
||||||
|
Condition::MatchesRegex(pat, _) => write!(f, "匹配“{}”", pat),
|
||||||
|
Condition::Prefix(v) => write!(f, "以“{}”开头", v),
|
||||||
|
Condition::Suffix(v) => write!(f, "以“{}”结尾", v),
|
||||||
|
Condition::GreaterThan(v) => write!(f, "大于“{}”", v),
|
||||||
|
Condition::LessThan(v) => write!(f, "小于“{}”", v),
|
||||||
|
Condition::Between(start, end) => write!(f, "在“{}”和“{}”之间", start, end),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for RuleTarget {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
fn get_field_name(rt: &RuleTarget, depth: usize) -> &'static str {
|
||||||
|
match rt {
|
||||||
|
RuleTarget::Title(_) => "标题",
|
||||||
|
RuleTarget::Tags(_) => "标签",
|
||||||
|
RuleTarget::FavTime(_) => "收藏时间",
|
||||||
|
RuleTarget::PubTime(_) => "发布时间",
|
||||||
|
RuleTarget::PageCount(_) => "视频分页数量",
|
||||||
|
RuleTarget::Not(inner) => {
|
||||||
|
if depth == 0 {
|
||||||
|
get_field_name(inner, depth + 1)
|
||||||
|
} else {
|
||||||
|
"格式化失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let field_name = get_field_name(self, 0);
|
||||||
|
match self {
|
||||||
|
RuleTarget::Not(inner) => match inner.as_ref() {
|
||||||
|
RuleTarget::Title(cond) | RuleTarget::Tags(cond) => write!(f, "{}不{}", field_name, cond),
|
||||||
|
RuleTarget::FavTime(cond) | RuleTarget::PubTime(cond) => {
|
||||||
|
write!(f, "{}不{}", field_name, cond)
|
||||||
|
}
|
||||||
|
RuleTarget::PageCount(cond) => write!(f, "{}不{}", field_name, cond),
|
||||||
|
RuleTarget::Not(_) => write!(f, "格式化失败"),
|
||||||
|
},
|
||||||
|
RuleTarget::Title(cond) | RuleTarget::Tags(cond) => write!(f, "{}{}", field_name, cond),
|
||||||
|
RuleTarget::FavTime(cond) | RuleTarget::PubTime(cond) => {
|
||||||
|
write!(f, "{}{}", field_name, cond)
|
||||||
|
}
|
||||||
|
RuleTarget::PageCount(cond) => write!(f, "{}{}", field_name, cond),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Rule {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let groups: Vec<String> = self
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|group| {
|
||||||
|
let conditions: Vec<String> = group.iter().map(|target| format!("({})", target)).collect();
|
||||||
|
format!("「{}」", conditions.join("且"))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
write!(f, "{}", groups.join("或"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_regex<'de, D>(deserializer: D) -> Result<(String, regex::Regex), D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let pattern = String::deserialize(deserializer)?;
|
||||||
|
// 反序列化时预编译 regex,优化性能
|
||||||
|
let regex = regex::Regex::new(&pattern).map_err(|e| serde::de::Error::custom(e))?;
|
||||||
|
Ok((pattern, regex))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_regex<S>(pattern: &String, _regex: ®ex::Regex, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(pattern)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use sea_orm::FromJsonQueryResult;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// reference: https://www.sea-ql.org/SeaORM/docs/generate-entity/column-types/#json-column
|
||||||
|
// 在 entity 中使用裸 Vec 仅在 postgres 中支持,sea-orm 会将其映射为 postgres array
|
||||||
|
// 如果需要实现跨数据库的 array,必须将其包裹在 wrapper type 中
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)]
|
||||||
|
pub struct StringVec(pub Vec<String>);
|
||||||
|
|
||||||
|
impl From<Vec<String>> for StringVec {
|
||||||
|
fn from(value: Vec<String>) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StringVec> for Vec<String> {
|
||||||
|
fn from(value: StringVec) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
use crate::rule::Rule;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
#[sea_orm(table_name = "collection")]
|
#[sea_orm(table_name = "collection")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
@@ -14,6 +16,7 @@ pub struct Model {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub latest_row_at: DateTime,
|
pub latest_row_at: DateTime,
|
||||||
|
pub rule: Option<Rule>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
use crate::rule::Rule;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
#[sea_orm(table_name = "favorite")]
|
#[sea_orm(table_name = "favorite")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
@@ -13,6 +15,7 @@ pub struct Model {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub latest_row_at: DateTime,
|
pub latest_row_at: DateTime,
|
||||||
|
pub rule: Option<Rule>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
use crate::rule::Rule;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
#[sea_orm(table_name = "submission")]
|
#[sea_orm(table_name = "submission")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
@@ -12,6 +14,7 @@ pub struct Model {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub latest_row_at: DateTime,
|
pub latest_row_at: DateTime,
|
||||||
|
pub rule: Option<Rule>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
use crate::string_vec::StringVec;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Default)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Default)]
|
||||||
#[sea_orm(table_name = "video")]
|
#[sea_orm(table_name = "video")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
@@ -25,7 +27,8 @@ pub struct Model {
|
|||||||
pub favtime: DateTime,
|
pub favtime: DateTime,
|
||||||
pub download_status: u32,
|
pub download_status: u32,
|
||||||
pub valid: bool,
|
pub valid: bool,
|
||||||
pub tags: Option<serde_json::Value>,
|
pub should_download: bool,
|
||||||
|
pub tags: Option<StringVec>,
|
||||||
pub single_page: Option<bool>,
|
pub single_page: Option<bool>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
use crate::rule::Rule;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
#[sea_orm(table_name = "watch_later")]
|
#[sea_orm(table_name = "watch_later")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
@@ -10,6 +12,7 @@ pub struct Model {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub latest_row_at: DateTime,
|
pub latest_row_at: DateTime,
|
||||||
|
pub rule: Option<Rule>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
|
mod custom_type;
|
||||||
mod entities;
|
mod entities;
|
||||||
|
|
||||||
|
pub use custom_type::*;
|
||||||
pub use entities::*;
|
pub use entities::*;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ mod m20250122_062926_add_latest_row_at;
|
|||||||
mod m20250612_090826_add_enabled;
|
mod m20250612_090826_add_enabled;
|
||||||
mod m20250613_043257_add_config;
|
mod m20250613_043257_add_config;
|
||||||
mod m20250712_080013_add_video_created_at_index;
|
mod m20250712_080013_add_video_created_at_index;
|
||||||
|
mod m20250903_094454_add_rule_and_should_download;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20250612_090826_add_enabled::Migration),
|
Box::new(m20250612_090826_add_enabled::Migration),
|
||||||
Box::new(m20250613_043257_add_config::Migration),
|
Box::new(m20250613_043257_add_config::Migration),
|
||||||
Box::new(m20250712_080013_add_video_created_at_index::Migration),
|
Box::new(m20250712_080013_add_video_created_at_index::Migration),
|
||||||
|
Box::new(m20250903_094454_add_rule_and_should_download::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
use sea_orm_migration::schema::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Video::Table)
|
||||||
|
.add_column(boolean(Video::ShouldDownload).default(true))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(WatchLater::Table)
|
||||||
|
.add_column(text_null(WatchLater::Rule))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Submission::Table)
|
||||||
|
.add_column(text_null(Submission::Rule))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Favorite::Table)
|
||||||
|
.add_column(text_null(Favorite::Rule))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Collection::Table)
|
||||||
|
.add_column(text_null(Collection::Rule))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Video::Table)
|
||||||
|
.drop_column(Video::ShouldDownload)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(WatchLater::Table)
|
||||||
|
.drop_column(WatchLater::Rule)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Submission::Table)
|
||||||
|
.drop_column(Submission::Rule)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Favorite::Table)
|
||||||
|
.drop_column(Favorite::Rule)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Collection::Table)
|
||||||
|
.drop_column(Collection::Rule)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Video {
|
||||||
|
Table,
|
||||||
|
ShouldDownload,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum WatchLater {
|
||||||
|
Table,
|
||||||
|
Rule,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Submission {
|
||||||
|
Table,
|
||||||
|
Rule,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Favorite {
|
||||||
|
Table,
|
||||||
|
Rule,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Collection {
|
||||||
|
Table,
|
||||||
|
Rule,
|
||||||
|
}
|
||||||
+8
-6
@@ -7,7 +7,7 @@
|
|||||||
"@eslint/compat": "^1.2.5",
|
"@eslint/compat": "^1.2.5",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@internationalized/date": "^3.8.1",
|
"@internationalized/date": "^3.8.1",
|
||||||
"@lucide/svelte": "^0.525.0",
|
"@lucide/svelte": "^0.544.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/kit": "2.22.2",
|
"@sveltejs/kit": "2.22.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/d3-scale": "^4.0.9",
|
"@types/d3-scale": "^4.0.9",
|
||||||
"@types/d3-shape": "^3.1.7",
|
"@types/d3-shape": "^3.1.7",
|
||||||
"bits-ui": "^2.8.6",
|
"bits-ui": "^2.11.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
@@ -155,7 +155,7 @@
|
|||||||
|
|
||||||
"@layerstack/utils": ["@layerstack/utils@2.0.0-next.12", "", { "dependencies": { "d3-array": "^3.2.4", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "lodash-es": "^4.17.21" } }, "sha512-fhGZUlSr3N+D44BYm37WKMGSEFyZBW+dwIqtGU8Cl54mR4TLQ/UwyGhdpgIHyH/x/8q1abE0fP0Dn6ZsrDE3BA=="],
|
"@layerstack/utils": ["@layerstack/utils@2.0.0-next.12", "", { "dependencies": { "d3-array": "^3.2.4", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "lodash-es": "^4.17.21" } }, "sha512-fhGZUlSr3N+D44BYm37WKMGSEFyZBW+dwIqtGU8Cl54mR4TLQ/UwyGhdpgIHyH/x/8q1abE0fP0Dn6ZsrDE3BA=="],
|
||||||
|
|
||||||
"@lucide/svelte": ["@lucide/svelte@0.525.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-dyUxkXzepagLUzL8jHQNdeH286nC66ClLACsg+Neu/bjkRJWPWMzkT+H0DKlE70QdkicGCfs1ZGmXCc351hmZA=="],
|
"@lucide/svelte": ["@lucide/svelte@0.544.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw=="],
|
||||||
|
|
||||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||||
|
|
||||||
@@ -301,7 +301,7 @@
|
|||||||
|
|
||||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
"bits-ui": ["bits-ui@2.8.10", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.29.1", "svelte-toolbelt": "^0.9.3", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-MOobkqapDZNrpcNmeL2g664xFmH4tZBOKBTxFmsQYMZQuybSZHQnPXy+AjM5XZEXRmCFx5+XRmo6+fC3vHh1hQ=="],
|
"bits-ui": ["bits-ui@2.11.0", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.31.1", "svelte-toolbelt": "^0.10.4", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-j/lOFHz6ZDWwj9sOUb6zYSJQdvPc7kr1IRyAdPjln4wOw9UVvKCosbRFEyP4JEzvNFX7HksMG4naDrDHta5bSA=="],
|
||||||
|
|
||||||
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
|
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
|
||||||
|
|
||||||
@@ -615,7 +615,7 @@
|
|||||||
|
|
||||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||||
|
|
||||||
"runed": ["runed@0.29.1", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-RGQEB8ZiWv4OvzBJhbMj2hMgRM8QrEptzTrDr7TDfkHaRePKjiUka4vJ9QHGY+8s87KymNvFoZAxFdQ4jtZNcA=="],
|
"runed": ["runed@0.31.1", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-v3czcTnO+EJjiPvD4dwIqfTdHLZ8oH0zJheKqAHh9QMViY7Qb29UlAMRpX7ZtHh7AFqV60KmfxaJ9QMy+L1igQ=="],
|
||||||
|
|
||||||
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
|
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
|
||||||
|
|
||||||
@@ -649,7 +649,7 @@
|
|||||||
|
|
||||||
"svelte-sonner": ["svelte-sonner@1.0.4", "", { "dependencies": { "runed": "^0.26.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-ctm9jeV0Rf3im2J6RU1emccrJFjRSdNSPsLlxaF62TLZw9bB1D40U/U7+wqEgohJY/X7FBdghdj0BFQF/IqKPQ=="],
|
"svelte-sonner": ["svelte-sonner@1.0.4", "", { "dependencies": { "runed": "^0.26.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-ctm9jeV0Rf3im2J6RU1emccrJFjRSdNSPsLlxaF62TLZw9bB1D40U/U7+wqEgohJY/X7FBdghdj0BFQF/IqKPQ=="],
|
||||||
|
|
||||||
"svelte-toolbelt": ["svelte-toolbelt@0.9.3", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.29.0", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw=="],
|
"svelte-toolbelt": ["svelte-toolbelt@0.10.5", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.29.0", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-8e+eWTgxw1aiLxhDE8Rb1X6AoLitqpJz+WhAul2W7W58C8KoLoJQf1TgQdFPBiCPJ0Jg5y0Zi1uyua9em4VS0w=="],
|
||||||
|
|
||||||
"tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="],
|
"tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="],
|
||||||
|
|
||||||
@@ -745,6 +745,8 @@
|
|||||||
|
|
||||||
"svelte-sonner/runed": ["runed@0.26.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-qWFv0cvLVRd8pdl/AslqzvtQyEn5KaIugEernwg9G98uJVSZcs/ygvPBvF80LA46V8pwRvSKnaVLDI3+i2wubw=="],
|
"svelte-sonner/runed": ["runed@0.26.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-qWFv0cvLVRd8pdl/AslqzvtQyEn5KaIugEernwg9G98uJVSZcs/ygvPBvF80LA46V8pwRvSKnaVLDI3+i2wubw=="],
|
||||||
|
|
||||||
|
"svelte-toolbelt/runed": ["runed@0.29.1", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-RGQEB8ZiWv4OvzBJhbMj2hMgRM8QrEptzTrDr7TDfkHaRePKjiUka4vJ9QHGY+8s87KymNvFoZAxFdQ4jtZNcA=="],
|
||||||
|
|
||||||
"tailwind-variants/tailwind-merge": ["tailwind-merge@3.0.2", "", {}, "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw=="],
|
"tailwind-variants/tailwind-merge": ["tailwind-merge@3.0.2", "", {}, "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw=="],
|
||||||
|
|
||||||
"vite/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
|
"vite/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
"@eslint/compat": "^1.2.5",
|
"@eslint/compat": "^1.2.5",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@internationalized/date": "^3.8.1",
|
"@internationalized/date": "^3.8.1",
|
||||||
"@lucide/svelte": "^0.525.0",
|
"@lucide/svelte": "^0.544.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/kit": "2.22.2",
|
"@sveltejs/kit": "2.22.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/d3-scale": "^4.0.9",
|
"@types/d3-scale": "^4.0.9",
|
||||||
"@types/d3-shape": "^3.1.7",
|
"@types/d3-shape": "^3.1.7",
|
||||||
"bits-ui": "^2.8.6",
|
"bits-ui": "^2.11.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
|||||||
+4
-3
@@ -21,7 +21,8 @@ import type {
|
|||||||
DashBoardResponse,
|
DashBoardResponse,
|
||||||
SysInfo,
|
SysInfo,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
ResetRequest
|
ResetRequest,
|
||||||
|
UpdateVideoSourceResponse
|
||||||
} from './types';
|
} from './types';
|
||||||
import { wsManager } from './ws';
|
import { wsManager } from './ws';
|
||||||
|
|
||||||
@@ -212,8 +213,8 @@ class ApiClient {
|
|||||||
type: string,
|
type: string,
|
||||||
id: number,
|
id: number,
|
||||||
request: UpdateVideoSourceRequest
|
request: UpdateVideoSourceRequest
|
||||||
): Promise<ApiResponse<boolean>> {
|
): Promise<ApiResponse<UpdateVideoSourceResponse>> {
|
||||||
return this.put<boolean>(`/video-sources/${type}/${id}`, request);
|
return this.put<UpdateVideoSourceResponse>(`/video-sources/${type}/${id}`, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getConfig(): Promise<ApiResponse<Config>> {
|
async getConfig(): Promise<ApiResponse<Config>> {
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||||
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
|
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||||
|
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||||
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
|
import type { Rule, RuleTarget, Condition } from '$lib/types';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rule: Rule | null;
|
||||||
|
onRuleChange: (rule: Rule | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { rule, onRuleChange }: Props = $props();
|
||||||
|
|
||||||
|
const FIELD_OPTIONS = [
|
||||||
|
{ value: 'title', label: '标题' },
|
||||||
|
{ value: 'tags', label: '标签' },
|
||||||
|
{ value: 'favTime', label: '收藏时间' },
|
||||||
|
{ value: 'pubTime', label: '发布时间' },
|
||||||
|
{ value: 'pageCount', label: '视频分页数量' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const getOperatorOptions = (field: string) => {
|
||||||
|
switch (field) {
|
||||||
|
case 'title':
|
||||||
|
case 'tags':
|
||||||
|
return [
|
||||||
|
{ value: 'equals', label: '等于' },
|
||||||
|
{ value: 'contains', label: '包含' },
|
||||||
|
{ value: 'prefix', label: '以...开头' },
|
||||||
|
{ value: 'suffix', label: '以...结尾' },
|
||||||
|
{ value: 'matchesRegex', label: '匹配正则' }
|
||||||
|
];
|
||||||
|
case 'pageCount':
|
||||||
|
return [
|
||||||
|
{ value: 'equals', label: '等于' },
|
||||||
|
{ value: 'greaterThan', label: '大于' },
|
||||||
|
{ value: 'lessThan', label: '小于' },
|
||||||
|
{ value: 'between', label: '范围' }
|
||||||
|
];
|
||||||
|
case 'favTime':
|
||||||
|
case 'pubTime':
|
||||||
|
return [
|
||||||
|
{ value: 'equals', label: '等于' },
|
||||||
|
{ value: 'greaterThan', label: '晚于' },
|
||||||
|
{ value: 'lessThan', label: '早于' },
|
||||||
|
{ value: 'between', label: '时间范围' }
|
||||||
|
];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LocalCondition {
|
||||||
|
field: string;
|
||||||
|
operator: string;
|
||||||
|
value: string;
|
||||||
|
value2?: string;
|
||||||
|
isNot: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocalAndGroup {
|
||||||
|
conditions: LocalCondition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let localRule: LocalAndGroup[] = $state([]);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (rule && rule.length > 0) {
|
||||||
|
localRule = rule.map((andGroup) => ({
|
||||||
|
conditions: andGroup.map((target) => convertRuleTargetToLocal(target))
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
localRule = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function convertRuleTargetToLocal(target: RuleTarget<string | number | Date>): LocalCondition {
|
||||||
|
if (typeof target.rule === 'object' && 'field' in target.rule) {
|
||||||
|
// 嵌套的 not
|
||||||
|
const innerCondition = convertRuleTargetToLocal(target.rule);
|
||||||
|
return {
|
||||||
|
...innerCondition,
|
||||||
|
isNot: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const condition = target.rule as Condition<string | number | Date>;
|
||||||
|
let value = '';
|
||||||
|
let value2 = '';
|
||||||
|
if (Array.isArray(condition.value)) {
|
||||||
|
value = String(condition.value[0] || '');
|
||||||
|
value2 = String(condition.value[1] || '');
|
||||||
|
} else {
|
||||||
|
value = String(condition.value || '');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
field: target.field,
|
||||||
|
operator: condition.operator,
|
||||||
|
value,
|
||||||
|
value2,
|
||||||
|
isNot: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertLocalToRule(): Rule | null {
|
||||||
|
if (localRule.length === 0) return null;
|
||||||
|
return localRule.map((andGroup) =>
|
||||||
|
andGroup.conditions.map((condition) => {
|
||||||
|
let value: string | number | Date | (string | number | Date)[];
|
||||||
|
if (condition.field === 'pageCount') {
|
||||||
|
if (condition.operator === 'between') {
|
||||||
|
value = [parseInt(condition.value) || 0, parseInt(condition.value2 || '0') || 0];
|
||||||
|
} else {
|
||||||
|
value = parseInt(condition.value) || 0;
|
||||||
|
}
|
||||||
|
} else if (condition.field === 'favTime' || condition.field === 'pubTime') {
|
||||||
|
if (condition.operator === 'between') {
|
||||||
|
value = [condition.value, condition.value2 || ''];
|
||||||
|
} else {
|
||||||
|
value = condition.value;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (condition.operator === 'between') {
|
||||||
|
value = [condition.value, condition.value2 || ''];
|
||||||
|
} else {
|
||||||
|
value = condition.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const conditionObj: Condition<string | number | Date> = {
|
||||||
|
operator: condition.operator,
|
||||||
|
value
|
||||||
|
};
|
||||||
|
|
||||||
|
let target: RuleTarget<string | number | Date> = {
|
||||||
|
field: condition.field,
|
||||||
|
rule: conditionObj
|
||||||
|
};
|
||||||
|
if (condition.isNot) {
|
||||||
|
target = {
|
||||||
|
field: 'not',
|
||||||
|
rule: target
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAndGroup() {
|
||||||
|
localRule.push({ conditions: [] });
|
||||||
|
onRuleChange?.(convertLocalToRule());
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAndGroup(index: number) {
|
||||||
|
localRule.splice(index, 1);
|
||||||
|
onRuleChange?.(convertLocalToRule());
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCondition(groupIndex: number) {
|
||||||
|
localRule[groupIndex].conditions.push({
|
||||||
|
field: 'title',
|
||||||
|
operator: 'contains',
|
||||||
|
value: '',
|
||||||
|
isNot: false
|
||||||
|
});
|
||||||
|
onRuleChange?.(convertLocalToRule());
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCondition(groupIndex: number, conditionIndex: number) {
|
||||||
|
localRule[groupIndex].conditions.splice(conditionIndex, 1);
|
||||||
|
onRuleChange?.(convertLocalToRule());
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCondition(
|
||||||
|
groupIndex: number,
|
||||||
|
conditionIndex: number,
|
||||||
|
field: string,
|
||||||
|
value: string
|
||||||
|
) {
|
||||||
|
const condition = localRule[groupIndex].conditions[conditionIndex];
|
||||||
|
if (field === 'field') {
|
||||||
|
condition.field = value;
|
||||||
|
const operators = getOperatorOptions(value);
|
||||||
|
condition.operator = operators[0]?.value || 'equals';
|
||||||
|
condition.value = '';
|
||||||
|
condition.value2 = '';
|
||||||
|
} else if (field === 'operator') {
|
||||||
|
condition.operator = value;
|
||||||
|
// 如果切换到/从 between 操作符,重置值
|
||||||
|
if (value === 'between') {
|
||||||
|
condition.value2 = condition.value2 || '';
|
||||||
|
}
|
||||||
|
} else if (field === 'value') {
|
||||||
|
condition.value = value;
|
||||||
|
} else if (field === 'value2') {
|
||||||
|
condition.value2 = value;
|
||||||
|
} else if (field === 'isNot') {
|
||||||
|
condition.isNot = value === 'true';
|
||||||
|
}
|
||||||
|
onRuleChange?.(convertLocalToRule());
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRules() {
|
||||||
|
localRule = [];
|
||||||
|
onRuleChange?.(convertLocalToRule());
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label class="text-sm font-medium">过滤规则</Label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#if localRule.length > 0}
|
||||||
|
<Button size="sm" variant="outline" onclick={clearRules}>清空规则</Button>
|
||||||
|
{/if}
|
||||||
|
<Button size="sm" onclick={addAndGroup}>
|
||||||
|
<PlusIcon class="mr-1 h-3 w-3" />
|
||||||
|
添加规则组
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if localRule.length === 0}
|
||||||
|
<div class="border-muted-foreground/25 rounded-lg border-2 border-dashed p-8 text-center">
|
||||||
|
<p class="text-muted-foreground mb-4 text-sm">暂无过滤规则,将下载所有视频</p>
|
||||||
|
<Button size="sm" onclick={addAndGroup}>
|
||||||
|
<PlusIcon class="mr-1 h-3 w-3" />
|
||||||
|
添加第一个规则组
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each localRule as andGroup, groupIndex (groupIndex)}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary">规则组 {groupIndex + 1}</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onclick={() => removeAndGroup(groupIndex)}
|
||||||
|
class="h-7 w-7 p-0"
|
||||||
|
>
|
||||||
|
<XIcon class="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="space-y-3">
|
||||||
|
{#each andGroup.conditions as condition, conditionIndex (conditionIndex)}
|
||||||
|
<div class="space-y-3 rounded-lg border p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Badge variant="secondary">条件 {conditionIndex + 1}</Badge>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onclick={() => removeCondition(groupIndex, conditionIndex)}
|
||||||
|
class="h-7 w-7 p-0"
|
||||||
|
>
|
||||||
|
<MinusIcon class="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 取反选项 -->
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`not-${groupIndex}-${conditionIndex}`}
|
||||||
|
checked={condition.isNot}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateCondition(
|
||||||
|
groupIndex,
|
||||||
|
conditionIndex,
|
||||||
|
'isNot',
|
||||||
|
checked ? 'true' : 'false'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Label for={`not-${groupIndex}-${conditionIndex}`} class="text-sm">
|
||||||
|
取反(NOT)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 字段和操作符 -->
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<!-- 字段选择 -->
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground text-xs">字段</Label>
|
||||||
|
<select
|
||||||
|
class="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
value={condition.field}
|
||||||
|
onchange={(e) =>
|
||||||
|
updateCondition(groupIndex, conditionIndex, 'field', e.currentTarget.value)}
|
||||||
|
>
|
||||||
|
{#each FIELD_OPTIONS as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作符选择 -->
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground text-xs">操作符</Label>
|
||||||
|
<select
|
||||||
|
class="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
value={condition.operator}
|
||||||
|
onchange={(e) =>
|
||||||
|
updateCondition(
|
||||||
|
groupIndex,
|
||||||
|
conditionIndex,
|
||||||
|
'operator',
|
||||||
|
e.currentTarget.value
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{#each getOperatorOptions(condition.field) as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 值输入 -->
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground text-xs">值</Label>
|
||||||
|
{#if condition.operator === 'between'}
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
{#if condition.field === 'pageCount'}
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="最小值"
|
||||||
|
class="h-9"
|
||||||
|
value={condition.value}
|
||||||
|
oninput={(e) =>
|
||||||
|
updateCondition(
|
||||||
|
groupIndex,
|
||||||
|
conditionIndex,
|
||||||
|
'value',
|
||||||
|
e.currentTarget.value
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="最大值"
|
||||||
|
class="h-9"
|
||||||
|
value={condition.value2 || ''}
|
||||||
|
oninput={(e) =>
|
||||||
|
updateCondition(
|
||||||
|
groupIndex,
|
||||||
|
conditionIndex,
|
||||||
|
'value2',
|
||||||
|
e.currentTarget.value
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{:else if condition.field === 'favTime' || condition.field === 'pubTime'}
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
placeholder="开始时间"
|
||||||
|
class="h-9"
|
||||||
|
value={condition.value}
|
||||||
|
oninput={(e) =>
|
||||||
|
updateCondition(
|
||||||
|
groupIndex,
|
||||||
|
conditionIndex,
|
||||||
|
'value',
|
||||||
|
e.currentTarget.value + ':00' // 前端选择器只能精确到分钟,此处附加额外的 :00 以满足后端传参条件
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
placeholder="结束时间"
|
||||||
|
class="h-9"
|
||||||
|
value={condition.value2 || ''}
|
||||||
|
oninput={(e) =>
|
||||||
|
updateCondition(
|
||||||
|
groupIndex,
|
||||||
|
conditionIndex,
|
||||||
|
'value2',
|
||||||
|
e.currentTarget.value + ':00'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="起始值"
|
||||||
|
class="h-9"
|
||||||
|
value={condition.value}
|
||||||
|
oninput={(e) =>
|
||||||
|
updateCondition(
|
||||||
|
groupIndex,
|
||||||
|
conditionIndex,
|
||||||
|
'value',
|
||||||
|
e.currentTarget.value
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="结束值"
|
||||||
|
class="h-9"
|
||||||
|
value={condition.value2 || ''}
|
||||||
|
oninput={(e) =>
|
||||||
|
updateCondition(
|
||||||
|
groupIndex,
|
||||||
|
conditionIndex,
|
||||||
|
'value2',
|
||||||
|
e.currentTarget.value
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if condition.field === 'pageCount'}
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="输入数值"
|
||||||
|
class="h-9"
|
||||||
|
value={condition.value}
|
||||||
|
oninput={(e) =>
|
||||||
|
updateCondition(groupIndex, conditionIndex, 'value', e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
{:else if condition.field === 'favTime' || condition.field === 'pubTime'}
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
placeholder="选择时间"
|
||||||
|
class="h-9"
|
||||||
|
value={condition.value}
|
||||||
|
oninput={(e) =>
|
||||||
|
updateCondition(
|
||||||
|
groupIndex,
|
||||||
|
conditionIndex,
|
||||||
|
'value',
|
||||||
|
e.currentTarget.value + ':00'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="输入文本"
|
||||||
|
class="h-9"
|
||||||
|
value={condition.value}
|
||||||
|
oninput={(e) =>
|
||||||
|
updateCondition(groupIndex, conditionIndex, 'value', e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => addCondition(groupIndex)}
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<PlusIcon class="mr-1 h-3 w-3" />
|
||||||
|
添加条件
|
||||||
|
</Button>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if localRule.length > 0}
|
||||||
|
<div class="text-muted-foreground bg-muted/50 rounded p-3 text-xs">
|
||||||
|
<p class="mb-1 font-medium">规则说明:</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li>• 多个规则组之间是"或"的关系,同一规则组内的条件是"且"的关系</li>
|
||||||
|
<li>
|
||||||
|
• 规则内配置的时间不包含时区,在处理时会默认应用<strong>服务器时区</strong
|
||||||
|
>,不受浏览器影响
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
import Content from './popover-content.svelte';
|
||||||
|
import Trigger from './popover-trigger.svelte';
|
||||||
|
const Root = PopoverPrimitive.Root;
|
||||||
|
const Close = PopoverPrimitive.Close;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Trigger,
|
||||||
|
Close,
|
||||||
|
//
|
||||||
|
Root as Popover,
|
||||||
|
Content as PopoverContent,
|
||||||
|
Trigger as PopoverTrigger,
|
||||||
|
Close as PopoverClose
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
sideOffset = 4,
|
||||||
|
align = 'center',
|
||||||
|
portalProps,
|
||||||
|
...restProps
|
||||||
|
}: PopoverPrimitive.ContentProps & {
|
||||||
|
portalProps?: PopoverPrimitive.PortalProps;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Portal {...portalProps}>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="popover-content"
|
||||||
|
{sideOffset}
|
||||||
|
{align}
|
||||||
|
class={cn(
|
||||||
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: PopoverPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Trigger
|
||||||
|
bind:ref
|
||||||
|
data-slot="popover-trigger"
|
||||||
|
class={cn('', className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -168,11 +168,27 @@ export interface InsertSubmissionRequest {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rule 相关类型
|
||||||
|
export interface Condition<T> {
|
||||||
|
operator: string;
|
||||||
|
value: T | T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleTarget<T> {
|
||||||
|
field: string;
|
||||||
|
rule: Condition<T> | RuleTarget<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AndGroup = RuleTarget<string | number | Date>[];
|
||||||
|
export type Rule = AndGroup[];
|
||||||
|
|
||||||
// 视频源详细信息类型
|
// 视频源详细信息类型
|
||||||
export interface VideoSourceDetail {
|
export interface VideoSourceDetail {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
rule?: Rule | null;
|
||||||
|
ruleDisplay?: string | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +204,7 @@ export interface VideoSourcesDetailsResponse {
|
|||||||
export interface UpdateVideoSourceRequest {
|
export interface UpdateVideoSourceRequest {
|
||||||
path: string;
|
path: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
rule?: Rule | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 配置相关类型
|
// 配置相关类型
|
||||||
@@ -295,3 +312,7 @@ export interface TaskStatus {
|
|||||||
last_finish: Date | null;
|
last_finish: Date | null;
|
||||||
next_run: Date | null;
|
next_run: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateVideoSourceResponse {
|
||||||
|
ruleDisplay?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,17 +8,17 @@
|
|||||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||||
import EditIcon from '@lucide/svelte/icons/edit';
|
import EditIcon from '@lucide/svelte/icons/edit';
|
||||||
import SaveIcon from '@lucide/svelte/icons/save';
|
|
||||||
import XIcon from '@lucide/svelte/icons/x';
|
|
||||||
import FolderIcon from '@lucide/svelte/icons/folder';
|
import FolderIcon from '@lucide/svelte/icons/folder';
|
||||||
import HeartIcon from '@lucide/svelte/icons/heart';
|
import HeartIcon from '@lucide/svelte/icons/heart';
|
||||||
import UserIcon from '@lucide/svelte/icons/user';
|
import UserIcon from '@lucide/svelte/icons/user';
|
||||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||||
|
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { setBreadcrumb } from '$lib/stores/breadcrumb';
|
import { setBreadcrumb } from '$lib/stores/breadcrumb';
|
||||||
import type { ApiError, VideoSourceDetail, VideoSourcesDetailsResponse } from '$lib/types';
|
import type { ApiError, VideoSourceDetail, VideoSourcesDetailsResponse, Rule } from '$lib/types';
|
||||||
import api from '$lib/api';
|
import api from '$lib/api';
|
||||||
|
import RuleEditor from '$lib/components/rule-editor.svelte';
|
||||||
|
|
||||||
let videoSourcesData: VideoSourcesDetailsResponse | null = null;
|
let videoSourcesData: VideoSourcesDetailsResponse | null = null;
|
||||||
let loading = false;
|
let loading = false;
|
||||||
@@ -29,19 +29,25 @@
|
|||||||
let addDialogType: 'favorites' | 'collections' | 'submissions' = 'favorites';
|
let addDialogType: 'favorites' | 'collections' | 'submissions' = 'favorites';
|
||||||
let adding = false;
|
let adding = false;
|
||||||
|
|
||||||
|
// 编辑对话框状态
|
||||||
|
let showEditDialog = false;
|
||||||
|
let editingSource: VideoSourceDetail | null = null;
|
||||||
|
let editingType = '';
|
||||||
|
let editingIdx: number = 0;
|
||||||
|
let saving = false;
|
||||||
|
|
||||||
|
// 编辑表单数据
|
||||||
|
let editForm = {
|
||||||
|
path: '',
|
||||||
|
enabled: false,
|
||||||
|
rule: null as Rule | null
|
||||||
|
};
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
let favoriteForm = { fid: '', path: '' };
|
let favoriteForm = { fid: '', path: '' };
|
||||||
let collectionForm = { sid: '', mid: '', collection_type: '2', path: '' }; // 默认为合集
|
let collectionForm = { sid: '', mid: '', collection_type: '2', path: '' }; // 默认为合集
|
||||||
let submissionForm = { upper_id: '', path: '' };
|
let submissionForm = { upper_id: '', path: '' };
|
||||||
|
|
||||||
type ExtendedVideoSource = VideoSourceDetail & {
|
|
||||||
type: string;
|
|
||||||
originalIndex: number;
|
|
||||||
editing?: boolean;
|
|
||||||
editingPath?: string;
|
|
||||||
editingEnabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TAB_CONFIG = {
|
const TAB_CONFIG = {
|
||||||
favorites: { label: '收藏夹', icon: HeartIcon },
|
favorites: { label: '收藏夹', icon: HeartIcon },
|
||||||
collections: { label: '合集 / 列表', icon: FolderIcon },
|
collections: { label: '合集 / 列表', icon: FolderIcon },
|
||||||
@@ -64,75 +70,65 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit(type: string, index: number) {
|
// 打开编辑对话框
|
||||||
if (!videoSourcesData) return;
|
function openEditDialog(type: string, source: VideoSourceDetail, idx: number) {
|
||||||
const sources = videoSourcesData[type as keyof VideoSourcesDetailsResponse];
|
editingSource = source;
|
||||||
if (!sources?.[index]) return;
|
editingType = type;
|
||||||
|
editingIdx = idx;
|
||||||
const source = sources[index] as ExtendedVideoSource;
|
editForm = {
|
||||||
source.editing = true;
|
path: source.path,
|
||||||
source.editingPath = source.path;
|
enabled: source.enabled,
|
||||||
source.editingEnabled = source.enabled;
|
rule: source.rule || null
|
||||||
videoSourcesData = { ...videoSourcesData };
|
};
|
||||||
|
showEditDialog = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit(type: string, index: number) {
|
// 保存编辑
|
||||||
if (!videoSourcesData) return;
|
async function saveEdit() {
|
||||||
const sources = videoSourcesData[type as keyof VideoSourcesDetailsResponse];
|
if (!editingSource) return;
|
||||||
if (!sources?.[index]) return;
|
|
||||||
|
|
||||||
const source = sources[index] as ExtendedVideoSource;
|
if (!editForm.path?.trim()) {
|
||||||
source.editing = false;
|
|
||||||
source.editingPath = undefined;
|
|
||||||
source.editingEnabled = undefined;
|
|
||||||
videoSourcesData = { ...videoSourcesData };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEdit(type: string, index: number) {
|
|
||||||
if (!videoSourcesData) return;
|
|
||||||
const sources = videoSourcesData[type as keyof VideoSourcesDetailsResponse];
|
|
||||||
if (!sources?.[index]) return;
|
|
||||||
|
|
||||||
const source = sources[index] as ExtendedVideoSource;
|
|
||||||
if (!source.editingPath?.trim()) {
|
|
||||||
toast.error('路径不能为空');
|
toast.error('路径不能为空');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saving = true;
|
||||||
try {
|
try {
|
||||||
await api.updateVideoSource(type, source.id, {
|
let response = await api.updateVideoSource(editingType, editingSource.id, {
|
||||||
path: source.editingPath,
|
path: editForm.path,
|
||||||
enabled: source.editingEnabled ?? false
|
enabled: editForm.enabled,
|
||||||
|
rule: editForm.rule
|
||||||
});
|
});
|
||||||
|
|
||||||
source.path = source.editingPath;
|
// 更新本地数据
|
||||||
source.enabled = source.editingEnabled ?? false;
|
if (videoSourcesData && editingSource) {
|
||||||
source.editing = false;
|
const sources = videoSourcesData[
|
||||||
source.editingPath = undefined;
|
editingType as keyof VideoSourcesDetailsResponse
|
||||||
source.editingEnabled = undefined;
|
] as VideoSourceDetail[];
|
||||||
videoSourcesData = { ...videoSourcesData };
|
sources[editingIdx] = {
|
||||||
|
...sources[editingIdx],
|
||||||
|
path: editForm.path,
|
||||||
|
enabled: editForm.enabled,
|
||||||
|
rule: editForm.rule,
|
||||||
|
ruleDisplay: response.data.ruleDisplay
|
||||||
|
};
|
||||||
|
videoSourcesData = { ...videoSourcesData };
|
||||||
|
}
|
||||||
|
|
||||||
|
showEditDialog = false;
|
||||||
toast.success('保存成功');
|
toast.success('保存成功');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('保存失败', {
|
toast.error('保存失败', {
|
||||||
description: (error as ApiError).message
|
description: (error as ApiError).message
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSourcesForTab(tabValue: string): ExtendedVideoSource[] {
|
function getSourcesForTab(tabValue: string): VideoSourceDetail[] {
|
||||||
if (!videoSourcesData) return [];
|
if (!videoSourcesData) return [];
|
||||||
const sources = videoSourcesData[
|
return videoSourcesData[tabValue as keyof VideoSourcesDetailsResponse] as VideoSourceDetail[];
|
||||||
tabValue as keyof VideoSourcesDetailsResponse
|
|
||||||
] as VideoSourceDetail[];
|
|
||||||
// 直接返回原始数据的引用,只添加必要的属性
|
|
||||||
return sources.map((source, originalIndex) => {
|
|
||||||
// 使用类型断言来扩展 VideoSourceDetail
|
|
||||||
const extendedSource = source as ExtendedVideoSource;
|
|
||||||
extendedSource.type = tabValue;
|
|
||||||
extendedSource.originalIndex = originalIndex;
|
|
||||||
return extendedSource;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开添加对话框
|
// 打开添加对话框
|
||||||
@@ -238,80 +234,60 @@
|
|||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head class="w-[30%] md:w-[25%]">名称</Table.Head>
|
<Table.Head class="w-[25%]">名称</Table.Head>
|
||||||
<Table.Head class="w-[30%] md:w-[40%]">下载路径</Table.Head>
|
<Table.Head class="w-[35%]">下载路径</Table.Head>
|
||||||
<Table.Head class="w-[25%] md:w-[20%]">状态</Table.Head>
|
<Table.Head class="w-[15%]">筛选规则</Table.Head>
|
||||||
<Table.Head class="w-[15%] text-right sm:w-[12%]">操作</Table.Head>
|
<Table.Head class="w-[15%]">状态</Table.Head>
|
||||||
|
<Table.Head class="w-[10%] text-right">操作</Table.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each sources as source, index (index)}
|
{#each sources as source, index (index)}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell class="w-[30%] font-medium md:w-[25%]">{source.name}</Table.Cell>
|
<Table.Cell class="font-medium">{source.name}</Table.Cell>
|
||||||
<Table.Cell class="w-[30%] md:w-[40%]">
|
<Table.Cell>
|
||||||
{#if source.editing}
|
<code
|
||||||
<input
|
class="bg-muted text-muted-foreground inline-flex h-8 items-center rounded px-3 py-1 text-sm"
|
||||||
bind:value={source.editingPath}
|
>
|
||||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-8 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
{source.path || '未设置'}
|
||||||
placeholder="输入下载路径"
|
</code>
|
||||||
/>
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{#if source.rule && source.rule.length > 0}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger>
|
||||||
|
<div class="rounded bg-blue-100 px-2 py-1 text-xs text-blue-800">
|
||||||
|
{source.rule.length} 条规则
|
||||||
|
</div>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content>
|
||||||
|
<p class="text-xs">{source.ruleDisplay}</p>
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<code
|
<span class="text-muted-foreground text-sm"></span>
|
||||||
class="bg-muted text-muted-foreground inline-flex h-8 items-center rounded px-3 py-1 text-sm"
|
|
||||||
>
|
|
||||||
{source.path || '未设置'}
|
|
||||||
</code>
|
|
||||||
{/if}
|
{/if}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="w-[25%] md:w-[20%]">
|
<Table.Cell>
|
||||||
{#if source.editing}
|
<div class="flex h-8 items-center gap-2">
|
||||||
<div class="flex h-8 items-center">
|
<Switch checked={source.enabled} disabled />
|
||||||
<Switch bind:checked={source.editingEnabled} />
|
<span class="text-muted-foreground text-sm whitespace-nowrap">
|
||||||
</div>
|
{source.enabled ? '已启用' : '已禁用'}
|
||||||
{:else}
|
</span>
|
||||||
<div class="flex h-8 items-center gap-2">
|
</div>
|
||||||
<Switch checked={source.enabled} disabled />
|
|
||||||
<span class="text-muted-foreground text-sm whitespace-nowrap">
|
|
||||||
{source.enabled ? '已启用' : '已禁用'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="w-[15%] text-right sm:w-[12%]">
|
<Table.Cell class="text-right">
|
||||||
{#if source.editing}
|
<Button
|
||||||
<div
|
size="sm"
|
||||||
class="flex flex-col items-end justify-end gap-1 sm:flex-row sm:items-center"
|
variant="outline"
|
||||||
>
|
onclick={() => openEditDialog(key, source, index)}
|
||||||
<Button
|
class="h-8 w-8 p-0"
|
||||||
size="sm"
|
title="编辑"
|
||||||
variant="outline"
|
>
|
||||||
onclick={() => saveEdit(key, source.originalIndex)}
|
<EditIcon class="h-3 w-3" />
|
||||||
class="h-7 w-7 p-0 sm:h-8 sm:w-8"
|
</Button>
|
||||||
title="保存"
|
|
||||||
>
|
|
||||||
<SaveIcon class="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onclick={() => cancelEdit(key, source.originalIndex)}
|
|
||||||
class="h-7 w-7 p-0 sm:h-8 sm:w-8"
|
|
||||||
title="取消"
|
|
||||||
>
|
|
||||||
<XIcon class="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onclick={() => startEdit(key, source.originalIndex)}
|
|
||||||
class="h-7 w-7 p-0 sm:h-8 sm:w-8"
|
|
||||||
title="编辑"
|
|
||||||
>
|
|
||||||
<EditIcon class="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -352,11 +328,50 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- 编辑对话框 -->
|
||||||
|
<Dialog.Root bind:open={showEditDialog}>
|
||||||
|
<Dialog.Content class="max-h-[85vh] w-4xl !max-w-none overflow-y-auto">
|
||||||
|
<Dialog.Title class="text-lg font-semibold">
|
||||||
|
编辑视频源: {editingSource?.name || ''}
|
||||||
|
</Dialog.Title>
|
||||||
|
<div class="mt-6 space-y-6">
|
||||||
|
<!-- 下载路径 -->
|
||||||
|
<div>
|
||||||
|
<Label for="edit-path" class="text-sm font-medium">下载路径</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-path"
|
||||||
|
type="text"
|
||||||
|
bind:value={editForm.path}
|
||||||
|
placeholder="请输入下载路径,例如:/path/to/download"
|
||||||
|
class="mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 启用状态 -->
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Switch bind:checked={editForm.enabled} />
|
||||||
|
<Label class="text-sm font-medium">启用此视频源</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 规则编辑器 -->
|
||||||
|
<div>
|
||||||
|
<RuleEditor rule={editForm.rule} onRuleChange={(rule) => (editForm.rule = rule)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-8 flex justify-end gap-3">
|
||||||
|
<Button variant="outline" onclick={() => (showEditDialog = false)} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onclick={saveEdit} disabled={saving}>
|
||||||
|
{saving ? '保存中...' : '保存'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<!-- 添加对话框 -->
|
||||||
<Dialog.Root bind:open={showAddDialog}>
|
<Dialog.Root bind:open={showAddDialog}>
|
||||||
<Dialog.Overlay class="data-[state=open]:animate-overlay-show fixed inset-0 bg-black/30" />
|
<Dialog.Content>
|
||||||
<Dialog.Content
|
|
||||||
class="data-[state=open]:animate-content-show bg-background fixed top-1/2 left-1/2 z-50 max-h-[85vh] w-full max-w-3xl -translate-x-1/2 -translate-y-1/2 rounded-lg border p-6 shadow-md outline-none"
|
|
||||||
>
|
|
||||||
<Dialog.Title class="text-lg font-semibold">
|
<Dialog.Title class="text-lg font-semibold">
|
||||||
{#if addDialogType === 'favorites'}
|
{#if addDialogType === 'favorites'}
|
||||||
添加收藏夹
|
添加收藏夹
|
||||||
|
|||||||
Reference in New Issue
Block a user