feat: 修改交互逻辑,支持前端查看日志 (#378)

This commit is contained in:
ᴀᴍᴛᴏᴀᴇʀ
2025-07-08 12:48:51 +08:00
committed by GitHub
parent 7c73a2f01a
commit 1affe4d594
71 changed files with 2049 additions and 1301 deletions
Generated
+14
View File
@@ -3811,6 +3811,7 @@ dependencies = [
"futures-core", "futures-core",
"pin-project-lite", "pin-project-lite",
"tokio", "tokio",
"tokio-util",
] ]
[[package]] [[package]]
@@ -3952,6 +3953,16 @@ dependencies = [
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-serde"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
dependencies = [
"serde",
"tracing-core",
]
[[package]] [[package]]
name = "tracing-subscriber" name = "tracing-subscriber"
version = "0.3.19" version = "0.3.19"
@@ -3963,12 +3974,15 @@ dependencies = [
"nu-ansi-term", "nu-ansi-term",
"once_cell", "once_cell",
"regex", "regex",
"serde",
"serde_json",
"sharded-slab", "sharded-slab",
"smallvec", "smallvec",
"thread_local", "thread_local",
"tracing", "tracing",
"tracing-core", "tracing-core",
"tracing-log", "tracing-log",
"tracing-serde",
] ]
[[package]] [[package]]
+2 -2
View File
@@ -67,12 +67,12 @@ strum = { version = "0.27.1", features = ["derive"] }
sysinfo = "0.35.2" sysinfo = "0.35.2"
thiserror = "2.0.12" thiserror = "2.0.12"
tokio = { version = "1.45.0", features = ["full"] } tokio = { version = "1.45.0", features = ["full"] }
tokio-stream = "0.1.17" tokio-stream = { version = "0.1.17", features = ["sync"] }
tokio-util = { version = "0.7.15", features = ["io", "rt"] } tokio-util = { version = "0.7.15", features = ["io", "rt"] }
toml = "0.8.22" toml = "0.8.22"
tower = "0.5.2" tower = "0.5.2"
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["chrono"] } tracing-subscriber = { version = "0.3.19", features = ["chrono", "json"] }
url = "2.5.4" url = "2.5.4"
validator = { version = "0.20.0", features = ["derive"] } validator = { version = "0.20.0", features = ["derive"] }
+1 -1
View File
@@ -5,4 +5,4 @@ mod response;
mod routes; mod routes;
mod wrapper; mod wrapper;
pub use routes::router; pub use routes::{MpscWriter, router};
@@ -0,0 +1,32 @@
mod mpsc;
use std::convert::Infallible;
use std::time::Duration;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::routing::get;
use axum::{Extension, Router};
use futures::{Stream, StreamExt};
pub use mpsc::MpscWriter;
use tokio_stream::wrappers::BroadcastStream;
pub(super) fn router() -> Router {
Router::new().route("/logs", get(logs))
}
async fn logs(Extension(log_writer): Extension<MpscWriter>) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let history = log_writer.log_history.lock();
let rx = log_writer.sender.subscribe();
let history_logs: Vec<String> = history.iter().cloned().collect();
drop(history);
let history_stream = { futures::stream::iter(history_logs.into_iter().map(|msg| Ok(Event::default().data(msg)))) };
let stream = BroadcastStream::new(rx).filter_map(async |msg| match msg {
Ok(log_message) => Some(Ok(Event::default().data(log_message))),
Err(e) => {
error!("Broadcast stream error: {:?}", e);
None
}
});
Sse::new(history_stream.chain(stream)).keep_alive(KeepAlive::new().interval(Duration::from_secs(10)))
}
@@ -0,0 +1,53 @@
use std::collections::VecDeque;
use std::sync::Arc;
use parking_lot::Mutex;
use tokio::sync::broadcast;
use tracing_subscriber::fmt::MakeWriter;
const MAX_HISTORY_LOGS: usize = 20;
pub struct MpscWriter {
pub sender: broadcast::Sender<String>,
pub log_history: Arc<Mutex<VecDeque<String>>>,
}
impl MpscWriter {
pub fn new(sender: broadcast::Sender<String>, log_history: Arc<Mutex<VecDeque<String>>>) -> Self {
MpscWriter { sender, log_history }
}
}
impl<'a> MakeWriter<'a> for MpscWriter {
type Writer = Self;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
impl std::io::Write for MpscWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let log_message = String::from_utf8_lossy(buf).to_string();
let _ = self.sender.send(log_message.clone());
let mut history = self.log_history.lock();
history.push_back(log_message);
if history.len() > MAX_HISTORY_LOGS {
history.pop_front();
}
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl Clone for MpscWriter {
fn clone(&self) -> Self {
MpscWriter {
sender: self.sender.clone(),
log_history: self.log_history.clone(),
}
}
}
+4
View File
@@ -18,10 +18,13 @@ use crate::config::VersionedConfig;
mod config; mod config;
mod dashboard; mod dashboard;
mod logs;
mod me; mod me;
mod video_sources; mod video_sources;
mod videos; mod videos;
pub use logs::MpscWriter;
pub fn router() -> Router { pub fn router() -> Router {
Router::new().route("/image-proxy", get(image_proxy)).nest( Router::new().route("/image-proxy", get(image_proxy)).nest(
"/api", "/api",
@@ -30,6 +33,7 @@ pub fn router() -> Router {
.merge(video_sources::router()) .merge(video_sources::router())
.merge(videos::router()) .merge(videos::router())
.merge(dashboard::router()) .merge(dashboard::router())
.merge(logs::router())
.layer(middleware::from_fn(auth)), .layer(middleware::from_fn(auth)),
) )
} }
+1 -1
View File
@@ -147,7 +147,7 @@ mod tests {
#[ignore = "only for manual test"] #[ignore = "only for manual test"]
#[tokio::test] #[tokio::test]
async fn test_video_info_type() { async fn test_video_info_type() {
init_logger("None,bili_sync=debug"); init_logger("None,bili_sync=debug", None);
let bili_client = BiliClient::new(); let bili_client = BiliClient::new();
// 请求 UP 主视频必须要获取 mixin key,使用 key 计算请求参数的签名,否则直接提示权限不足返回空 // 请求 UP 主视频必须要获取 mixin key,使用 key 计算请求参数的签名,否则直接提示权限不足返回空
let Ok(Some(mixin_key)) = bili_client.wbi_img().await.map(|wbi_img| wbi_img.into()) else { let Ok(Some(mixin_key)) = bili_client.wbi_img().await.map(|wbi_img| wbi_img.into()) else {
+13 -6
View File
@@ -12,16 +12,19 @@ mod task;
mod utils; mod utils;
mod workflow; mod workflow;
use std::collections::VecDeque;
use std::fmt::Debug; use std::fmt::Debug;
use std::future::Future; use std::future::Future;
use std::sync::Arc; use std::sync::Arc;
use bilibili::BiliClient; use bilibili::BiliClient;
use parking_lot::Mutex;
use sea_orm::DatabaseConnection; use sea_orm::DatabaseConnection;
use task::{http_server, video_downloader}; use task::{http_server, video_downloader};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker; use tokio_util::task::TaskTracker;
use crate::api::MpscWriter;
use crate::config::{ARGS, VersionedConfig}; use crate::config::{ARGS, VersionedConfig};
use crate::database::setup_database; use crate::database::setup_database;
use crate::utils::init_logger; use crate::utils::init_logger;
@@ -29,7 +32,7 @@ use crate::utils::signal::terminate;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let connection = init().await; let (connection, log_writer) = init().await;
let bili_client = Arc::new(BiliClient::new()); let bili_client = Arc::new(BiliClient::new());
let token = CancellationToken::new(); let token = CancellationToken::new();
@@ -37,7 +40,7 @@ async fn main() {
spawn_task( spawn_task(
"HTTP 服务", "HTTP 服务",
http_server(connection.clone(), bili_client.clone()), http_server(connection.clone(), bili_client.clone(), log_writer),
&tracker, &tracker,
token.clone(), token.clone(),
); );
@@ -73,9 +76,13 @@ fn spawn_task(
}); });
} }
/// 初始化日志系统、打印欢迎信息,初始化数据库连接和全局配置,最终返回数据库连接 /// 初始化日志系统、打印欢迎信息,初始化数据库连接和全局配置
async fn init() -> Arc<DatabaseConnection> { async fn init() -> (Arc<DatabaseConnection>, MpscWriter) {
init_logger(&ARGS.log_level); let (tx, _rx) = tokio::sync::broadcast::channel(30);
let log_history = Arc::new(Mutex::new(VecDeque::with_capacity(20)));
let log_writer = MpscWriter::new(tx, log_history.clone());
init_logger(&ARGS.log_level, Some(log_writer.clone()));
info!("欢迎使用 Bili-Sync,当前程序版本:{}", config::version()); info!("欢迎使用 Bili-Sync,当前程序版本:{}", config::version());
info!("项目地址:https://github.com/amtoaer/bili-sync"); info!("项目地址:https://github.com/amtoaer/bili-sync");
let connection = Arc::new(setup_database().await.expect("数据库初始化失败")); let connection = Arc::new(setup_database().await.expect("数据库初始化失败"));
@@ -83,7 +90,7 @@ async fn init() -> Arc<DatabaseConnection> {
VersionedConfig::init(&connection).await.expect("配置初始化失败"); VersionedConfig::init(&connection).await.expect("配置初始化失败");
info!("配置初始化完成"); info!("配置初始化完成");
connection (connection, log_writer)
} }
async fn handle_shutdown(tracker: TaskTracker, token: CancellationToken) { async fn handle_shutdown(tracker: TaskTracker, token: CancellationToken) {
+8 -3
View File
@@ -10,7 +10,7 @@ use reqwest::StatusCode;
use rust_embed_for_web::{EmbedableFile, RustEmbed}; use rust_embed_for_web::{EmbedableFile, RustEmbed};
use sea_orm::DatabaseConnection; use sea_orm::DatabaseConnection;
use crate::api::router; use crate::api::{MpscWriter, router};
use crate::bilibili::BiliClient; use crate::bilibili::BiliClient;
use crate::config::VersionedConfig; use crate::config::VersionedConfig;
@@ -20,11 +20,16 @@ use crate::config::VersionedConfig;
#[folder = "../../web/build"] #[folder = "../../web/build"]
struct Asset; struct Asset;
pub async fn http_server(database_connection: Arc<DatabaseConnection>, bili_client: Arc<BiliClient>) -> Result<()> { pub async fn http_server(
database_connection: Arc<DatabaseConnection>,
bili_client: Arc<BiliClient>,
log_writer: MpscWriter,
) -> Result<()> {
let app = router() let app = router()
.fallback_service(get(frontend_files)) .fallback_service(get(frontend_files))
.layer(Extension(database_connection)) .layer(Extension(database_connection))
.layer(Extension(bili_client)); .layer(Extension(bili_client))
.layer(Extension(log_writer));
let config = VersionedConfig::get().load_full(); let config = VersionedConfig::get().load_full();
let listener = tokio::net::TcpListener::bind(&config.bind_address) let listener = tokio::net::TcpListener::bind(&config.bind_address)
.await .await
+21 -3
View File
@@ -6,17 +6,35 @@ pub mod nfo;
pub mod signal; pub mod signal;
pub mod status; pub mod status;
pub mod validation; pub mod validation;
use tracing_subscriber::fmt;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::util::SubscriberInitExt;
pub fn init_logger(log_level: &str) { use crate::api::MpscWriter;
tracing_subscriber::fmt::Subscriber::builder()
pub fn init_logger(log_level: &str, log_writer: Option<MpscWriter>) {
let log = tracing_subscriber::fmt::Subscriber::builder()
.compact() .compact()
.with_env_filter(tracing_subscriber::EnvFilter::builder().parse_lossy(log_level)) .with_env_filter(tracing_subscriber::EnvFilter::builder().parse_lossy(log_level))
.with_target(false) .with_target(false)
.with_timer(tracing_subscriber::fmt::time::ChronoLocal::new( .with_timer(tracing_subscriber::fmt::time::ChronoLocal::new(
"%b %d %H:%M:%S".to_owned(), "%b %d %H:%M:%S".to_owned(),
)) ))
.finish() .finish();
if let Some(writer) = log_writer {
log.with(
fmt::layer()
.with_ansi(false)
.with_timer(tracing_subscriber::fmt::time::ChronoLocal::new(
"%b %d %H:%M:%S".to_owned(),
))
.json()
.flatten_event(true)
.with_writer(writer),
)
.try_init() .try_init()
.expect("初始化日志失败"); .expect("初始化日志失败");
} else {
log.try_init().expect("初始化日志失败");
}
} }
+37 -28
View File
@@ -3,13 +3,16 @@
"workspaces": { "workspaces": {
"": { "": {
"name": "my-app", "name": "my-app",
"dependencies": {
"@tanstack/svelte-query": "^5.81.5",
},
"devDependencies": { "devDependencies": {
"@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.525.0",
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "2.22.2",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
@@ -47,55 +50,57 @@
"@dagrejs/graphlib": ["@dagrejs/graphlib@2.2.4", "", {}, "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw=="], "@dagrejs/graphlib": ["@dagrejs/graphlib@2.2.4", "", {}, "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.6", "", { "os": "aix", "cpu": "ppc64" }, "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.5", "", { "os": "android", "cpu": "arm" }, "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.6", "", { "os": "android", "cpu": "arm" }, "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.5", "", { "os": "android", "cpu": "arm64" }, "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg=="], "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.6", "", { "os": "android", "cpu": "arm64" }, "sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.5", "", { "os": "android", "cpu": "x64" }, "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw=="], "@esbuild/android-x64": ["@esbuild/android-x64@0.25.6", "", { "os": "android", "cpu": "x64" }, "sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ=="], "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ=="], "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw=="], "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.6", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw=="], "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.5", "", { "os": "linux", "cpu": "arm" }, "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw=="], "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.6", "", { "os": "linux", "cpu": "arm" }, "sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg=="], "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA=="], "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.6", "", { "os": "linux", "cpu": "ia32" }, "sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg=="], "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.6", "", { "os": "linux", "cpu": "none" }, "sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg=="], "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.6", "", { "os": "linux", "cpu": "none" }, "sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ=="], "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA=="], "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.6", "", { "os": "linux", "cpu": "none" }, "sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ=="], "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.5", "", { "os": "linux", "cpu": "x64" }, "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw=="], "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.6", "", { "os": "linux", "cpu": "x64" }, "sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.5", "", { "os": "none", "cpu": "arm64" }, "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw=="], "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.6", "", { "os": "none", "cpu": "arm64" }, "sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.5", "", { "os": "none", "cpu": "x64" }, "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ=="], "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.6", "", { "os": "none", "cpu": "x64" }, "sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.5", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw=="], "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.6", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg=="], "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.6", "", { "os": "openbsd", "cpu": "x64" }, "sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA=="], "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.6", "", { "os": "none", "cpu": "arm64" }, "sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw=="], "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.6", "", { "os": "sunos", "cpu": "x64" }, "sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ=="], "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g=="], "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="],
@@ -207,7 +212,7 @@
"@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.8", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg=="], "@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.8", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg=="],
"@sveltejs/kit": ["@sveltejs/kit@2.21.1", "", { "dependencies": { "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-vLbtVwtDcK8LhJKnFkFYwM0uCdFmzioQnif0bjEYH1I24Arz22JPr/hLUiXGVYAwhu8INKx5qrdvr4tHgPwX6w=="], "@sveltejs/kit": ["@sveltejs/kit@2.22.2", "", { "dependencies": { "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0", "vitefu": "^1.0.6" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-2MvEpSYabUrsJAoq5qCOBGAlkICjfjunrnLcx3YAk2XV7TvAIhomlKsAgR4H/4uns5rAfYmj7Wet5KRtc8dPIg=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.0.3", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.0", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.15", "vitefu": "^1.0.4" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-MCFS6CrQDu1yGwspm4qtli0e63vaPCehf6V7pIMP15AsWgMKrqDGCPFF/0kn4SP0ii4aySu4Pa62+fIRGFMjgw=="], "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.0.3", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.0", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.15", "vitefu": "^1.0.4" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-MCFS6CrQDu1yGwspm4qtli0e63vaPCehf6V7pIMP15AsWgMKrqDGCPFF/0kn4SP0ii4aySu4Pa62+fIRGFMjgw=="],
@@ -249,6 +254,10 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.8", "", { "dependencies": { "@tailwindcss/node": "4.1.8", "@tailwindcss/oxide": "4.1.8", "tailwindcss": "4.1.8" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-CQ+I8yxNV5/6uGaJjiuymgw0kEQiNKRinYbZXPdx1fk5WgiyReG0VaUx/Xq6aVNSUNJFzxm6o8FNKS5aMaim5A=="], "@tailwindcss/vite": ["@tailwindcss/vite@4.1.8", "", { "dependencies": { "@tailwindcss/node": "4.1.8", "@tailwindcss/oxide": "4.1.8", "tailwindcss": "4.1.8" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-CQ+I8yxNV5/6uGaJjiuymgw0kEQiNKRinYbZXPdx1fk5WgiyReG0VaUx/Xq6aVNSUNJFzxm6o8FNKS5aMaim5A=="],
"@tanstack/query-core": ["@tanstack/query-core@5.81.5", "", {}, "sha512-ZJOgCy/z2qpZXWaj/oxvodDx07XcQa9BF92c0oINjHkoqUPsmm3uG08HpTaviviZ/N9eP1f9CM7mKSEkIo7O1Q=="],
"@tanstack/svelte-query": ["@tanstack/svelte-query@5.81.5", "", { "dependencies": { "@tanstack/query-core": "5.81.5" }, "peerDependencies": { "svelte": "^3.54.0 || ^4.0.0 || ^5.0.0" } }, "sha512-P2TaL+dGHWwQ83CyX8I9icb/1lYUSFwqQvGHI8jzFbacOEtVtQQXB0N12fotvn/BDn7Eh+tyCNiiMN56tFuFJw=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
@@ -391,7 +400,7 @@
"enhanced-resolve": ["enhanced-resolve@5.18.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg=="], "enhanced-resolve": ["enhanced-resolve@5.18.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg=="],
"esbuild": ["esbuild@0.25.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.5", "@esbuild/android-arm": "0.25.5", "@esbuild/android-arm64": "0.25.5", "@esbuild/android-x64": "0.25.5", "@esbuild/darwin-arm64": "0.25.5", "@esbuild/darwin-x64": "0.25.5", "@esbuild/freebsd-arm64": "0.25.5", "@esbuild/freebsd-x64": "0.25.5", "@esbuild/linux-arm": "0.25.5", "@esbuild/linux-arm64": "0.25.5", "@esbuild/linux-ia32": "0.25.5", "@esbuild/linux-loong64": "0.25.5", "@esbuild/linux-mips64el": "0.25.5", "@esbuild/linux-ppc64": "0.25.5", "@esbuild/linux-riscv64": "0.25.5", "@esbuild/linux-s390x": "0.25.5", "@esbuild/linux-x64": "0.25.5", "@esbuild/netbsd-arm64": "0.25.5", "@esbuild/netbsd-x64": "0.25.5", "@esbuild/openbsd-arm64": "0.25.5", "@esbuild/openbsd-x64": "0.25.5", "@esbuild/sunos-x64": "0.25.5", "@esbuild/win32-arm64": "0.25.5", "@esbuild/win32-ia32": "0.25.5", "@esbuild/win32-x64": "0.25.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ=="], "esbuild": ["esbuild@0.25.6", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.6", "@esbuild/android-arm": "0.25.6", "@esbuild/android-arm64": "0.25.6", "@esbuild/android-x64": "0.25.6", "@esbuild/darwin-arm64": "0.25.6", "@esbuild/darwin-x64": "0.25.6", "@esbuild/freebsd-arm64": "0.25.6", "@esbuild/freebsd-x64": "0.25.6", "@esbuild/linux-arm": "0.25.6", "@esbuild/linux-arm64": "0.25.6", "@esbuild/linux-ia32": "0.25.6", "@esbuild/linux-loong64": "0.25.6", "@esbuild/linux-mips64el": "0.25.6", "@esbuild/linux-ppc64": "0.25.6", "@esbuild/linux-riscv64": "0.25.6", "@esbuild/linux-s390x": "0.25.6", "@esbuild/linux-x64": "0.25.6", "@esbuild/netbsd-arm64": "0.25.6", "@esbuild/netbsd-x64": "0.25.6", "@esbuild/openbsd-arm64": "0.25.6", "@esbuild/openbsd-x64": "0.25.6", "@esbuild/openharmony-arm64": "0.25.6", "@esbuild/sunos-x64": "0.25.6", "@esbuild/win32-arm64": "0.25.6", "@esbuild/win32-ia32": "0.25.6", "@esbuild/win32-x64": "0.25.6" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
+5 -2
View File
@@ -7,7 +7,7 @@
"@internationalized/date": "^3.8.1", "@internationalized/date": "^3.8.1",
"@lucide/svelte": "^0.525.0", "@lucide/svelte": "^0.525.0",
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "2.22.2",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
@@ -47,5 +47,8 @@
"format": "prettier --write .", "format": "prettier --write .",
"lint": "prettier --check . && eslint ." "lint": "prettier --check . && eslint ."
}, },
"type": "module" "type": "module",
"dependencies": {
"@tanstack/svelte-query": "^5.81.5"
}
} }
+15 -9
View File
@@ -223,14 +223,22 @@ class ApiClient {
return this.get<DashBoardResponse>('/dashboard'); return this.get<DashBoardResponse>('/dashboard');
} }
// 获取系统信息流(SSE createLogStream(
async getSysInfoStream(): Promise<EventSource> { onMessage: (data: string) => void,
onError?: (error: Event) => void
): EventSource {
const token = localStorage.getItem('authToken'); const token = localStorage.getItem('authToken');
const url = `/api/dashboard/sysinfo${token ? `?token=${encodeURIComponent(token)}` : ''}`; const url = `/api/logs${token ? `?token=${encodeURIComponent(token)}` : ''}`;
return new EventSource(url); const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
onMessage(event.data);
};
if (onError) {
eventSource.onerror = onError;
}
return eventSource;
} }
// 创建系统信息流的便捷方法
createSysInfoStream( createSysInfoStream(
onMessage: (data: SysInfoResponse) => void, onMessage: (data: SysInfoResponse) => void,
onError?: (error: Event) => void onError?: (error: Event) => void
@@ -238,7 +246,6 @@ class ApiClient {
const token = localStorage.getItem('authToken'); const token = localStorage.getItem('authToken');
const url = `/api/dashboard/sysinfo${token ? `?token=${encodeURIComponent(token)}` : ''}`; const url = `/api/dashboard/sysinfo${token ? `?token=${encodeURIComponent(token)}` : ''}`;
const eventSource = new EventSource(url); const eventSource = new EventSource(url);
eventSource.onmessage = (event) => { eventSource.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data) as SysInfoResponse; const data = JSON.parse(event.data) as SysInfoResponse;
@@ -247,11 +254,9 @@ class ApiClient {
console.error('Failed to parse SSE data:', error); console.error('Failed to parse SSE data:', error);
} }
}; };
if (onError) { if (onError) {
eventSource.onerror = onError; eventSource.onerror = onError;
} }
return eventSource; return eventSource;
} }
} }
@@ -282,11 +287,12 @@ const api = {
getConfig: () => apiClient.getConfig(), getConfig: () => apiClient.getConfig(),
updateConfig: (config: Config) => apiClient.updateConfig(config), updateConfig: (config: Config) => apiClient.updateConfig(config),
getDashboard: () => apiClient.getDashboard(), getDashboard: () => apiClient.getDashboard(),
getSysInfoStream: () => apiClient.getSysInfoStream(),
createSysInfoStream: ( createSysInfoStream: (
onMessage: (data: SysInfoResponse) => void, onMessage: (data: SysInfoResponse) => void,
onError?: (error: Event) => void onError?: (error: Event) => void
) => apiClient.createSysInfoStream(onMessage, onError), ) => apiClient.createSysInfoStream(onMessage, onError),
createLogStream: (onMessage: (data: string) => void, onError?: (error: Event) => void) =>
apiClient.createLogStream(onMessage, onError),
setAuthToken: (token: string) => apiClient.setAuthToken(token), setAuthToken: (token: string) => apiClient.setAuthToken(token),
clearAuthToken: () => apiClient.clearAuthToken() clearAuthToken: () => apiClient.clearAuthToken()
}; };
+116 -198
View File
@@ -1,226 +1,144 @@
<script lang="ts"> <script lang="ts">
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'; import DatabaseIcon from '@lucide/svelte/icons/database';
import SettingsIcon from '@lucide/svelte/icons/settings'; import FileVideoIcon from '@lucide/svelte/icons/file-video';
import UserIcon from '@lucide/svelte/icons/user'; import BotIcon from '@lucide/svelte/icons/bot';
import ChartPieIcon from '@lucide/svelte/icons/chart-pie';
import HeartIcon from '@lucide/svelte/icons/heart'; import HeartIcon from '@lucide/svelte/icons/heart';
import FolderIcon from '@lucide/svelte/icons/folder'; import FolderIcon from '@lucide/svelte/icons/folder';
import DatabaseIcon from '@lucide/svelte/icons/database'; import UserIcon from '@lucide/svelte/icons/user';
import Settings2Icon from '@lucide/svelte/icons/settings-2';
import SquareTerminalIcon from '@lucide/svelte/icons/square-terminal';
import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js'; import type { ComponentProps } from 'svelte';
import {
appStateStore,
setVideoSourceFilter,
clearAll,
ToQuery,
resetCurrentPage
} from '$lib/stores/filter';
import { type VideoSourcesResponse } from '$lib/types'; let { ref = $bindable(null), ...restProps }: ComponentProps<typeof Sidebar.Root> = $props();
import { VIDEO_SOURCES } from '$lib/consts'; const data = {
import * as Collapsible from '$lib/components/ui/collapsible/index.js'; header: {
import { goto } from '$app/navigation'; title: 'Bili Sync',
import { videoSourceStore } from '$lib/stores/video-source'; subtitle: '后台管理系统',
const sidebar = useSidebar(); icon: BotIcon,
href: '/'
const items = Object.values(VIDEO_SOURCES); },
navMain: [
function handleSourceClick(sourceType: string, sourceId: number) { {
setVideoSourceFilter({ category: '总览',
type: sourceType, items: [
id: sourceId.toString() {
}); title: '仪表盘',
resetCurrentPage(); icon: ChartPieIcon,
goto(`/${ToQuery($appStateStore)}`); href: '/'
if (sidebar.isMobile) { },
sidebar.setOpenMobile(false); {
title: '日志',
icon: SquareTerminalIcon,
href: '/logs'
} }
]
},
{
category: '内容管理',
items: [
{
title: '视频',
icon: FileVideoIcon,
href: '/videos'
},
{
title: '视频源',
icon: DatabaseIcon,
href: '/video-sources'
} }
]
function handleLogoClick() { },
clearAll(); {
goto('/'); category: '快捷订阅',
items: [
if (sidebar.isMobile) { {
sidebar.setOpenMobile(false); title: '收藏夹',
icon: HeartIcon,
href: '/me/favorites'
},
{
title: '合集',
icon: FolderIcon,
href: '/me/collections'
},
{
title: 'up 主',
icon: UserIcon,
href: '/me/uppers'
} }
]
} }
],
footer: [
{
title: '设置',
icon: Settings2Icon,
href: '/settings'
}
]
};
</script> </script>
<Sidebar.Root class="border-border bg-background border-r"> <Sidebar.Root bind:ref variant="inset" {...restProps}>
<Sidebar.Header class="border-border flex h-[73px] items-center border-b"> <Sidebar.Header>
<a <Sidebar.Menu>
href="/" <Sidebar.MenuItem>
class="flex w-full items-center gap-3 px-4 py-3 hover:cursor-pointer" <Sidebar.MenuButton size="lg">
onclick={handleLogoClick} {#snippet child({ props })}
<a href={data.header.href} {...props}>
<div
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
> >
<div class="flex h-8 w-8 items-center justify-center overflow-hidden rounded-lg"> <data.header.icon class="size-4" />
<img src="/favicon.png" alt="Bili Sync" class="h-6 w-6" />
</div> </div>
<div class="grid flex-1 text-left text-sm leading-tight"> <div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">Bili Sync</span> <span class="truncate font-medium">{data.header.title}</span>
<span class="text-muted-foreground truncate text-xs">视频管理系统</span> <span class="truncate text-xs">{data.header.subtitle}</span>
</div> </div>
</a> </a>
</Sidebar.Header>
<Sidebar.Content class="flex flex-col px-2 py-3">
<div class="flex-1">
<Sidebar.Group>
<Sidebar.GroupLabel
class="text-muted-foreground mb-2 px-2 text-xs font-medium tracking-wider uppercase"
>
视频筛选
</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu class="space-y-1">
{#each items as item (item.type)}
<Collapsible.Root class="group/collapsible">
<Sidebar.MenuItem>
<Collapsible.Trigger class="w-full">
{#snippet child({ props })}
<Sidebar.MenuButton
{...props}
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 font-medium transition-all duration-200"
>
<div class="flex flex-1 items-center gap-3">
<item.icon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">{item.title}</span>
</div>
<ChevronRightIcon
class="text-muted-foreground h-3 w-3 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</Sidebar.MenuButton>
{/snippet} {/snippet}
</Collapsible.Trigger> </Sidebar.MenuButton>
<Collapsible.Content class="mt-1">
<div class="border-border ml-5 space-y-0.5 border-l pl-2">
{#if $videoSourceStore}
{#if $videoSourceStore[item.type as keyof VideoSourcesResponse]?.length > 0}
{#each $videoSourceStore[item.type as keyof VideoSourcesResponse] as source (source.id)}
<Sidebar.MenuItem>
<button
class="text-foreground hover:bg-accent/50 w-full cursor-pointer rounded-md px-3 py-2 text-left text-sm transition-all duration-200"
onclick={() => handleSourceClick(item.type, source.id)}
>
<span class="block truncate">{source.name}</span>
</button>
</Sidebar.MenuItem> </Sidebar.MenuItem>
{/each}
{:else}
<div class="text-muted-foreground px-3 py-2 text-sm">无数据</div>
{/if}
{:else}
<div class="text-muted-foreground px-3 py-2 text-sm">加载中...</div>
{/if}
</div>
</Collapsible.Content>
</Sidebar.MenuItem>
</Collapsible.Root>
{/each}
</Sidebar.Menu> </Sidebar.Menu>
</Sidebar.GroupContent> </Sidebar.Header>
</Sidebar.Group> <Sidebar.Content>
<Sidebar.Group> <Sidebar.Group>
<Sidebar.GroupLabel {#each data.navMain as group (group.category)}
class="text-muted-foreground mb-2 px-2 text-xs font-medium tracking-wider uppercase" <Sidebar.GroupLabel class="h-10">{group.category}</Sidebar.GroupLabel>
> <Sidebar.Menu>
快捷订阅 {#each group.items as item (item.title)}
</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu class="space-y-1">
<Sidebar.MenuItem> <Sidebar.MenuItem>
<Sidebar.MenuButton> <Sidebar.MenuButton class="h-8">
<a {#snippet child({ props })}
href="/me/favorites" <a href={item.href} {...props}>
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200" <item.icon class="size-4" />
onclick={() => { <span class="text-sm">{item.title}</span>
if (sidebar.isMobile) {
sidebar.setOpenMobile(false);
}
}}
>
<HeartIcon class="text-muted-foreground h-4 w-4" />
<span>创建的收藏夹</span>
</a>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton>
<a
href="/me/collections"
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200"
onclick={() => {
if (sidebar.isMobile) {
sidebar.setOpenMobile(false);
}
}}
>
<FolderIcon class="text-muted-foreground h-4 w-4" />
<span>关注的合集</span>
</a>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton>
<a
href="/me/uppers"
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200"
onclick={() => {
if (sidebar.isMobile) {
sidebar.setOpenMobile(false);
}
}}
>
<UserIcon class="text-muted-foreground h-4 w-4" />
<span>关注的 UP 主</span>
</a> </a>
{/snippet}
</Sidebar.MenuButton> </Sidebar.MenuButton>
</Sidebar.MenuItem> </Sidebar.MenuItem>
{/each}
</Sidebar.Menu> </Sidebar.Menu>
</Sidebar.GroupContent> {/each}
</Sidebar.Group> </Sidebar.Group>
</div>
<!-- 固定在底部的菜单选项 -->
<div class="border-border mt-auto border-t pt-4">
<Sidebar.Menu class="space-y-1">
<Sidebar.MenuItem>
<Sidebar.MenuButton>
<a
href="/video-sources"
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 font-medium transition-all duration-200"
onclick={() => {
if (sidebar.isMobile) {
sidebar.setOpenMobile(false);
}
}}
>
<div class="flex flex-1 items-center gap-3">
<DatabaseIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">视频源管理</span>
</div>
</a>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton>
<a
href="/settings"
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 font-medium transition-all duration-200"
onclick={() => {
if (sidebar.isMobile) {
sidebar.setOpenMobile(false);
}
}}
>
<div class="flex flex-1 items-center gap-3">
<SettingsIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">设置</span>
</div>
</a>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</div>
</Sidebar.Content> </Sidebar.Content>
<Sidebar.Footer>
<Sidebar.Separator />
<Sidebar.Menu>
{#each data.footer as item (item.title)}
<Sidebar.MenuItem>
<Sidebar.MenuButton class="h-8">
{#snippet child({ props })}
<a href={item.href} {...props}>
<item.icon class="size-4" />
<span class="text-sm">{item.title}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.Footer>
</Sidebar.Root> </Sidebar.Root>
+5 -14
View File
@@ -4,30 +4,21 @@
export let items: Array<{ export let items: Array<{
href?: string; href?: string;
label: string; label: string;
isActive?: boolean;
onClick?: () => void;
}> = [{ href: '/', label: '主页' }]; }> = [{ href: '/', label: '主页' }];
</script> </script>
<Breadcrumb.Root> <Breadcrumb.Root>
<Breadcrumb.List> <Breadcrumb.List>
{#each items as item, index (item.label)} {#each items as item, index (item.label)}
<Breadcrumb.Item> <Breadcrumb.Item class="hidden md:block">
{#if item.isActive || (!item.href && !item.onClick)} {#if item.href}
<Breadcrumb.Page>{item.label}</Breadcrumb.Page>
{:else if item.onClick}
<button
class="hover:text-foreground cursor-pointer transition-colors"
onclick={item.onClick}
>
{item.label}
</button>
{:else}
<Breadcrumb.Link href={item.href}>{item.label}</Breadcrumb.Link> <Breadcrumb.Link href={item.href}>{item.label}</Breadcrumb.Link>
{:else}
<Breadcrumb.Page>{item.label}</Breadcrumb.Page>
{/if} {/if}
</Breadcrumb.Item> </Breadcrumb.Item>
{#if index < items.length - 1} {#if index < items.length - 1}
<Breadcrumb.Separator /> <Breadcrumb.Separator class="hidden md:block" />
{/if} {/if}
{/each} {/each}
</Breadcrumb.List> </Breadcrumb.List>
@@ -0,0 +1,122 @@
<script lang="ts">
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import TrashIcon from '@lucide/svelte/icons/trash';
import { tick } from 'svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Command from '$lib/components/ui/command/index.js';
import { Button } from '$lib/components/ui/button/index.js';
export interface Filter {
name: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon: any;
values: Record<string, string>;
}
interface SelectedLabel {
type: string;
id: string;
}
interface Props {
filters: Record<string, Filter> | null;
selectedLabel: SelectedLabel | null;
onSelect?: (type: string, id: string) => void;
onRemove?: () => void;
}
let { filters, selectedLabel = $bindable(), onSelect, onRemove }: Props = $props();
let open = $state(false);
let triggerRef = $state<HTMLButtonElement>(null!);
// We want to refocus the trigger button when the user selects
// an item from the list so users can continue navigating the
// rest of the form with the keyboard.
function closeAndFocusTrigger() {
open = false;
tick().then(() => {
triggerRef.focus();
});
}
</script>
<div class="inline-flex items-center gap-1">
{#if filters}
<span class="bg-secondary text-secondary-foreground rounded-lg px-2 py-1 text-xs font-medium">
{#if selectedLabel && selectedLabel.type && selectedLabel.id}
{filters[selectedLabel.type]?.name || ''} : {filters[selectedLabel.type]!.values[
selectedLabel.id
] || ''}
{:else}
未应用
{/if}
</span>
{/if}
<DropdownMenu.Root bind:open>
<DropdownMenu.Trigger bind:ref={triggerRef}>
{#snippet child({ props })}
<Button variant="ghost" size="sm" {...props} class="h-6 w-6 p-0">
<EllipsisIcon class="h-3 w-3" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-[200px]" align="end">
<DropdownMenu.Group>
{#if filters}
{#each Object.entries(filters) as [key, filter] (key)}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>
<filter.icon class="mr-2 size-3" />
<span class="text-xs font-medium">
{filter.name}
</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="p-0">
<Command.Root
value={selectedLabel && selectedLabel.type === key ? selectedLabel.id : ''}
>
<Command.Input
class="text-xs"
autofocus
placeholder="查找{filter.name.toLowerCase()}..."
/>
<Command.List>
<Command.Empty class="text-xs"
>未找到"{filter.name.toLowerCase()}"</Command.Empty
>
<Command.Group>
{#each Object.entries(filter.values) as [id, name] (id)}
<Command.Item
value={id}
class="text-xs"
onSelect={() => {
closeAndFocusTrigger();
onSelect?.(key, id);
}}
>
{name}
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{/each}
{/if}
<DropdownMenu.Separator />
<DropdownMenu.Item
onclick={() => {
closeAndFocusTrigger();
onRemove?.();
}}
>
<TrashIcon class="mr-2 size-3" />
<span class="text-xs font-medium"> 移除筛选 </span>
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
@@ -1,24 +0,0 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge/index.js';
import XIcon from '@lucide/svelte/icons/x';
export let filterTitle: string = '';
export let filterName: string = '';
export let onRemove: () => void = () => {};
</script>
{#if filterTitle && filterName}
<div class="mb-4 flex items-center gap-2">
<span class="text-muted-foreground text-sm">当前筛选:</span>
<Badge variant="secondary" class="flex items-center gap-2 pr-1">
<span>{filterTitle} {filterName}</span>
<button
class="hover:bg-muted-foreground/20 ml-1 cursor-pointer rounded-full p-0.5 transition-colors"
onclick={onRemove}
type="button"
>
<XIcon class="h-3 w-3" />
</button>
</Badge>
</div>
{/if}
+15 -18
View File
@@ -1,31 +1,28 @@
<script lang="ts"> <script lang="ts">
import SearchIcon from '@lucide/svelte/icons/search'; import SearchIcon from '@lucide/svelte/icons/search';
import * as Input from '$lib/components/ui/input/index.js'; import * as Input from '$lib/components/ui/input/index.js';
import { Button } from '$lib/components/ui/button/index.js';
export let placeholder: string = '搜索视频..'; export let placeholder: string = '搜索视频..';
export let value: string = ''; export let value: string = '';
export let onSearch: ((query: string) => void) | undefined = undefined; export let onSearch: ((query: string) => void) | undefined = undefined;
function handleSearch() { function handleSearch() {
if (onSearch) { onSearch?.(value);
onSearch(value);
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
handleSearch();
}
} }
</script> </script>
<div class="flex w-full items-center space-x-2"> <div class="flex w-full max-w-48 items-center">
<div class="relative flex-1"> <div class="relative w-full">
<SearchIcon class="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" /> <SearchIcon class="text-muted-foreground absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2" />
<Input.Root type="text" {placeholder} bind:value onkeydown={handleKeydown} class="h-11 pl-10" /> <Input.Root
type="text"
{placeholder}
bind:value
class="h-8 w-full border-0 pr-3 pl-7 text-sm shadow-none focus-visible:ring-0"
onkeydown={(e: KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
}}
/>
</div> </div>
<Button onclick={handleSearch} size="default" class="h-11 flex-shrink-0 cursor-pointer px-8"
>搜索</Button
>
</div> </div>
+16 -18
View File
@@ -39,21 +39,16 @@
pageStatuses = { ...pageStatuses }; pageStatuses = { ...pageStatuses };
} }
// 编辑状态
let videoStatuses: number[] = []; let videoStatuses: number[] = [];
let pageStatuses: Record<number, number[]> = {}; let pageStatuses: Record<number, number[]> = {};
// 原始状态备份
let originalVideoStatuses: number[] = []; let originalVideoStatuses: number[] = [];
let originalPageStatuses: Record<number, number[]> = {}; let originalPageStatuses: Record<number, number[]> = {};
// 响应式更新状态 - 当 video 或 pages props 变化时重新初始化
$: { $: {
// 初始化视频状态
videoStatuses = [...video.download_status]; videoStatuses = [...video.download_status];
originalVideoStatuses = [...video.download_status]; originalVideoStatuses = [...video.download_status];
// 初始化分页状态
if (pages.length > 0) { if (pages.length > 0) {
pageStatuses = pages.reduce( pageStatuses = pages.reduce(
(acc, page) => { (acc, page) => {
@@ -77,20 +72,28 @@
function handleVideoStatusChange(taskIndex: number, newValue: number) { function handleVideoStatusChange(taskIndex: number, newValue: number) {
videoStatuses[taskIndex] = newValue; videoStatuses[taskIndex] = newValue;
videoStatuses = [...videoStatuses];
} }
function handlePageStatusChange(pageId: number, taskIndex: number, newValue: number) { function handlePageStatusChange(pageId: number, taskIndex: number, newValue: number) {
if (!pageStatuses[pageId]) { if (!pageStatuses[pageId]) {
pageStatuses[pageId] = []; return;
} }
pageStatuses[pageId][taskIndex] = newValue; pageStatuses[pageId][taskIndex] = newValue;
pageStatuses = { ...pageStatuses };
} }
function resetAllStatuses() { function resetAllStatuses() {
videoStatuses = [...originalVideoStatuses]; videoStatuses = [...originalVideoStatuses];
pageStatuses = { ...originalPageStatuses }; if (pages.length > 0) {
pageStatuses = pages.reduce(
(acc, page) => {
acc[page.id] = [...page.download_status];
return acc;
},
{} as Record<number, number[]>
);
} else {
pageStatuses = {};
}
} }
function hasVideoChanges(): boolean { function hasVideoChanges(): boolean {
@@ -112,8 +115,6 @@
function buildRequest(): UpdateVideoStatusRequest { function buildRequest(): UpdateVideoStatusRequest {
const request: UpdateVideoStatusRequest = {}; const request: UpdateVideoStatusRequest = {};
// 构建视频状态更新
if (hasVideoChanges()) {
request.video_updates = []; request.video_updates = [];
videoStatuses.forEach((status, index) => { videoStatuses.forEach((status, index) => {
if (status !== originalVideoStatuses[index]) { if (status !== originalVideoStatuses[index]) {
@@ -123,10 +124,6 @@
}); });
} }
}); });
}
// 构建分页状态更新
if (hasPageChanges()) {
request.page_updates = []; request.page_updates = [];
pages.forEach((page) => { pages.forEach((page) => {
const currentStatuses = pageStatuses[page.id] || []; const currentStatuses = pageStatuses[page.id] || [];
@@ -149,7 +146,6 @@
}); });
} }
}); });
}
return request; return request;
} }
@@ -159,8 +155,11 @@
toast.info('没有状态变更需要提交'); toast.info('没有状态变更需要提交');
return; return;
} }
const request = buildRequest(); const request = buildRequest();
if (!request.video_updates?.length && !request.page_updates?.length) {
toast.info('没有状态变更需要提交');
return;
}
onsubmit(request); onsubmit(request);
} }
</script> </script>
@@ -179,7 +178,6 @@
<div class="flex-1 overflow-y-auto px-6"> <div class="flex-1 overflow-y-auto px-6">
<div class="space-y-6 py-2"> <div class="space-y-6 py-2">
<!-- 视频状态编辑 -->
<div> <div>
<h3 class="mb-4 text-base font-medium">视频状态</h3> <h3 class="mb-4 text-base font-medium">视频状态</h3>
<div class="bg-card rounded-lg border p-4"> <div class="bg-card rounded-lg border p-4">
+56 -45
View File
@@ -67,11 +67,11 @@
function getSubtitle(): string { function getSubtitle(): string {
switch (type) { switch (type) {
case 'favorite': case 'favorite':
return `UP主ID: ${(item as FavoriteWithSubscriptionStatus).mid}`; return `uid: ${(item as FavoriteWithSubscriptionStatus).mid}`;
case 'collection': case 'collection':
return `UP主ID: ${(item as CollectionWithSubscriptionStatus).mid}`; return `uid: ${(item as CollectionWithSubscriptionStatus).mid}`;
case 'upper': case 'upper':
return ''; // UP主不需要副标题 return '';
default: default:
return ''; return '';
} }
@@ -158,13 +158,16 @@
const disabledReason = getDisabledReason(); const disabledReason = getDisabledReason();
</script> </script>
<Card class="group transition-shadow hover:shadow-md {disabled ? 'opacity-60 grayscale' : ''}"> <Card
<CardHeader class="pb-3"> class="hover:shadow-primary/5 border-border/50 group flex h-full flex-col transition-all hover:shadow-lg {disabled
<div class="flex items-start justify-between gap-3"> ? 'opacity-60'
<div class="flex min-w-0 flex-1 items-start gap-3"> : ''}"
<!-- 头像或图标 --> >
<CardHeader class="flex-shrink-0 pb-4">
<div class="flex items-start gap-3">
<!-- 头像或图标 - 简化设计 -->
<div <div
class="bg-muted flex h-12 w-12 shrink-0 items-center justify-center rounded-lg {disabled class="bg-accent/50 flex h-10 w-10 shrink-0 items-center justify-center rounded-full {disabled
? 'opacity-50' ? 'opacity-50'
: ''}" : ''}"
> >
@@ -172,82 +175,88 @@
<img <img
src={avatarUrl} src={avatarUrl}
alt={title} alt={title}
class="h-full w-full rounded-lg object-cover {disabled ? 'grayscale' : ''}" class="h-full w-full rounded-full object-cover {disabled ? 'grayscale' : ''}"
loading="lazy" loading="lazy"
/> />
{:else} {:else}
<Icon class="text-muted-foreground h-6 w-6" /> <Icon class="text-muted-foreground h-5 w-5" />
{/if} {/if}
</div> </div>
<!-- 标题和信息 --> <!-- 内容区域 -->
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1 space-y-2">
<div class="flex items-start justify-between gap-2">
<CardTitle <CardTitle
class="line-clamp-2 text-base leading-tight {disabled class="line-clamp-2 text-sm leading-relaxed font-medium {disabled
? 'text-muted-foreground line-through' ? 'text-muted-foreground line-through'
: ''}" : ''}"
{title} {title}
> >
{title} {title}
</CardTitle> </CardTitle>
{#if subtitle}
<div class="text-muted-foreground mt-1 flex items-center gap-1.5 text-sm"> <!-- 状态标记 -->
{#if disabled}
<Badge variant="destructive" class="shrink-0 text-xs">不可用</Badge>
{:else}
<Badge variant={subscribed ? 'outline' : 'secondary'} class="shrink-0 text-xs">
{subscribed ? '已订阅' : typeLabel}
</Badge>
{/if}
</div>
<!-- 副标题和描述 -->
{#if subtitle && !disabled}
<div class="text-muted-foreground flex items-center gap-1 text-sm">
<UserIcon class="h-3 w-3 shrink-0" /> <UserIcon class="h-3 w-3 shrink-0" />
<span class="truncate" title={subtitle}>{subtitle}</span> <span class="truncate" title={subtitle}>{subtitle}</span>
</div> </div>
{/if} {/if}
{#if description}
<p class="text-muted-foreground mt-1 line-clamp-2 text-xs" title={description}> {#if description && !disabled}
<p class="text-muted-foreground line-clamp-1 text-sm" title={description}>
{description} {description}
</p> </p>
{/if} {/if}
</div>
</div>
<!-- 状态标记 --> <!-- 计数信息 -->
<div class="flex shrink-0 flex-col items-end gap-2"> {#if count !== null && !disabled}
{#if disabled} <div class="text-muted-foreground text-sm">
<Badge variant="destructive" class="text-xs">不可用</Badge>
<div class="text-muted-foreground text-xs">
{disabledReason}
</div>
{:else}
<Badge variant={subscribed ? 'default' : 'outline'} class="text-xs">
{subscribed ? '已订阅' : typeLabel}
</Badge>
{#if count !== null}
<div class="text-muted-foreground text-xs">
{count} {count}
{countLabel} {countLabel}
</div> </div>
{/if} {/if}
{/if}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent class="pt-0"> <!-- 底部按钮区域 -->
<CardContent class="flex min-w-0 flex-1 flex-col justify-end pt-0 pb-4">
<div class="flex justify-end"> <div class="flex justify-end">
{#if disabled} {#if disabled}
<Button size="sm" variant="outline" disabled class="cursor-not-allowed opacity-50"> <Button
<XIcon class="mr-2 h-4 w-4" /> size="sm"
不可用 variant="outline"
disabled
class="h-8 cursor-not-allowed text-xs opacity-50"
>
<XIcon class="mr-1 h-3 w-3" />
{disabledReason}
</Button> </Button>
{:else if subscribed} {:else if subscribed}
<Button size="sm" variant="outline" disabled class="cursor-not-allowed"> <Button size="sm" variant="outline" disabled class="h-8 cursor-not-allowed text-xs">
<CheckIcon class="mr-2 h-4 w-4" /> <CheckIcon class="mr-1 h-3 w-3" />
已订阅 已订阅
</Button> </Button>
{:else} {:else}
<Button <Button
size="sm" size="sm"
variant="default" variant="outline"
onclick={handleSubscribe} onclick={handleSubscribe}
class="cursor-pointer" class="h-8 cursor-pointer text-xs font-medium"
{disabled}
> >
<PlusIcon class="mr-2 h-4 w-4" /> <PlusIcon class="mr-1 h-3 w-3" />
快捷订阅 订阅
</Button> </Button>
{/if} {/if}
</div> </div>
@@ -256,3 +265,5 @@
<!-- 订阅对话框 --> <!-- 订阅对话框 -->
<SubscriptionDialog bind:open={dialogOpen} {item} {type} onSuccess={handleSubscriptionSuccess} /> <SubscriptionDialog bind:open={dialogOpen} {item} {type} onSuccess={handleSubscriptionSuccess} />
<!-- 订阅对话框 -->
<SubscriptionDialog bind:open={dialogOpen} {item} {type} onSuccess={handleSubscriptionSuccess} />
@@ -8,4 +8,4 @@
}: CollapsiblePrimitive.RootProps = $props(); }: CollapsiblePrimitive.RootProps = $props();
</script> </script>
<CollapsiblePrimitive.Root bind:ref data-slot="collapsible" {...restProps} /> <CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />
@@ -1,8 +1,6 @@
import { Collapsible as CollapsiblePrimitive } from 'bits-ui'; import Root from './collapsible.svelte';
import Trigger from './collapsible-trigger.svelte';
const Root = CollapsiblePrimitive.Root; import Content from './collapsible-content.svelte';
const Trigger = CollapsiblePrimitive.Trigger;
const Content = CollapsiblePrimitive.Content;
export { export {
Root, Root,
@@ -0,0 +1,40 @@
<script lang="ts">
import type { Command as CommandPrimitive, Dialog as DialogPrimitive } from 'bits-ui';
import type { Snippet } from 'svelte';
import Command from './command.svelte';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import type { WithoutChildrenOrChild } from '$lib/utils.js';
let {
open = $bindable(false),
ref = $bindable(null),
value = $bindable(''),
title = 'Command Palette',
description = 'Search for a command to run',
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.RootProps> &
WithoutChildrenOrChild<CommandPrimitive.RootProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
title?: string;
description?: string;
} = $props();
</script>
<Dialog.Root bind:open {...restProps}>
<Dialog.Header class="sr-only">
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>{description}</Dialog.Description>
</Dialog.Header>
<Dialog.Content class="overflow-hidden p-0" {portalProps}>
<Command
class="**:data-[slot=command-input-wrapper]:h-12 [&_[data-command-group]]:px-2 [&_[data-command-group]:not([hidden])_~[data-command-group]]:pt-0 [&_[data-command-input-wrapper]_svg]:h-5 [&_[data-command-input-wrapper]_svg]:w-5 [&_[data-command-input]]:h-12 [&_[data-command-item]]:px-2 [&_[data-command-item]]:py-3 [&_[data-command-item]_svg]:h-5 [&_[data-command-item]_svg]:w-5"
{...restProps}
bind:value
bind:ref
{children}
/>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.EmptyProps = $props();
</script>
<CommandPrimitive.Empty
bind:ref
data-slot="command-empty"
class={cn('py-6 text-center text-sm', className)}
{...restProps}
/>
@@ -0,0 +1,30 @@
<script lang="ts">
import { Command as CommandPrimitive, useId } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
heading,
value,
...restProps
}: CommandPrimitive.GroupProps & {
heading?: string;
} = $props();
</script>
<CommandPrimitive.Group
bind:ref
data-slot="command-group"
class={cn('text-foreground overflow-hidden p-1', className)}
value={value ?? heading ?? `----${useId()}`}
{...restProps}
>
{#if heading}
<CommandPrimitive.GroupHeading class="text-muted-foreground px-2 py-1.5 text-xs font-medium">
{heading}
</CommandPrimitive.GroupHeading>
{/if}
<CommandPrimitive.GroupItems {children} />
</CommandPrimitive.Group>
@@ -0,0 +1,26 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import SearchIcon from '@lucide/svelte/icons/search';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
value = $bindable(''),
...restProps
}: CommandPrimitive.InputProps = $props();
</script>
<div class="flex h-9 items-center gap-2 border-b px-3" data-slot="command-input-wrapper">
<SearchIcon class="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
class={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className
)}
bind:ref
{...restProps}
bind:value
/>
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.ItemProps = $props();
</script>
<CommandPrimitive.Item
bind:ref
data-slot="command-item"
class={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.LinkItemProps = $props();
</script>
<CommandPrimitive.LinkItem
bind:ref
data-slot="command-item"
class={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.ListProps = $props();
</script>
<CommandPrimitive.List
bind:ref
data-slot="command-list"
class={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)}
{...restProps}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.SeparatorProps = $props();
</script>
<CommandPrimitive.Separator
bind:ref
data-slot="command-separator"
class={cn('bg-border -mx-1 h-px', className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="command-shortcut"
class={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,22 @@
<script lang="ts">
import { Command as CommandPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
value = $bindable(''),
class: className,
...restProps
}: CommandPrimitive.RootProps = $props();
</script>
<CommandPrimitive.Root
bind:value
bind:ref
data-slot="command"
class={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className
)}
{...restProps}
/>
@@ -0,0 +1,40 @@
import { Command as CommandPrimitive } from 'bits-ui';
import Root from './command.svelte';
import Dialog from './command-dialog.svelte';
import Empty from './command-empty.svelte';
import Group from './command-group.svelte';
import Item from './command-item.svelte';
import Input from './command-input.svelte';
import List from './command-list.svelte';
import Separator from './command-separator.svelte';
import Shortcut from './command-shortcut.svelte';
import LinkItem from './command-link-item.svelte';
const Loading = CommandPrimitive.Loading;
export {
Root,
Dialog,
Empty,
Group,
Item,
LinkItem,
Input,
List,
Separator,
Shortcut,
Loading,
//
Root as Command,
Dialog as CommandDialog,
Empty as CommandEmpty,
Group as CommandGroup,
Item as CommandItem,
LinkItem as CommandLinkItem,
Input as CommandInput,
List as CommandList,
Separator as CommandSeparator,
Shortcut as CommandShortcut,
Loading as CommandLoading
};
@@ -0,0 +1,41 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CheckIcon from '@lucide/svelte/icons/check';
import MinusIcon from '@lucide/svelte/icons/minus';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import type { Snippet } from 'svelte';
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
{#if indeterminate}
<MinusIcon class="size-4" />
{:else}
<CheckIcon class={cn('size-4', !checked && 'text-transparent')} />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>
@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
sideOffset = 4,
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: DropdownMenuPrimitive.PortalProps;
} = $props();
</script>
<DropdownMenuPrimitive.Portal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
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 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className
)}
{...restProps}
/>
</DropdownMenuPrimitive.Portal>
@@ -0,0 +1,22 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
</script>
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
inset,
variant = 'default',
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: 'default' | 'destructive';
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dropdown-menu-label"
data-inset={inset}
class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props();
</script>
<DropdownMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="dropdown-menu-radio-group"
{...restProps}
/>
@@ -0,0 +1,31 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CircleIcon from '@lucide/svelte/icons/circle';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<DropdownMenuPrimitive.RadioItem
bind:ref
data-slot="dropdown-menu-radio-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
{#if checked}
<CircleIcon class="size-2 fill-current" />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</DropdownMenuPrimitive.RadioItem>
@@ -0,0 +1,17 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SeparatorProps = $props();
</script>
<DropdownMenuPrimitive.Separator
bind:ref
data-slot="dropdown-menu-separator"
class={cn('bg-border -mx-1 my-1 h-px', className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="dropdown-menu-shortcut"
class={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,20 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SubContentProps = $props();
</script>
<DropdownMenuPrimitive.SubContent
bind:ref
data-slot="dropdown-menu-sub-content"
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 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...restProps}
/>
@@ -0,0 +1,29 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.SubTrigger
bind:ref
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
</script>
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />
@@ -0,0 +1,49 @@
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CheckboxItem from './dropdown-menu-checkbox-item.svelte';
import Content from './dropdown-menu-content.svelte';
import Group from './dropdown-menu-group.svelte';
import Item from './dropdown-menu-item.svelte';
import Label from './dropdown-menu-label.svelte';
import RadioGroup from './dropdown-menu-radio-group.svelte';
import RadioItem from './dropdown-menu-radio-item.svelte';
import Separator from './dropdown-menu-separator.svelte';
import Shortcut from './dropdown-menu-shortcut.svelte';
import Trigger from './dropdown-menu-trigger.svelte';
import SubContent from './dropdown-menu-sub-content.svelte';
import SubTrigger from './dropdown-menu-sub-trigger.svelte';
import GroupHeading from './dropdown-menu-group-heading.svelte';
const Sub = DropdownMenuPrimitive.Sub;
const Root = DropdownMenuPrimitive.Root;
export {
CheckboxItem,
Content,
Root as DropdownMenu,
CheckboxItem as DropdownMenuCheckboxItem,
Content as DropdownMenuContent,
Group as DropdownMenuGroup,
Item as DropdownMenuItem,
Label as DropdownMenuLabel,
RadioGroup as DropdownMenuRadioGroup,
RadioItem as DropdownMenuRadioItem,
Separator as DropdownMenuSeparator,
Shortcut as DropdownMenuShortcut,
Sub as DropdownMenuSub,
SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger,
Trigger as DropdownMenuTrigger,
GroupHeading as DropdownMenuGroupHeading,
Group,
GroupHeading,
Item,
Label,
RadioGroup,
RadioItem,
Root,
Separator,
Shortcut,
Sub,
SubContent,
SubTrigger,
Trigger
};
+1 -1
View File
@@ -24,7 +24,7 @@
bind:this={ref} bind:this={ref}
data-slot="input" data-slot="input"
class={cn( class={cn(
'selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', 'selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className className
@@ -11,7 +11,7 @@
<SeparatorPrimitive.Root <SeparatorPrimitive.Root
bind:ref bind:ref
data-slot="separator-root" data-slot="separator"
class={cn( class={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px', 'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className className
@@ -7,8 +7,6 @@
class: className, class: className,
...restProps ...restProps
}: SheetPrimitive.OverlayProps = $props(); }: SheetPrimitive.OverlayProps = $props();
export { className as class };
</script> </script>
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
@@ -22,7 +22,7 @@
onclick={sidebar.toggle} onclick={sidebar.toggle}
title="Toggle Sidebar" title="Toggle Sidebar"
class={cn( class={cn(
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex', 'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-[calc(1/2*100%-1px)] after:w-[2px] sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize', 'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize', '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full', 'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
@@ -14,6 +14,6 @@
bind:ref bind:ref
data-slot="sidebar-separator" data-slot="sidebar-separator"
data-sidebar="separator" data-sidebar="separator"
class={cn('bg-sidebar-border mx-2 w-auto', className)} class={cn('bg-sidebar-border', className)}
{...restProps} {...restProps}
/> />
@@ -70,7 +70,7 @@
'group-data-[collapsible=offcanvas]:w-0', 'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180', 'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset' variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]' ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)' : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
)} )}
></div> ></div>
@@ -34,9 +34,9 @@
class={cn( class={cn(
'bg-primary z-50 size-2.5 rotate-45 rounded-[2px]', 'bg-primary z-50 size-2.5 rotate-45 rounded-[2px]',
'data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]', 'data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]',
'data-[side=bottom]:translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]', 'data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]',
'data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2', 'data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2',
'data-[side=left]:translate-y-[calc(50%_-_3px)]', 'data-[side=left]:-translate-y-[calc(50%_-_3px)]',
arrowClasses arrowClasses
)} )}
{...props} {...props}
+24 -34
View File
@@ -17,8 +17,6 @@
export let customSubtitle: string = ''; // 自定义副标题 export let customSubtitle: string = ''; // 自定义副标题
export let taskNames: string[] = []; // 自定义任务名称 export let taskNames: string[] = []; // 自定义任务名称
export let showProgress: boolean = true; // 是否显示进度信息 export let showProgress: boolean = true; // 是否显示进度信息
export let progressHeight: string = 'h-2'; // 进度条高度
export let gap: string = 'gap-1'; // 进度条间距
export let onReset: (() => Promise<void>) | null = null; // 自定义重置函数 export let onReset: (() => Promise<void>) | null = null; // 自定义重置函数
export let resetDialogOpen = false; // 导出对话框状态,让父组件可以控制 export let resetDialogOpen = false; // 导出对话框状态,让父组件可以控制
export let resetting = false; export let resetting = false;
@@ -35,11 +33,11 @@
function getSegmentColor(status: number): string { function getSegmentColor(status: number): string {
if (status === 7) { if (status === 7) {
return 'bg-green-500'; // 绿色 - 成功 return 'bg-emerald-500'; // 恢复更高对比度的绿色
} else if (status === 0) { } else if (status === 0) {
return 'bg-yellow-500'; // 色 - 未开始 return 'bg-slate-400'; // 恢复更清晰的灰色 - 未开始
} else { } else {
return 'bg-red-500'; // 红色 - 失败 return 'bg-rose-500'; // 恢复更清晰的红色 - 失败
} }
} }
@@ -52,9 +50,9 @@
const failed = downloadStatus.filter((status) => status !== 7 && status !== 0).length; const failed = downloadStatus.filter((status) => status !== 7 && status !== 0).length;
if (completed === total) { if (completed === total) {
return { text: '全部完成', color: 'default' }; return { text: '完成', color: 'outline' }; // 更简洁的文案
} else if (failed > 0) { } else if (failed > 0) {
return { text: '部分失败', color: 'destructive' }; return { text: '失败', color: 'destructive' };
} else { } else {
return { text: '进行中', color: 'secondary' }; return { text: '进行中', color: 'secondary' };
} }
@@ -88,33 +86,30 @@
// 根据模式确定显示的标题和副标题 // 根据模式确定显示的标题和副标题
$: displayTitle = customTitle || video.name; $: displayTitle = customTitle || video.name;
$: displaySubtitle = customSubtitle || video.upper_name; $: displaySubtitle = customSubtitle || video.upper_name;
$: showUserIcon = mode === 'default';
$: cardClasses = $: cardClasses =
mode === 'default' mode === 'default'
? 'group flex h-full min-w-0 flex-col transition-shadow hover:shadow-md' ? 'group flex h-full min-w-0 flex-col transition-all hover:shadow-lg hover:shadow-primary/5 border-border/50'
: 'transition-shadow hover:shadow-md'; : 'transition-all hover:shadow-lg border-border/50';
</script> </script>
<Card class={cardClasses}> <Card class={cardClasses}>
<CardHeader class={mode === 'default' ? 'flex-shrink-0 pb-3' : 'pb-3'}> <CardHeader class="flex-shrink-0 pb-3">
<div class="flex min-w-0 items-start justify-between gap-2"> <div class="flex min-w-0 items-start justify-between gap-3">
<CardTitle <CardTitle
class="line-clamp-2 min-w-0 flex-1 cursor-default {mode === 'default' class="line-clamp-2 min-w-0 flex-1 cursor-default {mode === 'default'
? 'text-base' ? 'text-sm'
: 'text-base'} leading-tight" : 'text-sm'} leading-relaxed font-medium"
title={displayTitle} title={displayTitle}
> >
{displayTitle} {displayTitle}
</CardTitle> </CardTitle>
<Badge variant={overallStatus.color} class="shrink-0 text-xs"> <Badge variant={overallStatus.color} class="shrink-0 px-2 py-1 text-xs font-medium">
{overallStatus.text} {overallStatus.text}
</Badge> </Badge>
</div> </div>
{#if displaySubtitle} {#if displaySubtitle}
<div class="text-muted-foreground flex min-w-0 items-center gap-1 text-sm"> <div class="text-muted-foreground mt-1.5 flex min-w-0 items-center gap-1 text-sm">
{#if showUserIcon} <UserIcon class="h-3.5 w-3.5 shrink-0" />
<UserIcon class="h-3 w-3 shrink-0" />
{/if}
<span class="min-w-0 cursor-default truncate" title={displaySubtitle}> <span class="min-w-0 cursor-default truncate" title={displaySubtitle}>
{displaySubtitle} {displaySubtitle}
</span> </span>
@@ -122,34 +117,30 @@
{/if} {/if}
</CardHeader> </CardHeader>
<CardContent <CardContent
class={mode === 'default' ? 'flex min-w-0 flex-1 flex-col justify-end pt-0' : 'pt-0'} class={mode === 'default' ? 'flex min-w-0 flex-1 flex-col justify-end pt-0 pb-3' : 'pt-0 pb-4'}
> >
<div class="space-y-3"> <div class="space-y-3">
<!-- 进度条区域 --> <!-- 进度条区域 -->
{#if showProgress} {#if showProgress}
<div class="space-y-2"> <div class="space-y-2">
<div <!-- 进度信息 -->
class="text-muted-foreground flex justify-between {mode === 'default' <div class="text-muted-foreground flex justify-between text-sm font-medium">
? 'text-xs'
: 'text-xs'}"
>
<span class="truncate">下载进度</span> <span class="truncate">下载进度</span>
<span class="shrink-0">{completed}/{total}</span> <span class="shrink-0">{completed}/{total}</span>
</div> </div>
<!-- 进度条 --> <!-- 进度条 -->
<div class="flex w-full {gap}"> <div class="flex w-full gap-0.5">
{#each video.download_status as status, index (index)} {#each video.download_status as status, index (index)}
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger class="flex-1"> <Tooltip.Trigger class="flex-1">
<div <div
class="{progressHeight} w-full cursor-help rounded-sm transition-all {getSegmentColor( class="h-1.5 w-full cursor-help rounded-full transition-all {getSegmentColor(
status status
)}" )} hover:opacity-80"
></div> ></div>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content> <Tooltip.Content>
<p>{getTaskName(index)}: {getStatusText(status)}</p> <p class="text-sm">{getTaskName(index)}: {getStatusText(status)}</p>
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
{/each} {/each}
@@ -157,13 +148,12 @@
</div> </div>
{/if} {/if}
<!-- 操作按钮 -->
{#if showActions && mode === 'default'} {#if showActions && mode === 'default'}
<div class="flex min-w-0 gap-1.5"> <div class="flex min-w-0 gap-1.5 pt-1">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
class="min-w-0 flex-1 cursor-pointer px-2 text-xs" class="hover:bg-accent hover:text-accent-foreground h-8 min-w-0 flex-1 cursor-pointer px-2 text-xs font-medium"
onclick={handleViewDetail} onclick={handleViewDetail}
> >
<InfoIcon class="mr-1 h-3 w-3 shrink-0" /> <InfoIcon class="mr-1 h-3 w-3 shrink-0" />
@@ -172,7 +162,7 @@
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
class="shrink-0 cursor-pointer px-2" class="hover:bg-accent hover:text-accent-foreground h-8 shrink-0 cursor-pointer px-2"
onclick={() => (resetDialogOpen = true)} onclick={() => (resetDialogOpen = true)}
> >
<RotateCcwIcon class="h-3 w-3" /> <RotateCcwIcon class="h-3 w-3" />
+3 -3
View File
@@ -1,9 +1,9 @@
import { MediaQuery } from 'svelte/reactivity'; import { MediaQuery } from 'svelte/reactivity';
const MOBILE_BREAKPOINT = 768; const DEFAULT_MOBILE_BREAKPOINT = 768;
export class IsMobile extends MediaQuery { export class IsMobile extends MediaQuery {
constructor() { constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) {
super(`max-width: ${MOBILE_BREAKPOINT - 1}px`); super(`max-width: ${breakpoint - 1}px`);
} }
} }
View File
-2
View File
@@ -3,8 +3,6 @@ import { writable } from 'svelte/store';
export interface BreadcrumbItem { export interface BreadcrumbItem {
href?: string; href?: string;
label: string; label: string;
isActive?: boolean;
onClick?: () => void;
} }
export const breadcrumbStore = writable<BreadcrumbItem[]>([]); export const breadcrumbStore = writable<BreadcrumbItem[]>([]);
+1 -1
View File
@@ -28,7 +28,7 @@ export const ToQuery = (state: AppState): string => {
params.set(videoSource.type, videoSource.id); params.set(videoSource.type, videoSource.id);
} }
const queryString = params.toString(); const queryString = params.toString();
return queryString ? `?${queryString}` : ''; return queryString ? `videos?${queryString}` : 'videos';
}; };
export const setQuery = (query: string) => { export const setQuery = (query: string) => {
-13
View File
@@ -1,13 +0,0 @@
import { writable } from 'svelte/store';
import { type VideoSourcesResponse } from '$lib/types';
export const videoSourceStore = writable<VideoSourcesResponse | undefined>(undefined);
// 便捷的设置和清除方法
export const setVideoSources = (sources: VideoSourcesResponse) => {
videoSourceStore.set(sources);
};
export const clearFilter = () => {
videoSourceStore.set(undefined);
};
+11 -63
View File
@@ -1,78 +1,26 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import AppSidebar from '$lib/components/app-sidebar.svelte'; import AppSidebar from '$lib/components/app-sidebar.svelte';
import SearchBar from '$lib/components/search-bar.svelte';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { goto } from '$app/navigation';
import { appStateStore, resetCurrentPage, setQuery, ToQuery } from '$lib/stores/filter';
import { Toaster } from '$lib/components/ui/sonner/index.js';
import { breadcrumbStore } from '$lib/stores/breadcrumb';
import BreadCrumb from '$lib/components/bread-crumb.svelte'; import BreadCrumb from '$lib/components/bread-crumb.svelte';
import { videoSourceStore, setVideoSources } from '$lib/stores/video-source'; import { Separator } from '$lib/components/ui/separator/index.js';
import { onMount } from 'svelte'; import { breadcrumbStore } from '$lib/stores/breadcrumb';
import api from '$lib/api'; import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { toast } from 'svelte-sonner'; import { Toaster } from '$lib/components/ui/sonner/index.js';
import type { ApiError } from '$lib/types';
let dataLoaded = false;
async function handleSearch(query: string) {
setQuery(query);
resetCurrentPage();
goto(`/${ToQuery($appStateStore)}`);
}
// 初始化共用数据
onMount(async () => {
// 初始化视频源数据,所有组件都会用到
if (!$videoSourceStore) {
try {
const response = await api.getVideoSources();
setVideoSources(response.data);
} catch (error) {
console.error('加载视频来源失败:', error);
toast.error('加载视频来源失败', {
description: (error as ApiError).message
});
}
}
dataLoaded = true;
});
// 从全局状态获取当前查询值
$: searchValue = $appStateStore.query;
</script> </script>
<Toaster /> <Toaster />
<Sidebar.Provider> <Sidebar.Provider>
<div class="flex min-h-screen w-full">
<div data-sidebar="sidebar">
<AppSidebar /> <AppSidebar />
</div> <Sidebar.Inset class="flex flex-col" style="height: calc(100vh - 1rem)">
<Sidebar.Inset class="min-h-screen flex-1"> <header class="flex h-16 shrink-0 items-center gap-2">
<div <div class="flex items-center gap-2 px-4">
class="bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-[73px] w-full items-center border-b backdrop-blur" <Sidebar.Trigger class="-ml-1" />
> <Separator orientation="vertical" class="mr-2 data-[orientation=vertical]:h-4" />
<div class="flex w-full items-center gap-4 px-6">
<Sidebar.Trigger class="shrink-0" data-sidebar="trigger" />
<div class="flex-1">
<SearchBar onSearch={handleSearch} value={searchValue} />
</div>
</div>
</div>
<div class="bg-background min-h-screen w-full">
<div class="w-full px-6 py-6">
{#if $breadcrumbStore.length > 0}
<div class="mb-6">
<BreadCrumb items={$breadcrumbStore} /> <BreadCrumb items={$breadcrumbStore} />
</div> </div>
{/if} </header>
{#if dataLoaded} <div class="w-full overflow-y-auto px-6 py-2" style="scrollbar-width: thin;">
<slot /> <slot />
{/if}
</div>
</div> </div>
</Sidebar.Inset> </Sidebar.Inset>
</div>
</Sidebar.Provider> </Sidebar.Provider>
+381 -235
View File
@@ -1,89 +1,51 @@
<script lang="ts"> <script lang="ts">
import VideoCard from '$lib/components/video-card.svelte';
import FilterBadge from '$lib/components/filter-badge.svelte';
import Pagination from '$lib/components/pagination.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import api from '$lib/api';
import type { VideosResponse, VideoSourcesResponse, ApiError } from '$lib/types';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { goto } from '$app/navigation'; import { Progress } from '$lib/components/ui/progress/index.js';
import { videoSourceStore } from '$lib/stores/video-source'; import { Badge } from '$lib/components/ui/badge/index.js';
import { VIDEO_SOURCES } from '$lib/consts'; import * as Chart from '$lib/components/ui/chart/index.js';
import MyChartTooltip from '$lib/components/custom/my-chart-tooltip.svelte';
import { curveNatural } from 'd3-shape';
import { BarChart, AreaChart } from 'layerchart';
import { setBreadcrumb } from '$lib/stores/breadcrumb'; import { setBreadcrumb } from '$lib/stores/breadcrumb';
import {
appStateStore,
clearVideoSourceFilter,
resetCurrentPage,
setAll,
setCurrentPage,
ToQuery
} from '$lib/stores/filter';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import api from '$lib/api';
import type { DashBoardResponse, SysInfoResponse, ApiError } from '$lib/types';
import DatabaseIcon from '@lucide/svelte/icons/database';
import HeartIcon from '@lucide/svelte/icons/heart';
import FolderIcon from '@lucide/svelte/icons/folder';
import UserIcon from '@lucide/svelte/icons/user';
import ClockIcon from '@lucide/svelte/icons/clock';
import VideoIcon from '@lucide/svelte/icons/video';
import HardDriveIcon from '@lucide/svelte/icons/hard-drive';
import CpuIcon from '@lucide/svelte/icons/cpu';
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
const pageSize = 20; let dashboardData: DashBoardResponse | null = null;
let sysInfo: SysInfoResponse | null = null;
let videosData: VideosResponse | null = null;
let loading = false; let loading = false;
let sysInfoEventSource: EventSource | null = null;
let lastSearch: string | null = null; function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
let resetAllDialogOpen = false; const k = 1024;
let resettingAll = false; const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
function getApiParams(searchParams: URLSearchParams) { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
let videoSource = null;
for (const source of Object.values(VIDEO_SOURCES)) {
const value = searchParams.get(source.type);
if (value) {
videoSource = { type: source.type, id: value };
}
}
return {
query: searchParams.get('query') || '',
videoSource,
pageNum: parseInt(searchParams.get('page') || '0')
};
} }
function getFilterContent(type: string, id: string) { function formatCpu(cpu: number): string {
const filterTitle = Object.values(VIDEO_SOURCES).find((s) => s.type === type)?.title || ''; return `${cpu.toFixed(1)}%`;
let filterName = '';
const videoSources = $videoSourceStore;
if (videoSources && type && id) {
const sources = videoSources[type as keyof VideoSourcesResponse];
filterName = sources?.find((s) => s.id.toString() === id)?.name || '';
}
return {
title: filterTitle,
name: filterName
};
} }
async function loadVideos( async function loadDashboard() {
query: string,
pageNum: number = 0,
filter?: { type: string; id: string } | null
) {
loading = true; loading = true;
try { try {
const params: Record<string, string | number> = { const response = await api.getDashboard();
page: pageNum, dashboardData = response.data;
page_size: pageSize
};
if (query) {
params.query = query;
}
if (filter) {
params[filter.type] = parseInt(filter.id);
}
const result = await api.getVideos(params);
videosData = result.data;
} catch (error) { } catch (error) {
console.error('加载视频失败:', error); console.error('加载仪表盘数据失败:', error);
toast.error('加载视频失败', { toast.error('加载仪表盘数据失败', {
description: (error as ApiError).message description: (error as ApiError).message
}); });
} finally { } finally {
@@ -91,188 +53,372 @@
} }
} }
async function handlePageChange(pageNum: number) { // 启动系统信息流
setCurrentPage(pageNum); function startSysInfoStream() {
goto(`/${ToQuery($appStateStore)}`); sysInfoEventSource = api.createSysInfoStream(
(data) => {
sysInfo = data;
},
(error) => {
console.error('系统信息流错误:', error);
toast.error('系统信息流出现错误,请稍后重试');
}
);
} }
async function handleSearchParamsChange(searchParams: URLSearchParams) { // 停止系统信息流
const { query, videoSource, pageNum } = getApiParams(searchParams); function stopSysInfoStream() {
setAll(query, pageNum, videoSource); if (sysInfoEventSource) {
loadVideos(query, pageNum, videoSource); sysInfoEventSource.close();
sysInfoEventSource = null;
}
} }
function handleFilterRemove() { onMount(() => {
clearVideoSourceFilter(); setBreadcrumb([{ label: '仪表盘' }]);
resetCurrentPage(); loadDashboard();
goto(`/${ToQuery($appStateStore)}`); startSysInfoStream();
} return () => {
stopSysInfoStream();
async function handleResetVideo(id: number) { };
try {
const result = await api.resetVideo(id);
const data = result.data;
if (data.resetted) {
toast.success('重置成功', {
description: `视频「${data.video.name}」已重置`
}); });
const { query, currentPage, videoSource } = $appStateStore;
await loadVideos(query, currentPage, videoSource);
} else {
toast.info('重置无效', {
description: `视频「${data.video.name}」没有失败的状态,无需重置`
});
}
} catch (error) {
console.error('重置失败:', error);
toast.error('重置失败', {
description: (error as ApiError).message
});
}
}
async function handleResetAllVideos() { // 图表配置
resettingAll = true; const videoChartConfig = {
try { videos: {
const result = await api.resetAllVideos(); label: '视频数量',
const data = result.data; color: 'var(--chart-1)'
if (data.resetted) {
toast.success('重置成功', {
description: `已重置 ${data.resetted_videos_count} 个视频和 ${data.resetted_pages_count} 个分页`
});
const { query, currentPage, videoSource } = $appStateStore;
await loadVideos(query, currentPage, videoSource);
} else {
toast.info('没有需要重置的视频');
}
} catch (error) {
console.error('重置失败:', error);
toast.error('重置失败', {
description: (error as ApiError).message
});
} finally {
resettingAll = false;
resetAllDialogOpen = false;
}
} }
} satisfies Chart.ChartConfig;
$: if ($page.url.search !== lastSearch) { const memoryChartConfig = {
lastSearch = $page.url.search; used: {
handleSearchParamsChange($page.url.searchParams); label: '整体占用',
color: 'var(--chart-1)'
},
process: {
label: '程序占用',
color: 'var(--chart-2)'
} }
} satisfies Chart.ChartConfig;
onMount(async () => { const cpuChartConfig = {
setBreadcrumb([ used: {
label: '整体占用',
color: 'var(--chart-1)'
},
process: {
label: '程序占用',
color: 'var(--chart-2)'
}
} satisfies Chart.ChartConfig;
let memoryHistory: Array<{ time: Date; used: number; process: number }> = [];
let cpuHistory: Array<{ time: Date; used: number; process: number }> = [];
$: if (sysInfo) {
memoryHistory = [
...memoryHistory.slice(-19),
{ {
label: '主页', time: new Date(),
isActive: true used: sysInfo.used_memory,
process: sysInfo.process_memory
} }
]); ];
}); cpuHistory = [
...cpuHistory.slice(-19),
$: totalPages = videosData ? Math.ceil(videosData.total_count / pageSize) : 0; {
$: filterContent = $appStateStore.videoSource time: new Date(),
? getFilterContent($appStateStore.videoSource.type, $appStateStore.videoSource.id) used: sysInfo.used_cpu,
: { title: '', name: '' }; process: sysInfo.process_cpu
}
];
}
// 计算磁盘使用率
$: diskUsagePercent = sysInfo
? ((sysInfo.total_disk - sysInfo.available_disk) / sysInfo.total_disk) * 100
: 0;
</script> </script>
<svelte:head> <svelte:head>
<title>主页 - Bili Sync</title> <title>仪表盘 - Bili Sync</title>
<style>
body {
/* 避免最右侧 tooltip 溢出导致的无限抖动 */
overflow-x: hidden;
}
</style>
</svelte:head> </svelte:head>
<FilterBadge <div class="space-y-6">
filterTitle={filterContent.title} {#if loading}
filterName={filterContent.name}
onRemove={handleFilterRemove}
/>
<!-- 统计信息 -->
{#if videosData}
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="text-muted-foreground text-sm">
{videosData.total_count} 个视频
</div>
<div class="text-muted-foreground text-sm">
{totalPages}
</div>
</div>
<div class="flex items-center gap-2">
<Button
size="sm"
variant="outline"
class="cursor-pointer text-xs"
onclick={() => (resetAllDialogOpen = true)}
disabled={resettingAll || loading}
>
<RotateCcwIcon class="mr-1.5 h-3 w-3 {resettingAll ? 'animate-spin' : ''}" />
重置所有视频
</Button>
</div>
</div>
{/if}
<!-- 视频卡片网格 -->
{#if loading}
<div class="flex items-center justify-center py-12"> <div class="flex items-center justify-center py-12">
<div class="text-muted-foreground">加载中...</div> <div class="text-muted-foreground">加载中...</div>
</div> </div>
{:else if videosData?.videos.length}
<div
style="display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; width: 100%; max-width: none; justify-items: start;"
>
{#each videosData.videos as video (video.id)}
<div style="max-width: 400px; width: 100%;">
<VideoCard
{video}
onReset={async () => {
await handleResetVideo(video.id);
}}
/>
</div>
{/each}
</div>
<!-- 翻页组件 -->
<Pagination
currentPage={$appStateStore.currentPage}
{totalPages}
onPageChange={handlePageChange}
/>
{:else}
<div class="flex items-center justify-center py-12">
<div class="space-y-2 text-center">
<p class="text-muted-foreground">暂无视频数据</p>
<p class="text-muted-foreground text-sm">尝试搜索或检查视频来源配置</p>
</div>
</div>
{/if}
<!-- 重置所有视频确认对话框 -->
<AlertDialog.Root bind:open={resetAllDialogOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>重置所有视频</AlertDialog.Title>
<AlertDialog.Description>
此操作将重置所有视频和分页的失败状态为未下载状态,使它们在下次下载任务中重新尝试。
<br />
<strong class="text-destructive">此操作不可撤销,确定要继续吗?</strong>
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel disabled={resettingAll}>取消</AlertDialog.Cancel>
<AlertDialog.Action
onclick={handleResetAllVideos}
disabled={resettingAll}
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{#if resettingAll}
<RotateCcwIcon class="mr-2 h-4 w-4 animate-spin" />
重置中...
{:else} {:else}
确认重置 <div class="grid gap-4 md:grid-cols-3">
<Card class="md:col-span-1">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">存储空间</CardTitle>
<HardDriveIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if sysInfo}
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="text-2xl font-bold">{formatBytes(sysInfo.available_disk)} 可用</div>
<div class="text-muted-foreground text-sm">
{formatBytes(sysInfo.total_disk)}
</div>
</div>
<Progress value={diskUsagePercent} class="h-2" />
<div class="text-muted-foreground text-xs">
已使用 {diskUsagePercent.toFixed(1)}% 的存储空间
</div>
</div>
{:else}
<div class="text-muted-foreground text-sm">加载中...</div>
{/if} {/if}
</AlertDialog.Action> </CardContent>
</AlertDialog.Footer> </Card>
</AlertDialog.Content> <Card class="md:col-span-2">
</AlertDialog.Root> <CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">当前监听</CardTitle>
<DatabaseIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if dashboardData}
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<HeartIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">收藏夹</span>
</div>
<Badge variant="outline">{dashboardData.enabled_favorites}</Badge>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<FolderIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">合集</span>
</div>
<Badge variant="outline">{dashboardData.enabled_collections}</Badge>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UserIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">投稿</span>
</div>
<Badge variant="outline">{dashboardData.enabled_submissions}</Badge>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<ClockIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">稍后再看</span>
</div>
<Badge variant="outline">
{dashboardData.enable_watch_later ? '启用' : '禁用'}
</Badge>
</div>
</div>
{:else}
<div class="text-muted-foreground text-sm">加载中...</div>
{/if}
</CardContent>
</Card>
</div>
<div class="grid grid-cols-1 gap-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">最近入库</CardTitle>
<VideoIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if dashboardData && dashboardData.videos_by_day.length > 0}
<div class="mb-4 space-y-2">
<div class="flex items-center justify-between text-sm">
<span>近七日共新增视频</span>
<span class="font-medium"
>{dashboardData.videos_by_day.reduce((sum, v) => sum + v.cnt, 0)}</span
>
</div>
</div>
<Chart.Container config={videoChartConfig} class="h-[200px] w-full">
<BarChart
data={dashboardData.videos_by_day}
x="day"
axis="x"
series={[
{
key: 'cnt',
label: '新增视频',
color: videoChartConfig.videos.color
}
]}
props={{
bars: {
stroke: 'none',
rounded: 'all',
radius: 8,
initialHeight: 0
},
highlight: { area: { fill: 'none' } },
xAxis: { format: () => '' }
}}
>
{#snippet tooltip()}
<MyChartTooltip indicator="line" />
{/snippet}
</BarChart>
</Chart.Container>
{:else}
<div class="text-muted-foreground flex h-[300px] items-center justify-center text-sm">
暂无视频统计数据
</div>
{/if}</CardContent
>
</Card>
</div>
<!-- 第三行:系统监控 -->
<div class="grid gap-4 md:grid-cols-2">
<!-- 内存使用情况 -->
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">内存使用情况</CardTitle>
<MemoryStickIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if sysInfo}
<div class="mb-4 space-y-2">
<div class="flex items-center justify-between text-sm">
<span>当前内存使用</span>
<span class="font-medium"
>{formatBytes(sysInfo.used_memory)} / {formatBytes(sysInfo.total_memory)}</span
>
</div>
</div>
{/if}
{#if memoryHistory.length > 0}
<Chart.Container config={memoryChartConfig} class="h-[150px] w-full">
<AreaChart
data={memoryHistory}
x="time"
axis="x"
series={[
{
key: 'used',
label: memoryChartConfig.used.label,
color: memoryChartConfig.used.color
},
{
key: 'process',
label: memoryChartConfig.process.label,
color: memoryChartConfig.process.color
}
]}
props={{
area: {
curve: curveNatural,
line: { class: 'stroke-1' },
'fill-opacity': 0.4
},
xAxis: {
format: () => ''
}
}}
>
{#snippet tooltip()}
<MyChartTooltip
labelFormatter={(v: Date) => {
return new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
}).format(v);
}}
valueFormatter={(v: number) => formatBytes(v)}
indicator="line"
/>
{/snippet}
</AreaChart>
</Chart.Container>
{:else}
<div class="text-muted-foreground flex h-[200px] items-center justify-center text-sm">
等待数据...
</div>
{/if}
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">CPU 使用情况</CardTitle>
<CpuIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if sysInfo}
<div class="mb-4 space-y-2">
<div class="flex items-center justify-between text-sm">
<span>当前 CPU 使用率</span>
<span class="font-medium">{formatCpu(sysInfo.used_cpu)}</span>
</div>
</div>
{/if}
{#if cpuHistory.length > 0}
<Chart.Container config={cpuChartConfig} class="h-[150px] w-full">
<AreaChart
data={cpuHistory}
x="time"
axis="x"
series={[
{
key: 'used',
label: cpuChartConfig.used.label,
color: cpuChartConfig.used.color
},
{
key: 'process',
label: cpuChartConfig.process.label,
color: cpuChartConfig.process.color
}
]}
props={{
area: {
curve: curveNatural,
line: { class: 'stroke-1' },
'fill-opacity': 0.4
},
xAxis: {
format: () => ''
}
}}
>
{#snippet tooltip()}
<MyChartTooltip
labelFormatter={(v: Date) => {
return new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
}).format(v);
}}
valueFormatter={(v: number) => formatCpu(v)}
indicator="line"
/>
{/snippet}
</AreaChart>
</Chart.Container>
{:else}
<div class="text-muted-foreground flex h-[150px] items-center justify-center text-sm">
等待数据...
</div>
{/if}
</CardContent>
</Card>
</div>
{/if}
</div>
-425
View File
@@ -1,425 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { Progress } from '$lib/components/ui/progress/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Chart from '$lib/components/ui/chart/index.js';
import MyChartTooltip from '$lib/components/custom/my-chart-tooltip.svelte';
import { curveNatural } from 'd3-shape';
import { BarChart, AreaChart } from 'layerchart';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import { toast } from 'svelte-sonner';
import api from '$lib/api';
import type { DashBoardResponse, SysInfoResponse, ApiError } from '$lib/types';
import DatabaseIcon from '@lucide/svelte/icons/database';
import HeartIcon from '@lucide/svelte/icons/heart';
import FolderIcon from '@lucide/svelte/icons/folder';
import UserIcon from '@lucide/svelte/icons/user';
import ClockIcon from '@lucide/svelte/icons/clock';
import VideoIcon from '@lucide/svelte/icons/video';
import HardDriveIcon from '@lucide/svelte/icons/hard-drive';
import CpuIcon from '@lucide/svelte/icons/cpu';
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
let dashboardData: DashBoardResponse | null = null;
let sysInfo: SysInfoResponse | null = null;
let loading = false;
let sysInfoEventSource: EventSource | null = null;
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatCpu(cpu: number): string {
return `${cpu.toFixed(1)}%`;
}
async function loadDashboard() {
loading = true;
try {
const response = await api.getDashboard();
dashboardData = response.data;
} catch (error) {
console.error('加载仪表盘数据失败:', error);
toast.error('加载仪表盘数据失败', {
description: (error as ApiError).message
});
} finally {
loading = false;
}
}
// 启动系统信息流
function startSysInfoStream() {
try {
sysInfoEventSource = api.createSysInfoStream(
(data) => {
sysInfo = data;
},
(_error) => {
toast.error('系统信息流异常中断');
}
);
} catch (error) {
console.error('启动系统信息流失败:', error);
}
}
// 停止系统信息流
function stopSysInfoStream() {
if (sysInfoEventSource) {
sysInfoEventSource.close();
sysInfoEventSource = null;
}
}
onMount(() => {
setBreadcrumb([{ label: '仪表盘', isActive: true }]);
loadDashboard();
startSysInfoStream();
});
onDestroy(() => {
stopSysInfoStream();
});
// 图表配置
const videoChartConfig = {
videos: {
label: '视频数量',
color: 'var(--chart-1)'
}
} satisfies Chart.ChartConfig;
const memoryChartConfig = {
used: {
label: '整体占用',
color: 'var(--chart-1)'
},
process: {
label: '程序占用',
color: 'var(--chart-2)'
}
} satisfies Chart.ChartConfig;
const cpuChartConfig = {
used: {
label: '整体占用',
color: 'var(--chart-1)'
},
process: {
label: '程序占用',
color: 'var(--chart-2)'
}
} satisfies Chart.ChartConfig;
// 内存和 CPU 数据历史记录
let memoryHistory: Array<{ time: Date; used: number; process: number }> = [];
let cpuHistory: Array<{ time: Date; used: number; process: number }> = [];
// 更新历史数据
$: if (sysInfo) {
memoryHistory = [
...memoryHistory.slice(-19),
{
time: new Date(),
used: sysInfo.used_memory,
process: sysInfo.process_memory
}
];
cpuHistory = [
...cpuHistory.slice(-19),
{
time: new Date(),
used: sysInfo.used_cpu,
process: sysInfo.process_cpu
}
];
}
// 计算磁盘使用率
$: diskUsagePercent = sysInfo
? ((sysInfo.total_disk - sysInfo.available_disk) / sysInfo.total_disk) * 100
: 0;
</script>
<svelte:head>
<title>仪表盘 - Bili Sync</title>
</svelte:head>
<div class="space-y-6">
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="text-muted-foreground">加载中...</div>
</div>
{:else}
<div class="grid gap-4 md:grid-cols-3">
<!-- 存储空间卡片 -->
<Card class="md:col-span-1">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">存储空间</CardTitle>
<HardDriveIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if sysInfo}
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="text-2xl font-bold">{formatBytes(sysInfo.available_disk)} 可用</div>
<div class="text-muted-foreground text-sm">
{formatBytes(sysInfo.total_disk)}
</div>
</div>
<Progress value={diskUsagePercent} class="h-2" />
<div class="text-muted-foreground text-xs">
已使用 {diskUsagePercent.toFixed(1)}% 的存储空间
</div>
</div>
{:else}
<div class="text-muted-foreground text-sm">加载中...</div>
{/if}
</CardContent>
</Card>
<Card class="md:col-span-2">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">当前监听</CardTitle>
<DatabaseIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if dashboardData}
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<HeartIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">收藏夹</span>
</div>
<Badge variant="outline">{dashboardData.enabled_favorites}</Badge>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<FolderIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">合集</span>
</div>
<Badge variant="outline">{dashboardData.enabled_collections}</Badge>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UserIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">投稿</span>
</div>
<Badge variant="outline">{dashboardData.enabled_submissions}</Badge>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<ClockIcon class="text-muted-foreground h-4 w-4" />
<span class="text-sm">稍后再看</span>
</div>
<Badge variant="outline">
{dashboardData.enable_watch_later ? '启用' : '禁用'}
</Badge>
</div>
</div>
{:else}
<div class="text-muted-foreground text-sm">加载中...</div>
{/if}
</CardContent>
</Card>
</div>
<div class="grid grid-cols-1 gap-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">最近入库</CardTitle>
<VideoIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if dashboardData && dashboardData.videos_by_day.length > 0}
<div class="mb-4 space-y-2">
<div class="flex items-center justify-between text-sm">
<span>近七日共新增视频</span>
<span class="font-medium"
>{dashboardData.videos_by_day.reduce((sum, v) => sum + v.cnt, 0)}</span
>
</div>
</div>
<Chart.Container config={videoChartConfig} class="h-[200px] w-full">
<BarChart
data={dashboardData.videos_by_day}
x="day"
axis="x"
series={[
{
key: 'cnt',
label: '新增视频',
color: videoChartConfig.videos.color
}
]}
props={{
bars: {
stroke: 'none',
rounded: 'all',
radius: 8,
initialHeight: 0
},
highlight: { area: { fill: 'none' } },
xAxis: { format: () => '' }
}}
>
{#snippet tooltip()}
<MyChartTooltip indicator="line" />
{/snippet}
</BarChart>
</Chart.Container>
{:else}
<div class="text-muted-foreground flex h-[300px] items-center justify-center text-sm">
暂无视频统计数据
</div>
{/if}</CardContent
>
</Card>
</div>
<!-- 第三行:系统监控 -->
<div class="grid gap-4 md:grid-cols-2">
<!-- 内存使用情况 -->
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">内存使用情况</CardTitle>
<MemoryStickIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if sysInfo}
<div class="mb-4 space-y-2">
<div class="flex items-center justify-between text-sm">
<span>当前内存使用</span>
<span class="font-medium"
>{formatBytes(sysInfo.used_memory)} / {formatBytes(sysInfo.total_memory)}</span
>
</div>
</div>
{/if}
{#if memoryHistory.length > 0}
<Chart.Container config={memoryChartConfig} class="h-[150px] w-full">
<AreaChart
data={memoryHistory}
x="time"
axis="x"
series={[
{
key: 'used',
label: memoryChartConfig.used.label,
color: memoryChartConfig.used.color
},
{
key: 'process',
label: memoryChartConfig.process.label,
color: memoryChartConfig.process.color
}
]}
props={{
area: {
curve: curveNatural,
line: { class: 'stroke-1' },
'fill-opacity': 0.4
},
xAxis: {
format: () => ''
}
}}
>
{#snippet tooltip()}
<MyChartTooltip
labelFormatter={(v: Date) => {
return new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
}).format(v);
}}
valueFormatter={(v: number) => formatBytes(v)}
indicator="line"
/>
{/snippet}
</AreaChart>
</Chart.Container>
{:else}
<div class="text-muted-foreground flex h-[200px] items-center justify-center text-sm">
等待数据...
</div>
{/if}
</CardContent>
</Card>
<!-- CPU 使用情况 -->
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">CPU 使用情况</CardTitle>
<CpuIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if sysInfo}
<div class="mb-4 space-y-2">
<div class="flex items-center justify-between text-sm">
<span>当前 CPU 使用率</span>
<span class="font-medium">{formatCpu(sysInfo.used_cpu)}</span>
</div>
</div>
{/if}
{#if cpuHistory.length > 0}
<Chart.Container config={cpuChartConfig} class="h-[150px] w-full">
<AreaChart
data={cpuHistory}
x="time"
axis="x"
series={[
{
key: 'used',
label: cpuChartConfig.used.label,
color: cpuChartConfig.used.color
},
{
key: 'process',
label: cpuChartConfig.process.label,
color: cpuChartConfig.process.color
}
]}
props={{
area: {
curve: curveNatural,
line: { class: 'stroke-1' },
'fill-opacity': 0.4
},
xAxis: {
format: () => ''
}
}}
>
{#snippet tooltip()}
<MyChartTooltip
labelFormatter={(v: Date) => {
return new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
}).format(v);
}}
valueFormatter={(v: number) => formatCpu(v)}
indicator="line"
/>
{/snippet}
</AreaChart>
</Chart.Container>
{:else}
<div class="text-muted-foreground flex h-[150px] items-center justify-center text-sm">
等待数据...
</div>
{/if}
</CardContent>
</Card>
</div>
{/if}
</div>
+96
View File
@@ -0,0 +1,96 @@
<script lang="ts">
import api from '$lib/api';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import { onMount } from 'svelte';
import { Badge } from '$lib/components/ui/badge';
import { toast } from 'svelte-sonner';
let logEventSource: EventSource | null = null;
let logs: Array<{ timestamp: string; level: string; message: string }> = [];
let shouldAutoScroll = true;
function checkScrollPosition() {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
shouldAutoScroll = scrollTop + clientHeight >= scrollHeight - 5;
}
function scrollToBottom() {
if (shouldAutoScroll) {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' });
}
}
function startLogStream() {
if (logEventSource) {
logEventSource.close();
}
logEventSource = api.createLogStream(
(data: string) => {
logs = [...logs.slice(-200), JSON.parse(data)];
setTimeout(scrollToBottom, 0);
},
(error: Event) => {
console.error('日志流错误:', error);
toast.error('日志流出现错误,请稍后重试');
}
);
}
function stopLogStream() {
if (logEventSource) {
logEventSource.close();
logEventSource = null;
}
}
onMount(() => {
setBreadcrumb([{ label: '日志' }]);
window.addEventListener('scroll', checkScrollPosition);
startLogStream();
return () => {
stopLogStream();
window.removeEventListener('scroll', checkScrollPosition);
};
});
function getLevelColor(level: string) {
switch (level) {
case 'ERROR':
return 'text-red-600';
case 'WARN':
return 'text-yellow-600';
case 'INFO':
default:
return 'text-green-600';
}
}
</script>
<svelte:head>
<title>日志 - Bili Sync</title>
</svelte:head>
<div class="space-y-1">
{#each logs as log, index (index)}
<div
class="flex items-center gap-3 rounded-md p-1 font-mono text-xs {index % 2 === 0
? 'bg-muted/50'
: 'bg-background'}"
>
<span class="text-muted-foreground w-32 shrink-0">
{log.timestamp}
</span>
<Badge
class="w-16 shrink-0 justify-center {getLevelColor(log.level)} bg-primary/90 font-semibold"
>
{log.level}
</Badge>
<span class="flex-1 break-all">
{log.message}
</span>
</div>
{/each}
{#if logs.length === 0}
<div class="text-muted-foreground py-8 text-center">暂无日志记录</div>
{/if}
</div>
+2 -11
View File
@@ -1,11 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import SubscriptionCard from '$lib/components/subscription-card.svelte'; import SubscriptionCard from '$lib/components/subscription-card.svelte';
import Pagination from '$lib/components/pagination.svelte'; import Pagination from '$lib/components/pagination.svelte';
import { setBreadcrumb } from '$lib/stores/breadcrumb'; import { setBreadcrumb } from '$lib/stores/breadcrumb';
import { appStateStore, ToQuery } from '$lib/stores/filter';
import api from '$lib/api'; import api from '$lib/api';
import type { CollectionWithSubscriptionStatus, ApiError } from '$lib/types'; import type { CollectionWithSubscriptionStatus, ApiError } from '$lib/types';
@@ -45,14 +43,7 @@
onMount(async () => { onMount(async () => {
setBreadcrumb([ setBreadcrumb([
{ {
label: '主页', label: '我关注的合集'
onClick: () => {
goto(`/${ToQuery($appStateStore)}`);
}
},
{
label: '关注的合集',
isActive: true
} }
]); ]);
await loadCollections(); await loadCollections();
@@ -67,7 +58,7 @@
<div> <div>
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<div class="text-muted-foreground text-sm"> <div class=" text-sm">
{#if !loading} {#if !loading}
{totalCount} 个合集 {totalCount} 个合集
{/if} {/if}
+4 -12
View File
@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import SubscriptionCard from '$lib/components/subscription-card.svelte'; import SubscriptionCard from '$lib/components/subscription-card.svelte';
import { setBreadcrumb } from '$lib/stores/breadcrumb'; import { setBreadcrumb } from '$lib/stores/breadcrumb';
import { appStateStore, ToQuery } from '$lib/stores/filter';
import api from '$lib/api'; import api from '$lib/api';
import type { FavoriteWithSubscriptionStatus, ApiError } from '$lib/types'; import type { FavoriteWithSubscriptionStatus, ApiError } from '$lib/types';
@@ -32,15 +32,7 @@
} }
onMount(async () => { onMount(async () => {
setBreadcrumb([ setBreadcrumb([{ label: '我创建的收藏夹' }]);
{
label: '主页',
onClick: () => {
goto(`/${ToQuery($appStateStore)}`);
}
},
{ label: '我的收藏夹', isActive: true }
]);
await loadFavorites(); await loadFavorites();
}); });
@@ -52,7 +44,7 @@
<div> <div>
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<div class="text-muted-foreground text-sm"> <div class="text-sm">
{#if !loading} {#if !loading}
{favorites.length} 个收藏夹 {favorites.length} 个收藏夹
{/if} {/if}
+3 -14
View File
@@ -1,11 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import SubscriptionCard from '$lib/components/subscription-card.svelte'; import SubscriptionCard from '$lib/components/subscription-card.svelte';
import Pagination from '$lib/components/pagination.svelte'; import Pagination from '$lib/components/pagination.svelte';
import { setBreadcrumb } from '$lib/stores/breadcrumb'; import { setBreadcrumb } from '$lib/stores/breadcrumb';
import { appStateStore, ToQuery } from '$lib/stores/filter';
import api from '$lib/api'; import api from '$lib/api';
import type { UpperWithSubscriptionStatus, ApiError } from '$lib/types'; import type { UpperWithSubscriptionStatus, ApiError } from '$lib/types';
@@ -43,16 +41,7 @@
} }
onMount(async () => { onMount(async () => {
setBreadcrumb([ setBreadcrumb([{ label: '我关注的 UP 主' }]);
{
label: '主页',
onClick: () => {
goto(`/${ToQuery($appStateStore)}`);
}
},
{ label: '关注的UP主', isActive: true }
]);
await loadUppers(); await loadUppers();
}); });
@@ -65,9 +54,9 @@
<div> <div>
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<div class="text-muted-foreground text-sm"> <div class=" text-sm">
{#if !loading} {#if !loading}
{totalCount} 个UP主 {totalCount} UP
{/if} {/if}
</div> </div>
</div> </div>
+2 -12
View File
@@ -10,8 +10,6 @@
import api from '$lib/api'; import api from '$lib/api';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { setBreadcrumb } from '$lib/stores/breadcrumb'; import { setBreadcrumb } from '$lib/stores/breadcrumb';
import { goto } from '$app/navigation';
import { appStateStore, ToQuery } from '$lib/stores/filter';
import type { Config, ApiError } from '$lib/types'; import type { Config, ApiError } from '$lib/types';
let frontendToken = ''; // 前端认证token let frontendToken = ''; // 前端认证token
@@ -45,8 +43,8 @@
try { try {
api.setAuthToken(frontendToken.trim()); api.setAuthToken(frontendToken.trim());
localStorage.setItem('authToken', frontendToken.trim()); localStorage.setItem('authToken', frontendToken.trim());
loadConfig();
toast.success('前端认证成功'); toast.success('前端认证成功');
loadConfig(); // 认证成功后加载配置
} catch (error) { } catch (error) {
console.error('前端认证失败:', error); console.error('前端认证失败:', error);
toast.error('认证失败,请检查Token是否正确'); toast.error('认证失败,请检查Token是否正确');
@@ -75,15 +73,7 @@
} }
onMount(() => { onMount(() => {
setBreadcrumb([ setBreadcrumb([{ label: '设置' }]);
{
label: '主页',
onClick: () => {
goto(`/${ToQuery($appStateStore)}`);
}
},
{ label: '设置', isActive: true }
]);
const savedToken = localStorage.getItem('authToken'); const savedToken = localStorage.getItem('authToken');
if (savedToken) { if (savedToken) {
+2 -12
View File
@@ -17,8 +17,6 @@
import PlusIcon from '@lucide/svelte/icons/plus'; import PlusIcon from '@lucide/svelte/icons/plus';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { setBreadcrumb } from '$lib/stores/breadcrumb'; import { setBreadcrumb } from '$lib/stores/breadcrumb';
import { goto } from '$app/navigation';
import { appStateStore, ToQuery } from '$lib/stores/filter';
import type { ApiError, VideoSourceDetail, VideoSourcesDetailsResponse } from '$lib/types'; import type { ApiError, VideoSourceDetail, VideoSourcesDetailsResponse } from '$lib/types';
import api from '$lib/api'; import api from '$lib/api';
@@ -200,15 +198,7 @@
// 初始化 // 初始化
onMount(() => { onMount(() => {
setBreadcrumb([ setBreadcrumb([{ label: '视频源' }]);
{
label: '主页',
onClick: () => {
goto(`/${ToQuery($appStateStore)}`);
}
},
{ label: '视频源管理', isActive: true }
]);
loadVideoSources(); loadVideoSources();
}); });
</script> </script>
@@ -236,7 +226,7 @@
{@const sources = getSourcesForTab(key)} {@const sources = getSourcesForTab(key)}
<Tabs.Content value={key} class="mt-6"> <Tabs.Content value={key} class="mt-6">
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-medium">{config.label}管理</h3> <div></div>
{#if key === 'favorites' || key === 'collections' || key === 'submissions'} {#if key === 'favorites' || key === 'collections' || key === 'submissions'}
<Button size="sm" onclick={() => openAddDialog(key)} class="flex items-center gap-2"> <Button size="sm" onclick={() => openAddDialog(key)} class="flex items-center gap-2">
<PlusIcon class="h-4 w-4" /> <PlusIcon class="h-4 w-4" />
+3 -7
View File
@@ -46,12 +46,10 @@
onMount(() => { onMount(() => {
setBreadcrumb([ setBreadcrumb([
{ {
label: '主页', label: '视频',
onClick: () => { href: `/${ToQuery($appStateStore)}`
goto(`/${ToQuery($appStateStore)}`);
}
}, },
{ label: '视频详情', isActive: true } { label: '视频详情' }
]); ]);
}); });
@@ -149,8 +147,6 @@
}} }}
mode="detail" mode="detail"
showActions={false} showActions={false}
progressHeight="h-3"
gap="gap-2"
taskNames={['视频封面', '视频信息', 'UP主头像', 'UP主信息', '分P下载']} taskNames={['视频封面', '视频信息', 'UP主头像', 'UP主信息', '分P下载']}
bind:resetDialogOpen bind:resetDialogOpen
bind:resetting bind:resetting
+294
View File
@@ -0,0 +1,294 @@
<script lang="ts">
import VideoCard from '$lib/components/video-card.svelte';
import Pagination from '$lib/components/pagination.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import api from '$lib/api';
import type { VideosResponse, VideoSourcesResponse, ApiError, VideoSource } from '$lib/types';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { VIDEO_SOURCES } from '$lib/consts';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import {
appStateStore,
resetCurrentPage,
setAll,
setCurrentPage,
setQuery,
ToQuery
} from '$lib/stores/filter';
import { toast } from 'svelte-sonner';
import DropdownFilter, { type Filter } from '$lib/components/dropdown-filter.svelte';
import SearchBar from '$lib/components/search-bar.svelte';
const pageSize = 20;
let videosData: VideosResponse | null = null;
let loading = false;
let lastSearch: string | null = null;
let resetAllDialogOpen = false;
let resettingAll = false;
let videoSources: VideoSourcesResponse | null = null;
let filters: Record<string, Filter> | null = null;
function getApiParams(searchParams: URLSearchParams) {
let videoSource = null;
for (const source of Object.values(VIDEO_SOURCES)) {
const value = searchParams.get(source.type);
if (value) {
videoSource = { type: source.type, id: value };
}
}
return {
query: searchParams.get('query') || '',
videoSource,
pageNum: parseInt(searchParams.get('page') || '0')
};
}
async function loadVideos(
query: string,
pageNum: number = 0,
filter?: { type: string; id: string } | null
) {
loading = true;
try {
const params: Record<string, string | number> = {
page: pageNum,
page_size: pageSize
};
if (query) {
params.query = query;
}
if (filter) {
params[filter.type] = parseInt(filter.id);
}
const result = await api.getVideos(params);
videosData = result.data;
} catch (error) {
console.error('加载视频失败:', error);
toast.error('加载视频失败', {
description: (error as ApiError).message
});
} finally {
loading = false;
}
}
async function handlePageChange(pageNum: number) {
setCurrentPage(pageNum);
goto(`/${ToQuery($appStateStore)}`);
}
async function handleSearchParamsChange(searchParams: URLSearchParams) {
const { query, videoSource, pageNum } = getApiParams(searchParams);
setAll(query, pageNum, videoSource);
loadVideos(query, pageNum, videoSource);
}
async function handleResetVideo(id: number) {
try {
const result = await api.resetVideo(id);
const data = result.data;
if (data.resetted) {
toast.success('重置成功', {
description: `视频「${data.video.name}」已重置`
});
const { query, currentPage, videoSource } = $appStateStore;
await loadVideos(query, currentPage, videoSource);
} else {
toast.info('重置无效', {
description: `视频「${data.video.name}」没有失败的状态,无需重置`
});
}
} catch (error) {
console.error('重置失败:', error);
toast.error('重置失败', {
description: (error as ApiError).message
});
}
}
async function handleResetAllVideos() {
resettingAll = true;
try {
const result = await api.resetAllVideos();
const data = result.data;
if (data.resetted) {
toast.success('重置成功', {
description: `已重置 ${data.resetted_videos_count} 个视频和 ${data.resetted_pages_count} 个分页`
});
const { query, currentPage, videoSource } = $appStateStore;
await loadVideos(query, currentPage, videoSource);
} else {
toast.info('没有需要重置的视频');
}
} catch (error) {
console.error('重置失败:', error);
toast.error('重置失败', {
description: (error as ApiError).message
});
} finally {
resettingAll = false;
resetAllDialogOpen = false;
}
}
$: if ($page.url.search !== lastSearch) {
lastSearch = $page.url.search;
handleSearchParamsChange($page.url.searchParams);
}
$: if (videoSources) {
filters = Object.fromEntries(
Object.values(VIDEO_SOURCES).map((source) => [
source.type,
{
name: source.title,
icon: source.icon,
values: Object.fromEntries(
(videoSources![source.type as keyof VideoSourcesResponse] as VideoSource[]).map(
(item) => [item.id, item.name]
)
)
}
])
);
} else {
filters = null;
}
onMount(async () => {
setBreadcrumb([
{
label: '视频'
}
]);
videoSources = (await api.getVideoSources()).data;
});
$: totalPages = videosData ? Math.ceil(videosData.total_count / pageSize) : 0;
</script>
<svelte:head>
<title>主页 - Bili Sync</title>
</svelte:head>
<div class="mb-4 flex items-center justify-between">
<SearchBar
placeholder="搜索标题.."
value={$appStateStore.query}
onSearch={(value) => {
setQuery(value);
resetCurrentPage();
goto(`/${ToQuery($appStateStore)}`);
}}
></SearchBar>
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">筛选视频源:</span>
<DropdownFilter
{filters}
selectedLabel={$appStateStore.videoSource}
onSelect={(type, id) => {
setAll('', 0, { type, id });
goto(`/${ToQuery($appStateStore)}`);
}}
onRemove={() => {
setAll('', 0, null);
goto(`/${ToQuery($appStateStore)}`);
}}
/>
</div>
</div>
{#if videosData}
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-6">
<div class=" text-sm font-medium">
{videosData.total_count} 个视频
</div>
<div class=" text-sm font-medium">
{totalPages}
</div>
</div>
<div class="flex items-center gap-2">
<Button
size="sm"
variant="outline"
class="hover:bg-accent hover:text-accent-foreground h-8 cursor-pointer text-xs font-medium"
onclick={() => (resetAllDialogOpen = true)}
disabled={resettingAll || loading}
>
<RotateCcwIcon class="mr-1.5 h-3 w-3 {resettingAll ? 'animate-spin' : ''}" />
重置所有
</Button>
</div>
</div>
{/if}
{#if loading}
<div class="flex items-center justify-center py-16">
<div class="text-muted-foreground/70 text-sm">加载中...</div>
</div>
{:else if videosData?.videos.length}
<div
class="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
{#each videosData.videos as video (video.id)}
<VideoCard
{video}
onReset={async () => {
await handleResetVideo(video.id);
}}
/>
{/each}
</div>
<!-- 翻页组件 -->
<Pagination
currentPage={$appStateStore.currentPage}
{totalPages}
onPageChange={handlePageChange}
/>
{:else}
<div class="flex items-center justify-center py-16">
<div class="space-y-3 text-center">
<p class="text-muted-foreground text-sm">暂无视频数据</p>
<p class="text-muted-foreground/70 text-xs">尝试搜索或检查视频来源配置</p>
</div>
</div>
{/if}
<!-- 重置所有视频确认对话框 -->
<AlertDialog.Root bind:open={resetAllDialogOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>重置所有视频</AlertDialog.Title>
<AlertDialog.Description>
此操作将重置所有视频和分页的失败状态为未下载状态,使它们在下次下载任务中重新尝试。
<br />
<strong class="text-destructive">此操作不可撤销,确定要继续吗?</strong>
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel disabled={resettingAll}>取消</AlertDialog.Cancel>
<AlertDialog.Action
onclick={handleResetAllVideos}
disabled={resettingAll}
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{#if resettingAll}
<RotateCcwIcon class="mr-2 h-4 w-4 animate-spin" />
重置中...
{:else}
确认重置
{/if}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>