Compare commits
2 Commits
caed30716b
...
1a47b87b4d
Author | SHA1 | Date | |
---|---|---|---|
1a47b87b4d | |||
ba17778a8f |
@ -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
71
backend/src/api/fields.rs
Normal 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(())
|
||||||
|
}
|
@ -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
5
backend/src/api/page.rs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
pub enum PageState {
|
||||||
|
Publicity,
|
||||||
|
Hidden,
|
||||||
|
Privacy,
|
||||||
|
}
|
1
backend/src/api/post.rs
Normal file
1
backend/src/api/post.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
@ -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,
|
||||||
}),
|
}))
|
||||||
))
|
|
||||||
}
|
}
|
@ -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?;
|
||||||
|
@ -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?;
|
||||||
|
@ -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 {
|
||||||
|
@ -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<()> {
|
||||||
|
@ -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,
|
||||||
)?);
|
)?);
|
||||||
|
|
||||||
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -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 {
|
||||||
|
@ -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>
|
||||||
|
@ -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>);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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
53
frontend/core/theme.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 读取主题和模板中的模板
|
||||||
|
}
|
@ -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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>;
|
||||||
|
@ -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",
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
@ -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; // 当前页码
|
||||||
|
@ -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>([]),
|
||||||
|
@ -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.
|
||||||
|
Loading…
Reference in New Issue
Block a user