feat: 实现视频的筛选规则 (#457)
This commit is contained in:
14
Cargo.lock
generated
14
Cargo.lock
generated
@@ -533,7 +533,10 @@ dependencies = [
|
||||
name = "bili_sync_entity"
|
||||
version = "2.6.3"
|
||||
dependencies = [
|
||||
"derivative",
|
||||
"regex",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -1034,6 +1037,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
|
||||
@@ -29,6 +29,7 @@ clap = { version = "4.5.41", features = ["env", "string"] }
|
||||
cookie = "0.18.1"
|
||||
cow-utils = "0.1.3"
|
||||
dashmap = "6.1.0"
|
||||
derivative = "2.2.0"
|
||||
dirs = "6.0.0"
|
||||
enum_dispatch = "0.3.13"
|
||||
float-ord = "0.3.2"
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use chrono::Utc;
|
||||
use futures::Stream;
|
||||
@@ -63,6 +64,10 @@ impl VideoSource for collection::Model {
|
||||
None
|
||||
}
|
||||
|
||||
fn rule(&self) -> Option<&Rule> {
|
||||
self.rule.as_ref()
|
||||
}
|
||||
|
||||
async fn refresh<'a>(
|
||||
self,
|
||||
bili_client: &'a BiliClient,
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use futures::Stream;
|
||||
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>(
|
||||
self,
|
||||
bili_client: &'a BiliClient,
|
||||
|
||||
@@ -19,6 +19,7 @@ use sea_orm::sea_query::SimpleExpr;
|
||||
#[rustfmt::skip]
|
||||
use bili_sync_entity::collection::Model as Collection;
|
||||
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::watch_later::Model as WatchLater;
|
||||
|
||||
@@ -68,6 +69,8 @@ pub trait VideoSource {
|
||||
video_info.ok()
|
||||
}
|
||||
|
||||
fn rule(&self) -> Option<&Rule>;
|
||||
|
||||
fn log_refresh_video_start(&self) {
|
||||
info!("开始扫描{}..", self.display_name());
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use futures::Stream;
|
||||
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>(
|
||||
self,
|
||||
bili_client: &'a BiliClient,
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::Result;
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use futures::Stream;
|
||||
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>(
|
||||
self,
|
||||
bili_client: &'a BiliClient,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use serde::Deserialize;
|
||||
use validator::Validate;
|
||||
|
||||
@@ -86,4 +87,5 @@ pub struct UpdateVideoSourceRequest {
|
||||
#[validate(custom(function = "crate::utils::validation::validate_path"))]
|
||||
pub path: String,
|
||||
pub enabled: bool,
|
||||
pub rule: Option<Rule>,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use bili_sync_entity::rule::Rule;
|
||||
use bili_sync_entity::*;
|
||||
use sea_orm::{DerivePartialModel, FromQueryResult};
|
||||
use serde::Serialize;
|
||||
@@ -169,9 +170,19 @@ pub struct SysInfo {
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromQueryResult)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VideoSourceDetail {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub rule: Option<Rule>,
|
||||
#[serde(default)]
|
||||
pub rule_display: Option<String>,
|
||||
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::{
|
||||
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::bilibili::{BiliClient, Collection, CollectionItem, FavoriteList, Submission};
|
||||
|
||||
@@ -75,13 +77,14 @@ pub async fn get_video_sources(
|
||||
pub async fn get_video_sources_details(
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
) -> 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()
|
||||
.select_only()
|
||||
.columns([
|
||||
collection::Column::Id,
|
||||
collection::Column::Name,
|
||||
collection::Column::Path,
|
||||
collection::Column::Rule,
|
||||
collection::Column::Enabled
|
||||
])
|
||||
.into_model::<VideoSourceDetail>()
|
||||
@@ -92,22 +95,31 @@ pub async fn get_video_sources_details(
|
||||
favorite::Column::Id,
|
||||
favorite::Column::Name,
|
||||
favorite::Column::Path,
|
||||
favorite::Column::Rule,
|
||||
favorite::Column::Enabled
|
||||
])
|
||||
.into_model::<VideoSourceDetail>()
|
||||
.all(&db),
|
||||
submission::Entity::find()
|
||||
.select_only()
|
||||
.column(submission::Column::Id)
|
||||
.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>()
|
||||
.all(&db),
|
||||
watch_later::Entity::find()
|
||||
.select_only()
|
||||
.column(watch_later::Column::Id)
|
||||
.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>()
|
||||
.all(&db)
|
||||
)?;
|
||||
@@ -116,9 +128,18 @@ pub async fn get_video_sources_details(
|
||||
id: 1,
|
||||
name: "稍后再看".to_string(),
|
||||
path: String::new(),
|
||||
rule: None,
|
||||
rule_display: None,
|
||||
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 {
|
||||
collections,
|
||||
favorites,
|
||||
@@ -132,24 +153,28 @@ pub async fn update_video_source(
|
||||
Path((source_type, id)): Path<(String, i32)>,
|
||||
Extension(db): Extension<DatabaseConnection>,
|
||||
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() {
|
||||
"collections" => collection::Entity::find_by_id(id).one(&db).await?.map(|model| {
|
||||
let mut active_model: collection::ActiveModel = model.into();
|
||||
active_model.path = Set(request.path);
|
||||
active_model.enabled = Set(request.enabled);
|
||||
active_model.rule = Set(request.rule);
|
||||
_ActiveModel::Collection(active_model)
|
||||
}),
|
||||
"favorites" => favorite::Entity::find_by_id(id).one(&db).await?.map(|model| {
|
||||
let mut active_model: favorite::ActiveModel = model.into();
|
||||
active_model.path = Set(request.path);
|
||||
active_model.enabled = Set(request.enabled);
|
||||
active_model.rule = Set(request.rule);
|
||||
_ActiveModel::Favorite(active_model)
|
||||
}),
|
||||
"submissions" => submission::Entity::find_by_id(id).one(&db).await?.map(|model| {
|
||||
let mut active_model: submission::ActiveModel = model.into();
|
||||
active_model.path = Set(request.path);
|
||||
active_model.enabled = Set(request.enabled);
|
||||
active_model.rule = Set(request.rule);
|
||||
_ActiveModel::Submission(active_model)
|
||||
}),
|
||||
"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();
|
||||
active_model.path = Set(request.path);
|
||||
active_model.enabled = Set(request.enabled);
|
||||
active_model.rule = Set(request.rule);
|
||||
Some(_ActiveModel::WatchLater(active_model))
|
||||
}
|
||||
None => {
|
||||
@@ -170,6 +196,7 @@ pub async fn update_video_source(
|
||||
Some(_ActiveModel::WatchLater(watch_later::ActiveModel {
|
||||
path: Set(request.path),
|
||||
enabled: Set(request.enabled),
|
||||
rule: Set(request.rule),
|
||||
..Default::default()
|
||||
}))
|
||||
}
|
||||
@@ -181,7 +208,7 @@ pub async fn update_video_source(
|
||||
return Err(InnerApiError::NotFound(id).into());
|
||||
};
|
||||
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),
|
||||
name: Set(favorite_info.title.clone()),
|
||||
path: Set(request.path),
|
||||
enabled: Set(true),
|
||||
enabled: Set(false),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&db)
|
||||
@@ -225,7 +252,7 @@ pub async fn insert_collection(
|
||||
r#type: Set(collection_info.collection_type.into()),
|
||||
name: Set(collection_info.name.clone()),
|
||||
path: Set(request.path),
|
||||
enabled: Set(true),
|
||||
enabled: Set(false),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&db)
|
||||
@@ -246,7 +273,7 @@ pub async fn insert_submission(
|
||||
upper_id: Set(upper.mid.parse()?),
|
||||
upper_name: Set(upper.name),
|
||||
path: Set(request.path),
|
||||
enabled: Set(true),
|
||||
enabled: Set(false),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&db)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use anyhow::{Result, ensure};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use futures::TryStreamExt;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use prost::Message;
|
||||
@@ -16,19 +16,6 @@ pub struct Video<'a> {
|
||||
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)]
|
||||
pub struct PageInfo {
|
||||
pub cid: i64,
|
||||
@@ -84,8 +71,8 @@ impl<'a> Video<'a> {
|
||||
Ok(serde_json::from_value(res["data"].take())?)
|
||||
}
|
||||
|
||||
pub async fn get_tags(&self) -> Result<Vec<Tag>> {
|
||||
let mut res = self
|
||||
pub async fn get_tags(&self) -> Result<Vec<String>> {
|
||||
let res = self
|
||||
.client
|
||||
.request(Method::GET, "https://api.bilibili.com/x/web-interface/view/detail/tag")
|
||||
.await
|
||||
@@ -96,7 +83,12 @@ impl<'a> Video<'a> {
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.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> {
|
||||
|
||||
@@ -124,7 +124,7 @@ impl VideoInfo {
|
||||
ctime: Set(ctime.naive_utc()),
|
||||
pubtime: Set(pubtime.naive_utc()),
|
||||
favtime: if base_model.favtime != NaiveDateTime::default() {
|
||||
NotSet // 之前设置了 favtime,不覆盖
|
||||
Set(base_model.favtime) // 之前设置了 favtime,使用之前的值(等价于 unset,但设置上以支持后续的规则匹配)
|
||||
} else {
|
||||
Set(pubtime.naive_utc()) // 未设置过 favtime,使用 pubtime 填充
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod filenamify;
|
||||
pub mod format_arg;
|
||||
pub mod model;
|
||||
pub mod nfo;
|
||||
pub mod rule;
|
||||
pub mod signal;
|
||||
pub mod status;
|
||||
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::Category.eq(2))
|
||||
.and(video::Column::SinglePage.is_not_null())
|
||||
.and(video::Column::ShouldDownload.eq(true))
|
||||
.and(additional_expr),
|
||||
)
|
||||
.find_with_related(page::Entity)
|
||||
|
||||
@@ -261,7 +261,7 @@ mod tests {
|
||||
chrono::NaiveTime::from_hms_opt(3, 3, 3).unwrap(),
|
||||
),
|
||||
bvid: "BV1nWcSeeEkV".to_string(),
|
||||
tags: Some(serde_json::json!(["tag1", "tag2"])),
|
||||
tags: Some(vec!["tag1".to_owned(), "tag2".to_owned()].into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
@@ -343,10 +343,7 @@ impl<'a> From<&'a video::Model> for Movie<'a> {
|
||||
NFOTimeType::FavTime => video.favtime,
|
||||
NFOTimeType::PubTime => video.pubtime,
|
||||
},
|
||||
tags: video
|
||||
.tags
|
||||
.as_ref()
|
||||
.and_then(|tags| serde_json::from_value(tags.clone()).ok()),
|
||||
tags: video.tags.as_ref().map(|tags| tags.clone().into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,10 +360,7 @@ impl<'a> From<&'a video::Model> for TVShow<'a> {
|
||||
NFOTimeType::FavTime => video.favtime,
|
||||
NFOTimeType::PubTime => video.pubtime,
|
||||
},
|
||||
tags: video
|
||||
.tags
|
||||
.as_ref()
|
||||
.and_then(|tags| serde_json::from_value(tags.clone()).ok()),
|
||||
tags: video.tags.as_ref().map(|tags| tags.clone().into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
224
crates/bili_sync/src/utils/rule.rs
Normal file
224
crates/bili_sync/src/utils/rule.rs
Normal file
@@ -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>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
mod tests {
|
||||
use anyhow::anyhow;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::utils::model::{
|
||||
update_videos_model,
|
||||
};
|
||||
use crate::utils::nfo::NFO;
|
||||
use crate::utils::rule::FieldEvaluatable;
|
||||
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);
|
||||
video_source.set_relation_id(&mut video_active_model);
|
||||
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?;
|
||||
create_pages(pages, &txn).await?;
|
||||
video_active_model.save(&txn).await?;
|
||||
|
||||
@@ -5,5 +5,8 @@ edition = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
derivative = { workspace = true }
|
||||
sea-orm = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
2
crates/bili_sync_entity/src/custom_type/mod.rs
Normal file
2
crates/bili_sync_entity/src/custom_type/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod rule;
|
||||
pub mod string_vec;
|
||||
120
crates/bili_sync_entity/src/custom_type/rule.rs
Normal file
120
crates/bili_sync_entity/src/custom_type/rule.rs
Normal file
@@ -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)
|
||||
}
|
||||
20
crates/bili_sync_entity/src/custom_type/string_vec.rs
Normal file
20
crates/bili_sync_entity/src/custom_type/string_vec.rs
Normal file
@@ -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 crate::rule::Rule;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "collection")]
|
||||
pub struct Model {
|
||||
@@ -14,6 +16,7 @@ pub struct Model {
|
||||
pub path: String,
|
||||
pub created_at: String,
|
||||
pub latest_row_at: DateTime,
|
||||
pub rule: Option<Rule>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use crate::rule::Rule;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "favorite")]
|
||||
pub struct Model {
|
||||
@@ -13,6 +15,7 @@ pub struct Model {
|
||||
pub path: String,
|
||||
pub created_at: String,
|
||||
pub latest_row_at: DateTime,
|
||||
pub rule: Option<Rule>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use crate::rule::Rule;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "submission")]
|
||||
pub struct Model {
|
||||
@@ -12,6 +14,7 @@ pub struct Model {
|
||||
pub path: String,
|
||||
pub created_at: String,
|
||||
pub latest_row_at: DateTime,
|
||||
pub rule: Option<Rule>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use crate::string_vec::StringVec;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Default)]
|
||||
#[sea_orm(table_name = "video")]
|
||||
pub struct Model {
|
||||
@@ -25,7 +27,8 @@ pub struct Model {
|
||||
pub favtime: DateTime,
|
||||
pub download_status: u32,
|
||||
pub valid: bool,
|
||||
pub tags: Option<serde_json::Value>,
|
||||
pub should_download: bool,
|
||||
pub tags: Option<StringVec>,
|
||||
pub single_page: Option<bool>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use crate::rule::Rule;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "watch_later")]
|
||||
pub struct Model {
|
||||
@@ -10,6 +12,7 @@ pub struct Model {
|
||||
pub path: String,
|
||||
pub created_at: String,
|
||||
pub latest_row_at: DateTime,
|
||||
pub rule: Option<Rule>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
mod custom_type;
|
||||
mod entities;
|
||||
|
||||
pub use custom_type::*;
|
||||
pub use entities::*;
|
||||
|
||||
@@ -8,6 +8,7 @@ mod m20250122_062926_add_latest_row_at;
|
||||
mod m20250612_090826_add_enabled;
|
||||
mod m20250613_043257_add_config;
|
||||
mod m20250712_080013_add_video_created_at_index;
|
||||
mod m20250903_094454_add_rule_and_should_download;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -23,6 +24,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20250612_090826_add_enabled::Migration),
|
||||
Box::new(m20250613_043257_add_config::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,
|
||||
}
|
||||
14
web/bun.lock
14
web/bun.lock
@@ -7,7 +7,7 @@
|
||||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@internationalized/date": "^3.8.1",
|
||||
"@lucide/svelte": "^0.525.0",
|
||||
"@lucide/svelte": "^0.544.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "2.22.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
@@ -16,7 +16,7 @@
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-shape": "^3.1.7",
|
||||
"bits-ui": "^2.8.6",
|
||||
"bits-ui": "^2.11.0",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint": "^9.18.0",
|
||||
"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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -301,7 +301,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -615,7 +615,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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-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=="],
|
||||
|
||||
@@ -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-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=="],
|
||||
|
||||
"vite/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@internationalized/date": "^3.8.1",
|
||||
"@lucide/svelte": "^0.525.0",
|
||||
"@lucide/svelte": "^0.544.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "2.22.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
@@ -14,7 +14,7 @@
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-shape": "^3.1.7",
|
||||
"bits-ui": "^2.8.6",
|
||||
"bits-ui": "^2.11.0",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
|
||||
@@ -21,7 +21,8 @@ import type {
|
||||
DashBoardResponse,
|
||||
SysInfo,
|
||||
TaskStatus,
|
||||
ResetRequest
|
||||
ResetRequest,
|
||||
UpdateVideoSourceResponse
|
||||
} from './types';
|
||||
import { wsManager } from './ws';
|
||||
|
||||
@@ -212,8 +213,8 @@ class ApiClient {
|
||||
type: string,
|
||||
id: number,
|
||||
request: UpdateVideoSourceRequest
|
||||
): Promise<ApiResponse<boolean>> {
|
||||
return this.put<boolean>(`/video-sources/${type}/${id}`, request);
|
||||
): Promise<ApiResponse<UpdateVideoSourceResponse>> {
|
||||
return this.put<UpdateVideoSourceResponse>(`/video-sources/${type}/${id}`, request);
|
||||
}
|
||||
|
||||
async getConfig(): Promise<ApiResponse<Config>> {
|
||||
|
||||
479
web/src/lib/components/rule-editor.svelte
Normal file
479
web/src/lib/components/rule-editor.svelte
Normal file
@@ -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>
|
||||
17
web/src/lib/components/ui/popover/index.ts
Normal file
17
web/src/lib/components/ui/popover/index.ts
Normal file
@@ -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
|
||||
};
|
||||
29
web/src/lib/components/ui/popover/popover-content.svelte
Normal file
29
web/src/lib/components/ui/popover/popover-content.svelte
Normal file
@@ -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>
|
||||
17
web/src/lib/components/ui/popover/popover-trigger.svelte
Normal file
17
web/src/lib/components/ui/popover/popover-trigger.svelte
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
rule?: Rule | null;
|
||||
ruleDisplay?: string | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -188,6 +204,7 @@ export interface VideoSourcesDetailsResponse {
|
||||
export interface UpdateVideoSourceRequest {
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
rule?: Rule | null;
|
||||
}
|
||||
|
||||
// 配置相关类型
|
||||
@@ -295,3 +312,7 @@ export interface TaskStatus {
|
||||
last_finish: 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 Dialog from '$lib/components/ui/dialog/index.js';
|
||||
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 HeartIcon from '@lucide/svelte/icons/heart';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
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 RuleEditor from '$lib/components/rule-editor.svelte';
|
||||
|
||||
let videoSourcesData: VideoSourcesDetailsResponse | null = null;
|
||||
let loading = false;
|
||||
@@ -29,19 +29,25 @@
|
||||
let addDialogType: 'favorites' | 'collections' | 'submissions' = 'favorites';
|
||||
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 collectionForm = { sid: '', mid: '', collection_type: '2', path: '' }; // 默认为合集
|
||||
let submissionForm = { upper_id: '', path: '' };
|
||||
|
||||
type ExtendedVideoSource = VideoSourceDetail & {
|
||||
type: string;
|
||||
originalIndex: number;
|
||||
editing?: boolean;
|
||||
editingPath?: string;
|
||||
editingEnabled?: boolean;
|
||||
};
|
||||
|
||||
const TAB_CONFIG = {
|
||||
favorites: { label: '收藏夹', icon: HeartIcon },
|
||||
collections: { label: '合集 / 列表', icon: FolderIcon },
|
||||
@@ -64,75 +70,65 @@
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(type: string, index: number) {
|
||||
if (!videoSourcesData) return;
|
||||
const sources = videoSourcesData[type as keyof VideoSourcesDetailsResponse];
|
||||
if (!sources?.[index]) return;
|
||||
|
||||
const source = sources[index] as ExtendedVideoSource;
|
||||
source.editing = true;
|
||||
source.editingPath = source.path;
|
||||
source.editingEnabled = source.enabled;
|
||||
videoSourcesData = { ...videoSourcesData };
|
||||
// 打开编辑对话框
|
||||
function openEditDialog(type: string, source: VideoSourceDetail, idx: number) {
|
||||
editingSource = source;
|
||||
editingType = type;
|
||||
editingIdx = idx;
|
||||
editForm = {
|
||||
path: source.path,
|
||||
enabled: source.enabled,
|
||||
rule: source.rule || null
|
||||
};
|
||||
showEditDialog = true;
|
||||
}
|
||||
|
||||
function cancelEdit(type: string, index: number) {
|
||||
if (!videoSourcesData) return;
|
||||
const sources = videoSourcesData[type as keyof VideoSourcesDetailsResponse];
|
||||
if (!sources?.[index]) return;
|
||||
// 保存编辑
|
||||
async function saveEdit() {
|
||||
if (!editingSource) return;
|
||||
|
||||
const source = sources[index] as ExtendedVideoSource;
|
||||
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()) {
|
||||
if (!editForm.path?.trim()) {
|
||||
toast.error('路径不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await api.updateVideoSource(type, source.id, {
|
||||
path: source.editingPath,
|
||||
enabled: source.editingEnabled ?? false
|
||||
let response = await api.updateVideoSource(editingType, editingSource.id, {
|
||||
path: editForm.path,
|
||||
enabled: editForm.enabled,
|
||||
rule: editForm.rule
|
||||
});
|
||||
|
||||
source.path = source.editingPath;
|
||||
source.enabled = source.editingEnabled ?? false;
|
||||
source.editing = false;
|
||||
source.editingPath = undefined;
|
||||
source.editingEnabled = undefined;
|
||||
videoSourcesData = { ...videoSourcesData };
|
||||
// 更新本地数据
|
||||
if (videoSourcesData && editingSource) {
|
||||
const sources = videoSourcesData[
|
||||
editingType as keyof VideoSourcesDetailsResponse
|
||||
] as VideoSourceDetail[];
|
||||
sources[editingIdx] = {
|
||||
...sources[editingIdx],
|
||||
path: editForm.path,
|
||||
enabled: editForm.enabled,
|
||||
rule: editForm.rule,
|
||||
ruleDisplay: response.data.ruleDisplay
|
||||
};
|
||||
videoSourcesData = { ...videoSourcesData };
|
||||
}
|
||||
|
||||
showEditDialog = false;
|
||||
toast.success('保存成功');
|
||||
} catch (error) {
|
||||
toast.error('保存失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getSourcesForTab(tabValue: string): ExtendedVideoSource[] {
|
||||
function getSourcesForTab(tabValue: string): VideoSourceDetail[] {
|
||||
if (!videoSourcesData) return [];
|
||||
const sources = videoSourcesData[
|
||||
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;
|
||||
});
|
||||
return videoSourcesData[tabValue as keyof VideoSourcesDetailsResponse] as VideoSourceDetail[];
|
||||
}
|
||||
|
||||
// 打开添加对话框
|
||||
@@ -238,80 +234,60 @@
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[30%] md:w-[25%]">名称</Table.Head>
|
||||
<Table.Head class="w-[30%] md:w-[40%]">下载路径</Table.Head>
|
||||
<Table.Head class="w-[25%] md:w-[20%]">状态</Table.Head>
|
||||
<Table.Head class="w-[15%] text-right sm:w-[12%]">操作</Table.Head>
|
||||
<Table.Head class="w-[25%]">名称</Table.Head>
|
||||
<Table.Head class="w-[35%]">下载路径</Table.Head>
|
||||
<Table.Head class="w-[15%]">筛选规则</Table.Head>
|
||||
<Table.Head class="w-[15%]">状态</Table.Head>
|
||||
<Table.Head class="w-[10%] text-right">操作</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each sources as source, index (index)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="w-[30%] font-medium md:w-[25%]">{source.name}</Table.Cell>
|
||||
<Table.Cell class="w-[30%] md:w-[40%]">
|
||||
{#if source.editing}
|
||||
<input
|
||||
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"
|
||||
placeholder="输入下载路径"
|
||||
/>
|
||||
<Table.Cell class="font-medium">{source.name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<code
|
||||
class="bg-muted text-muted-foreground inline-flex h-8 items-center rounded px-3 py-1 text-sm"
|
||||
>
|
||||
{source.path || '未设置'}
|
||||
</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}
|
||||
<code
|
||||
class="bg-muted text-muted-foreground inline-flex h-8 items-center rounded px-3 py-1 text-sm"
|
||||
>
|
||||
{source.path || '未设置'}
|
||||
</code>
|
||||
<span class="text-muted-foreground text-sm"></span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="w-[25%] md:w-[20%]">
|
||||
{#if source.editing}
|
||||
<div class="flex h-8 items-center">
|
||||
<Switch bind:checked={source.editingEnabled} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-8 items-center gap-2">
|
||||
<Switch checked={source.enabled} disabled />
|
||||
<span class="text-muted-foreground text-sm whitespace-nowrap">
|
||||
{source.enabled ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
<Table.Cell>
|
||||
<div class="flex h-8 items-center gap-2">
|
||||
<Switch checked={source.enabled} disabled />
|
||||
<span class="text-muted-foreground text-sm whitespace-nowrap">
|
||||
{source.enabled ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="w-[15%] text-right sm:w-[12%]">
|
||||
{#if source.editing}
|
||||
<div
|
||||
class="flex flex-col items-end justify-end gap-1 sm:flex-row sm:items-center"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => saveEdit(key, source.originalIndex)}
|
||||
class="h-7 w-7 p-0 sm:h-8 sm:w-8"
|
||||
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 class="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => openEditDialog(key, source, index)}
|
||||
class="h-8 w-8 p-0"
|
||||
title="编辑"
|
||||
>
|
||||
<EditIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
@@ -352,11 +328,50 @@
|
||||
</div>
|
||||
{/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.Overlay class="data-[state=open]:animate-overlay-show fixed inset-0 bg-black/30" />
|
||||
<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.Content>
|
||||
<Dialog.Title class="text-lg font-semibold">
|
||||
{#if addDialogType === 'favorites'}
|
||||
添加收藏夹
|
||||
|
||||
Reference in New Issue
Block a user