mirror of
https://github.com/amtoaer/bili-sync.git
synced 2026-09-08 08:57:30 +08:00
feat: 修改交互逻辑,支持前端查看日志 (#378)
This commit is contained in:
Generated
+14
@@ -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
@@ -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"] }
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]>([]);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
};
|
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user