优化SQL查询构建,新增NOT条件支持,修复文本转义逻辑,更新主题切换逻辑,改进加载动画,更新依赖项,重构主题管理组件,修复多个小问题。
This commit is contained in:
parent
507595a269
commit
195f8de576
@ -199,7 +199,7 @@ impl SafeValue {
|
|||||||
SafeValue::Float(f) => Ok(f.to_string()),
|
SafeValue::Float(f) => Ok(f.to_string()),
|
||||||
SafeValue::Text(s, level) => {
|
SafeValue::Text(s, level) => {
|
||||||
TextValidator::default().validate(s, *level)?;
|
TextValidator::default().validate(s, *level)?;
|
||||||
Ok(format!("{}", s.replace('\'', "''")))
|
Ok(format!("{}", s))
|
||||||
}
|
}
|
||||||
SafeValue::DateTime(dt) => Ok(format!("{}", dt.to_rfc3339())),
|
SafeValue::DateTime(dt) => Ok(format!("{}", dt.to_rfc3339())),
|
||||||
}
|
}
|
||||||
@ -254,8 +254,7 @@ pub enum Operator {
|
|||||||
Lte,
|
Lte,
|
||||||
Like,
|
Like,
|
||||||
In,
|
In,
|
||||||
IsNull,
|
IsNull
|
||||||
IsNotNull,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Operator {
|
impl Operator {
|
||||||
@ -269,8 +268,7 @@ impl Operator {
|
|||||||
Operator::Lte => "<=",
|
Operator::Lte => "<=",
|
||||||
Operator::Like => "LIKE",
|
Operator::Like => "LIKE",
|
||||||
Operator::In => "IN",
|
Operator::In => "IN",
|
||||||
Operator::IsNull => "IS NULL",
|
Operator::IsNull => "IS NULL"
|
||||||
Operator::IsNotNull => "IS NOT NULL",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -297,6 +295,7 @@ pub enum WhereClause {
|
|||||||
And(Vec<WhereClause>),
|
And(Vec<WhereClause>),
|
||||||
Or(Vec<WhereClause>),
|
Or(Vec<WhereClause>),
|
||||||
Condition(Condition),
|
Condition(Condition),
|
||||||
|
Not(Condition)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@ -463,6 +462,10 @@ impl QueryBuilder {
|
|||||||
WhereClause::Condition(condition) => {
|
WhereClause::Condition(condition) => {
|
||||||
self.build_condition(condition, &mut params, param_index)?
|
self.build_condition(condition, &mut params, param_index)?
|
||||||
}
|
}
|
||||||
|
WhereClause::Not(condition) => {
|
||||||
|
let condition_sql = self.build_condition(condition, &mut params, param_index)?;
|
||||||
|
format!("NOT ({})", condition_sql)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((sql, params))
|
Ok((sql, params))
|
||||||
|
@ -77,7 +77,7 @@ impl Database {
|
|||||||
db: Arc::new(db),
|
db: Arc::new(db),
|
||||||
prefix: Arc::new(database.db_prefix.clone()),
|
prefix: Arc::new(database.db_prefix.clone()),
|
||||||
db_type: Arc::new(match database.db_type.to_lowercase().as_str() {
|
db_type: Arc::new(match database.db_type.to_lowercase().as_str() {
|
||||||
// "postgresql" => DatabaseType::PostgreSQL,
|
"postgresql" => DatabaseType::PostgreSQL,
|
||||||
"mysql" => DatabaseType::MySQL,
|
"mysql" => DatabaseType::MySQL,
|
||||||
"sqllite" => DatabaseType::SQLite,
|
"sqllite" => DatabaseType::SQLite,
|
||||||
_ => return Err("unknown database type".into_custom_error()),
|
_ => return Err("unknown database type".into_custom_error()),
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
use super::builder::{Condition, Identifier, Operator, SafeValue, ValidationLevel, WhereClause};
|
use super::builder::{Condition, Identifier, Operator, SafeValue, ValidationLevel, WhereClause};
|
||||||
use super::DatabaseType;
|
use super::DatabaseType;
|
||||||
use crate::common::error::{CustomErrorInto, CustomResult};
|
use crate::common::error::{CustomErrorInto, CustomResult};
|
||||||
use std::fmt::Display;
|
use std::fmt::{format, Display};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum FieldType {
|
pub enum FieldType {
|
||||||
@ -200,6 +200,7 @@ impl Field {
|
|||||||
Err("Invalid IN clause value".into_custom_error())
|
Err("Invalid IN clause value".into_custom_error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Operator::IsNull => Ok(format!("{} IS NULL", field_name)),
|
||||||
Operator::Eq
|
Operator::Eq
|
||||||
| Operator::Ne
|
| Operator::Ne
|
||||||
| Operator::Gt
|
| Operator::Gt
|
||||||
@ -220,9 +221,26 @@ impl Field {
|
|||||||
_ => Err("Unsupported operator for CHECK constraint".into_custom_error()),
|
_ => Err("Unsupported operator for CHECK constraint".into_custom_error()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
WhereClause::And(conditions) => {
|
||||||
Err("Only simple conditions are supported for CHECK constraints"
|
let conditions: CustomResult<Vec<String>> = conditions
|
||||||
.into_custom_error())
|
.iter()
|
||||||
|
.map(|c| Self::build_check_constraint(c))
|
||||||
|
.collect();
|
||||||
|
Ok(format!("({})", conditions?.join(" AND ")))
|
||||||
|
}
|
||||||
|
WhereClause::Or(conditions) => {
|
||||||
|
let conditions: CustomResult<Vec<String>> = conditions
|
||||||
|
.iter()
|
||||||
|
.map(|c| Self::build_check_constraint(c))
|
||||||
|
.collect();
|
||||||
|
Ok(format!("({})", conditions?.join(" OR ")))
|
||||||
|
}
|
||||||
|
WhereClause::Not(condition) => {
|
||||||
|
let inner_condition = WhereClause::Condition(condition.clone());
|
||||||
|
Ok(format!(
|
||||||
|
"NOT ({})",
|
||||||
|
Self::build_check_constraint(&inner_condition)?
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -372,8 +390,62 @@ impl SchemaBuilder {
|
|||||||
pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomResult<String> {
|
pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomResult<String> {
|
||||||
let db_prefix = db_prefix.to_string()?;
|
let db_prefix = db_prefix.to_string()?;
|
||||||
let mut schema = SchemaBuilder::new();
|
let mut schema = SchemaBuilder::new();
|
||||||
let user_level = "('contributor', 'administrator')";
|
let role_check = WhereClause::Or(vec![
|
||||||
let content_state = "('draft', 'published', 'private', 'hidden')";
|
WhereClause::Condition(Condition::new(
|
||||||
|
"role".to_string(),
|
||||||
|
Operator::Eq,
|
||||||
|
Some(SafeValue::Text(
|
||||||
|
"'contributor'".to_string(),
|
||||||
|
ValidationLevel::Raw,
|
||||||
|
)),
|
||||||
|
)?),
|
||||||
|
WhereClause::Condition(Condition::new(
|
||||||
|
"role".to_string(),
|
||||||
|
Operator::Eq,
|
||||||
|
Some(SafeValue::Text(
|
||||||
|
"'administrator'".to_string(),
|
||||||
|
ValidationLevel::Raw,
|
||||||
|
)),
|
||||||
|
)?),
|
||||||
|
]);
|
||||||
|
let content_state_check = WhereClause::Or(vec![
|
||||||
|
WhereClause::Condition(Condition::new(
|
||||||
|
"status".to_string(),
|
||||||
|
Operator::Eq,
|
||||||
|
Some(SafeValue::Text(
|
||||||
|
"'published'".to_string(),
|
||||||
|
ValidationLevel::Raw,
|
||||||
|
)),
|
||||||
|
)?),
|
||||||
|
WhereClause::Condition(Condition::new(
|
||||||
|
"status".to_string(),
|
||||||
|
Operator::Eq,
|
||||||
|
Some(SafeValue::Text(
|
||||||
|
"'private'".to_string(),
|
||||||
|
ValidationLevel::Raw,
|
||||||
|
)),
|
||||||
|
)?),
|
||||||
|
WhereClause::Condition(Condition::new(
|
||||||
|
"status".to_string(),
|
||||||
|
Operator::Eq,
|
||||||
|
Some(SafeValue::Text(
|
||||||
|
"'hidden'".to_string(),
|
||||||
|
ValidationLevel::Raw,
|
||||||
|
)),
|
||||||
|
)?),
|
||||||
|
]);
|
||||||
|
let target_type_check = WhereClause::Or(vec![
|
||||||
|
WhereClause::Condition(Condition::new(
|
||||||
|
"target_type".to_string(),
|
||||||
|
Operator::Eq,
|
||||||
|
Some(SafeValue::Text("'post'".to_string(), ValidationLevel::Raw)),
|
||||||
|
)?),
|
||||||
|
WhereClause::Condition(Condition::new(
|
||||||
|
"target_type".to_string(),
|
||||||
|
Operator::Eq,
|
||||||
|
Some(SafeValue::Text("'page'".to_string(), ValidationLevel::Raw)),
|
||||||
|
)?),
|
||||||
|
]);
|
||||||
|
|
||||||
// 用户表
|
// 用户表
|
||||||
let mut users_table = Table::new(&format!("{}users", db_prefix))?;
|
let mut users_table = Table::new(&format!("{}users", db_prefix))?;
|
||||||
@ -405,16 +477,7 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
|
|||||||
.add_field(Field::new(
|
.add_field(Field::new(
|
||||||
"role",
|
"role",
|
||||||
FieldType::VarChar(20),
|
FieldType::VarChar(20),
|
||||||
FieldConstraint::new()
|
FieldConstraint::new().not_null().check(role_check.clone()),
|
||||||
.not_null()
|
|
||||||
.check(WhereClause::Condition(Condition::new(
|
|
||||||
"role".to_string(),
|
|
||||||
Operator::In,
|
|
||||||
Some(SafeValue::Text(
|
|
||||||
user_level.to_string(),
|
|
||||||
ValidationLevel::Relaxed,
|
|
||||||
)),
|
|
||||||
)?)),
|
|
||||||
ValidationLevel::Strict,
|
ValidationLevel::Strict,
|
||||||
)?)
|
)?)
|
||||||
.add_field(Field::new(
|
.add_field(Field::new(
|
||||||
@ -438,9 +501,7 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
|
|||||||
.add_field(Field::new(
|
.add_field(Field::new(
|
||||||
"last_login_at",
|
"last_login_at",
|
||||||
FieldType::Timestamp,
|
FieldType::Timestamp,
|
||||||
FieldConstraint::new()
|
FieldConstraint::new().not_null().default(SafeValue::Text(
|
||||||
.not_null()
|
|
||||||
.default(SafeValue::Text(
|
|
||||||
"CURRENT_TIMESTAMP".to_string(),
|
"CURRENT_TIMESTAMP".to_string(),
|
||||||
ValidationLevel::Strict,
|
ValidationLevel::Strict,
|
||||||
)),
|
)),
|
||||||
@ -480,16 +541,7 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
|
|||||||
.add_field(Field::new(
|
.add_field(Field::new(
|
||||||
"status",
|
"status",
|
||||||
FieldType::VarChar(20),
|
FieldType::VarChar(20),
|
||||||
FieldConstraint::new()
|
FieldConstraint::new().not_null().check(content_state_check.clone()),
|
||||||
.not_null()
|
|
||||||
.check(WhereClause::Condition(Condition::new(
|
|
||||||
"status".to_string(),
|
|
||||||
Operator::In,
|
|
||||||
Some(SafeValue::Text(
|
|
||||||
content_state.to_string(),
|
|
||||||
ValidationLevel::Standard,
|
|
||||||
)),
|
|
||||||
)?)),
|
|
||||||
ValidationLevel::Strict,
|
ValidationLevel::Strict,
|
||||||
)?);
|
)?);
|
||||||
|
|
||||||
@ -535,16 +587,7 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
|
|||||||
.add_field(Field::new(
|
.add_field(Field::new(
|
||||||
"status",
|
"status",
|
||||||
FieldType::VarChar(20),
|
FieldType::VarChar(20),
|
||||||
FieldConstraint::new()
|
FieldConstraint::new().not_null().check(content_state_check.clone()),
|
||||||
.not_null()
|
|
||||||
.check(WhereClause::Condition(Condition::new(
|
|
||||||
"status".to_string(),
|
|
||||||
Operator::In,
|
|
||||||
Some(SafeValue::Text(
|
|
||||||
content_state.to_string(),
|
|
||||||
ValidationLevel::Standard,
|
|
||||||
)),
|
|
||||||
)?)),
|
|
||||||
ValidationLevel::Strict,
|
ValidationLevel::Strict,
|
||||||
)?)
|
)?)
|
||||||
.add_field(Field::new(
|
.add_field(Field::new(
|
||||||
@ -681,44 +724,44 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
|
|||||||
FieldType::Integer(true),
|
FieldType::Integer(true),
|
||||||
FieldConstraint::new().primary(),
|
FieldConstraint::new().primary(),
|
||||||
ValidationLevel::Strict,
|
ValidationLevel::Strict,
|
||||||
)?).add_field(Field::new(
|
)?)
|
||||||
|
.add_field(Field::new(
|
||||||
"target_type",
|
"target_type",
|
||||||
FieldType::VarChar(20),
|
FieldType::VarChar(20),
|
||||||
FieldConstraint::new()
|
FieldConstraint::new().not_null().check(target_type_check.clone()),
|
||||||
.not_null()
|
|
||||||
.check(WhereClause::Condition(Condition::new(
|
|
||||||
"target_type".to_string(),
|
|
||||||
Operator::In,
|
|
||||||
Some(SafeValue::Text(
|
|
||||||
"('post', 'page')".to_string(),
|
|
||||||
ValidationLevel::Standard,
|
|
||||||
)),
|
|
||||||
)?)),
|
|
||||||
ValidationLevel::Strict,
|
ValidationLevel::Strict,
|
||||||
)?).add_field(Field::new(
|
)?)
|
||||||
|
.add_field(Field::new(
|
||||||
"target_id",
|
"target_id",
|
||||||
FieldType::Integer(false),
|
FieldType::Integer(false),
|
||||||
FieldConstraint::new()
|
FieldConstraint::new().not_null(),
|
||||||
.not_null()
|
|
||||||
.check(WhereClause::Condition(Condition::new(
|
|
||||||
"(target_type = 'post' AND EXISTS (SELECT 1 FROM posts WHERE id = target_id)) OR \
|
|
||||||
(target_type = 'page' AND EXISTS (SELECT 1 FROM pages WHERE id = target_id))".to_string(),
|
|
||||||
Operator::Raw,
|
|
||||||
None,
|
|
||||||
)?)),
|
|
||||||
ValidationLevel::Strict,
|
ValidationLevel::Strict,
|
||||||
)?).add_field(Field::new(
|
)?)
|
||||||
|
.add_field(Field::new(
|
||||||
"meta_key",
|
"meta_key",
|
||||||
FieldType::VarChar(50),
|
FieldType::VarChar(50),
|
||||||
FieldConstraint::new().not_null(),
|
FieldConstraint::new().not_null(),
|
||||||
ValidationLevel::Strict,
|
ValidationLevel::Strict,
|
||||||
)?).add_field(Field::new(
|
)?)
|
||||||
|
.add_field(Field::new(
|
||||||
"meta_value",
|
"meta_value",
|
||||||
FieldType::Text,
|
FieldType::Text,
|
||||||
FieldConstraint::new(),
|
FieldConstraint::new(),
|
||||||
ValidationLevel::Strict,
|
ValidationLevel::Strict,
|
||||||
)?);
|
)?);
|
||||||
|
|
||||||
|
metadata_table.add_index(Index::new(
|
||||||
|
"fk_metadata_posts",
|
||||||
|
vec!["target_id".to_string()],
|
||||||
|
false,
|
||||||
|
)?);
|
||||||
|
|
||||||
|
metadata_table.add_index(Index::new(
|
||||||
|
"fk_metadata_pages",
|
||||||
|
vec!["target_id".to_string()],
|
||||||
|
false,
|
||||||
|
)?);
|
||||||
|
|
||||||
metadata_table.add_index(Index::new(
|
metadata_table.add_index(Index::new(
|
||||||
"idx_metadata_target",
|
"idx_metadata_target",
|
||||||
vec!["target_type".to_string(), "target_id".to_string()],
|
vec!["target_type".to_string(), "target_id".to_string()],
|
||||||
@ -735,36 +778,32 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
|
|||||||
FieldType::Integer(true),
|
FieldType::Integer(true),
|
||||||
FieldConstraint::new().primary(),
|
FieldConstraint::new().primary(),
|
||||||
ValidationLevel::Strict,
|
ValidationLevel::Strict,
|
||||||
)?).add_field(Field::new(
|
)?)
|
||||||
|
.add_field(Field::new(
|
||||||
"target_type",
|
"target_type",
|
||||||
FieldType::VarChar(20),
|
FieldType::VarChar(20),
|
||||||
FieldConstraint::new()
|
FieldConstraint::new().not_null().check(target_type_check.clone()),
|
||||||
.not_null()
|
|
||||||
.check(WhereClause::Condition(Condition::new(
|
|
||||||
"target_type".to_string(),
|
|
||||||
Operator::In,
|
|
||||||
Some(SafeValue::Text(
|
|
||||||
"('post', 'page')".to_string(),
|
|
||||||
ValidationLevel::Standard,
|
|
||||||
)),
|
|
||||||
)?)),
|
|
||||||
ValidationLevel::Strict,
|
ValidationLevel::Strict,
|
||||||
)?).add_field(Field::new(
|
)?)
|
||||||
|
.add_field(Field::new(
|
||||||
"target_id",
|
"target_id",
|
||||||
FieldType::Integer(false),
|
FieldType::Integer(false),
|
||||||
FieldConstraint::new().not_null(),
|
FieldConstraint::new().not_null(),
|
||||||
ValidationLevel::Strict,
|
ValidationLevel::Strict,
|
||||||
)?).add_field(Field::new(
|
)?)
|
||||||
|
.add_field(Field::new(
|
||||||
"field_key",
|
"field_key",
|
||||||
FieldType::VarChar(50),
|
FieldType::VarChar(50),
|
||||||
FieldConstraint::new().not_null(),
|
FieldConstraint::new().not_null(),
|
||||||
ValidationLevel::Strict,
|
ValidationLevel::Strict,
|
||||||
)?).add_field(Field::new(
|
)?)
|
||||||
|
.add_field(Field::new(
|
||||||
"field_value",
|
"field_value",
|
||||||
FieldType::Text,
|
FieldType::Text,
|
||||||
FieldConstraint::new(),
|
FieldConstraint::new(),
|
||||||
ValidationLevel::Strict,
|
ValidationLevel::Strict,
|
||||||
)?).add_field(Field::new(
|
)?)
|
||||||
|
.add_field(Field::new(
|
||||||
"field_type",
|
"field_type",
|
||||||
FieldType::VarChar(20),
|
FieldType::VarChar(20),
|
||||||
FieldConstraint::new().not_null(),
|
FieldConstraint::new().not_null(),
|
||||||
@ -788,37 +827,45 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
|
|||||||
FieldType::VarChar(50),
|
FieldType::VarChar(50),
|
||||||
FieldConstraint::new().primary(),
|
FieldConstraint::new().primary(),
|
||||||
ValidationLevel::Strict,
|
ValidationLevel::Strict,
|
||||||
)?).add_field(Field::new(
|
)?)
|
||||||
|
.add_field(Field::new(
|
||||||
"slug",
|
"slug",
|
||||||
FieldType::VarChar(50),
|
FieldType::VarChar(50),
|
||||||
FieldConstraint::new().not_null().unique(),
|
FieldConstraint::new().not_null().unique(),
|
||||||
ValidationLevel::Strict,
|
ValidationLevel::Strict,
|
||||||
)?).add_field(Field::new(
|
)?)
|
||||||
|
.add_field(Field::new(
|
||||||
"type",
|
"type",
|
||||||
FieldType::VarChar(20),
|
FieldType::VarChar(20),
|
||||||
FieldConstraint::new()
|
FieldConstraint::new()
|
||||||
.not_null()
|
.not_null()
|
||||||
.check(WhereClause::Condition(Condition::new(
|
.check(WhereClause::Or(vec![
|
||||||
|
WhereClause::Condition(Condition::new(
|
||||||
"type".to_string(),
|
"type".to_string(),
|
||||||
Operator::In,
|
Operator::Eq,
|
||||||
Some(SafeValue::Text(
|
Some(SafeValue::Text(
|
||||||
"('tag', 'category')".to_string(),
|
"'tag'".to_string(),
|
||||||
ValidationLevel::Standard,
|
ValidationLevel::Raw,
|
||||||
)),
|
)),
|
||||||
)?)),
|
)?),
|
||||||
|
WhereClause::Condition(Condition::new(
|
||||||
|
"type".to_string(),
|
||||||
|
Operator::Eq,
|
||||||
|
Some(SafeValue::Text(
|
||||||
|
"'category'".to_string(),
|
||||||
|
ValidationLevel::Raw,
|
||||||
|
)),
|
||||||
|
)?),
|
||||||
|
])),
|
||||||
ValidationLevel::Strict,
|
ValidationLevel::Strict,
|
||||||
)?).add_field(Field::new(
|
)?)
|
||||||
"parent_id",
|
.add_field(Field::new(
|
||||||
|
"parent_name",
|
||||||
FieldType::VarChar(50),
|
FieldType::VarChar(50),
|
||||||
FieldConstraint::new()
|
FieldConstraint::new()
|
||||||
.foreign_key(format!("{}taxonomies", db_prefix), "name".to_string())
|
.foreign_key(format!("{}taxonomies", db_prefix), "name".to_string())
|
||||||
.on_delete(ForeignKeyAction::SetNull)
|
.on_delete(ForeignKeyAction::SetNull)
|
||||||
.on_update(ForeignKeyAction::Cascade)
|
.on_update(ForeignKeyAction::Cascade),
|
||||||
.check(WhereClause::Condition(Condition::new(
|
|
||||||
"(type = 'category' OR parent_id IS NULL)".to_string(),
|
|
||||||
Operator::Raw,
|
|
||||||
None,
|
|
||||||
)?)),
|
|
||||||
ValidationLevel::Strict,
|
ValidationLevel::Strict,
|
||||||
)?);
|
)?);
|
||||||
|
|
||||||
@ -836,7 +883,8 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
|
|||||||
.on_delete(ForeignKeyAction::Cascade)
|
.on_delete(ForeignKeyAction::Cascade)
|
||||||
.on_update(ForeignKeyAction::Cascade),
|
.on_update(ForeignKeyAction::Cascade),
|
||||||
ValidationLevel::Strict,
|
ValidationLevel::Strict,
|
||||||
)?).add_field(Field::new(
|
)?)
|
||||||
|
.add_field(Field::new(
|
||||||
"taxonomy_id",
|
"taxonomy_id",
|
||||||
FieldType::VarChar(50),
|
FieldType::VarChar(50),
|
||||||
FieldConstraint::new()
|
FieldConstraint::new()
|
||||||
|
@ -456,7 +456,17 @@ const AdminConfig: React.FC<StepProps> = ({ onNext }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SetupComplete: React.FC = () => (
|
const SetupComplete: React.FC = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
// 添加延迟后刷新页面
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
<StepContainer title="安装完成">
|
<StepContainer title="安装完成">
|
||||||
<Flex direction="column" align="center" gap="4">
|
<Flex direction="column" align="center" gap="4">
|
||||||
<Text size="5" weight="medium">
|
<Text size="5" weight="medium">
|
||||||
@ -470,16 +480,51 @@ const SetupComplete: React.FC = () => (
|
|||||||
</Box>
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
</StepContainer>
|
</StepContainer>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function SetupPage() {
|
export default function SetupPage() {
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
const [isClient, setIsClient] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 在客户端组件挂载后更新状态
|
// 标记客户端渲染完成
|
||||||
const initStatus = Number(import.meta.env.VITE_INIT_STATUS ?? 0) + 1;
|
setIsClient(true);
|
||||||
setCurrentStep(initStatus);
|
|
||||||
|
// 获取初始化状态
|
||||||
|
const initStatus = Number(import.meta.env.VITE_INIT_STATUS ?? 0);
|
||||||
|
|
||||||
|
// 如果已完成初始化,直接刷新页面
|
||||||
|
if (initStatus >= 3) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则设置当前步骤
|
||||||
|
setCurrentStep(initStatus + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 在服务端渲染时或客户端首次渲染时,返回加载状态
|
||||||
|
if (!isClient) {
|
||||||
|
return (
|
||||||
|
<Theme
|
||||||
|
grayColor="gray"
|
||||||
|
accentColor="gray"
|
||||||
|
radius="medium"
|
||||||
|
panelBackground="solid"
|
||||||
|
appearance="inherit"
|
||||||
|
>
|
||||||
|
<Box className="min-h-screen w-full">
|
||||||
|
<Flex justify="center" pt="2">
|
||||||
|
<Box className="w-20 h-20">
|
||||||
|
<Echoes />
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
</Theme>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Theme
|
<Theme
|
||||||
grayColor="gray"
|
grayColor="gray"
|
||||||
@ -490,8 +535,10 @@ export default function SetupPage() {
|
|||||||
>
|
>
|
||||||
<Box className="min-h-screen w-full">
|
<Box className="min-h-screen w-full">
|
||||||
<Box position="fixed" top="2" right="4">
|
<Box position="fixed" top="2" right="4">
|
||||||
|
<Box className="w-10 h-10 flex items-center justify-center [&_button]:w-10 [&_button]:h-10 [&_svg]:w-6 [&_svg]:h-6 [&_button]:text-[--gray-12] [&_button:hover]:text-[--accent-9]">
|
||||||
<ThemeModeToggle />
|
<ThemeModeToggle />
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Flex justify="center" pt="2">
|
<Flex justify="center" pt="2">
|
||||||
<Box className="w-20 h-20">
|
<Box className="w-20 h-20">
|
||||||
|
@ -7,7 +7,7 @@ import {
|
|||||||
} from "@remix-run/react";
|
} from "@remix-run/react";
|
||||||
import { NotificationProvider } from "hooks/Notification";
|
import { NotificationProvider } from "hooks/Notification";
|
||||||
import { Theme } from "@radix-ui/themes";
|
import { Theme } from "@radix-ui/themes";
|
||||||
import { ThemeScript } from "hooks/ThemeMode";
|
import { ThemeScript } from "hooks/themeMode";
|
||||||
|
|
||||||
import "~/index.css";
|
import "~/index.css";
|
||||||
|
|
||||||
@ -70,3 +70,4 @@ export function Layout() {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
return <Layout />;
|
return <Layout />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -757,7 +757,7 @@ export const ParticleImage = ({
|
|||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
// 增加一个小的边距以确保<EFBFBD><EFBFBD>盖
|
// 增加一个小的边距以确保盖
|
||||||
const padding = 2; // 添加2像素的距
|
const padding = 2; // 添加2像素的距
|
||||||
canvas.width = width + padding * 2;
|
canvas.width = width + padding * 2;
|
||||||
canvas.height = height + padding * 2;
|
canvas.height = height + padding * 2;
|
||||||
@ -961,7 +961,7 @@ export const ParticleImage = ({
|
|||||||
containerRef.current.removeChild(renderer.domElement);
|
containerRef.current.removeChild(renderer.domElement);
|
||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
}
|
}
|
||||||
// 清有 GSAP <EFBFBD><EFBFBD><EFBFBD>画
|
// 清有 GSAP 画
|
||||||
gsap.killTweensOf('*');
|
gsap.killTweensOf('*');
|
||||||
|
|
||||||
// 移除 resize 监听
|
// 移除 resize 监听
|
||||||
@ -1043,11 +1043,6 @@ const loadingQueue = {
|
|||||||
add(url: string, instanceId: string, isLongConnection = false) {
|
add(url: string, instanceId: string, isLongConnection = false) {
|
||||||
const key = `${instanceId}:${url}`;
|
const key = `${instanceId}:${url}`;
|
||||||
if (!this.items.has(key)) {
|
if (!this.items.has(key)) {
|
||||||
console.log('[Queue] Adding:', key,
|
|
||||||
isLongConnection ? '(long connection)' : '',
|
|
||||||
'Current processing:', this.currentProcessing,
|
|
||||||
'Max concurrent:', this.maxConcurrent
|
|
||||||
);
|
|
||||||
|
|
||||||
this.items.set(key, {
|
this.items.set(key, {
|
||||||
isProcessing: false,
|
isProcessing: false,
|
||||||
@ -1056,7 +1051,7 @@ const loadingQueue = {
|
|||||||
lastActiveTime: Date.now()
|
lastActiveTime: Date.now()
|
||||||
});
|
});
|
||||||
|
|
||||||
// 连接不再直接进入备选队列,而是等待加载完成后再加入
|
// 连接<EFBFBD><EFBFBD>再直接进入备选队列,而是等待加载完成后再加入
|
||||||
this.processQueue();
|
this.processQueue();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -1064,32 +1059,15 @@ const loadingQueue = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
processQueue() {
|
processQueue() {
|
||||||
console.log('[Queue] Processing queue:', {
|
|
||||||
availableSlots: this.availableSlots,
|
|
||||||
currentProcessing: this.currentProcessing,
|
|
||||||
pendingQueueSize: this.pendingQueue.size,
|
|
||||||
totalItems: this.items.size
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.availableSlots > 0) {
|
if (this.availableSlots > 0) {
|
||||||
let nextKey: string | undefined;
|
let nextKey: string | undefined;
|
||||||
|
|
||||||
// 优先从备选队列中获取已加载完成的长连接
|
// 修改这部分逻辑,不再区分长连接和普通请求
|
||||||
if (this.pendingQueue.size > 0) {
|
const nextItem = Array.from(this.items.entries())
|
||||||
nextKey = Array.from(this.pendingQueue)[0];
|
.find(([_, item]) => !item.isProcessing);
|
||||||
this.pendingQueue.delete(nextKey);
|
|
||||||
console.log('[Queue] Processing from pending queue:', nextKey);
|
if (nextItem) {
|
||||||
} else {
|
nextKey = nextItem[0];
|
||||||
// 如果没有待处理的长连接,处理普通请求
|
|
||||||
const normalItem = Array.from(this.items.entries())
|
|
||||||
.find(([_, item]) =>
|
|
||||||
!item.isProcessing &&
|
|
||||||
!item.isLongConnection
|
|
||||||
);
|
|
||||||
if (normalItem) {
|
|
||||||
nextKey = normalItem[0];
|
|
||||||
console.log('[Queue] Processing normal request:', nextKey);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextKey) {
|
if (nextKey) {
|
||||||
@ -1101,17 +1079,12 @@ const loadingQueue = {
|
|||||||
startProcessing(key: string) {
|
startProcessing(key: string) {
|
||||||
const item = this.items.get(key);
|
const item = this.items.get(key);
|
||||||
if (item && !item.isProcessing) {
|
if (item && !item.isProcessing) {
|
||||||
console.log('[Queue] Start processing:', key, {
|
|
||||||
isLongConnection: item.isLongConnection,
|
|
||||||
isError: key.includes('error')
|
|
||||||
});
|
|
||||||
|
|
||||||
item.isProcessing = true;
|
item.isProcessing = true;
|
||||||
|
|
||||||
// 只有普通请求且不是错误状态时才增加处理数量
|
// 只有普通请求且不是错误状态时才增加处理数量
|
||||||
if (!item.isLongConnection && !key.includes('error')) {
|
if (!item.isLongConnection && !key.includes('error')) {
|
||||||
this.currentProcessing++;
|
this.currentProcessing++;
|
||||||
console.log('[Queue] Increased processing count:', this.currentProcessing);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -1122,7 +1095,6 @@ const loadingQueue = {
|
|||||||
const item = this.items.get(key);
|
const item = this.items.get(key);
|
||||||
if (item?.isLongConnection) {
|
if (item?.isLongConnection) {
|
||||||
this.pendingQueue.add(key);
|
this.pendingQueue.add(key);
|
||||||
console.log('[Queue] Added to pending queue:', key);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -1130,27 +1102,16 @@ const loadingQueue = {
|
|||||||
const key = `${instanceId}:${url}`;
|
const key = `${instanceId}:${url}`;
|
||||||
const item = this.items.get(key);
|
const item = this.items.get(key);
|
||||||
|
|
||||||
console.log('[Queue] Removing:', key,
|
|
||||||
'Is long connection:', item?.isLongConnection,
|
|
||||||
'Was processing:', item?.isProcessing,
|
|
||||||
'Has error:', key.includes('error')
|
|
||||||
);
|
|
||||||
|
|
||||||
// 只有普通请求且正在处理时才减少处理数量
|
// 只有普通请求且正在处理时才减少处理数量
|
||||||
if (item?.isProcessing && !item.isLongConnection && !key.includes('error')) {
|
if (item?.isProcessing && !item.isLongConnection && !key.includes('error')) {
|
||||||
this.currentProcessing--;
|
this.currentProcessing--;
|
||||||
console.log('[Queue] Decreased processing count:', this.currentProcessing);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保从队列中移除
|
// 确保从队列中移除
|
||||||
this.items.delete(key);
|
this.items.delete(key);
|
||||||
this.pendingQueue.delete(key);
|
this.pendingQueue.delete(key);
|
||||||
|
|
||||||
console.log('[Queue] After remove - Processing:', this.currentProcessing,
|
|
||||||
'Pending queue size:', this.pendingQueue.size,
|
|
||||||
'Total items:', this.items.size
|
|
||||||
);
|
|
||||||
|
|
||||||
// 如果是错误状态,立即处理下一个请求
|
// 如果是错误状态,立即处理下一个请求
|
||||||
if (key.includes('error')) {
|
if (key.includes('error')) {
|
||||||
this.processQueue();
|
this.processQueue();
|
||||||
@ -1166,10 +1127,9 @@ const loadingQueue = {
|
|||||||
const key = `${instanceId}:${url}`;
|
const key = `${instanceId}:${url}`;
|
||||||
const item = this.items.get(key);
|
const item = this.items.get(key);
|
||||||
|
|
||||||
// 错误状态不占用槽位,只在第一次检查时输出日志
|
// 错误状态的处理保持不变
|
||||||
if (key.includes('error')) {
|
if (key.includes('error')) {
|
||||||
if (!item?.lastLogTime) {
|
if (!item?.lastLogTime) {
|
||||||
console.log('[Queue] Can process (error):', key, true);
|
|
||||||
if (item) {
|
if (item) {
|
||||||
item.lastLogTime = Date.now();
|
item.lastLogTime = Date.now();
|
||||||
}
|
}
|
||||||
@ -1177,22 +1137,13 @@ const loadingQueue = {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 长连接在备选队列中时可以处理
|
// 移除长连接的特殊处理,统一处理所有请求
|
||||||
if (item?.isLongConnection && this.pendingQueue.has(key)) {
|
|
||||||
if (!item.lastLogTime || Date.now() - item.lastLogTime > 1000) {
|
|
||||||
console.log('[Queue] Can process (pending long):', key, true);
|
|
||||||
item.lastLogTime = Date.now();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canProcess = item?.isProcessing || false;
|
const canProcess = item?.isProcessing || false;
|
||||||
// 只在状态发生变化时或者每秒最多输出一次日志
|
|
||||||
if (item &&
|
if (item &&
|
||||||
(item.lastProcessState !== canProcess ||
|
(item.lastProcessState !== canProcess ||
|
||||||
!item.lastLogTime ||
|
!item.lastLogTime ||
|
||||||
Date.now() - item.lastLogTime > 1000)) {
|
Date.now() - item.lastLogTime > 1000)) {
|
||||||
console.log('[Queue] Can process (normal):', key, canProcess);
|
|
||||||
item.lastProcessState = canProcess;
|
item.lastProcessState = canProcess;
|
||||||
item.lastLogTime = Date.now();
|
item.lastLogTime = Date.now();
|
||||||
}
|
}
|
||||||
@ -1233,7 +1184,6 @@ export const ImageLoader = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!src) return;
|
if (!src) return;
|
||||||
|
|
||||||
console.log('[Queue] Effect triggered for:', src, 'Instance:', instanceId.current);
|
|
||||||
setShowImage(false);
|
setShowImage(false);
|
||||||
setAnimationComplete(false);
|
setAnimationComplete(false);
|
||||||
setCanShowParticles(false);
|
setCanShowParticles(false);
|
||||||
@ -1246,7 +1196,6 @@ export const ImageLoader = ({
|
|||||||
const timeSinceLastAnimation = now - lastAnimationTime;
|
const timeSinceLastAnimation = now - lastAnimationTime;
|
||||||
|
|
||||||
if (particleLoadQueue.size === 0) {
|
if (particleLoadQueue.size === 0) {
|
||||||
console.log('[Queue] Starting immediate animation for:', src, 'Instance:', instanceId.current);
|
|
||||||
particleLoadQueue.add(src);
|
particleLoadQueue.add(src);
|
||||||
setCanShowParticles(true);
|
setCanShowParticles(true);
|
||||||
lastAnimationTime = now;
|
lastAnimationTime = now;
|
||||||
@ -1258,12 +1207,10 @@ export const ImageLoader = ({
|
|||||||
Math.min(ANIMATION_THRESHOLD, timeSinceLastAnimation)
|
Math.min(ANIMATION_THRESHOLD, timeSinceLastAnimation)
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('[Queue] Scheduling delayed animation for:', src, 'Instance:', instanceId.current);
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const key = `${instanceId.current}:${src}`;
|
const key = `${instanceId.current}:${src}`;
|
||||||
if (!loadingQueue.items.has(key)) return;
|
if (!loadingQueue.items.has(key)) return;
|
||||||
|
|
||||||
console.log('[Queue] Starting delayed animation for:', src, 'Instance:', instanceId.current);
|
|
||||||
particleLoadQueue.add(src);
|
particleLoadQueue.add(src);
|
||||||
setCanShowParticles(true);
|
setCanShowParticles(true);
|
||||||
lastAnimationTime = Date.now();
|
lastAnimationTime = Date.now();
|
||||||
@ -1282,7 +1229,6 @@ export const ImageLoader = ({
|
|||||||
const cleanup = checkQueue();
|
const cleanup = checkQueue();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log('[Queue] Cleanup effect for:', src, 'Instance:', instanceId.current);
|
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
loadingQueue.remove(src, instanceId.current);
|
loadingQueue.remove(src, instanceId.current);
|
||||||
};
|
};
|
||||||
@ -1393,7 +1339,6 @@ export const ImageLoader = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
console.log('[Image Loader] Error loading image:', src);
|
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
clearTimeout(timeoutRef.current);
|
clearTimeout(timeoutRef.current);
|
||||||
}
|
}
|
||||||
@ -1429,7 +1374,7 @@ export const ImageLoader = ({
|
|||||||
};
|
};
|
||||||
}, [src, preloadImage]);
|
}, [src, preloadImage]);
|
||||||
|
|
||||||
// 添加一个新的状来控制粒子动画
|
// 添加一个<EFBFBD><EFBFBD><EFBFBD>的状来控制粒子动画
|
||||||
const [canShowParticles, setCanShowParticles] = useState(false);
|
const [canShowParticles, setCanShowParticles] = useState(false);
|
||||||
|
|
||||||
// 添加加载动画组件
|
// 添加加载动画组件
|
||||||
@ -1449,25 +1394,20 @@ export const ImageLoader = ({
|
|||||||
src={src}
|
src={src}
|
||||||
status={status}
|
status={status}
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
console.log('[ParticleImage] onLoad START:', src);
|
|
||||||
if (imageRef.current) {
|
if (imageRef.current) {
|
||||||
// 保持为空
|
// 保持为空
|
||||||
}
|
}
|
||||||
console.log('[ParticleImage] onLoad END:', src);
|
|
||||||
}}
|
}}
|
||||||
onAnimationComplete={() => {
|
onAnimationComplete={() => {
|
||||||
console.log('[ParticleImage] Animation START:', src);
|
|
||||||
if (imageRef.current && src) {
|
if (imageRef.current && src) {
|
||||||
setShowImage(true);
|
setShowImage(true);
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
console.log('[ParticleImage] Setting animation complete:', src);
|
|
||||||
setAnimationComplete(true);
|
setAnimationComplete(true);
|
||||||
particleLoadQueue.delete(src);
|
particleLoadQueue.delete(src);
|
||||||
loadingQueue.remove(src, instanceId.current);
|
loadingQueue.remove(src, instanceId.current);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log('[ParticleImage] Fading image:', src);
|
|
||||||
const img = document.querySelector(`img[src="${imageRef.current?.src}"]`) as HTMLImageElement;
|
const img = document.querySelector(`img[src="${imageRef.current?.src}"]`) as HTMLImageElement;
|
||||||
if (img) {
|
if (img) {
|
||||||
img.style.opacity = '1';
|
img.style.opacity = '1';
|
||||||
@ -1475,7 +1415,6 @@ export const ImageLoader = ({
|
|||||||
}, 50);
|
}, 50);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.log('[ParticleImage] Animation END:', src);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -4,89 +4,97 @@ import { Button } from "@radix-ui/themes";
|
|||||||
|
|
||||||
const THEME_KEY = "theme-preference";
|
const THEME_KEY = "theme-preference";
|
||||||
|
|
||||||
// 添加这个脚本来预先设置主题,避免闪烁
|
// 修改主题脚本,确保在服务端和客户端都能正确初始化
|
||||||
const themeScript = `
|
const themeScript = `
|
||||||
(function() {
|
(function() {
|
||||||
function getInitialTheme() {
|
try {
|
||||||
const savedTheme = localStorage.getItem("${THEME_KEY}");
|
const savedTheme = localStorage.getItem("${THEME_KEY}");
|
||||||
if (savedTheme) {
|
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
document.documentElement.className = savedTheme;
|
const theme = savedTheme || (prefersDark ? "dark" : "light");
|
||||||
return savedTheme;
|
document.documentElement.dataset.theme = theme;
|
||||||
}
|
document.documentElement.classList.remove('light', 'dark');
|
||||||
|
document.documentElement.classList.add(theme);
|
||||||
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
} catch (e) {
|
||||||
const theme = isDark ? "dark" : "light";
|
console.error('[ThemeScript] Error:', e);
|
||||||
document.documentElement.className = theme;
|
document.documentElement.dataset.theme = 'light';
|
||||||
localStorage.setItem("${THEME_KEY}", theme);
|
document.documentElement.classList.remove('light', 'dark');
|
||||||
return theme;
|
document.documentElement.classList.add('light');
|
||||||
}
|
|
||||||
|
|
||||||
// 确保在 DOM 内容加载前执行
|
|
||||||
if (document.documentElement) {
|
|
||||||
getInitialTheme();
|
|
||||||
} else {
|
|
||||||
document.addEventListener('DOMContentLoaded', getInitialTheme);
|
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// ThemeScript 组件需要尽早执行
|
||||||
export const ThemeScript = () => {
|
export const ThemeScript = () => {
|
||||||
return <script dangerouslySetInnerHTML={{ __html: themeScript }} />;
|
return (
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: themeScript,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 客户端专用的 hook
|
||||||
|
const useClientOnly = (callback: () => void, deps: any[] = []) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}, deps);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ThemeModeToggle: React.FC = () => {
|
export const ThemeModeToggle: React.FC = () => {
|
||||||
const [isDark, setIsDark] = useState<boolean | null>(null);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [theme, setTheme] = useState<string>(() => {
|
||||||
// 初始化主题状态
|
if (typeof document !== 'undefined') {
|
||||||
useEffect(() => {
|
return document.documentElement.dataset.theme || 'light';
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const initTheme = () => {
|
|
||||||
const savedTheme = localStorage.getItem(THEME_KEY);
|
|
||||||
const currentTheme = document.documentElement.className;
|
|
||||||
|
|
||||||
// 确保 localStorage 和 DOM 的主题状态一致
|
|
||||||
if (savedTheme && savedTheme !== currentTheme) {
|
|
||||||
document.documentElement.className = savedTheme;
|
|
||||||
}
|
}
|
||||||
|
return 'light';
|
||||||
|
});
|
||||||
|
|
||||||
setIsDark(savedTheme === 'dark' || currentTheme === 'dark');
|
useClientOnly(() => {
|
||||||
};
|
const currentTheme = document.documentElement.dataset.theme || 'light';
|
||||||
|
document.documentElement.classList.remove('light', 'dark');
|
||||||
|
document.documentElement.classList.add(currentTheme);
|
||||||
|
setTheme(currentTheme);
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
initTheme();
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
// 监听系统主题变化
|
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|
||||||
const handleSystemThemeChange = (e: MediaQueryListEvent) => {
|
const handleSystemThemeChange = (e: MediaQueryListEvent) => {
|
||||||
if (!localStorage.getItem(THEME_KEY)) {
|
if (!localStorage.getItem(THEME_KEY)) {
|
||||||
const newTheme = e.matches ? 'dark' : 'light';
|
const newTheme = e.matches ? 'dark' : 'light';
|
||||||
document.documentElement.className = newTheme;
|
updateTheme(newTheme);
|
||||||
setIsDark(e.matches);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaQuery.addEventListener('change', handleSystemThemeChange);
|
mediaQuery.addEventListener('change', handleSystemThemeChange);
|
||||||
|
return () => mediaQuery.removeEventListener('change', handleSystemThemeChange);
|
||||||
|
}, [mounted]);
|
||||||
|
|
||||||
return () => {
|
const updateTheme = (newTheme: string) => {
|
||||||
mediaQuery.removeEventListener('change', handleSystemThemeChange);
|
document.documentElement.dataset.theme = newTheme;
|
||||||
|
document.documentElement.classList.remove('light', 'dark');
|
||||||
|
document.documentElement.classList.add(newTheme);
|
||||||
|
setTheme(newTheme);
|
||||||
};
|
};
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
if (isDark === null) return;
|
const newTheme = theme === 'dark' ? 'light' : 'dark';
|
||||||
const newIsDark = !isDark;
|
updateTheme(newTheme);
|
||||||
setIsDark(newIsDark);
|
|
||||||
const newTheme = newIsDark ? "dark" : "light";
|
|
||||||
document.documentElement.className = newTheme;
|
|
||||||
localStorage.setItem(THEME_KEY, newTheme);
|
localStorage.setItem(THEME_KEY, newTheme);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isDark === null) {
|
if (!mounted) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="w-full h-full p-0 rounded-lg transition-all duration-300 transform"
|
className="w-full h-full p-0 rounded-lg transition-all duration-300 transform"
|
||||||
aria-label="Loading theme"
|
aria-label="Theme toggle"
|
||||||
>
|
>
|
||||||
<MoonIcon className="w-full h-full" />
|
<MoonIcon className="w-full h-full" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -100,7 +108,7 @@ export const ThemeModeToggle: React.FC = () => {
|
|||||||
className="w-full h-full p-0 rounded-lg transition-all duration-300 transform"
|
className="w-full h-full p-0 rounded-lg transition-all duration-300 transform"
|
||||||
aria-label="Toggle theme"
|
aria-label="Toggle theme"
|
||||||
>
|
>
|
||||||
{isDark ? (
|
{theme === 'dark' ? (
|
||||||
<SunIcon className="w-full h-full" />
|
<SunIcon className="w-full h-full" />
|
||||||
) : (
|
) : (
|
||||||
<MoonIcon className="w-full h-full" />
|
<MoonIcon className="w-full h-full" />
|
||||||
@ -109,44 +117,34 @@ export const ThemeModeToggle: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新类型定义
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
__THEME__?: "light" | "dark";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useThemeMode = () => {
|
export const useThemeMode = () => {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
const [mode, setMode] = useState<"light" | "dark">("light");
|
const [mode, setMode] = useState<"light" | "dark">("light");
|
||||||
|
|
||||||
useEffect(() => {
|
useClientOnly(() => {
|
||||||
if (typeof window !== "undefined") {
|
const handleThemeChange = () => {
|
||||||
const saved = localStorage.getItem(THEME_KEY);
|
const currentTheme = document.documentElement.dataset.theme as "light" | "dark";
|
||||||
if (saved) {
|
setMode(currentTheme || "light");
|
||||||
setMode(saved as "light" | "dark");
|
|
||||||
} else {
|
|
||||||
const isDark = window.matchMedia(
|
|
||||||
"(prefers-color-scheme: dark)",
|
|
||||||
).matches;
|
|
||||||
setMode(isDark ? "dark" : "light");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听主题变化事件
|
|
||||||
const handleThemeChange = (e: CustomEvent) => {
|
|
||||||
setMode(e.detail.theme);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener(
|
handleThemeChange();
|
||||||
"theme-change",
|
setMounted(true);
|
||||||
handleThemeChange as EventListener,
|
|
||||||
);
|
const observer = new MutationObserver((mutations) => {
|
||||||
return () =>
|
mutations.forEach((mutation) => {
|
||||||
window.removeEventListener(
|
if (mutation.attributeName === 'data-theme') {
|
||||||
"theme-change",
|
handleThemeChange();
|
||||||
handleThemeChange as EventListener,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['data-theme']
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { mode };
|
return { mode: mounted ? mode : "light" };
|
||||||
};
|
};
|
||||||
|
@ -64,6 +64,7 @@
|
|||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"tailwindcss": "^3.4.4",
|
"tailwindcss": "^3.4.4",
|
||||||
|
"terser": "^5.37.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.1.6",
|
"typescript": "^5.1.6",
|
||||||
"vite": "^5.1.0",
|
"vite": "^5.1.0",
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { vitePlugin as remix } from "@remix-run/dev";
|
import { vitePlugin as remix } from "@remix-run/dev";
|
||||||
import { defineConfig, loadEnv } from "vite";
|
import { defineConfig, loadEnv, ConfigEnv, UserConfig } from "vite";
|
||||||
import tsconfigPaths from "vite-tsconfig-paths";
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
import { readEnvFile } from "./server/env";
|
import { readEnvFile } from "./server/env";
|
||||||
@ -30,7 +30,7 @@ const createDefineConfig = (config: EnvConfig) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineConfig(async ({ mode }) => {
|
export default defineConfig(async ({ mode }: ConfigEnv): Promise<UserConfig> => {
|
||||||
// 确保每次都读取最新的环境变量
|
// 确保每次都读取最新的环境变量
|
||||||
const currentConfig = await getLatestEnv();
|
const currentConfig = await getLatestEnv();
|
||||||
const env = loadEnv(mode, process.cwd(), "VITE_");
|
const env = loadEnv(mode, process.cwd(), "VITE_");
|
||||||
@ -64,8 +64,7 @@ export default defineConfig(async ({ mode }) => {
|
|||||||
],
|
],
|
||||||
define: createDefineConfig(currentConfig),
|
define: createDefineConfig(currentConfig),
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: currentConfig.VITE_ADDRESS,
|
||||||
address: currentConfig.VITE_ADDRESS,
|
|
||||||
port: Number(env.VITE_SYSTEM_PORT ?? currentConfig.VITE_PORT),
|
port: Number(env.VITE_SYSTEM_PORT ?? currentConfig.VITE_PORT),
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
hmr: true,
|
hmr: true,
|
||||||
@ -89,9 +88,28 @@ export default defineConfig(async ({ mode }) => {
|
|||||||
envPrefix: "VITE_",
|
envPrefix: "VITE_",
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
// 移除 manualChunks 配置
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
// 根据模块路径进行代码分割
|
||||||
|
if (id.includes('node_modules')) {
|
||||||
|
return 'vendor';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
chunkSizeWarningLimit: 1500
|
chunkSizeWarningLimit: 1500,
|
||||||
|
minify: 'terser',
|
||||||
|
terserOptions: {
|
||||||
|
compress: {
|
||||||
|
drop_console: true,
|
||||||
|
drop_debugger: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cssMinify: true,
|
||||||
|
cssCodeSplit: true,
|
||||||
|
sourcemap: mode !== 'production',
|
||||||
|
assetsInlineLimit: 4096,
|
||||||
|
reportCompressedSize: false,
|
||||||
},
|
},
|
||||||
ssr: {
|
ssr: {
|
||||||
noExternal: ['three', '@react-three/fiber', '@react-three/drei', 'gsap']
|
noExternal: ['three', '@react-three/fiber', '@react-three/drei', 'gsap']
|
||||||
|
Loading…
Reference in New Issue
Block a user