Compare commits

..

2 Commits

Author SHA1 Message Date
lsy
1a47b87b4d Merge remote-tracking branch 'gitea/master' 2024-12-18 21:58:54 +08:00
lsy
ba17778a8f 数据库:合并自定义字段和设计字段,所有限制移到应用层限制
后端:删除数据库构建需要等级,jwt添加rote信息
前端:修复post,删除插件机制,优化http
2024-12-18 21:54:37 +08:00
31 changed files with 429 additions and 869 deletions

View File

@ -6,15 +6,17 @@ use chrono::Duration;
use rocket::{http::Status, post, response::status, serde::json::Json, State};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::api::Role;
#[derive(Deserialize, Serialize)]
pub struct TokenSystemData {
pub struct TokenData {
username: String,
password: String,
}
#[post("/system", format = "application/json", data = "<data>")]
pub async fn token_system(
state: &State<Arc<AppState>>,
data: Json<TokenSystemData>,
data: Json<TokenData>,
) -> AppResult<String> {
let sql = state.sql_get().await.into_app_result()?;
let mut builder = builder::QueryBuilder::new(
@ -38,17 +40,6 @@ pub async fn token_system(
)
.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::Condition::new(
"role".to_string(),
@ -62,12 +53,15 @@ pub async fn token_system(
),
]));
println!("db: {:?}", sql.get_type());
let values = sql
.get_db()
.execute_query(&builder)
.await
.into_app_result()?;
let password = values
.first()
.and_then(|row| row.get("password_hash"))
@ -80,6 +74,7 @@ pub async fn token_system(
Ok(security::jwt::generate_jwt(
security::jwt::CustomClaims {
name: "system".into(),
role: Role::Administrator.to_string(),
},
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 settings;
pub mod fields;
pub mod page;
pub mod post;
pub mod setup;
pub mod users;
use crate::security::jwt;
use rocket::http::Status;
use rocket::request::{FromRequest, Outcome, Request};
use rocket::routes;
use crate::api::users::Role;
use rocket::http::Status;
use crate::security::jwt;
pub struct Token(String);
@ -26,6 +30,7 @@ impl<'r> FromRequest<'r> for Token {
}
}
pub struct SystemToken(String);
#[rocket::async_trait]
@ -43,10 +48,11 @@ impl<'r> FromRequest<'r> for SystemToken {
}
}
pub fn jwt_routes() -> Vec<rocket::Route> {
routes![auth::token::token_system]
}
pub fn configure_routes() -> Vec<rocket::Route> {
routes![settings::system_config_get]
pub fn fields_routes() -> Vec<rocket::Route> {
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,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::error::{AppResult, AppResultInto};
use crate::common::helpers;
@ -45,7 +47,7 @@ pub struct StepAccountData {
}
#[derive(Deserialize, Serialize, Debug)]
pub struct InstallReplyData {
pub struct StepAccountResponse {
token: String,
username: String,
password: String,
@ -55,7 +57,7 @@ pub struct InstallReplyData {
pub async fn setup_account(
data: Json<StepAccountData>,
state: &State<Arc<AppState>>,
) -> AppResult<status::Custom<Json<InstallReplyData>>> {
) -> AppResult<Json<StepAccountResponse>> {
let mut config = config::Config::read().unwrap_or_default();
if config.init.administrator {
return Err(status::Custom(
@ -73,10 +75,6 @@ pub async fn setup_account(
state.sql_get().await.into_app_result()?
};
let system_credentials = (
helpers::generate_random_string(20),
helpers::generate_random_string(20),
);
users::insert_user(
&sql,
@ -84,32 +82,49 @@ pub async fn setup_account(
username: data.username.clone(),
email: data.email,
password: data.password,
role: "administrator".to_string(),
role: Role::Administrator,
},
)
.await
.into_app_result()?;
users::insert_user(
&sql,
users::RegisterData {
let system_credentials = (
helpers::generate_random_string(20),
helpers::generate_random_string(20),
);
let system_account = users::RegisterData {
username: system_credentials.0.clone(),
email: "author@lsy22.com".to_string(),
password: system_credentials.1.clone(),
role: "administrator".to_string(),
},
role: Role::Administrator,
};
users::insert_user(
&sql,
system_account,
)
.await
.into_app_result()?;
settings::insert_setting(
fields::insert_fields(
&sql,
"system".to_string(),
"settings".to_string(),
Json(json!(settings::SystemConfigure {
author_name: data.username.clone(),
..settings::SystemConfigure::default()
})),
TargetType::System,
0,
FieldType::Meta,
"keywords".to_string(),
"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
.into_app_result()?;
@ -117,6 +132,7 @@ pub async fn setup_account(
let token = security::jwt::generate_jwt(
security::jwt::CustomClaims {
name: data.username,
role: Role::Administrator.to_string(),
},
Duration::days(7),
)
@ -125,12 +141,9 @@ pub async fn setup_account(
config::Config::write(config).into_app_result()?;
state.trigger_restart().await.into_app_result()?;
Ok(status::Custom(
Status::Ok,
Json(InstallReplyData {
Ok(Json(StepAccountResponse {
token,
username: system_credentials.0,
password: system_credentials.1,
}),
))
}))
}

View File

@ -4,6 +4,7 @@ use crate::storage::{sql, sql::builder};
use regex::Regex;
use rocket::{get, http::Status, post, response::status, serde::json::Json, State};
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
#[derive(Deserialize, Serialize)]
pub struct LoginData {
@ -11,24 +12,30 @@ pub struct LoginData {
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)]
pub struct RegisterData {
pub username: String,
pub email: String,
pub password: String,
pub role: String,
pub role: Role,
}
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 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(
"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?;

View File

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

View File

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

View File

@ -6,8 +6,7 @@ use crate::common::error::{CustomErrorInto, CustomResult};
use crate::config;
use async_trait::async_trait;
use serde_json::Value;
use sqlx::mysql::MySqlPool;
use sqlx::{Column, Executor, Row, TypeInfo};
use sqlx::{mysql::MySqlPool, Column, Executor, Row, TypeInfo};
use std::collections::HashMap;
#[derive(Clone)]
@ -54,7 +53,7 @@ impl DatabaseTrait for Mysql {
builder: &builder::QueryBuilder,
) -> CustomResult<Vec<HashMap<String, Value>>> {
let (query, values) = builder.build()?;
println!("查询语句: {}", query);
let mut sqlx_query = sqlx::query(&query);
for value in values {
@ -69,6 +68,7 @@ impl DatabaseTrait for Mysql {
}
let rows = sqlx_query.fetch_all(&self.pool).await?;
println!("查询结果: {:?}", rows);
Ok(rows
.into_iter()
@ -119,7 +119,7 @@ impl DatabaseTrait for Mysql {
let new_pool = Self::connect(&db_config, true).await?.pool;
new_pool.execute(grammar.as_str()).await?;
new_pool.close();
new_pool.close().await;
Ok(())
}
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 crate::common::error::{CustomErrorInto, CustomResult};
use std::fmt::{format, Display};
use std::fmt::Display;
#[derive(Debug, Clone, PartialEq)]
pub enum FieldType {
@ -59,7 +59,6 @@ pub struct Field {
pub name: Identifier,
pub field_type: FieldType,
pub constraints: FieldConstraint,
pub validation_level: ValidationLevel,
}
#[derive(Debug, Clone)]
@ -144,13 +143,11 @@ impl Field {
name: &str,
field_type: FieldType,
constraints: FieldConstraint,
validation_level: ValidationLevel,
) -> CustomResult<Self> {
Ok(Self {
name: Identifier::new(name.to_string())?,
field_type,
constraints,
validation_level,
})
}
@ -390,62 +387,6 @@ impl SchemaBuilder {
pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomResult<String> {
let db_prefix = db_prefix.to_string()?;
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))?;
@ -454,31 +395,26 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
"username",
FieldType::VarChar(100),
FieldConstraint::new().primary(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"avatar_url",
FieldType::VarChar(255),
FieldConstraint::new(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"email",
FieldType::VarChar(255),
FieldConstraint::new().unique().not_null(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"password_hash",
FieldType::VarChar(255),
FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"role",
FieldType::VarChar(20),
FieldConstraint::new().not_null().check(role_check.clone()),
ValidationLevel::Strict,
FieldConstraint::new().not_null(),
)?)
.add_field(Field::new(
"created_at",
@ -487,7 +423,6 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
"CURRENT_TIMESTAMP".to_string(),
ValidationLevel::Strict,
)),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"updated_at",
@ -496,16 +431,6 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
"CURRENT_TIMESTAMP".to_string(),
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)?;
@ -518,45 +443,49 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
"id",
FieldType::Integer(true),
FieldConstraint::new().primary(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"title",
FieldType::VarChar(255),
FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"content",
FieldType::Text,
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(
"template",
FieldType::VarChar(50),
FieldConstraint::new(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"status",
FieldType::VarChar(20),
FieldConstraint::new()
.not_null()
.check(content_state_check.clone()),
ValidationLevel::Strict,
FieldConstraint::new().not_null(),
)?);
schema.add_table(pages_table)?;
// posts
// 文章
let mut posts_table = Table::new(&format!("{}posts", db_prefix))?;
posts_table
.add_field(Field::new(
"id",
FieldType::Integer(true),
FieldConstraint::new().primary(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"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())
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"cover_image",
FieldType::VarChar(255),
FieldConstraint::new(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"title",
FieldType::VarChar(255),
FieldConstraint::new(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"content",
FieldType::Text,
FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"status",
FieldType::VarChar(20),
FieldConstraint::new()
.not_null()
.check(content_state_check.clone()),
ValidationLevel::Strict,
FieldConstraint::new().not_null(),
)?)
.add_field(Field::new(
"is_editor",
@ -600,13 +522,11 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
FieldConstraint::new()
.not_null()
.default(SafeValue::Bool(false)),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"draft_content",
FieldType::Text,
FieldConstraint::new(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"created_at",
@ -615,7 +535,6 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
"CURRENT_TIMESTAMP".to_string(),
ValidationLevel::Strict,
)),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"updated_at",
@ -624,13 +543,6 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
"CURRENT_TIMESTAMP".to_string(),
ValidationLevel::Strict,
)),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"published_at",
FieldType::Timestamp,
FieldConstraint::new(),
ValidationLevel::Strict,
)?);
schema.add_table(posts_table)?;
@ -642,7 +554,6 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
"id",
FieldType::Integer(true),
FieldConstraint::new().primary(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"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())
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"name",
FieldType::VarChar(255),
FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"size_bytes",
FieldType::BigInt,
FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"storage_path",
FieldType::VarChar(255),
FieldConstraint::new().not_null().unique(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"mime_type",
FieldType::VarChar(50),
FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"category",
FieldType::VarChar(50),
FieldConstraint::new(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"description",
FieldType::VarChar(255),
FieldConstraint::new(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"created_at",
@ -697,172 +601,69 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
"CURRENT_TIMESTAMP".to_string(),
ValidationLevel::Strict,
)),
ValidationLevel::Strict,
)?);
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))?;
custom_fields_table
let mut fields_table = Table::new(&format!("{}fields", db_prefix))?;
fields_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,
FieldConstraint::new().not_null(),
)?)
.add_field(Field::new(
"target_id",
FieldType::Integer(false),
FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"field_type",
FieldType::VarChar(50),
FieldConstraint::new().not_null(),
)?)
.add_field(Field::new(
"field_key",
FieldType::VarChar(50),
FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"field_value",
FieldType::Text,
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(
"idx_custom_fields_target",
fields_table.add_index(Index::new(
"idx_fields_target",
vec!["target_type".to_string(), "target_id".to_string()],
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))?;
taxonomies_table
.add_field(Field::new(
"name",
FieldType::VarChar(50),
FieldConstraint::new().primary(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"slug",
FieldType::VarChar(50),
FieldConstraint::new().not_null().unique(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"type",
FieldType::VarChar(20),
FieldConstraint::new()
.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,
FieldConstraint::new().not_null(),
)?)
.add_field(Field::new(
"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())
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
ValidationLevel::Strict,
)?);
schema.add_table(taxonomies_table)?;
// 替换为新的 post_taxonomies
// 分类—标签_文章 关系
let mut post_taxonomies_table = Table::new(&format!("{}post_taxonomies", db_prefix))?;
post_taxonomies_table
.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())
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"taxonomy_id",
"taxonomy_name",
FieldType::VarChar(50),
FieldConstraint::new()
.not_null()
.foreign_key(format!("{}taxonomies", db_prefix), "name".to_string())
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
ValidationLevel::Strict,
)?);
post_taxonomies_table.add_index(Index::new(
"pk_post_taxonomies",
vec!["post_id".to_string(), "taxonomy_id".to_string()],
vec!["post_id".to_string(), "taxonomy_name".to_string()],
true,
)?);

View File

@ -17,7 +17,7 @@ pub struct Sqlite {
#[async_trait]
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()?
.join("assets")
.join("sqllite")
@ -111,7 +111,7 @@ impl DatabaseTrait for Sqlite {
let pool = Self::connect(&db_config, false).await?.pool;
pool.execute(grammar.as_str()).await?;
pool.close();
pool.close().await;
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_USERNAME: string;
VITE_API_PASSWORD: string;
VITE_PATTERN: string;
}
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_USERNAME: "",
VITE_API_PASSWORD: "",
VITE_PATTERN: "true",
} as const;
};
// 扩展 ImportMeta 接口
declare global {

View File

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

View File

@ -1,27 +1,17 @@
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 post from "themes/echoes/post";
import { memo, useCallback } from "react";
import login from "~/dashboard/login";
import React, { memo, useCallback } from "react";
import adminLayout from "~/dashboard/layout";
import dashboard from "~/dashboard/index";
import posts from "~/dashboard/posts";
import comments from "~/dashboard/comments";
import categories from "./dashboard/categories";
import settings from "./dashboard/settings";
import files from "./dashboard/files";
import themes from "./dashboard/themes";
import categories from "~/dashboard/categories";
import settings from "~/dashboard/settings";
import files from "~/dashboard/files";
import themes from "~/dashboard/themes";
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) => {
@ -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 renderDashboardLayout = createLayoutRenderer(adminLayout);
const Login = createComponentRenderer("./dashboard/login");
const posts = createComponentRenderer("themes/echoes/posts");
const Routes = memo(() => {
const location = useLocation();
const [mainPath, subPath] = location.pathname.split("/").filter(Boolean);
// 使用 useCallback 缓存渲染函数
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) => {
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);
case "post":
return renderContent(post);
case "posts":
return renderContent(posts);
case "login":
return login.render(args);
return <Login args={args} />;
case "dashboard":
// 管理后台路由
if (!subPath) {
@ -83,8 +106,6 @@ const Routes = memo(() => {
return renderDashboardContent(themes);
case "users":
return renderDashboardContent(users);
case "plugins":
return renderDashboardContent(plugins);
default:
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 {
title: string;
message: string;
detail?: string;
}
export class HttpClient {
private static instance: HttpClient;
private timeout: number;
private readonly timeout: number;
private constructor(timeout = 10000) {
this.timeout = timeout;
@ -34,41 +35,83 @@ export class HttpClient {
return { ...options, headers };
}
private async handleResponse(response: Response): Promise<any> {
private async handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const contentType = response.headers.get("content-type");
let message;
let errorDetail = {
status: response.status,
statusText: response.statusText,
message: "",
raw: "",
};
try {
if (contentType?.includes("application/json")) {
const error = await response.json();
message = error.message || "";
errorDetail.message = error.message || "";
errorDetail.raw = JSON.stringify(error, null, 2);
} else {
const textError = await response.text();
message = textError || "";
errorDetail.message = textError;
errorDetail.raw = textError;
}
} 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) {
case 400:
errorDetail.message = errorDetail.message || "请求参数错误";
break;
case 401:
errorDetail.message = "未授权访问";
break;
case 403:
errorDetail.message = "访问被禁止";
break;
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;
}
const errorResponse: ErrorResponse = {
title: `${response.status} ${response.statusText}`,
message: message,
title: `${errorDetail.status} ${errorDetail.statusText}`,
message: errorDetail.message,
detail: `请求URL: ${response.url}\n状态码: ${errorDetail.status}\n原始错误: ${errorDetail.raw}`,
};
console.error("[HTTP Error]:", errorResponse);
throw errorResponse;
}
try {
const contentType = response.headers.get("content-type");
return contentType?.includes("application/json")
? response.json()
: response.text();
if (contentType?.includes("application/json")) {
return await response.json();
}
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>(
@ -94,22 +137,23 @@ export class HttpClient {
return await this.handleResponse(response);
} catch (error: any) {
if (error.name === "AbortError") {
const errorResponse: ErrorResponse = {
throw {
title: "请求超时",
message: "服务器响应时间过长,请稍后重试",
detail: `请求URL: ${url}${endpoint}\n超时时间: ${this.timeout}ms`,
};
throw errorResponse;
}
if ((error as ErrorResponse).title && (error as ErrorResponse).message) {
throw error;
}
console.log(error);
const errorResponse: ErrorResponse = {
title: "未知错误",
console.error("[Request Error]:", error);
throw {
title: "请求失败",
message: error.message || "发生未知错误",
detail: `请求URL: ${url}${endpoint}\n错误详情: ${error.stack || error}`,
};
throw errorResponse;
} finally {
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 { CapabilityService } from "core/capability";
import { Serializable } from "interface/serializableType";
import { createElement, memo } from "react";
import React, { createElement, memo } from "react";
export class Layout {
private http: HttpClient;
private capability: CapabilityService;
private readonly http: HttpClient;
private readonly MemoizedElement: React.MemoExoticComponent<
(props: {
children: React.ReactNode;
args?: Serializable;
onTouchStart?: (e: TouchEvent) => void;
onTouchEnd?: (e: TouchEvent) => void;
http: HttpClient;
}) => React.ReactNode
>;
@ -19,29 +16,20 @@ export class Layout {
public element: (props: {
children: React.ReactNode;
args?: Serializable;
onTouchStart?: (e: TouchEvent) => void;
onTouchEnd?: (e: TouchEvent) => void;
http: HttpClient;
}) => React.ReactNode,
services?: {
http?: HttpClient;
capability?: CapabilityService;
},
) {
this.http = services?.http || HttpClient.getInstance();
this.capability = services?.capability || CapabilityService.getInstance();
this.MemoizedElement = memo(element);
}
render(props: {
children: React.ReactNode;
args?: Serializable;
onTouchStart?: (e: TouchEvent) => void;
onTouchEnd?: (e: TouchEvent) => void;
}) {
render(props: { children: React.ReactNode; args?: Serializable }) {
return createElement(this.MemoizedElement, {
...props,
onTouchStart: props.onTouchStart,
onTouchEnd: props.onTouchEnd,
http: this.http,
});
}
}

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 { CapabilityService } from "core/capability";
import { Serializable } from "interface/serializableType";
import { Layout } from "./layout";
import React from "react";
export class Template {
private http: HttpClient;
private capability: CapabilityService;
private readonly http: HttpClient;
constructor(
public config: {
layout?: Layout;
styles?: string[];
scripts?: string[];
description?: string;
},
public element: (props: {
http: HttpClient;
args: Serializable;
}) => React.ReactNode,
services?: {
http?: HttpClient;
capability?: CapabilityService;
},
) {
this.http = services?.http || HttpClient.getInstance();
this.capability = services?.capability || CapabilityService.getInstance();
}
render(args: Serializable) {
const content = this.element({
return this.element({
http: this.http,
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>;
layout?: string;
configuration: Configuration;
loading?: string;
error?: string;
manage?: string;
routes: Map<string, string>;

View File

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

View File

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

View File

@ -210,11 +210,11 @@ export default new Layout(({ children, args }) => {
>
<nav>
<Container size="4">
<Flex justify="between" align="center" className="h-20 px-4">
<Flex justify="between" align="center" className="h-16 px-4">
{/* Logo 区域 */}
<Flex align="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 />
</Box>
</Link>

View File

@ -4,9 +4,10 @@ import React, {
useCallback,
useRef,
useEffect,
ComponentPropsWithoutRef,
} from "react";
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 { oneLight } from "react-syntax-highlighter/dist/cjs/styles/prism";
import {
@ -20,10 +21,8 @@ import {
} from "@radix-ui/themes";
import { CalendarIcon, CodeIcon } from "@radix-ui/react-icons";
import type { PostDisplay } from "interface/fields";
import type { MetaFunction } from "@remix-run/node";
import { getColorScheme } from "themes/echoes/utils/colorScheme";
import MarkdownIt from "markdown-it";
import { ComponentPropsWithoutRef } from "react";
import remarkGfm from "remark-gfm";
import { toast } from "hooks/Notification";
import rehypeRaw from "rehype-raw";
@ -372,7 +371,7 @@ function greet(user: User): string {
Markdown
1.
2.
2.
3.
> 💡 **** 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 {
code: string;
@ -508,7 +482,7 @@ const generateSequentialId = (() => {
};
})();
export default new Template({}, ({ http, args }) => {
export default new Template(({}) => {
const [toc, setToc] = useState<string[]>([]);
const [tocItems, setTocItems] = useState<TocItem[]>([]);
const [activeId, setActiveId] = useState<string>("");
@ -564,7 +538,7 @@ export default new Template({}, ({ http, args }) => {
md.render(mockPost.content);
// 只在 ID 数组发生化时更新
// 只在 ID 数组发生化时更新
const newIds = tocArray.map((item) => item.id);
if (JSON.stringify(headingIds.current) !== JSON.stringify(newIds)) {
headingIds.current = [...newIds];
@ -580,15 +554,15 @@ export default new Template({}, ({ http, args }) => {
if (tocArray.length > 0 && !activeId) {
setActiveId(tocArray[0].id);
}
}, [mockPost.content, mockPost.id, activeId]);
}, [activeId]);
useEffect(() => {
if (headingIdsArrays[mockPost.id] && headingIds.current.length === 0) {
headingIds.current = [...headingIdsArrays[mockPost.id]];
}
}, [headingIdsArrays, mockPost.id]);
}, [headingIdsArrays]);
const components = useMemo(() => {
const components: Components = useMemo(() => {
return {
h1: ({ children, ...props }: ComponentPropsWithoutRef<"h1">) => {
const headingId = headingIds.current.shift();
@ -626,14 +600,13 @@ export default new Template({}, ({ http, args }) => {
</h3>
);
},
p: ({
node,
...props
}: ComponentPropsWithoutRef<"p"> & { node?: any }) => (
p: ({ children, ...props }: ComponentPropsWithoutRef<"p">) => (
<p
className="text-sm sm:text-base md:text-lg leading-relaxed mb-3 sm:mb-4 text-[--gray-11]"
{...props}
/>
>
{children}
</p>
),
ul: ({ children, ...props }: ComponentPropsWithoutRef<"ul">) => (
<ul
@ -911,15 +884,11 @@ export default new Template({}, ({ http, args }) => {
),
// 修改 summary 组件
summary: ({
node,
...props
}: ComponentPropsWithoutRef<"summary"> & { node?: any }) => (
summary: (props: ComponentPropsWithoutRef<"summary">) => (
<summary
className="px-4 py-3 cursor-pointer hover:bg-[--gray-3] transition-colors
text-[--gray-12] font-medium select-none
marker:text-[--gray-11]
"
marker:text-[--gray-11]"
{...props}
/>
),
@ -1148,7 +1117,7 @@ export default new Template({}, ({ http, args }) => {
</>
);
// 在组顶部添加 useMemo 包静态内容
// 在组顶部添加 useMemo 包静态内容
const PostContent = useMemo(() => {
// 在渲染内容前重置 headingIds
if (headingIdsArrays[mockPost.id]) {
@ -1180,7 +1149,7 @@ export default new Template({}, ({ http, args }) => {
</div>
</Box>
);
}, [mockPost.content, components, mockPost.id, headingIdsArrays]); // 添加必要的依赖
}, [components, headingIdsArrays]);
return (
<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 totalPages = 25; // 假设有25页
const currentPage = 1; // 当前页码

View File

@ -9,19 +9,35 @@ const themeConfig: ThemeConfig = {
configuration: {
nav: {
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",
templates: new Map([
[
"page",
"posts",
{
path: "./templates/page",
path: "posts",
name: "文章列表模板",
description: "博客首页展示模板",
},
],
[
"post",
{
path: "post",
name: "文章详情模板",
description: "文章详情展示模板",
},
],
[
"about",
{
path: "about",
name: "关于页面模板",
description: "关于页面展示模板",
},
],
]),
routes: new Map<string, string>([]),

View File

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