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 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
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 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
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::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,
|
||||
}),
|
||||
))
|
||||
}))
|
||||
}
|
@ -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?;
|
||||
|
@ -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?;
|
||||
|
@ -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 {
|
||||
|
@ -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<()> {
|
||||
|
@ -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,
|
||||
)?);
|
||||
|
||||
|
@ -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(())
|
||||
}
|
||||
|
@ -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_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 {
|
||||
|
@ -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>
|
||||
|
@ -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>);
|
||||
}
|
||||
|
@ -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 {
|
||||
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);
|
||||
}
|
||||
|
@ -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 { 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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 { 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;
|
||||
}
|
||||
}
|
||||
|
@ -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>;
|
||||
|
@ -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",
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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; // 当前页码
|
||||
|
@ -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>([]),
|
||||
|
@ -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.
|
||||
|
Loading…
Reference in New Issue
Block a user