数据库:合并自定义字段和设计字段,所有限制移到应用层限制

后端:删除数据库构建需要等级,jwt添加rote信息
前端:修复post,删除插件机制,优化http
This commit is contained in:
lsy 2024-12-18 21:54:37 +08:00
parent f336271ad6
commit ba17778a8f
32 changed files with 429 additions and 985 deletions

View File

@ -6,15 +6,17 @@ use chrono::Duration;
use rocket::{http::Status, post, response::status, serde::json::Json, State}; use rocket::{http::Status, post, response::status, serde::json::Json, State};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use crate::api::Role;
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
pub struct TokenSystemData { pub struct TokenData {
username: String, username: String,
password: String, password: String,
} }
#[post("/system", format = "application/json", data = "<data>")] #[post("/system", format = "application/json", data = "<data>")]
pub async fn token_system( pub async fn token_system(
state: &State<Arc<AppState>>, state: &State<Arc<AppState>>,
data: Json<TokenSystemData>, data: Json<TokenData>,
) -> AppResult<String> { ) -> AppResult<String> {
let sql = state.sql_get().await.into_app_result()?; let sql = state.sql_get().await.into_app_result()?;
let mut builder = builder::QueryBuilder::new( let mut builder = builder::QueryBuilder::new(
@ -38,17 +40,6 @@ pub async fn token_system(
) )
.into_app_result()?, .into_app_result()?,
), ),
builder::WhereClause::Condition(
builder::Condition::new(
"email".to_string(),
builder::Operator::Eq,
Some(builder::SafeValue::Text(
"author@lsy22.com".into(),
builder::ValidationLevel::Relaxed,
)),
)
.into_app_result()?,
),
builder::WhereClause::Condition( builder::WhereClause::Condition(
builder::Condition::new( builder::Condition::new(
"role".to_string(), "role".to_string(),
@ -62,12 +53,15 @@ pub async fn token_system(
), ),
])); ]));
println!("db: {:?}", sql.get_type());
let values = sql let values = sql
.get_db() .get_db()
.execute_query(&builder) .execute_query(&builder)
.await .await
.into_app_result()?; .into_app_result()?;
let password = values let password = values
.first() .first()
.and_then(|row| row.get("password_hash")) .and_then(|row| row.get("password_hash"))
@ -80,6 +74,7 @@ pub async fn token_system(
Ok(security::jwt::generate_jwt( Ok(security::jwt::generate_jwt(
security::jwt::CustomClaims { security::jwt::CustomClaims {
name: "system".into(), name: "system".into(),
role: Role::Administrator.to_string(),
}, },
Duration::minutes(1), Duration::minutes(1),
) )

71
backend/src/api/fields.rs Normal file
View File

@ -0,0 +1,71 @@
use crate::{
common::error::{AppResult, CustomResult},
storage::sql::{self, builder},
};
use builder::{SafeValue, SqlOperation, ValidationLevel};
use std::fmt::{Display, Formatter};
pub enum TargetType {
Post,
Page,
Theme,
System,
}
impl Display for TargetType {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
match self {
TargetType::Post => write!(f, "post"),
TargetType::Page => write!(f, "page"),
TargetType::Theme => write!(f, "theme"),
TargetType::System => write!(f, "system"),
}
}
}
pub enum FieldType {
Data,
Meta,
}
impl Display for FieldType {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
match self {
FieldType::Data => write!(f, "data"),
FieldType::Meta => write!(f, "meta"),
}
}
}
pub async fn insert_fields(
sql: &sql::Database,
target_type: TargetType,
target_id: i64,
field_type: FieldType,
field_key: String,
field_value: String,
) -> CustomResult<()> {
let mut builder = builder::QueryBuilder::new(
SqlOperation::Insert,
sql.table_name("fields"),
sql.get_type(),
)?;
builder.set_value(
"target_type".to_string(),
SafeValue::Text(target_type.to_string(), ValidationLevel::Strict),
)?;
builder.set_value("target_id".to_string(), SafeValue::Integer(target_id))?;
builder.set_value(
"field_type".to_string(),
SafeValue::Text(field_type.to_string(), ValidationLevel::Raw),
)?;
builder.set_value(
"field_key".to_string(),
SafeValue::Text(field_key, ValidationLevel::Raw),
)?;
builder.set_value(
"field_value".to_string(),
SafeValue::Text(field_value, ValidationLevel::Raw),
)?;
sql.get_db().execute_query(&builder).await?;
Ok(())
}

View File

@ -1,11 +1,15 @@
pub mod auth; pub mod auth;
pub mod settings; pub mod fields;
pub mod page;
pub mod post;
pub mod setup; pub mod setup;
pub mod users; pub mod users;
use crate::security::jwt;
use rocket::http::Status;
use rocket::request::{FromRequest, Outcome, Request}; use rocket::request::{FromRequest, Outcome, Request};
use rocket::routes; use rocket::routes;
use crate::api::users::Role;
use rocket::http::Status;
use crate::security::jwt;
pub struct Token(String); pub struct Token(String);
@ -26,6 +30,7 @@ impl<'r> FromRequest<'r> for Token {
} }
} }
pub struct SystemToken(String); pub struct SystemToken(String);
#[rocket::async_trait] #[rocket::async_trait]
@ -43,10 +48,11 @@ impl<'r> FromRequest<'r> for SystemToken {
} }
} }
pub fn jwt_routes() -> Vec<rocket::Route> { pub fn jwt_routes() -> Vec<rocket::Route> {
routes![auth::token::token_system] routes![auth::token::token_system]
} }
pub fn configure_routes() -> Vec<rocket::Route> { pub fn fields_routes() -> Vec<rocket::Route> {
routes![settings::system_config_get] routes![]
} }

5
backend/src/api/page.rs Normal file
View File

@ -0,0 +1,5 @@
pub enum PageState {
Publicity,
Hidden,
Privacy,
}

1
backend/src/api/post.rs Normal file
View File

@ -0,0 +1 @@

View File

@ -1,116 +0,0 @@
use super::SystemToken;
use crate::common::error::{AppResult, AppResultInto, CustomResult};
use crate::storage::{sql, sql::builder};
use crate::AppState;
use rocket::{
get,
http::Status,
serde::json::{Json, Value},
State,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::Arc;
#[derive(Deserialize, Serialize)]
pub struct SystemConfigure {
pub author_name: String,
pub current_theme: String,
pub site_keyword: String,
pub site_description: String,
pub admin_path: String,
}
impl Default for SystemConfigure {
fn default() -> Self {
Self {
author_name: "lsy".to_string(),
current_theme: "echoes".to_string(),
site_keyword: "echoes".to_string(),
site_description: "echoes是一个高效、可扩展的博客平台".to_string(),
admin_path: "admin".to_string(),
}
}
}
pub async fn get_setting(
sql: &sql::Database,
comfig_type: String,
name: String,
) -> CustomResult<Json<Value>> {
let name_condition = builder::Condition::new(
"name".to_string(),
builder::Operator::Eq,
Some(builder::SafeValue::Text(
format!("{}_{}", comfig_type, name),
builder::ValidationLevel::Strict,
)),
)?;
let where_clause = builder::WhereClause::Condition(name_condition);
let mut sql_builder = builder::QueryBuilder::new(
builder::SqlOperation::Select,
sql.table_name("settings"),
sql.get_type(),
)?;
sql_builder
.add_condition(where_clause)
.add_field("data".to_string())?;
println!("{:?}", sql_builder.build());
let result = sql.get_db().execute_query(&sql_builder).await?;
Ok(Json(json!(result)))
}
pub async fn insert_setting(
sql: &sql::Database,
comfig_type: String,
name: String,
data: Json<Value>,
) -> CustomResult<()> {
let mut builder = builder::QueryBuilder::new(
builder::SqlOperation::Insert,
sql.table_name("settings"),
sql.get_type(),
)?;
builder.set_value(
"name".to_string(),
builder::SafeValue::Text(
format!("{}_{}", comfig_type, name).to_string(),
builder::ValidationLevel::Strict,
),
)?;
builder.set_value(
"data".to_string(),
builder::SafeValue::Text(data.to_string(), builder::ValidationLevel::Relaxed),
)?;
sql.get_db().execute_query(&builder).await?;
Ok(())
}
#[get("/system")]
pub async fn system_config_get(
state: &State<Arc<AppState>>,
_token: SystemToken,
) -> AppResult<Json<Value>> {
let sql = state.sql_get().await.into_app_result()?;
let settings = get_setting(&sql, "system".to_string(), sql.table_name("settings"))
.await
.into_app_result()?;
Ok(settings)
}
#[get("/theme/<name>")]
pub async fn theme_config_get(
state: &State<Arc<AppState>>,
_token: SystemToken,
name: String,
) -> AppResult<Json<Value>> {
let sql = state.sql_get().await.into_app_result()?;
let settings = get_setting(&sql, "theme".to_string(), name)
.await
.into_app_result()?;
Ok(settings)
}

View File

@ -1,4 +1,6 @@
use super::{settings, users}; use super::fields::{FieldType, TargetType};
use super::users::Role;
use super::{fields, users};
use crate::common::config; use crate::common::config;
use crate::common::error::{AppResult, AppResultInto}; use crate::common::error::{AppResult, AppResultInto};
use crate::common::helpers; use crate::common::helpers;
@ -45,7 +47,7 @@ pub struct StepAccountData {
} }
#[derive(Deserialize, Serialize, Debug)] #[derive(Deserialize, Serialize, Debug)]
pub struct InstallReplyData { pub struct StepAccountResponse {
token: String, token: String,
username: String, username: String,
password: String, password: String,
@ -55,7 +57,7 @@ pub struct InstallReplyData {
pub async fn setup_account( pub async fn setup_account(
data: Json<StepAccountData>, data: Json<StepAccountData>,
state: &State<Arc<AppState>>, state: &State<Arc<AppState>>,
) -> AppResult<status::Custom<Json<InstallReplyData>>> { ) -> AppResult<Json<StepAccountResponse>> {
let mut config = config::Config::read().unwrap_or_default(); let mut config = config::Config::read().unwrap_or_default();
if config.init.administrator { if config.init.administrator {
return Err(status::Custom( return Err(status::Custom(
@ -73,10 +75,6 @@ pub async fn setup_account(
state.sql_get().await.into_app_result()? state.sql_get().await.into_app_result()?
}; };
let system_credentials = (
helpers::generate_random_string(20),
helpers::generate_random_string(20),
);
users::insert_user( users::insert_user(
&sql, &sql,
@ -84,32 +82,49 @@ pub async fn setup_account(
username: data.username.clone(), username: data.username.clone(),
email: data.email, email: data.email,
password: data.password, password: data.password,
role: "administrator".to_string(), role: Role::Administrator,
}, },
) )
.await .await
.into_app_result()?; .into_app_result()?;
users::insert_user( let system_credentials = (
&sql, helpers::generate_random_string(20),
users::RegisterData { helpers::generate_random_string(20),
);
let system_account = users::RegisterData {
username: system_credentials.0.clone(), username: system_credentials.0.clone(),
email: "author@lsy22.com".to_string(), email: "author@lsy22.com".to_string(),
password: system_credentials.1.clone(), password: system_credentials.1.clone(),
role: "administrator".to_string(), role: Role::Administrator,
}, };
users::insert_user(
&sql,
system_account,
) )
.await .await
.into_app_result()?; .into_app_result()?;
settings::insert_setting( fields::insert_fields(
&sql, &sql,
"system".to_string(), TargetType::System,
"settings".to_string(), 0,
Json(json!(settings::SystemConfigure { FieldType::Meta,
author_name: data.username.clone(), "keywords".to_string(),
..settings::SystemConfigure::default() "echoes,blog,个人博客".to_string(),
})), )
.await
.into_app_result()?;
fields::insert_fields(
&sql,
TargetType::System,
0,
FieldType::Data,
"current_theme".to_string(),
"echoes".to_string(),
) )
.await .await
.into_app_result()?; .into_app_result()?;
@ -117,6 +132,7 @@ pub async fn setup_account(
let token = security::jwt::generate_jwt( let token = security::jwt::generate_jwt(
security::jwt::CustomClaims { security::jwt::CustomClaims {
name: data.username, name: data.username,
role: Role::Administrator.to_string(),
}, },
Duration::days(7), Duration::days(7),
) )
@ -125,12 +141,9 @@ pub async fn setup_account(
config::Config::write(config).into_app_result()?; config::Config::write(config).into_app_result()?;
state.trigger_restart().await.into_app_result()?; state.trigger_restart().await.into_app_result()?;
Ok(status::Custom( Ok(Json(StepAccountResponse {
Status::Ok,
Json(InstallReplyData {
token, token,
username: system_credentials.0, username: system_credentials.0,
password: system_credentials.1, password: system_credentials.1,
}), }))
))
} }

View File

@ -4,6 +4,7 @@ use crate::storage::{sql, sql::builder};
use regex::Regex; use regex::Regex;
use rocket::{get, http::Status, post, response::status, serde::json::Json, State}; use rocket::{get, http::Status, post, response::status, serde::json::Json, State};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
pub struct LoginData { pub struct LoginData {
@ -11,24 +12,30 @@ pub struct LoginData {
pub password: String, pub password: String,
} }
#[derive(Debug)]
pub enum Role {
Administrator,
Visitor,
}
impl Display for Role {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
match self {
Role::Administrator => write!(f, "administrator"),
Role::Visitor => write!(f, "visitor"),
}
}
}
#[derive(Debug)] #[derive(Debug)]
pub struct RegisterData { pub struct RegisterData {
pub username: String, pub username: String,
pub email: String, pub email: String,
pub password: String, pub password: String,
pub role: String, pub role: Role,
} }
pub async fn insert_user(sql: &sql::Database, data: RegisterData) -> CustomResult<()> { pub async fn insert_user(sql: &sql::Database, data: RegisterData) -> CustomResult<()> {
let role = match data.role.as_str() {
"administrator" | "contributor" => data.role,
_ => {
return Err(
"Invalid role. Must be either 'administrator' or 'contributor'".into_custom_error(),
)
}
};
let password_hash = bcrypt::generate_hash(&data.password)?; let password_hash = bcrypt::generate_hash(&data.password)?;
let re = Regex::new(r"([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+)")?; let re = Regex::new(r"([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+)")?;
@ -57,7 +64,7 @@ pub async fn insert_user(sql: &sql::Database, data: RegisterData) -> CustomResul
)? )?
.set_value( .set_value(
"role".to_string(), "role".to_string(),
builder::SafeValue::Text(role, builder::ValidationLevel::Strict), builder::SafeValue::Text(data.role.to_string(), builder::ValidationLevel::Strict),
)?; )?;
sql.get_db().execute_query(&builder).await?; sql.get_db().execute_query(&builder).await?;

View File

@ -106,9 +106,7 @@ async fn main() -> CustomResult<()> {
rocket_builder = rocket_builder.mount("/", rocket::routes![api::setup::setup_account]); rocket_builder = rocket_builder.mount("/", rocket::routes![api::setup::setup_account]);
} else { } else {
state.sql_link(&config.sql_config).await?; state.sql_link(&config.sql_config).await?;
rocket_builder = rocket_builder rocket_builder = rocket_builder.mount("/auth/token", api::jwt_routes());
.mount("/auth/token", api::jwt_routes())
.mount("/config", api::configure_routes());
} }
let rocket = rocket_builder.ignite().await?; let rocket = rocket_builder.ignite().await?;

View File

@ -9,6 +9,7 @@ use std::{env, fs, path::PathBuf};
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CustomClaims { pub struct CustomClaims {
pub name: String, pub name: String,
pub role: String,
} }
pub enum SecretKey { pub enum SecretKey {

View File

@ -6,8 +6,7 @@ use crate::common::error::{CustomErrorInto, CustomResult};
use crate::config; use crate::config;
use async_trait::async_trait; use async_trait::async_trait;
use serde_json::Value; use serde_json::Value;
use sqlx::mysql::MySqlPool; use sqlx::{mysql::MySqlPool, Column, Executor, Row, TypeInfo};
use sqlx::{Column, Executor, Row, TypeInfo};
use std::collections::HashMap; use std::collections::HashMap;
#[derive(Clone)] #[derive(Clone)]
@ -54,7 +53,7 @@ impl DatabaseTrait for Mysql {
builder: &builder::QueryBuilder, builder: &builder::QueryBuilder,
) -> CustomResult<Vec<HashMap<String, Value>>> { ) -> CustomResult<Vec<HashMap<String, Value>>> {
let (query, values) = builder.build()?; let (query, values) = builder.build()?;
println!("查询语句: {}", query);
let mut sqlx_query = sqlx::query(&query); let mut sqlx_query = sqlx::query(&query);
for value in values { for value in values {
@ -69,6 +68,7 @@ impl DatabaseTrait for Mysql {
} }
let rows = sqlx_query.fetch_all(&self.pool).await?; let rows = sqlx_query.fetch_all(&self.pool).await?;
println!("查询结果: {:?}", rows);
Ok(rows Ok(rows
.into_iter() .into_iter()
@ -119,7 +119,7 @@ impl DatabaseTrait for Mysql {
let new_pool = Self::connect(&db_config, true).await?.pool; let new_pool = Self::connect(&db_config, true).await?.pool;
new_pool.execute(grammar.as_str()).await?; new_pool.execute(grammar.as_str()).await?;
new_pool.close(); new_pool.close().await;
Ok(()) Ok(())
} }
async fn close(&self) -> CustomResult<()> { async fn close(&self) -> CustomResult<()> {

View File

@ -1,7 +1,7 @@
use super::builder::{Condition, Identifier, Operator, SafeValue, ValidationLevel, WhereClause}; use super::builder::{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::{format, Display}; use std::fmt::Display;
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum FieldType { pub enum FieldType {
@ -59,7 +59,6 @@ pub struct Field {
pub name: Identifier, pub name: Identifier,
pub field_type: FieldType, pub field_type: FieldType,
pub constraints: FieldConstraint, pub constraints: FieldConstraint,
pub validation_level: ValidationLevel,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -144,13 +143,11 @@ impl Field {
name: &str, name: &str,
field_type: FieldType, field_type: FieldType,
constraints: FieldConstraint, constraints: FieldConstraint,
validation_level: ValidationLevel,
) -> CustomResult<Self> { ) -> CustomResult<Self> {
Ok(Self { Ok(Self {
name: Identifier::new(name.to_string())?, name: Identifier::new(name.to_string())?,
field_type, field_type,
constraints, constraints,
validation_level,
}) })
} }
@ -390,62 +387,6 @@ 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 role_check = WhereClause::Or(vec![
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))?;
@ -454,31 +395,26 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
"username", "username",
FieldType::VarChar(100), FieldType::VarChar(100),
FieldConstraint::new().primary(), FieldConstraint::new().primary(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"avatar_url", "avatar_url",
FieldType::VarChar(255), FieldType::VarChar(255),
FieldConstraint::new(), FieldConstraint::new(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"email", "email",
FieldType::VarChar(255), FieldType::VarChar(255),
FieldConstraint::new().unique().not_null(), FieldConstraint::new().unique().not_null(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"password_hash", "password_hash",
FieldType::VarChar(255), FieldType::VarChar(255),
FieldConstraint::new().not_null(), FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"role", "role",
FieldType::VarChar(20), FieldType::VarChar(20),
FieldConstraint::new().not_null().check(role_check.clone()), FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"created_at", "created_at",
@ -487,7 +423,6 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
"CURRENT_TIMESTAMP".to_string(), "CURRENT_TIMESTAMP".to_string(),
ValidationLevel::Strict, ValidationLevel::Strict,
)), )),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"updated_at", "updated_at",
@ -496,16 +431,6 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
"CURRENT_TIMESTAMP".to_string(), "CURRENT_TIMESTAMP".to_string(),
ValidationLevel::Strict, ValidationLevel::Strict,
)), )),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"last_login_at",
FieldType::Timestamp,
FieldConstraint::new().not_null().default(SafeValue::Text(
"CURRENT_TIMESTAMP".to_string(),
ValidationLevel::Strict,
)),
ValidationLevel::Strict,
)?); )?);
schema.add_table(users_table)?; schema.add_table(users_table)?;
@ -518,45 +443,49 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
"id", "id",
FieldType::Integer(true), FieldType::Integer(true),
FieldConstraint::new().primary(), FieldConstraint::new().primary(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"title", "title",
FieldType::VarChar(255), FieldType::VarChar(255),
FieldConstraint::new().not_null(), FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"content", "content",
FieldType::Text, FieldType::Text,
FieldConstraint::new().not_null(), FieldConstraint::new().not_null(),
ValidationLevel::Strict, )?)
.add_field(Field::new(
"is_editor",
FieldType::Boolean,
FieldConstraint::new()
.not_null()
.default(SafeValue::Bool(false)),
)?)
.add_field(Field::new(
"draft_content",
FieldType::Text,
FieldConstraint::new(),
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"template", "template",
FieldType::VarChar(50), FieldType::VarChar(50),
FieldConstraint::new(), FieldConstraint::new(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"status", "status",
FieldType::VarChar(20), FieldType::VarChar(20),
FieldConstraint::new() FieldConstraint::new().not_null(),
.not_null()
.check(content_state_check.clone()),
ValidationLevel::Strict,
)?); )?);
schema.add_table(pages_table)?; schema.add_table(pages_table)?;
// posts // 文章
let mut posts_table = Table::new(&format!("{}posts", db_prefix))?; let mut posts_table = Table::new(&format!("{}posts", db_prefix))?;
posts_table posts_table
.add_field(Field::new( .add_field(Field::new(
"id", "id",
FieldType::Integer(true), FieldType::Integer(true),
FieldConstraint::new().primary(), FieldConstraint::new().primary(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"author_name", "author_name",
@ -566,33 +495,26 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
.foreign_key(format!("{}users", db_prefix), "username".to_string()) .foreign_key(format!("{}users", db_prefix), "username".to_string())
.on_delete(ForeignKeyAction::Cascade) .on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade), .on_update(ForeignKeyAction::Cascade),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"cover_image", "cover_image",
FieldType::VarChar(255), FieldType::VarChar(255),
FieldConstraint::new(), FieldConstraint::new(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"title", "title",
FieldType::VarChar(255), FieldType::VarChar(255),
FieldConstraint::new(), FieldConstraint::new(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"content", "content",
FieldType::Text, FieldType::Text,
FieldConstraint::new().not_null(), FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"status", "status",
FieldType::VarChar(20), FieldType::VarChar(20),
FieldConstraint::new() FieldConstraint::new().not_null(),
.not_null()
.check(content_state_check.clone()),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"is_editor", "is_editor",
@ -600,13 +522,11 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
FieldConstraint::new() FieldConstraint::new()
.not_null() .not_null()
.default(SafeValue::Bool(false)), .default(SafeValue::Bool(false)),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"draft_content", "draft_content",
FieldType::Text, FieldType::Text,
FieldConstraint::new(), FieldConstraint::new(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"created_at", "created_at",
@ -615,7 +535,6 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
"CURRENT_TIMESTAMP".to_string(), "CURRENT_TIMESTAMP".to_string(),
ValidationLevel::Strict, ValidationLevel::Strict,
)), )),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"updated_at", "updated_at",
@ -624,13 +543,6 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
"CURRENT_TIMESTAMP".to_string(), "CURRENT_TIMESTAMP".to_string(),
ValidationLevel::Strict, ValidationLevel::Strict,
)), )),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"published_at",
FieldType::Timestamp,
FieldConstraint::new(),
ValidationLevel::Strict,
)?); )?);
schema.add_table(posts_table)?; schema.add_table(posts_table)?;
@ -642,7 +554,6 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
"id", "id",
FieldType::Integer(true), FieldType::Integer(true),
FieldConstraint::new().primary(), FieldConstraint::new().primary(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"author_id", "author_id",
@ -652,43 +563,36 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
.foreign_key(format!("{}users", db_prefix), "username".to_string()) .foreign_key(format!("{}users", db_prefix), "username".to_string())
.on_delete(ForeignKeyAction::Cascade) .on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade), .on_update(ForeignKeyAction::Cascade),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"name", "name",
FieldType::VarChar(255), FieldType::VarChar(255),
FieldConstraint::new().not_null(), FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"size_bytes", "size_bytes",
FieldType::BigInt, FieldType::BigInt,
FieldConstraint::new().not_null(), FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"storage_path", "storage_path",
FieldType::VarChar(255), FieldType::VarChar(255),
FieldConstraint::new().not_null().unique(), FieldConstraint::new().not_null().unique(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"mime_type", "mime_type",
FieldType::VarChar(50), FieldType::VarChar(50),
FieldConstraint::new().not_null(), FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"category", "category",
FieldType::VarChar(50), FieldType::VarChar(50),
FieldConstraint::new(), FieldConstraint::new(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"description", "description",
FieldType::VarChar(255), FieldType::VarChar(255),
FieldConstraint::new(), FieldConstraint::new(),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"created_at", "created_at",
@ -697,172 +601,69 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
"CURRENT_TIMESTAMP".to_string(), "CURRENT_TIMESTAMP".to_string(),
ValidationLevel::Strict, ValidationLevel::Strict,
)), )),
ValidationLevel::Strict,
)?); )?);
schema.add_table(resources_table)?; schema.add_table(resources_table)?;
// 配置表
let mut settings_table = Table::new(&format!("{}settings", db_prefix))?;
settings_table
.add_field(Field::new(
"name",
FieldType::VarChar(50),
FieldConstraint::new().primary(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"data",
FieldType::Text,
FieldConstraint::new(),
ValidationLevel::Strict,
)?);
schema.add_table(settings_table)?;
// 元数据表
let mut metadata_table = Table::new(&format!("{}metadata", db_prefix))?;
metadata_table
.add_field(Field::new(
"id",
FieldType::Integer(true),
FieldConstraint::new().primary(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"target_type",
FieldType::VarChar(20),
FieldConstraint::new()
.not_null()
.check(target_type_check.clone()),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"target_id",
FieldType::Integer(false),
FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"meta_key",
FieldType::VarChar(50),
FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"meta_value",
FieldType::Text,
FieldConstraint::new(),
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(
"idx_metadata_target",
vec!["target_type".to_string(), "target_id".to_string()],
false,
)?);
schema.add_table(metadata_table)?;
// 自定义字段表 // 自定义字段表
let mut custom_fields_table = Table::new(&format!("{}custom_fields", db_prefix))?; let mut fields_table = Table::new(&format!("{}fields", db_prefix))?;
custom_fields_table fields_table
.add_field(Field::new( .add_field(Field::new(
"id", "id",
FieldType::Integer(true), FieldType::Integer(true),
FieldConstraint::new().primary(), FieldConstraint::new().primary(),
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(),
.not_null()
.check(target_type_check.clone()),
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, )?)
.add_field(Field::new(
"field_type",
FieldType::VarChar(50),
FieldConstraint::new().not_null(),
)?) )?)
.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,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"field_value", "field_value",
FieldType::Text, FieldType::Text,
FieldConstraint::new(), FieldConstraint::new(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"field_type",
FieldType::VarChar(20),
FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?); )?);
custom_fields_table.add_index(Index::new( fields_table.add_index(Index::new(
"idx_custom_fields_target", "idx_fields_target",
vec!["target_type".to_string(), "target_id".to_string()], vec!["target_type".to_string(), "target_id".to_string()],
false, false,
)?); )?);
schema.add_table(custom_fields_table)?; schema.add_table(fields_table)?;
// 在 generate_schema 函数中,删除原有的 tags_tables 和 categories_table // 分类—标签 表
// 替换为新的 taxonomies 表
let mut taxonomies_table = Table::new(&format!("{}taxonomies", db_prefix))?; let mut taxonomies_table = Table::new(&format!("{}taxonomies", db_prefix))?;
taxonomies_table taxonomies_table
.add_field(Field::new( .add_field(Field::new(
"name", "name",
FieldType::VarChar(50), FieldType::VarChar(50),
FieldConstraint::new().primary(), FieldConstraint::new().primary(),
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,
)?) )?)
.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::Or(vec![
WhereClause::Condition(Condition::new(
"type".to_string(),
Operator::Eq,
Some(SafeValue::Text("'tag'".to_string(), ValidationLevel::Raw)),
)?),
WhereClause::Condition(Condition::new(
"type".to_string(),
Operator::Eq,
Some(SafeValue::Text(
"'category'".to_string(),
ValidationLevel::Raw,
)),
)?),
])),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"parent_name", "parent_name",
@ -871,12 +672,11 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
.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),
ValidationLevel::Strict,
)?); )?);
schema.add_table(taxonomies_table)?; schema.add_table(taxonomies_table)?;
// 替换为新的 post_taxonomies // 分类—标签_文章 关系
let mut post_taxonomies_table = Table::new(&format!("{}post_taxonomies", db_prefix))?; let mut post_taxonomies_table = Table::new(&format!("{}post_taxonomies", db_prefix))?;
post_taxonomies_table post_taxonomies_table
.add_field(Field::new( .add_field(Field::new(
@ -887,22 +687,20 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
.foreign_key(format!("{}posts", db_prefix), "id".to_string()) .foreign_key(format!("{}posts", db_prefix), "id".to_string())
.on_delete(ForeignKeyAction::Cascade) .on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade), .on_update(ForeignKeyAction::Cascade),
ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
"taxonomy_id", "taxonomy_name",
FieldType::VarChar(50), FieldType::VarChar(50),
FieldConstraint::new() FieldConstraint::new()
.not_null() .not_null()
.foreign_key(format!("{}taxonomies", db_prefix), "name".to_string()) .foreign_key(format!("{}taxonomies", db_prefix), "name".to_string())
.on_delete(ForeignKeyAction::Cascade) .on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade), .on_update(ForeignKeyAction::Cascade),
ValidationLevel::Strict,
)?); )?);
post_taxonomies_table.add_index(Index::new( post_taxonomies_table.add_index(Index::new(
"pk_post_taxonomies", "pk_post_taxonomies",
vec!["post_id".to_string(), "taxonomy_id".to_string()], vec!["post_id".to_string(), "taxonomy_name".to_string()],
true, true,
)?); )?);

View File

@ -17,7 +17,7 @@ pub struct Sqlite {
#[async_trait] #[async_trait]
impl DatabaseTrait for Sqlite { impl DatabaseTrait for Sqlite {
async fn connect(db_config: &config::SqlConfig, db: bool) -> CustomResult<Self> { async fn connect(db_config: &config::SqlConfig, _db: bool) -> CustomResult<Self> {
let db_file = env::current_dir()? let db_file = env::current_dir()?
.join("assets") .join("assets")
.join("sqllite") .join("sqllite")
@ -111,7 +111,7 @@ impl DatabaseTrait for Sqlite {
let pool = Self::connect(&db_config, false).await?.pool; let pool = Self::connect(&db_config, false).await?.pool;
pool.execute(grammar.as_str()).await?; pool.execute(grammar.as_str()).await?;
pool.close(); pool.close().await;
Ok(()) Ok(())
} }

View File

@ -1,289 +0,0 @@
import { Template } from "interface/template";
import {
Container,
Heading,
Text,
Box,
Flex,
Card,
Button,
TextField,
DropdownMenu,
ScrollArea,
Dialog,
Tabs,
Switch,
IconButton,
} from "@radix-ui/themes";
import {
PlusIcon,
MagnifyingGlassIcon,
DownloadIcon,
GearIcon,
CodeIcon,
Cross2Icon,
CheckIcon,
UpdateIcon,
TrashIcon,
ExclamationTriangleIcon,
} from "@radix-ui/react-icons";
import { useState } from "react";
import type { PluginConfig } from "interface/plugin";
// 模拟插件数据
const mockPlugins: (PluginConfig & {
id: number;
preview?: string;
installed?: boolean;
})[] = [
{
id: 1,
name: "comment-system",
displayName: "评论系统",
version: "1.0.0",
description: "支持多种评论系统集成包括Disqus、Gitalk等",
author: "Admin",
enabled: true,
icon: "https://api.iconify.design/material-symbols:comment.svg",
preview:
"https://images.unsplash.com/photo-1516116216624-53e697fedbea?w=500&auto=format",
managePath: "/dashboard/plugins/comment-system",
installed: true,
configuration: {
system: {
title: "评论系统配置",
description: "配置评论系统参数",
data: {
provider: "gitalk",
clientId: "",
clientSecret: "",
},
},
},
routes: new Set(),
},
{
id: 2,
name: "image-optimization",
displayName: "图片优化",
version: "1.0.0",
description: "自动优化上传的图片,支持压缩、裁剪、水印等功能",
author: "ThirdParty",
enabled: false,
icon: "https://api.iconify.design/material-symbols:image.svg",
preview:
"https://images.unsplash.com/photo-1618005198919-d3d4b5a92ead?w=500&auto=format",
installed: true,
configuration: {
system: {
title: "图片优化配置",
description: "配置图片优化参数",
data: {
quality: 80,
maxWidth: 1920,
watermark: false,
},
},
},
routes: new Set(),
},
];
// 模拟市场插件数据
interface MarketPlugin {
id: number;
name: string;
displayName: string;
version: string;
description: string;
author: string;
preview?: string;
downloads: number;
rating: number;
}
const marketPlugins: MarketPlugin[] = [
{
id: 4,
name: "image-optimization",
displayName: "图片优化",
version: "1.0.0",
description: "自动优化上传的图片,支持压缩、裁剪、水印等功能",
author: "ThirdParty",
preview:
"https://images.unsplash.com/photo-1516116216624-53e697fedbea?w=500&auto=format",
downloads: 1200,
rating: 4.5,
},
{
id: 5,
name: "markdown-plus",
displayName: "Markdown增强",
version: "2.0.0",
description: "增强的Markdown编辑器支持更多扩展语法和实时预览",
author: "ThirdParty",
preview:
"https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=500&auto=format",
downloads: 3500,
rating: 4.8,
},
];
export default new Template({}, ({ http, args }) => {
const [searchTerm, setSearchTerm] = useState("");
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [selectedPlugin, setSelectedPlugin] = useState<
(typeof mockPlugins)[0] | null
>(null);
// 处理插件启用/禁用
const handleTogglePlugin = (pluginId: number) => {
// 这里添加启用/禁用插件的逻辑
console.log("Toggle plugin:", pluginId);
};
return (
<Box>
{/* 页面标题和操作栏 */}
<Flex justify="between" align="center" className="mb-6">
<Box>
<Heading size="6" className="text-[--gray-12] mb-2">
</Heading>
<Text className="text-[--gray-11]">
{mockPlugins.length}
</Text>
</Box>
<Button
className="bg-[--accent-9]"
onClick={() => setIsAddDialogOpen(true)}
>
<PlusIcon className="w-4 h-4" />
</Button>
</Flex>
{/* 搜索栏 */}
<Box className="w-full sm:w-64 mb-6">
<TextField.Root
placeholder="搜索插件..."
value={searchTerm}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSearchTerm(e.target.value)
}
>
<TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" />
</TextField.Slot>
</TextField.Root>
</Box>
{/* 插件列表 */}
<Box className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{mockPlugins.map((plugin) => (
<Card
key={plugin.id}
className="p-4 border border-[--gray-6] hover-card"
>
{/* 插件预览图 */}
{plugin.preview && (
<Box className="aspect-video mb-4 rounded-lg overflow-hidden bg-[--gray-3]">
<img
src={plugin.preview}
alt={plugin.displayName}
className="w-full h-full object-cover"
/>
</Box>
)}
{/* 插件信息 */}
<Flex direction="column" gap="2">
<Flex justify="between" align="center">
<Heading size="3">{plugin.displayName}</Heading>
<Switch
checked={plugin.enabled}
onCheckedChange={() => handleTogglePlugin(plugin.id)}
/>
</Flex>
<Text size="1" className="text-[--gray-11]">
{plugin.version} · {plugin.author}
</Text>
<Text size="2" className="text-[--gray-11] line-clamp-2">
{plugin.description}
</Text>
{/* 操作按钮 */}
<Flex gap="2" mt="2">
{plugin.managePath && plugin.enabled && (
<Button
variant="soft"
className="flex-1"
onClick={() => {
if (plugin.managePath) {
window.location.href = plugin.managePath;
}
}}
>
<GearIcon className="w-4 h-4" />
</Button>
)}
<Button variant="soft" color="red" className="flex-1">
<TrashIcon className="w-4 h-4" />
</Button>
</Flex>
</Flex>
</Card>
))}
</Box>
{/* 安装插件对话框 */}
<Dialog.Root open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<Dialog.Content style={{ maxWidth: 500 }}>
<Dialog.Title></Dialog.Title>
<Dialog.Description size="2" mb="4">
</Dialog.Description>
<Box className="mt-4">
<Box className="border-2 border-dashed border-[--gray-6] rounded-lg p-8 text-center">
<input
type="file"
className="hidden"
id="plugin-upload"
accept=".zip"
onChange={(e) => {
console.log(e.target.files);
}}
/>
<label htmlFor="plugin-upload" className="cursor-pointer">
<CodeIcon className="w-12 h-12 mx-auto mb-4 text-[--gray-9]" />
<Text className="text-[--gray-11] mb-2">
</Text>
<Text size="1" className="text-[--gray-10]">
.zip
</Text>
</label>
</Box>
</Box>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button variant="soft" color="gray">
</Button>
</Dialog.Close>
<Dialog.Close>
<Button className="bg-[--accent-9]"></Button>
</Dialog.Close>
</Flex>
</Dialog.Content>
</Dialog.Root>
</Box>
);
});

View File

@ -5,7 +5,6 @@ export interface EnvConfig {
VITE_API_BASE_URL: string; VITE_API_BASE_URL: string;
VITE_API_USERNAME: string; VITE_API_USERNAME: string;
VITE_API_PASSWORD: string; VITE_API_PASSWORD: string;
VITE_PATTERN: string;
} }
export const DEFAULT_CONFIG: EnvConfig = { export const DEFAULT_CONFIG: EnvConfig = {
@ -15,8 +14,7 @@ export const DEFAULT_CONFIG: EnvConfig = {
VITE_API_BASE_URL: "http://127.0.0.1:22000", VITE_API_BASE_URL: "http://127.0.0.1:22000",
VITE_API_USERNAME: "", VITE_API_USERNAME: "",
VITE_API_PASSWORD: "", VITE_API_PASSWORD: "",
VITE_PATTERN: "true", };
} as const;
// 扩展 ImportMeta 接口 // 扩展 ImportMeta 接口
declare global { declare global {

View File

@ -6,8 +6,7 @@ import {
ScrollRestoration, ScrollRestoration,
} 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 { ThemeScript } from "hooks/ThemeMode";
import { ThemeScript } from "hooks/themeMode";
import "~/index.css"; import "~/index.css";
@ -17,6 +16,10 @@ export function Layout() {
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta httpEquiv="Expires" content="0" /> <meta httpEquiv="Expires" content="0" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>Echoes</title> <title>Echoes</title>
<ThemeScript /> <ThemeScript />
@ -28,11 +31,9 @@ export function Layout() {
suppressHydrationWarning={true} suppressHydrationWarning={true}
data-cz-shortcut-listen="false" data-cz-shortcut-listen="false"
> >
<Theme grayColor="slate" radius="medium" scaling="100%">
<NotificationProvider> <NotificationProvider>
<Outlet /> <Outlet />
</NotificationProvider> </NotificationProvider>
</Theme>
<ScrollRestoration /> <ScrollRestoration />
<Scripts /> <Scripts />
</body> </body>

View File

@ -1,27 +1,17 @@
import ErrorPage from "hooks/Error"; import ErrorPage from "hooks/Error";
import layout from "themes/echoes/layout";
import article from "themes/echoes/posts";
import about from "themes/echoes/about";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import post from "themes/echoes/post"; import post from "themes/echoes/post";
import { memo, useCallback } from "react"; import React, { memo, useCallback } from "react";
import login from "~/dashboard/login";
import adminLayout from "~/dashboard/layout"; import adminLayout from "~/dashboard/layout";
import dashboard from "~/dashboard/index"; import dashboard from "~/dashboard/index";
import posts from "~/dashboard/posts";
import comments from "~/dashboard/comments"; import comments from "~/dashboard/comments";
import categories from "./dashboard/categories"; import categories from "~/dashboard/categories";
import settings from "./dashboard/settings"; import settings from "~/dashboard/settings";
import files from "./dashboard/files"; import files from "~/dashboard/files";
import themes from "./dashboard/themes"; import themes from "~/dashboard/themes";
import users from "~/dashboard/users"; import users from "~/dashboard/users";
import plugins from "./dashboard/plugins"; import layout from "~/dashboard/layout";
const args = {
title: "我的页面",
theme: "dark",
nav: '<a href="/">index</a><a href="/error">error</a><a href="/about">about</a><a href="/post">post</a><a href="/login">login</a><a href="/dashboard">dashboard</a>',
} as const;
// 创建布局渲染器的工厂函数 // 创建布局渲染器的工厂函数
const createLayoutRenderer = (layoutComponent: any) => { const createLayoutRenderer = (layoutComponent: any) => {
@ -33,22 +23,53 @@ const createLayoutRenderer = (layoutComponent: any) => {
}; };
}; };
// 创建组件的工厂函数
const createComponentRenderer = (path: string) => {
return React.lazy(async () => {
const module = await import(/* @vite-ignore */ path);
return {
default: (props: any) => {
if (typeof module.default.render === "function") {
return module.default.render(props);
}
},
};
});
};
// 使用工厂函数创建不同的布局渲染器 // 使用工厂函数创建不同的布局渲染器
const renderLayout = createLayoutRenderer(layout); const renderLayout = createLayoutRenderer(layout);
const renderDashboardLayout = createLayoutRenderer(adminLayout); const renderDashboardLayout = createLayoutRenderer(adminLayout);
const Login = createComponentRenderer("./dashboard/login");
const posts = createComponentRenderer("themes/echoes/posts");
const Routes = memo(() => { const Routes = memo(() => {
const location = useLocation(); const location = useLocation();
const [mainPath, subPath] = location.pathname.split("/").filter(Boolean); const [mainPath, subPath] = location.pathname.split("/").filter(Boolean);
// 使用 useCallback 缓存渲染函数 // 使用 useCallback 缓存渲染函数
const renderContent = useCallback((Component: any) => { const renderContent = useCallback((Component: any) => {
return renderLayout(Component.render(args)); if (React.isValidElement(Component)) {
return renderLayout(Component);
}
return renderLayout(
<React.Suspense fallback={<div>Loading...</div>}>
{Component.render ? Component.render(args) : <Component args={args} />}
</React.Suspense>,
);
}, []); }, []);
// 添加管理后台内容渲染函数 // 添加管理后台内容渲染函数
const renderDashboardContent = useCallback((Component: any) => { const renderDashboardContent = useCallback((Component: any) => {
return renderDashboardLayout(Component.render(args)); if (React.isValidElement(Component)) {
return renderDashboardLayout(Component);
}
return renderDashboardLayout(
<React.Suspense fallback={<div>Loading...</div>}>
{Component.render ? Component.render(args) : <Component args={args} />}
</React.Suspense>,
);
}, []); }, []);
// 前台路由 // 前台路由
@ -59,8 +80,10 @@ const Routes = memo(() => {
return renderContent(about); return renderContent(about);
case "post": case "post":
return renderContent(post); return renderContent(post);
case "posts":
return renderContent(posts);
case "login": case "login":
return login.render(args); return <Login args={args} />;
case "dashboard": case "dashboard":
// 管理后台路由 // 管理后台路由
if (!subPath) { if (!subPath) {
@ -83,8 +106,6 @@ const Routes = memo(() => {
return renderDashboardContent(themes); return renderDashboardContent(themes);
case "users": case "users":
return renderDashboardContent(users); return renderDashboardContent(users);
case "plugins":
return renderDashboardContent(plugins);
default: default:
return renderDashboardContent(<div>404 </div>); return renderDashboardContent(<div>404 </div>);
} }

View File

@ -1,85 +0,0 @@
export interface CapabilityProps<T> {
name: string;
description?: string;
execute: (...args: any[]) => Promise<T>;
}
export class CapabilityService {
private capabilities: Map<
string,
Set<{
source: string;
capability: CapabilityProps<any>;
}>
> = new Map();
private static instance: CapabilityService;
private constructor() {}
public static getInstance(): CapabilityService {
if (!this.instance) {
this.instance = new CapabilityService();
}
return this.instance;
}
private register(
capabilityName: string,
source: string,
capability: CapabilityProps<any>,
) {
const handlers = this.capabilities.get(capabilityName) || new Set();
handlers.add({ source, capability });
}
private executeCapabilityMethod<T>(
capabilityName: string,
...args: any[]
): Set<T> {
const results = new Set<T>();
const handlers = this.capabilities.get(capabilityName);
if (handlers) {
handlers.forEach(({ capability }) => {
const methodFunction = capability["execute"];
if (methodFunction) {
methodFunction(...args)
.then((data) => results.add(data as T))
.catch((error) =>
console.error(`Error executing method ${capabilityName}:`, error),
);
}
});
}
return results;
}
private removeCapability(source: string) {
this.capabilities.forEach((capability_s, capabilityName) => {
const newHandlers = new Set(
Array.from(capability_s).filter(
(capability) => capability.source !== source,
),
);
this.capabilities.set(capabilityName, newHandlers);
});
}
private removeCapabilitys(capability: string) {
this.capabilities.delete(capability);
}
public validateCapability(capability: CapabilityProps<any>): boolean {
if (!capability.name || !capability.execute) {
return false;
}
const namePattern = /^[a-z][a-zA-Z0-9_]*$/;
if (!namePattern.test(capability.name)) {
return false;
}
return true;
}
}

View File

@ -2,11 +2,12 @@ import { DEFAULT_CONFIG } from "~/env";
export interface ErrorResponse { export interface ErrorResponse {
title: string; title: string;
message: string; message: string;
detail?: string;
} }
export class HttpClient { export class HttpClient {
private static instance: HttpClient; private static instance: HttpClient;
private timeout: number; private readonly timeout: number;
private constructor(timeout = 10000) { private constructor(timeout = 10000) {
this.timeout = timeout; this.timeout = timeout;
@ -34,41 +35,83 @@ export class HttpClient {
return { ...options, headers }; return { ...options, headers };
} }
private async handleResponse(response: Response): Promise<any> { private async handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) { if (!response.ok) {
const contentType = response.headers.get("content-type"); const contentType = response.headers.get("content-type");
let message; let errorDetail = {
status: response.status,
statusText: response.statusText,
message: "",
raw: "",
};
try { try {
if (contentType?.includes("application/json")) { if (contentType?.includes("application/json")) {
const error = await response.json(); const error = await response.json();
message = error.message || ""; errorDetail.message = error.message || "";
errorDetail.raw = JSON.stringify(error, null, 2);
} else { } else {
const textError = await response.text(); const textError = await response.text();
message = textError || ""; errorDetail.message = textError;
errorDetail.raw = textError;
} }
} catch (e) { } catch (e) {
console.error("解析响应错误:", e); console.error("[Response Parse Error]:", e);
errorDetail.message = "响应解析失败";
errorDetail.raw = e instanceof Error ? e.message : String(e);
} }
switch (response.status) { switch (response.status) {
case 400:
errorDetail.message = errorDetail.message || "请求参数错误";
break;
case 401:
errorDetail.message = "未授权访问";
break;
case 403:
errorDetail.message = "访问被禁止";
break;
case 404: case 404:
message = "请求的资源不存在"; errorDetail.message = "请求的资源不存在";
break;
case 500:
errorDetail.message = "服务器内部错误";
break;
case 502:
errorDetail.message = "网关错误";
break;
case 503:
errorDetail.message = "服务暂时不可用";
break;
case 504:
errorDetail.message = "网关超时";
break; break;
} }
const errorResponse: ErrorResponse = { const errorResponse: ErrorResponse = {
title: `${response.status} ${response.statusText}`, title: `${errorDetail.status} ${errorDetail.statusText}`,
message: message, message: errorDetail.message,
detail: `请求URL: ${response.url}\n状态码: ${errorDetail.status}\n原始错误: ${errorDetail.raw}`,
}; };
console.error("[HTTP Error]:", errorResponse);
throw errorResponse; throw errorResponse;
} }
try {
const contentType = response.headers.get("content-type"); const contentType = response.headers.get("content-type");
return contentType?.includes("application/json") if (contentType?.includes("application/json")) {
? response.json() return await response.json();
: response.text(); }
return (await response.text()) as T;
} catch (e) {
console.error("[Response Parse Error]:", e);
throw {
title: "响应解析错误",
message: "服务器返回的数据格式不正确",
detail: e instanceof Error ? e.message : String(e),
};
}
} }
private async request<T>( private async request<T>(
@ -94,22 +137,23 @@ export class HttpClient {
return await this.handleResponse(response); return await this.handleResponse(response);
} catch (error: any) { } catch (error: any) {
if (error.name === "AbortError") { if (error.name === "AbortError") {
const errorResponse: ErrorResponse = { throw {
title: "请求超时", title: "请求超时",
message: "服务器响应时间过长,请稍后重试", message: "服务器响应时间过长,请稍后重试",
detail: `请求URL: ${url}${endpoint}\n超时时间: ${this.timeout}ms`,
}; };
throw errorResponse;
} }
if ((error as ErrorResponse).title && (error as ErrorResponse).message) { if ((error as ErrorResponse).title && (error as ErrorResponse).message) {
throw error; throw error;
} }
console.log(error);
const errorResponse: ErrorResponse = { console.error("[Request Error]:", error);
title: "未知错误", throw {
title: "请求失败",
message: error.message || "发生未知错误", message: error.message || "发生未知错误",
detail: `请求URL: ${url}${endpoint}\n错误详情: ${error.stack || error}`,
}; };
throw errorResponse;
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }

View File

@ -1,19 +0,0 @@
import { ReactNode } from "react"; // Import React
import { LoaderFunction } from "react-router-dom";
import { Template } from "interface/template";
export class TemplateManager {
private static instance: TemplateManager;
private templates = new Map<string, Template>();
private constructor() {}
public static getInstance(): TemplateManager {
if (!TemplateManager.instance) {
TemplateManager.instance = new TemplateManager();
}
return TemplateManager.instance;
}
// 读取主题和模板中的模板
}

53
frontend/core/theme.ts Normal file
View File

@ -0,0 +1,53 @@
import React from "react";
import { Configuration } from "interface/serializableType";
import {ThemeConfig} from "interface/theme";
import {HttpClient} from "core/http"
// 创建布局渲染器的工厂函数
const createLayoutRenderer = (layoutComponent: any, args: Configuration) => {
return (children: React.ReactNode) => {
return layoutComponent.render({
children,
args,
});
};
};
// 创建组件的工厂函数
const createComponentRenderer = (path: string) => {
return React.lazy(async () => {
const module = await import(/* @vite-ignore */ path);
return {
default: (props: any) => {
if (typeof module.default.render === "function") {
return module.default.render(props);
}
},
};
});
};
export class TemplateManager {
private static instance: TemplateManager;
private routes = new Map<string, string>();
private layout: React.FC | undefined;
private error: React.FC | undefined;
private loading: React.FC | undefined;
private field : ThemeConfig;
private constructor() {
const http=HttpClient.getInstance();
http.systemToken()
}
public static getInstance(): TemplateManager {
if (!TemplateManager.instance) {
TemplateManager.instance = new TemplateManager();
}
return TemplateManager.instance;
}
// 读取主题和模板中的模板
}

View File

@ -1,17 +1,14 @@
import { HttpClient } from "core/http"; import { HttpClient } from "core/http";
import { CapabilityService } from "core/capability";
import { Serializable } from "interface/serializableType"; import { Serializable } from "interface/serializableType";
import { createElement, memo } from "react"; import React, { createElement, memo } from "react";
export class Layout { export class Layout {
private http: HttpClient; private readonly http: HttpClient;
private capability: CapabilityService;
private readonly MemoizedElement: React.MemoExoticComponent< private readonly MemoizedElement: React.MemoExoticComponent<
(props: { (props: {
children: React.ReactNode; children: React.ReactNode;
args?: Serializable; args?: Serializable;
onTouchStart?: (e: TouchEvent) => void; http: HttpClient;
onTouchEnd?: (e: TouchEvent) => void;
}) => React.ReactNode }) => React.ReactNode
>; >;
@ -19,29 +16,20 @@ export class Layout {
public element: (props: { public element: (props: {
children: React.ReactNode; children: React.ReactNode;
args?: Serializable; args?: Serializable;
onTouchStart?: (e: TouchEvent) => void; http: HttpClient;
onTouchEnd?: (e: TouchEvent) => void;
}) => React.ReactNode, }) => React.ReactNode,
services?: { services?: {
http?: HttpClient; http?: HttpClient;
capability?: CapabilityService;
}, },
) { ) {
this.http = services?.http || HttpClient.getInstance(); this.http = services?.http || HttpClient.getInstance();
this.capability = services?.capability || CapabilityService.getInstance();
this.MemoizedElement = memo(element); this.MemoizedElement = memo(element);
} }
render(props: { render(props: { children: React.ReactNode; args?: Serializable }) {
children: React.ReactNode;
args?: Serializable;
onTouchStart?: (e: TouchEvent) => void;
onTouchEnd?: (e: TouchEvent) => void;
}) {
return createElement(this.MemoizedElement, { return createElement(this.MemoizedElement, {
...props, ...props,
onTouchStart: props.onTouchStart, http: this.http,
onTouchEnd: props.onTouchEnd,
}); });
} }
} }

View File

@ -1,14 +0,0 @@
import { Configuration, PathDescription } from "interface/serializableType";
export interface PluginConfig {
name: string;
version: string;
displayName: string;
description?: string;
author?: string;
enabled: boolean;
icon?: string;
managePath?: string;
configuration?: Configuration;
routes: Set<PathDescription>;
}

View File

@ -1,45 +1,26 @@
import { HttpClient } from "core/http"; import { HttpClient } from "core/http";
import { CapabilityService } from "core/capability";
import { Serializable } from "interface/serializableType"; import { Serializable } from "interface/serializableType";
import { Layout } from "./layout"; import React from "react";
export class Template { export class Template {
private http: HttpClient; private readonly http: HttpClient;
private capability: CapabilityService;
constructor( constructor(
public config: {
layout?: Layout;
styles?: string[];
scripts?: string[];
description?: string;
},
public element: (props: { public element: (props: {
http: HttpClient; http: HttpClient;
args: Serializable; args: Serializable;
}) => React.ReactNode, }) => React.ReactNode,
services?: { services?: {
http?: HttpClient; http?: HttpClient;
capability?: CapabilityService;
}, },
) { ) {
this.http = services?.http || HttpClient.getInstance(); this.http = services?.http || HttpClient.getInstance();
this.capability = services?.capability || CapabilityService.getInstance();
} }
render(args: Serializable) { render(args: Serializable) {
const content = this.element({ return this.element({
http: this.http, http: this.http,
args, args,
}); });
if (this.config.layout) {
return this.config.layout.render({
children: content,
args,
});
}
return content;
} }
} }

View File

@ -10,6 +10,7 @@ export interface ThemeConfig {
templates: Map<string, PathDescription>; templates: Map<string, PathDescription>;
layout?: string; layout?: string;
configuration: Configuration; configuration: Configuration;
loading?: string;
error?: string; error?: string;
manage?: string; manage?: string;
routes: Map<string, string>; routes: Map<string, string>;

View File

@ -31,7 +31,6 @@
"isbot": "^4.1.0", "isbot": "^4.1.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"markdown-it-toc-done-right": "^4.2.0", "markdown-it-toc-done-right": "^4.2.0",
"r": "^0.0.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",

View File

@ -41,7 +41,7 @@ const skills = [
{ name: "Python", level: 70 }, { name: "Python", level: 70 },
]; ];
export default new Template({}, ({ http, args }) => { export default new Template(({}) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);

View File

@ -210,11 +210,11 @@ export default new Layout(({ children, args }) => {
> >
<nav> <nav>
<Container size="4"> <Container size="4">
<Flex justify="between" align="center" className="h-20 px-4"> <Flex justify="between" align="center" className="h-16 px-4">
{/* Logo 区域 */} {/* Logo 区域 */}
<Flex align="center"> <Flex align="center">
<Link href="/" className="hover-text flex items-center"> <Link href="/" className="hover-text flex items-center">
<Box className="w-20 h-20 [&_path]:transition-all [&_path]:duration-200 group-hover:[&_path]:stroke-[--accent-9]"> <Box className="w-20 h-8 [&_path]:transition-all [&_path]:duration-200 group-hover:[&_path]:stroke-[--accent-9]">
<Echoes /> <Echoes />
</Box> </Box>
</Link> </Link>

View File

@ -4,9 +4,10 @@ import React, {
useCallback, useCallback,
useRef, useRef,
useEffect, useEffect,
ComponentPropsWithoutRef,
} from "react"; } from "react";
import { Template } from "interface/template"; import { Template } from "interface/template";
import ReactMarkdown from "react-markdown"; import ReactMarkdown, { Components } from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneLight } from "react-syntax-highlighter/dist/cjs/styles/prism"; import { oneLight } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { import {
@ -20,10 +21,8 @@ import {
} from "@radix-ui/themes"; } from "@radix-ui/themes";
import { CalendarIcon, CodeIcon } from "@radix-ui/react-icons"; import { CalendarIcon, CodeIcon } from "@radix-ui/react-icons";
import type { PostDisplay } from "interface/fields"; import type { PostDisplay } from "interface/fields";
import type { MetaFunction } from "@remix-run/node";
import { getColorScheme } from "themes/echoes/utils/colorScheme"; import { getColorScheme } from "themes/echoes/utils/colorScheme";
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import { ComponentPropsWithoutRef } from "react";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { toast } from "hooks/Notification"; import { toast } from "hooks/Notification";
import rehypeRaw from "rehype-raw"; import rehypeRaw from "rehype-raw";
@ -372,7 +371,7 @@ function greet(user: User): string {
Markdown Markdown
1. 1.
2. 2.
3. 3.
> 💡 **** Markdown > 💡 **** Markdown
@ -417,31 +416,6 @@ function greet(user: User): string {
], ],
}; };
// 添 meta 函数
export const meta: MetaFunction = () => {
const description =
mockPost.metadata?.find((m) => m.metaKey === "description")?.metaValue ||
"";
const keywords =
mockPost.metadata?.find((m) => m.metaKey === "keywords")?.metaValue || "";
return [
{ title: mockPost.title },
{ name: "description", content: description },
{ name: "keywords", content: keywords },
// 添 Open Graph 标
{ property: "og:title", content: mockPost.title },
{ property: "og:description", content: description },
{ property: "og:image", content: mockPost.coverImage },
{ property: "og:type", content: "article" },
// 添加 Twitter 卡片标签
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: mockPost.title },
{ name: "twitter:description", content: description },
{ name: "twitter:image", content: mockPost.coverImage },
];
};
// 添加复制能的接口 // 添加复制能的接口
interface CopyButtonProps { interface CopyButtonProps {
code: string; code: string;
@ -508,7 +482,7 @@ const generateSequentialId = (() => {
}; };
})(); })();
export default new Template({}, ({ http, args }) => { export default new Template(({}) => {
const [toc, setToc] = useState<string[]>([]); const [toc, setToc] = useState<string[]>([]);
const [tocItems, setTocItems] = useState<TocItem[]>([]); const [tocItems, setTocItems] = useState<TocItem[]>([]);
const [activeId, setActiveId] = useState<string>(""); const [activeId, setActiveId] = useState<string>("");
@ -564,7 +538,7 @@ export default new Template({}, ({ http, args }) => {
md.render(mockPost.content); md.render(mockPost.content);
// 只在 ID 数组发生化时更新 // 只在 ID 数组发生化时更新
const newIds = tocArray.map((item) => item.id); const newIds = tocArray.map((item) => item.id);
if (JSON.stringify(headingIds.current) !== JSON.stringify(newIds)) { if (JSON.stringify(headingIds.current) !== JSON.stringify(newIds)) {
headingIds.current = [...newIds]; headingIds.current = [...newIds];
@ -580,15 +554,15 @@ export default new Template({}, ({ http, args }) => {
if (tocArray.length > 0 && !activeId) { if (tocArray.length > 0 && !activeId) {
setActiveId(tocArray[0].id); setActiveId(tocArray[0].id);
} }
}, [mockPost.content, mockPost.id, activeId]); }, [activeId]);
useEffect(() => { useEffect(() => {
if (headingIdsArrays[mockPost.id] && headingIds.current.length === 0) { if (headingIdsArrays[mockPost.id] && headingIds.current.length === 0) {
headingIds.current = [...headingIdsArrays[mockPost.id]]; headingIds.current = [...headingIdsArrays[mockPost.id]];
} }
}, [headingIdsArrays, mockPost.id]); }, [headingIdsArrays]);
const components = useMemo(() => { const components: Components = useMemo(() => {
return { return {
h1: ({ children, ...props }: ComponentPropsWithoutRef<"h1">) => { h1: ({ children, ...props }: ComponentPropsWithoutRef<"h1">) => {
const headingId = headingIds.current.shift(); const headingId = headingIds.current.shift();
@ -626,14 +600,13 @@ export default new Template({}, ({ http, args }) => {
</h3> </h3>
); );
}, },
p: ({ p: ({ children, ...props }: ComponentPropsWithoutRef<"p">) => (
node,
...props
}: ComponentPropsWithoutRef<"p"> & { node?: any }) => (
<p <p
className="text-sm sm:text-base md:text-lg leading-relaxed mb-3 sm:mb-4 text-[--gray-11]" className="text-sm sm:text-base md:text-lg leading-relaxed mb-3 sm:mb-4 text-[--gray-11]"
{...props} {...props}
/> >
{children}
</p>
), ),
ul: ({ children, ...props }: ComponentPropsWithoutRef<"ul">) => ( ul: ({ children, ...props }: ComponentPropsWithoutRef<"ul">) => (
<ul <ul
@ -911,15 +884,11 @@ export default new Template({}, ({ http, args }) => {
), ),
// 修改 summary 组件 // 修改 summary 组件
summary: ({ summary: (props: ComponentPropsWithoutRef<"summary">) => (
node,
...props
}: ComponentPropsWithoutRef<"summary"> & { node?: any }) => (
<summary <summary
className="px-4 py-3 cursor-pointer hover:bg-[--gray-3] transition-colors className="px-4 py-3 cursor-pointer hover:bg-[--gray-3] transition-colors
text-[--gray-12] font-medium select-none text-[--gray-12] font-medium select-none
marker:text-[--gray-11] marker:text-[--gray-11]"
"
{...props} {...props}
/> />
), ),
@ -1148,7 +1117,7 @@ export default new Template({}, ({ http, args }) => {
</> </>
); );
// 在组顶部添加 useMemo 包静态内容 // 在组顶部添加 useMemo 包静态内容
const PostContent = useMemo(() => { const PostContent = useMemo(() => {
// 在渲染内容前重置 headingIds // 在渲染内容前重置 headingIds
if (headingIdsArrays[mockPost.id]) { if (headingIdsArrays[mockPost.id]) {
@ -1180,7 +1149,7 @@ export default new Template({}, ({ http, args }) => {
</div> </div>
</Box> </Box>
); );
}, [mockPost.content, components, mockPost.id, headingIdsArrays]); // 添加必要的依赖 }, [components, headingIdsArrays]);
return ( return (
<Container <Container

View File

@ -251,7 +251,7 @@ const mockArticles: PostDisplay[] = [
}, },
]; ];
export default new Template({}, ({ http, args }) => { export default new Template(({}) => {
const articleData = useMemo(() => mockArticles, []); const articleData = useMemo(() => mockArticles, []);
const totalPages = 25; // 假设有25页 const totalPages = 25; // 假设有25页
const currentPage = 1; // 当前页码 const currentPage = 1; // 当前页码

View File

@ -9,19 +9,35 @@ const themeConfig: ThemeConfig = {
configuration: { configuration: {
nav: { nav: {
title: "导航配置", title: "导航配置",
data: '<a href="h">你好</a> <a href="h">不好</a>', data: '<a href="/">index</a><a href="/error">error</a><a href="/about">about</a><a href="/post">post</a><a href="/login">login</a><a href="/dashboard">dashboard</a>',
}, },
}, },
layout: "layout.tsx", layout: "layout.tsx",
templates: new Map([ templates: new Map([
[ [
"page", "posts",
{ {
path: "./templates/page", path: "posts",
name: "文章列表模板", name: "文章列表模板",
description: "博客首页展示模板", description: "博客首页展示模板",
}, },
], ],
[
"post",
{
path: "post",
name: "文章详情模板",
description: "文章详情展示模板",
},
],
[
"about",
{
path: "about",
name: "关于页面模板",
description: "关于页面展示模板",
},
],
]), ]),
routes: new Map<string, string>([]), routes: new Map<string, string>([]),

View File

@ -5,7 +5,8 @@
"**/.server/**/*.ts", "**/.server/**/*.ts",
"**/.server/**/*.tsx", "**/.server/**/*.tsx",
"**/.client/**/*.ts", "**/.client/**/*.ts",
"**/.client/**/*.tsx" "**/.client/**/*.tsx",
"**/interface/**/*.ts"
], ],
"compilerOptions": { "compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"], "lib": ["DOM", "DOM.Iterable", "ES2022"],
@ -23,7 +24,7 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"~/*": ["./app/*"] "~/*": ["./app/*"],
}, },
// Vite takes care of building everything, not tsc. // Vite takes care of building everything, not tsc.