数据库:由后端动态构建

后端:更新数据库配置,支持MySQL和SQLite,重构SQL查询构建,重构数据库初始化操作;
前端:调整初始化状态逻辑,改进主题切换功能。
This commit is contained in:
lsy 2024-11-28 23:10:00 +08:00
parent bc42edd38e
commit 3daf6280a7
19 changed files with 1427 additions and 383 deletions

View File

@ -3,13 +3,19 @@ name = "echoes"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
rocket = { version = "0.5", features = ["json"] } rocket = { version = "0.5", features = ["json"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
toml = "0.8.19" toml = "0.8.19"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8.2", features = ["runtime-tokio-native-tls", "postgres"] } sqlx = { version = "0.8.2", features = [
"runtime-tokio-native-tls",
"postgres",
"mysql",
"sqlite"
] }
async-trait = "0.1.83" async-trait = "0.1.83"
jwt-compact = { version = "0.8.0", features = ["ed25519-dalek"] } jwt-compact = { version = "0.8.0", features = ["ed25519-dalek"] }
ed25519-dalek = "2.1.1" ed25519-dalek = "2.1.1"
@ -17,5 +23,4 @@ rand = "0.8.5"
chrono = "0.4" chrono = "0.4"
regex = "1.11.1" regex = "1.11.1"
bcrypt = "0.16" bcrypt = "0.16"
uuid = { version = "1.11.0", features = ["v4", "serde"] }
hex = "0.4.3" hex = "0.4.3"

View File

@ -23,8 +23,12 @@ pub async fn token_system(
state: &State<Arc<AppState>>, state: &State<Arc<AppState>>,
data: Json<TokenSystemData>, data: Json<TokenSystemData>,
) -> AppResult<String> { ) -> AppResult<String> {
let sql = state
.sql_get()
.await
.into_app_result()?;
let mut builder = let mut builder =
builder::QueryBuilder::new(builder::SqlOperation::Select, "users".to_string()) builder::QueryBuilder::new(builder::SqlOperation::Select, sql.table_name("users"), sql.get_type())
.into_app_result()?; .into_app_result()?;
builder builder
.add_field("password_hash".to_string()) .add_field("password_hash".to_string())
@ -56,9 +60,8 @@ pub async fn token_system(
builder::Condition::new( builder::Condition::new(
"role".to_string(), "role".to_string(),
builder::Operator::Eq, builder::Operator::Eq,
Some(builder::SafeValue::Enum( Some(builder::SafeValue::Text(
"administrator".into(), "administrator".into(),
"user_role".into(),
builder::ValidationLevel::Standard, builder::ValidationLevel::Standard,
)), )),
) )
@ -66,10 +69,7 @@ pub async fn token_system(
), ),
])); ]));
let values = state let values = sql
.sql_get()
.await
.into_app_result()?
.get_db() .get_db()
.execute_query(&builder) .execute_query(&builder)
.await .await
@ -83,6 +83,8 @@ pub async fn token_system(
status::Custom(Status::NotFound, "Invalid system user or password".into()) status::Custom(Status::NotFound, "Invalid system user or password".into())
})?; })?;
println!("{}\n{}",&data.password,password.clone());
security::bcrypt::verify_hash(&data.password, password) security::bcrypt::verify_hash(&data.password, password)
.map_err(|_| status::Custom(Status::Forbidden, "Invalid password".into()))?; .map_err(|_| status::Custom(Status::Forbidden, "Invalid password".into()))?;

View File

@ -2,6 +2,7 @@ use super::SystemToken;
use crate::storage::{sql, sql::builder}; use crate::storage::{sql, sql::builder};
use crate::common::error::{AppResult, AppResultInto, CustomResult}; use crate::common::error::{AppResult, AppResultInto, CustomResult};
use crate::AppState; use crate::AppState;
use rocket::data;
use rocket::{ use rocket::{
get, get,
http::Status, http::Status,
@ -40,7 +41,7 @@ pub async fn get_setting(
name: String, name: String,
) -> CustomResult<Json<Value>> { ) -> CustomResult<Json<Value>> {
let name_condition = builder::Condition::new( let name_condition = builder::Condition::new(
"key".to_string(), "name".to_string(),
builder::Operator::Eq, builder::Operator::Eq,
Some(builder::SafeValue::Text( Some(builder::SafeValue::Text(
format!("{}_{}", comfig_type, name), format!("{}_{}", comfig_type, name),
@ -51,7 +52,7 @@ pub async fn get_setting(
let where_clause = builder::WhereClause::Condition(name_condition); let where_clause = builder::WhereClause::Condition(name_condition);
let mut sql_builder = let mut sql_builder =
builder::QueryBuilder::new(builder::SqlOperation::Select, "settings".to_string())?; builder::QueryBuilder::new(builder::SqlOperation::Select, sql.table_name("settings"),sql.get_type())?;
sql_builder sql_builder
.add_condition(where_clause) .add_condition(where_clause)
@ -69,9 +70,9 @@ pub async fn insert_setting(
data: Json<Value>, data: Json<Value>,
) -> CustomResult<()> { ) -> CustomResult<()> {
let mut builder = let mut builder =
builder::QueryBuilder::new(builder::SqlOperation::Insert, "settings".to_string())?; builder::QueryBuilder::new(builder::SqlOperation::Insert, sql.table_name("settings"),sql.get_type())?;
builder.set_value( builder.set_value(
"key".to_string(), "name".to_string(),
builder::SafeValue::Text( builder::SafeValue::Text(
format!("{}_{}", comfig_type, name).to_string(), format!("{}_{}", comfig_type, name).to_string(),
builder::ValidationLevel::Strict, builder::ValidationLevel::Strict,
@ -79,7 +80,7 @@ pub async fn insert_setting(
)?; )?;
builder.set_value( builder.set_value(
"data".to_string(), "data".to_string(),
builder::SafeValue::Json(data.into_inner()), builder::SafeValue::Text(data.to_string(),builder::ValidationLevel::Relaxed),
)?; )?;
sql.get_db().execute_query(&builder).await?; sql.get_db().execute_query(&builder).await?;
Ok(()) Ok(())
@ -91,7 +92,7 @@ pub async fn system_config_get(
_token: SystemToken, _token: SystemToken,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let sql = state.sql_get().await.into_app_result()?; let sql = state.sql_get().await.into_app_result()?;
let settings = get_setting(&sql, "system".to_string(), "settings".to_string()) let settings = get_setting(&sql, "system".to_string(), sql.table_name("settings"))
.await .await
.into_app_result()?; .into_app_result()?;
Ok(settings) Ok(settings)

View File

@ -1,35 +1,22 @@
use super::{settings, users}; use super::{settings, users};
use crate::common::config;
use crate::common::error::{AppResult, AppResultInto};
use crate::common::helpers;
use crate::security; use crate::security;
use crate::storage::sql; use crate::storage::sql;
use crate::common::error::{AppResult, AppResultInto};
use crate::AppState; use crate::AppState;
use crate::common::config;
use crate::common::helpers;
use chrono::Duration; use chrono::Duration;
use rocket::data;
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 serde_json::json; use serde_json::json;
use std::sync::Arc; use std::sync::Arc;
#[derive(Deserialize, Serialize,Debug)] #[post("/sql", format = "application/json", data = "<sql_config>")]
pub struct InstallData { pub async fn setup_sql(
username: String, sql_config: Json<config::SqlConfig>,
email: String,
password: String,
sql_config: config::SqlConfig,
}
#[derive(Deserialize, Serialize,Debug)]
pub struct InstallReplyData {
token: String,
username: String,
password: String,
}
#[post("/sql", format = "application/json", data = "<data>")]
pub async fn steup_sql(
data: Json<InstallData>,
state: &State<Arc<AppState>>, state: &State<Arc<AppState>>,
) -> AppResult<status::Custom<Json<InstallReplyData>>> { ) -> AppResult<String> {
let mut config = config::Config::read().unwrap_or_default(); let mut config = config::Config::read().unwrap_or_default();
if config.init.sql { if config.init.sql {
return Err(status::Custom( return Err(status::Custom(
@ -37,19 +24,57 @@ pub async fn steup_sql(
"Database already initialized".to_string(), "Database already initialized".to_string(),
)); ));
} }
let sql_config = sql_config.into_inner();
config.init.sql = true;
config.sql_config = sql_config.clone();
sql::Database::initial_setup(sql_config.clone())
.await
.into_app_result()?;
config::Config::write(config).into_app_result()?;
state.trigger_restart().await.into_app_result()?;
Ok("Database installation successful".to_string())
}
#[derive(Deserialize, Serialize, Debug)]
pub struct StepAccountData {
username: String,
email: String,
password: String,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct InstallReplyData {
token: String,
username: String,
password: String,
}
#[post("/administrator", format = "application/json", data = "<data>")]
pub async fn setup_account(
data: Json<StepAccountData>,
state: &State<Arc<AppState>>,
) -> AppResult<status::Custom<Json<InstallReplyData>>> {
let mut config = config::Config::read().unwrap_or_default();
if config.init.administrator {
return Err(status::Custom(
Status::BadRequest,
"Administrator user has been set".to_string(),
));
}
security::jwt::generate_key().into_app_result()?;
let data = data.into_inner(); let data = data.into_inner();
let sql = { let sql = {
config.init.sql = true; state.sql_link(&config.sql_config).await.into_app_result()?;
config.sql_config = data.sql_config.clone();
sql::Database::initial_setup(data.sql_config.clone())
.await
.into_app_result()?;
security::jwt::generate_key().into_app_result()?;
state.sql_link(&data.sql_config).await.into_app_result()?;
state.sql_get().await.into_app_result()? state.sql_get().await.into_app_result()?
}; };
let system_credentials = ( let system_credentials = (
helpers::generate_random_string(20), helpers::generate_random_string(20),
helpers::generate_random_string(20), helpers::generate_random_string(20),
@ -79,7 +104,6 @@ pub async fn steup_sql(
.await .await
.into_app_result()?; .into_app_result()?;
settings::insert_setting( settings::insert_setting(
&sql, &sql,
"system".to_string(), "system".to_string(),
@ -93,11 +117,13 @@ pub async fn steup_sql(
.into_app_result()?; .into_app_result()?;
let token = security::jwt::generate_jwt( let token = security::jwt::generate_jwt(
security::jwt::CustomClaims { name: data.username }, security::jwt::CustomClaims {
name: data.username,
},
Duration::days(7), Duration::days(7),
) )
.into_app_result()?; .into_app_result()?;
config.init.administrator=true;
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()?;

View File

@ -20,37 +20,32 @@ pub struct RegisterData {
pub role: String, pub role: String,
} }
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() { let role = match data.role.as_str() {
"administrator" | "contributor" => data.role, "administrator" | "contributor" => data.role,
_ => return Err("Invalid role. Must be either 'administrator' or 'contributor'".into_custom_error()), _ => return Err("Invalid role. Must be either 'administrator' or 'contributor'".into_custom_error()),
}; };
let password_hash = bcrypt::generate_hash(&data.password)?;
let mut builder = let mut builder =
builder::QueryBuilder::new(builder::SqlOperation::Insert, "users".to_string())?; builder::QueryBuilder::new(builder::SqlOperation::Insert, sql.table_name("users"), sql.get_type())?;
builder builder
.set_value( .set_value(
"username".to_string(), "username".to_string(),
builder::SafeValue::Text(data.username, builder::ValidationLevel::Relaxed), builder::SafeValue::Text(data.username, builder::ValidationLevel::Standard),
)? )?
.set_value( .set_value(
"email".to_string(), "email".to_string(),
builder::SafeValue::Text(data.email, builder::ValidationLevel::Relaxed), builder::SafeValue::Text(data.email, builder::ValidationLevel::Standard),
)? )?
.set_value( .set_value(
"password_hash".to_string(), "password_hash".to_string(),
builder::SafeValue::Text( builder::SafeValue::Text(password_hash, builder::ValidationLevel::Relaxed),
bcrypt::generate_hash(&data.password)?,
builder::ValidationLevel::Relaxed,
),
)? )?
.set_value( .set_value(
"role".to_string(), "role".to_string(),
builder::SafeValue::Enum( builder::SafeValue::Text(role, builder::ValidationLevel::Strict),
role,
"user_role".to_string(),
builder::ValidationLevel::Standard,
),
)?; )?;
sql.get_db().execute_query(&builder).await?; sql.get_db().execute_query(&builder).await?;

View File

@ -47,17 +47,19 @@ pub struct SqlConfig {
pub user: String, pub user: String,
pub password: String, pub password: String,
pub db_name: String, pub db_name: String,
pub db_prefix:String,
} }
impl Default for SqlConfig { impl Default for SqlConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
db_type: "postgresql".to_string(), db_type: "sqllite".to_string(),
address: "localhost".to_string(), address: "".to_string(),
port: 5432, port: 0,
user: "postgres".to_string(), user: "".to_string(),
password: "postgres".to_string(), password: "".to_string(),
db_name: "echoes".to_string(), db_name: "echoes".to_string(),
db_prefix: "echoes_".to_string(),
} }
} }
} }

View File

@ -3,12 +3,12 @@ mod common;
mod storage; mod storage;
mod api; mod api;
use storage::sql; use crate::common::config;
use common::error::{CustomErrorInto, CustomResult}; use common::error::{CustomErrorInto, CustomResult};
use rocket::Shutdown; use rocket::Shutdown;
use std::sync::Arc; use std::sync::Arc;
use storage::sql;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::common::config;
pub struct AppState { pub struct AppState {
db: Arc<Mutex<Option<sql::Database>>>, db: Arc<Mutex<Option<sql::Database>>>,
shutdown: Arc<Mutex<Option<Shutdown>>>, shutdown: Arc<Mutex<Option<Shutdown>>>,
@ -25,11 +25,7 @@ impl AppState {
} }
pub async fn sql_get(&self) -> CustomResult<sql::Database> { pub async fn sql_get(&self) -> CustomResult<sql::Database> {
self.db self.db.lock().await.clone().ok_or_else(|| "数据库未连接".into_custom_error())
.lock()
.await
.clone()
.ok_or_else(|| "数据库未连接".into_custom_error())
} }
pub async fn sql_link(&self, config: &config::SqlConfig) -> CustomResult<()> { pub async fn sql_link(&self, config: &config::SqlConfig) -> CustomResult<()> {
@ -37,57 +33,53 @@ impl AppState {
Ok(()) Ok(())
} }
pub async fn set_shutdown(&self, shutdown: Shutdown) { pub async fn set_shutdown(&self, shutdown: Shutdown) {
*self.shutdown.lock().await = Some(shutdown); *self.shutdown.lock().await = Some(shutdown);
} }
pub async fn trigger_restart(&self) -> CustomResult<()> { pub async fn trigger_restart(&self) -> CustomResult<()> {
*self.restart_progress.lock().await = true; *self.restart_progress.lock().await = true;
self.shutdown self.shutdown.lock().await.take().ok_or_else(|| "未能获取rocket的shutdown".into_custom_error())?.notify();
.lock()
.await
.take()
.ok_or_else(|| "未能获取rocket的shutdown".into_custom_error())?
.notify();
Ok(()) Ok(())
} }
} }
#[rocket::main] #[rocket::main]
async fn main() -> CustomResult<()> { async fn main() -> CustomResult<()> {
let config = config::Config::read().unwrap_or_default(); let config = config::Config::read().unwrap_or_else(|e| {
eprintln!("配置读取失败: {}", e);
config::Config::default()
});
let state = Arc::new(AppState::new()); let state = Arc::new(AppState::new());
let rocket_config = rocket::Config::figment() let rocket_config = rocket::Config::figment().merge(("address", config.address)).merge(("port", config.port));
.merge(("address", config.address))
.merge(("port", config.port));
let mut rocket_builder = rocket::build() let mut rocket_builder = rocket::build().configure(rocket_config).manage(state.clone());
.configure(rocket_config)
.manage(state.clone());
if !config.info.install { if !config.init.sql {
rocket_builder = rocket_builder.mount("/", rocket::routes![api::setup::install]); rocket_builder = rocket_builder.mount("/", rocket::routes![api::setup::setup_sql]);
} else if !config.init.administrator {
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("/config", api::configure_routes());
.mount("/auth/token", api::jwt_routes())
.mount("/config", api::configure_routes());
} }
let rocket = rocket_builder.ignite().await?; let rocket = rocket_builder.ignite().await?;
rocket rocket.state::<Arc<AppState>>().ok_or_else(|| "未能获取AppState".into_custom_error())?.set_shutdown(rocket.shutdown()).await;
.state::<Arc<AppState>>()
.ok_or_else(|| "未能获取AppState".into_custom_error())?
.set_shutdown(rocket.shutdown())
.await;
rocket.launch().await?; rocket.launch().await?;
if *state.restart_progress.lock().await { if *state.restart_progress.lock().await {
if let Ok(current_exe) = std::env::current_exe() { if let Ok(current_exe) = std::env::current_exe() {
let _ = std::process::Command::new(current_exe).spawn(); match std::process::Command::new(current_exe).spawn() {
Ok(_) => println!("成功启动新进程"),
Err(e) => eprintln!("启动新进程失败: {}", e),
};
} else {
eprintln!("获取当前可执行文件路径失败");
} }
} }
std::process::exit(0); std::process::exit(0);

View File

@ -2,16 +2,16 @@ use crate::common::error::{CustomErrorInto, CustomResult};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use regex::Regex; use regex::Regex;
use serde::Serialize; use serde::Serialize;
use serde_json::Value as JsonValue;
use std::collections::HashMap; use std::collections::HashMap;
use std::hash::Hash; use std::hash::Hash;
use uuid::Uuid; use crate::sql::schema::DatabaseType;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Copy, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Hash, Copy, Serialize)]
pub enum ValidationLevel { pub enum ValidationLevel {
Strict, Strict,
Standard, Standard,
Relaxed, Relaxed,
Raw,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -28,6 +28,7 @@ impl Default for TextValidator {
(ValidationLevel::Strict, 100), (ValidationLevel::Strict, 100),
(ValidationLevel::Standard, 1000), (ValidationLevel::Standard, 1000),
(ValidationLevel::Relaxed, 100000), (ValidationLevel::Relaxed, 100000),
(ValidationLevel::Raw, usize::MAX),
]); ]);
let level_allowed_chars = HashMap::from([ let level_allowed_chars = HashMap::from([
@ -43,6 +44,7 @@ impl Default for TextValidator {
'}', '@', '#', '$', '%', '^', '&', '*', '+', '=', '<', '>', '/', '\\', '}', '@', '#', '$', '%', '^', '&', '*', '+', '=', '<', '>', '/', '\\',
], ],
), ),
(ValidationLevel::Raw, vec![]),
]); ]);
TextValidator { TextValidator {
@ -74,6 +76,9 @@ impl Default for TextValidator {
impl TextValidator { impl TextValidator {
pub fn validate(&self, text: &str, level: ValidationLevel) -> CustomResult<()> { pub fn validate(&self, text: &str, level: ValidationLevel) -> CustomResult<()> {
if level == ValidationLevel::Raw {
return self.validate_sql_patterns(text);
}
let max_length = self let max_length = self
.level_max_lengths .level_max_lengths
.get(&level) .get(&level)
@ -140,6 +145,9 @@ impl TextValidator {
pub fn validate_strict(&self, text: &str) -> CustomResult<()> { pub fn validate_strict(&self, text: &str) -> CustomResult<()> {
self.validate(text, ValidationLevel::Strict) self.validate(text, ValidationLevel::Strict)
} }
pub fn validate_raw(&self, text: &str) -> CustomResult<()> {
self.validate(text, ValidationLevel::Raw)
}
pub fn sanitize(&self, text: &str) -> CustomResult<String> { pub fn sanitize(&self, text: &str) -> CustomResult<String> {
self.validate_relaxed(text)?; self.validate_relaxed(text)?;
@ -155,77 +163,31 @@ pub enum SafeValue {
Float(f64), Float(f64),
Text(String, ValidationLevel), Text(String, ValidationLevel),
DateTime(DateTime<Utc>), DateTime(DateTime<Utc>),
Uuid(Uuid), }
Binary(Vec<u8>),
Array(Vec<SafeValue>), impl std::fmt::Display for SafeValue {
Json(JsonValue), fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
Enum(String, String, ValidationLevel), match self {
SafeValue::Null => write!(f, "NULL"),
SafeValue::Bool(b) => write!(f, "{}", b),
SafeValue::Integer(i) => write!(f, "{}", i),
SafeValue::Float(f_val) => write!(f, "{}", f_val),
SafeValue::Text(s, _) => write!(f, "{}", s),
SafeValue::DateTime(dt) => write!(f, "{}", dt.to_rfc3339()),
}
}
} }
impl SafeValue { impl SafeValue {
pub fn from_json(value: JsonValue, level: ValidationLevel) -> CustomResult<Self> {
match value {
JsonValue::Null => Ok(SafeValue::Null),
JsonValue::Bool(b) => Ok(SafeValue::Bool(b)),
JsonValue::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(SafeValue::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(SafeValue::Float(f))
} else {
Err("Invalid number format".into_custom_error())
}
}
JsonValue::String(s) => {
TextValidator::default().validate(&s, level)?;
Ok(SafeValue::Text(s, level))
}
JsonValue::Array(arr) => Ok(SafeValue::Array(
arr.into_iter()
.map(|item| SafeValue::from_json(item, level))
.collect::<CustomResult<Vec<_>>>()?,
)),
JsonValue::Object(_) => {
Self::validate_json_structure(&value, level)?;
Ok(SafeValue::Json(value))
}
}
}
fn validate_json_structure(value: &JsonValue, level: ValidationLevel) -> CustomResult<()> {
let validator = TextValidator::default();
match value {
JsonValue::Object(map) => {
for (key, val) in map {
validator.validate(key, level)?;
Self::validate_json_structure(val, level)?;
}
}
JsonValue::Array(arr) => {
arr.iter()
.try_for_each(|item| Self::validate_json_structure(item, level))?;
}
JsonValue::String(s) => validator.validate(s, level)?,
_ => {}
}
Ok(())
}
fn get_sql_type(&self) -> CustomResult<String> { fn get_sql_type(&self) -> CustomResult<String> {
let sql_type = match self { let sql_type = match self {
SafeValue::Null => "NULL", SafeValue::Null => "NULL",
SafeValue::Bool(_) => "boolean", SafeValue::Bool(_) => "BOOLEAN",
SafeValue::Integer(_) => "bigint", SafeValue::Integer(_) => "INTEGER",
SafeValue::Float(_) => "double precision", SafeValue::Float(_) => "REAL",
SafeValue::Text(_, _) => "text", SafeValue::Text(_, _) => "TEXT",
SafeValue::DateTime(_) => "timestamp with time zone", SafeValue::DateTime(_) => "TEXT",
SafeValue::Uuid(_) => "uuid",
SafeValue::Binary(_) => "bytea",
SafeValue::Array(_) | SafeValue::Json(_) => "jsonb",
SafeValue::Enum(_, enum_type, level) => {
TextValidator::default().validate(enum_type, *level)?;
return Ok(enum_type.replace('\'', "''"));
}
}; };
Ok(sql_type.to_string()) Ok(sql_type.to_string())
} }
@ -233,37 +195,27 @@ impl SafeValue {
pub fn to_sql_string(&self) -> CustomResult<String> { pub fn to_sql_string(&self) -> CustomResult<String> {
match self { match self {
SafeValue::Null => Ok("NULL".to_string()), SafeValue::Null => Ok("NULL".to_string()),
SafeValue::Bool(b) => Ok(b.to_string()), SafeValue::Bool(b) => Ok(if *b { "true" } else { "false" }.to_string()),
SafeValue::Integer(i) => Ok(i.to_string()), SafeValue::Integer(i) => Ok(i.to_string()),
SafeValue::Float(f) => Ok(f.to_string()), SafeValue::Float(f) => Ok(f.to_string()),
SafeValue::Text(s, level) => { SafeValue::Text(s, level) => {
TextValidator::default().validate(s, *level)?; TextValidator::default().validate(s, *level)?;
Ok(s.replace('\'', "''")) Ok(format!("{}", s.replace('\'', "''")))
}
SafeValue::DateTime(dt) => Ok(format!("'{}'", dt.to_rfc3339())),
SafeValue::Uuid(u) => Ok(format!("'{}'", u)),
SafeValue::Binary(b) => Ok(format!("'\\x{}'", hex::encode(b))),
SafeValue::Array(arr) => {
let values: CustomResult<Vec<_>> = arr.iter().map(|v| v.to_sql_string()).collect();
Ok(format!("ARRAY[{}]", values?.join(",")))
}
SafeValue::Json(j) => {
let json_str = serde_json::to_string(j)?;
TextValidator::default().validate(&json_str, ValidationLevel::Relaxed)?;
Ok(json_str.replace('\'', "''"))
}
SafeValue::Enum(s, _, level) => {
TextValidator::default().validate(s, *level)?;
Ok(s.to_string())
} }
SafeValue::DateTime(dt) => Ok(format!("{}", dt.to_rfc3339())),
} }
} }
fn to_param_sql(&self, param_index: usize) -> CustomResult<String> { fn to_param_sql(&self, param_index: usize, db_type: DatabaseType) -> CustomResult<String> {
if matches!(self, SafeValue::Null) { if matches!(self, SafeValue::Null) {
Ok("NULL".to_string()) return Ok("NULL".to_string());
} else { }
Ok(format!("${}::{}", param_index, self.get_sql_type()?))
// 根据数据库类型返回不同的参数占位符
match db_type {
DatabaseType::MySQL => Ok("?".to_string()),
DatabaseType::PostgreSQL => Ok(format!("${}", param_index)),
DatabaseType::SQLite => Ok("?".to_string()),
} }
} }
} }
@ -305,12 +257,10 @@ pub enum Operator {
In, In,
IsNull, IsNull,
IsNotNull, IsNotNull,
JsonContains,
JsonExists,
} }
impl Operator { impl Operator {
fn as_str(&self) -> &'static str { pub fn as_str(&self) -> &'static str {
match self { match self {
Operator::Eq => "=", Operator::Eq => "=",
Operator::Ne => "!=", Operator::Ne => "!=",
@ -322,17 +272,15 @@ impl Operator {
Operator::In => "IN", Operator::In => "IN",
Operator::IsNull => "IS NULL", Operator::IsNull => "IS NULL",
Operator::IsNotNull => "IS NOT NULL", Operator::IsNotNull => "IS NOT NULL",
Operator::JsonContains => "@>",
Operator::JsonExists => "?",
} }
} }
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Condition { pub struct Condition {
field: Identifier, pub field: Identifier,
operator: Operator, pub operator: Operator,
value: Option<SafeValue>, pub value: Option<SafeValue>,
} }
impl Condition { impl Condition {
@ -362,10 +310,11 @@ pub struct QueryBuilder {
order_by: Option<Identifier>, order_by: Option<Identifier>,
limit: Option<i32>, limit: Option<i32>,
offset: Option<i32>, offset: Option<i32>,
db_type: DatabaseType,
} }
impl QueryBuilder { impl QueryBuilder {
pub fn new(operation: SqlOperation, table: String) -> CustomResult<Self> { pub fn new(operation: SqlOperation, table: String, db_type: DatabaseType) -> CustomResult<Self> {
Ok(QueryBuilder { Ok(QueryBuilder {
operation, operation,
table: Identifier::new(table)?, table: Identifier::new(table)?,
@ -375,6 +324,7 @@ impl QueryBuilder {
order_by: None, order_by: None,
limit: None, limit: None,
offset: None, offset: None,
db_type,
}) })
} }
@ -438,7 +388,7 @@ impl QueryBuilder {
if matches!(value, SafeValue::Null) { if matches!(value, SafeValue::Null) {
placeholders.push("NULL".to_string()); placeholders.push("NULL".to_string());
} else { } else {
placeholders.push(format!("${}::{}", params.len() + 1, value.get_sql_type()?)); placeholders.push(value.to_param_sql(params.len() + 1, self.db_type)?);
params.push(value.clone()); params.push(value.clone());
} }
} }
@ -458,11 +408,13 @@ impl QueryBuilder {
let mut updates = Vec::new(); let mut updates = Vec::new();
for (field, value) in &self.values { for (field, value) in &self.values {
let set_sql = format!( let placeholder = if matches!(value, SafeValue::Null) {
"{} = {}", "NULL".to_string()
field.as_str(), } else {
value.to_param_sql(params.len() + 1)? value.to_param_sql(params.len() + 1, self.db_type)?
); };
let set_sql = format!("{} = {}", field.as_str(), placeholder);
if !matches!(value, SafeValue::Null) { if !matches!(value, SafeValue::Null) {
params.push(value.clone()); params.push(value.clone());
} }
@ -542,11 +494,17 @@ impl QueryBuilder {
) -> CustomResult<String> { ) -> CustomResult<String> {
match &condition.value { match &condition.value {
Some(value) => { Some(value) => {
let placeholder = if matches!(value, SafeValue::Null) {
"NULL".to_string()
} else {
value.to_param_sql(param_index, self.db_type)?
};
let sql = format!( let sql = format!(
"{} {} {}", "{} {} {}",
condition.field.as_str(), condition.field.as_str(),
condition.operator.as_str(), condition.operator.as_str(),
value.to_param_sql(param_index)? placeholder
); );
if !matches!(value, SafeValue::Null) { if !matches!(value, SafeValue::Null) {
params.push(value.clone()); params.push(value.clone());

View File

@ -1,11 +1,16 @@
mod postgresql; mod postgresql;
mod mysql;
mod sqllite;
pub mod builder;
mod schema;
use crate::config; use crate::config;
use crate::common::error::{CustomErrorInto, CustomResult}; use crate::common::error::{CustomErrorInto, CustomResult};
use async_trait::async_trait; use async_trait::async_trait;
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
pub mod builder; use schema::DatabaseType;
#[async_trait] #[async_trait]
pub trait DatabaseTrait: Send + Sync { pub trait DatabaseTrait: Send + Sync {
async fn connect(database: &config::SqlConfig) -> CustomResult<Self> async fn connect(database: &config::SqlConfig) -> CustomResult<Self>
where where
@ -22,6 +27,8 @@ pub trait DatabaseTrait: Send + Sync {
#[derive(Clone)] #[derive(Clone)]
pub struct Database { pub struct Database {
pub db: Arc<Box<dyn DatabaseTrait>>, pub db: Arc<Box<dyn DatabaseTrait>>,
pub prefix: Arc<String>,
pub db_type: Arc<String>
} }
impl Database { impl Database {
@ -29,22 +36,45 @@ impl Database {
&self.db &self.db
} }
pub fn get_prefix(&self) -> &str {
&self.prefix
}
pub fn get_type(&self) -> DatabaseType {
match self.db_type.as_str() {
"postgresql" => DatabaseType::PostgreSQL,
"mysql" => DatabaseType::MySQL,
_ => DatabaseType::SQLite,
}
}
pub fn table_name(&self, name: &str) -> String {
format!("{}{}", self.prefix, name)
}
pub async fn link(database: &config::SqlConfig) -> CustomResult<Self> { pub async fn link(database: &config::SqlConfig) -> CustomResult<Self> {
let db = match database.db_type.as_str() { let db: Box<dyn DatabaseTrait> = match database.db_type.as_str() {
"postgresql" => postgresql::Postgresql::connect(database).await?, "postgresql" => Box::new(postgresql::Postgresql::connect(database).await?),
"mysql" => Box::new(mysql::Mysql::connect(database).await?),
"sqllite" => Box::new(sqllite::Sqlite::connect(database).await?),
_ => return Err("unknown database type".into_custom_error()), _ => return Err("unknown database type".into_custom_error()),
}; };
Ok(Self { Ok(Self {
db: Arc::new(Box::new(db)), db: Arc::new(db),
prefix: Arc::new(database.db_prefix.clone()),
db_type: Arc::new(database.db_type.clone())
}) })
} }
pub async fn initial_setup(database: config::SqlConfig) -> CustomResult<()> { pub async fn initial_setup(database: config::SqlConfig) -> CustomResult<()> {
match database.db_type.as_str() { match database.db_type.as_str() {
"postgresql" => postgresql::Postgresql::initialization(database).await?, "postgresql" => postgresql::Postgresql::initialization(database).await?,
"mysql" => mysql::Mysql::initialization(database).await?,
"sqllite" => sqllite::Sqlite::initialization(database).await?,
_ => return Err("unknown database type".into_custom_error()), _ => return Err("unknown database type".into_custom_error()),
}; };
Ok(()) Ok(())
} }
} }

View File

@ -0,0 +1,110 @@
use super::{builder::{self, SafeValue}, schema, DatabaseTrait};
use crate::config;
use crate::common::error::CustomResult;
use async_trait::async_trait;
use serde_json::Value;
use sqlx::mysql::MySqlPool;
use sqlx::{Column, Executor, Row, TypeInfo};
use std::collections::HashMap;
use chrono::{DateTime, Utc};
#[derive(Clone)]
pub struct Mysql {
pool: MySqlPool,
}
#[async_trait]
impl DatabaseTrait for Mysql {
async fn initialization(db_config: config::SqlConfig) -> CustomResult<()> {
let db_prefix = SafeValue::Text(format!("{}",db_config.db_prefix), builder::ValidationLevel::Strict);
let grammar = schema::generate_schema(schema::DatabaseType::MySQL,db_prefix)?;
let connection_str = format!(
"mysql://{}:{}@{}:{}",
db_config.user, db_config.password, db_config.address, db_config.port
);
let pool = MySqlPool::connect(&connection_str).await?;
pool.execute(format!("CREATE DATABASE `{}`", db_config.db_name).as_str()).await?;
pool.execute(format!(
"ALTER DATABASE `{}` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci",
db_config.db_name
).as_str()).await?;
let new_connection_str = format!(
"mysql://{}:{}@{}:{}/{}",
db_config.user,
db_config.password,
db_config.address,
db_config.port,
db_config.db_name
);
let new_pool = MySqlPool::connect(&new_connection_str).await?;
new_pool.execute(grammar.as_str()).await?;
Ok(())
}
async fn connect(db_config: &config::SqlConfig) -> CustomResult<Self> {
let connection_str = format!(
"mysql://{}:{}@{}:{}/{}",
db_config.user,
db_config.password,
db_config.address,
db_config.port,
db_config.db_name
);
let pool = MySqlPool::connect(&connection_str).await?;
Ok(Mysql { pool })
}
async fn execute_query<'a>(
&'a self,
builder: &builder::QueryBuilder,
) -> CustomResult<Vec<HashMap<String, Value>>> {
let (query, values) = builder.build()?;
let mut sqlx_query = sqlx::query(&query);
for value in values {
match value {
SafeValue::Null => sqlx_query = sqlx_query.bind(None::<String>),
SafeValue::Bool(b) => sqlx_query = sqlx_query.bind(b),
SafeValue::Integer(i) => sqlx_query = sqlx_query.bind(i),
SafeValue::Float(f) => sqlx_query = sqlx_query.bind(f),
SafeValue::Text(s, _) => sqlx_query = sqlx_query.bind(s),
SafeValue::DateTime(dt) => sqlx_query = sqlx_query.bind(dt.to_rfc3339()),
}
}
let rows = sqlx_query.fetch_all(&self.pool).await?;
Ok(rows
.into_iter()
.map(|row| {
row.columns()
.iter()
.map(|col| {
let value = match col.type_info().name() {
"INT4" | "INT8" => Value::Number(
row.try_get::<i64, _>(col.name()).unwrap_or_default().into(),
),
"FLOAT4" | "FLOAT8" => Value::Number(
serde_json::Number::from_f64(
row.try_get::<f64, _>(col.name()).unwrap_or(0.0),
)
.unwrap_or_else(|| 0.into()),
),
"BOOL" => Value::Bool(row.try_get(col.name()).unwrap_or_default()),
_ => Value::String(row.try_get(col.name()).unwrap_or_default()),
};
(col.name().to_string(), value)
})
.collect()
})
.collect())
}
}

View File

@ -1,11 +1,15 @@
use super::{builder, DatabaseTrait}; use super::{
builder::{self, SafeValue},
schema, DatabaseTrait,
};
use crate::common::error::{CustomError, CustomErrorInto, CustomResult};
use crate::config; use crate::config;
use crate::common::error::CustomResult;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde_json::Value; use serde_json::Value;
use sqlx::{Column, Executor, PgPool, Row, TypeInfo}; use sqlx::{Column, Executor, PgPool, Row, TypeInfo};
use std::collections::HashMap; use std::collections::HashMap;
use std::{env, fs};
#[derive(Clone)] #[derive(Clone)]
pub struct Postgresql { pub struct Postgresql {
pool: PgPool, pool: PgPool,
@ -14,13 +18,11 @@ pub struct Postgresql {
#[async_trait] #[async_trait]
impl DatabaseTrait for Postgresql { impl DatabaseTrait for Postgresql {
async fn initialization(db_config: config::SqlConfig) -> CustomResult<()> { async fn initialization(db_config: config::SqlConfig) -> CustomResult<()> {
let path = env::current_dir()? let db_prefix = SafeValue::Text(
.join("src") format!("{}", db_config.db_prefix),
.join("storage") builder::ValidationLevel::Strict,
.join("sql") );
.join("postgresql") let grammar = schema::generate_schema(schema::DatabaseType::PostgreSQL, db_prefix)?;
.join("schema.sql");
let grammar = fs::read_to_string(&path)?;
let connection_str = format!( let connection_str = format!(
"postgres://{}:{}@{}:{}", "postgres://{}:{}@{}:{}",
@ -70,7 +72,14 @@ impl DatabaseTrait for Postgresql {
let mut sqlx_query = sqlx::query(&query); let mut sqlx_query = sqlx::query(&query);
for value in values { for value in values {
sqlx_query = sqlx_query.bind(value.to_sql_string()?); match value {
SafeValue::Null => sqlx_query = sqlx_query.bind(None::<String>),
SafeValue::Bool(b) => sqlx_query = sqlx_query.bind(b),
SafeValue::Integer(i) => sqlx_query = sqlx_query.bind(i),
SafeValue::Float(f) => sqlx_query = sqlx_query.bind(f),
SafeValue::Text(s, _) => sqlx_query = sqlx_query.bind(s),
SafeValue::DateTime(dt) => sqlx_query = sqlx_query.bind(dt.to_rfc3339()),
}
} }
let rows = sqlx_query.fetch_all(&self.pool).await?; let rows = sqlx_query.fetch_all(&self.pool).await?;
@ -92,7 +101,6 @@ impl DatabaseTrait for Postgresql {
.unwrap_or_else(|| 0.into()), .unwrap_or_else(|| 0.into()),
), ),
"BOOL" => Value::Bool(row.try_get(col.name()).unwrap_or_default()), "BOOL" => Value::Bool(row.try_get(col.name()).unwrap_or_default()),
"JSON" | "JSONB" => row.try_get(col.name()).unwrap_or(Value::Null),
_ => Value::String(row.try_get(col.name()).unwrap_or_default()), _ => Value::String(row.try_get(col.name()).unwrap_or_default()),
}; };
(col.name().to_string(), value) (col.name().to_string(), value)
@ -102,3 +110,9 @@ impl DatabaseTrait for Postgresql {
.collect()) .collect())
} }
} }
impl Postgresql {
fn get_sdb(&self){
let a=self.pool;
}
}

View File

@ -1,102 +0,0 @@
-- 自定义类型定义
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TYPE user_role AS ENUM ('contributor', 'administrator');
CREATE TYPE content_status AS ENUM ('draft', 'published', 'private', 'hidden');
-- 用户表
CREATE TABLE users
(
username VARCHAR(100) PRIMARY KEY,
avatar_url VARCHAR(255),
email VARCHAR(255) UNIQUE NOT NULL,
profile_icon VARCHAR(255),
password_hash VARCHAR(255) NOT NULL,
role user_role NOT NULL DEFAULT 'contributor',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 页面表
CREATE TABLE pages
(
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
meta_keywords VARCHAR(255) NOT NULL,
meta_description VARCHAR(255) NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
template VARCHAR(50),
custom_fields JSON,
status content_status DEFAULT 'draft'
);
-- 文章表
CREATE TABLE posts
(
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
author_id VARCHAR(100) NOT NULL REFERENCES users (username) ON DELETE CASCADE,
cover_image VARCHAR(255),
title VARCHAR(255) NOT NULL,
meta_keywords VARCHAR(255) NOT NULL,
meta_description VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
status content_status DEFAULT 'draft',
is_editor BOOLEAN DEFAULT FALSE,
draft_content TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
published_at TIMESTAMP,
CONSTRAINT check_update_time CHECK (updated_at >= created_at)
);
-- 标签表
CREATE TABLE tags
(
name VARCHAR(50) PRIMARY KEY CHECK (LOWER(name) = name),
icon VARCHAR(255)
);
-- 文章标签关联表
CREATE TABLE post_tags
(
post_id UUID REFERENCES posts (id) ON DELETE CASCADE,
tag_id VARCHAR(50) REFERENCES tags (name) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
-- 分类表
CREATE TABLE categories
(
name VARCHAR(50) PRIMARY KEY,
parent_id VARCHAR(50),
FOREIGN KEY (parent_id) REFERENCES categories (name)
);
-- 文章分类关联表
CREATE TABLE post_categories
(
post_id UUID REFERENCES posts (id) ON DELETE CASCADE,
category_id VARCHAR(50) REFERENCES categories (name) ON DELETE CASCADE,
PRIMARY KEY (post_id, category_id)
);
-- 资源库表
CREATE TABLE resources
(
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
author_id VARCHAR(100) NOT NULL REFERENCES users (username) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
size_bytes BIGINT NOT NULL,
storage_path VARCHAR(255) NOT NULL UNIQUE,
file_type VARCHAR(50) NOT NULL,
category VARCHAR(50),
description VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 配置表
CREATE TABLE settings
(
key VARCHAR(50) PRIMARY KEY CHECK (LOWER(key) = key),
data JSON
);

View File

@ -0,0 +1,812 @@
use super::builder::{Condition, Identifier, Operator, SafeValue, ValidationLevel, WhereClause};
use crate::common::error::{CustomErrorInto, CustomResult};
use std::{collections::HashMap, fmt::format};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DatabaseType {
PostgreSQL,
MySQL,
SQLite,
}
#[derive(Debug, Clone, PartialEq)]
pub enum FieldType {
Integer(bool),
BigInt,
VarChar(usize),
Text,
Boolean,
Timestamp,
}
#[derive(Debug, Clone)]
pub struct FieldConstraint {
pub is_primary: bool,
pub is_unique: bool,
pub is_nullable: bool,
pub default_value: Option<SafeValue>,
pub check_constraint: Option<WhereClause>,
pub foreign_key: Option<ForeignKey>,
}
#[derive(Debug, Clone)]
pub enum ForeignKeyAction {
Cascade,
Restrict,
SetNull,
NoAction,
SetDefault,
}
#[derive(Debug, Clone)]
pub struct ForeignKey {
pub ref_table: String,
pub ref_column: String,
pub on_delete: Option<ForeignKeyAction>,
pub on_update: Option<ForeignKeyAction>,
}
impl ToString for ForeignKeyAction {
fn to_string(&self) -> String {
match self {
ForeignKeyAction::Cascade => "CASCADE",
ForeignKeyAction::Restrict => "RESTRICT",
ForeignKeyAction::SetNull => "SET NULL",
ForeignKeyAction::NoAction => "NO ACTION",
ForeignKeyAction::SetDefault => "SET DEFAULT",
}
.to_string()
}
}
#[derive(Debug, Clone)]
pub struct Field {
pub name: Identifier,
pub field_type: FieldType,
pub constraints: FieldConstraint,
pub validation_level: ValidationLevel,
}
#[derive(Debug, Clone)]
pub struct Table {
pub name: Identifier,
pub fields: Vec<Field>,
pub indexes: Vec<Index>,
}
#[derive(Debug, Clone)]
pub struct Index {
pub name: Identifier,
pub fields: Vec<Identifier>,
pub is_unique: bool,
}
impl FieldConstraint {
pub fn new() -> Self {
Self {
is_primary: false,
is_unique: false,
is_nullable: true,
default_value: None,
check_constraint: None,
foreign_key: None,
}
}
pub fn primary(mut self) -> Self {
self.is_primary = true;
self.is_nullable = false;
self
}
pub fn unique(mut self) -> Self {
self.is_unique = true;
self
}
pub fn not_null(mut self) -> Self {
self.is_nullable = false;
self
}
pub fn default(mut self, value: SafeValue) -> Self {
self.default_value = Some(value);
self
}
pub fn check(mut self, clause: WhereClause) -> Self {
self.check_constraint = Some(clause);
self
}
pub fn foreign_key(mut self, ref_table: String, ref_column: String) -> Self {
self.foreign_key = Some(ForeignKey {
ref_table,
ref_column,
on_delete: None,
on_update: None,
});
self
}
pub fn on_delete(mut self, action: ForeignKeyAction) -> Self {
if let Some(ref mut fk) = self.foreign_key {
fk.on_delete = Some(action);
}
self
}
pub fn on_update(mut self, action: ForeignKeyAction) -> Self {
if let Some(ref mut fk) = self.foreign_key {
fk.on_update = Some(action);
}
self
}
}
impl Field {
pub fn new(
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,
})
}
fn field_type_sql(&self, db_type: DatabaseType) -> CustomResult<String> {
Ok(match &self.field_type {
FieldType::Integer(auto_increment) => {
if *auto_increment && self.constraints.is_primary {
match db_type {
DatabaseType::MySQL => "INT AUTO_INCREMENT".to_string(),
DatabaseType::PostgreSQL => {
"INTEGER GENERATED ALWAYS AS IDENTITY".to_string()
}
DatabaseType::SQLite => "INTEGER".to_string(),
}
} else {
match db_type {
DatabaseType::MySQL => "INT".to_string(),
_ => "INTEGER".to_string(),
}
}
}
FieldType::BigInt => "BIGINT".to_string(),
FieldType::VarChar(size) => format!("VARCHAR({})", size),
FieldType::Text => "TEXT".to_string(),
FieldType::Boolean => match db_type {
DatabaseType::PostgreSQL => "BOOLEAN".to_string(),
DatabaseType::MySQL => "BOOLEAN".to_string(),
DatabaseType::SQLite => "INTEGER".to_string(),
},
FieldType::Timestamp => match db_type {
DatabaseType::PostgreSQL => "TIMESTAMP WITH TIME ZONE".to_string(),
DatabaseType::MySQL => "TIMESTAMP".to_string(),
DatabaseType::SQLite => "TEXT".to_string(),
},
})
}
fn build_check_constraint(check: &WhereClause) -> CustomResult<String> {
match check {
WhereClause::Condition(condition) => {
let field_name = condition.field.as_str();
match condition.operator {
Operator::In => {
if let Some(SafeValue::Text(values, _)) = &condition.value {
Ok(format!("{} IN {}", field_name, values))
} else {
Err("Invalid IN clause value".into_custom_error())
}
}
Operator::Eq
| Operator::Ne
| Operator::Gt
| Operator::Lt
| Operator::Gte
| Operator::Lte => {
if let Some(value) = &condition.value {
Ok(format!(
"{} {} {}",
field_name,
condition.operator.as_str(),
value.to_sql_string()?
))
} else {
Err("Missing value for comparison".into_custom_error())
}
}
_ => Err("Unsupported operator for CHECK constraint".into_custom_error()),
}
}
_ => {
Err("Only simple conditions are supported for CHECK constraints"
.into_custom_error())
}
}
}
pub fn to_sql(&self, db_type: DatabaseType) -> CustomResult<String> {
let mut sql = format!("{} {}", self.name.as_str(), self.field_type_sql(db_type)?);
if !self.constraints.is_nullable {
sql.push_str(" NOT NULL");
}
if self.constraints.is_unique {
sql.push_str(" UNIQUE");
}
if self.constraints.is_primary {
match (db_type, &self.field_type) {
(DatabaseType::SQLite, FieldType::Integer(true)) => {
sql.push_str(" PRIMARY KEY AUTOINCREMENT");
}
(DatabaseType::MySQL, FieldType::Integer(true)) => {
sql.push_str(" PRIMARY KEY");
}
(DatabaseType::PostgreSQL, FieldType::Integer(true)) => {
sql.push_str(" PRIMARY KEY");
}
_ => sql.push_str(" PRIMARY KEY"),
}
}
if let Some(default) = &self.constraints.default_value {
sql.push_str(&format!(" DEFAULT {}", default.to_sql_string()?));
}
if let Some(check) = &self.constraints.check_constraint {
let check_sql = Self::build_check_constraint(check)?;
sql.push_str(&format!(" CHECK ({})", check_sql));
}
if let Some(fk) = &self.constraints.foreign_key {
sql.push_str(&format!(" REFERENCES {}({})", fk.ref_table, fk.ref_column));
if let Some(on_delete) = &fk.on_delete {
sql.push_str(&format!(" ON DELETE {}", on_delete.to_string()));
}
if let Some(on_update) = &fk.on_update {
sql.push_str(&format!(" ON UPDATE {}", on_update.to_string()));
}
}
Ok(sql)
}
}
impl Table {
pub fn new(name: &str) -> CustomResult<Self> {
Ok(Self {
name: Identifier::new(name.to_string())?,
fields: Vec::new(),
indexes: Vec::new(),
})
}
pub fn add_field(&mut self, field: Field) -> &mut Self {
self.fields.push(field);
self
}
pub fn add_index(&mut self, index: Index) -> &mut Self {
self.indexes.push(index);
self
}
pub fn to_sql(&self, db_type: DatabaseType) -> CustomResult<String> {
let fields_sql: CustomResult<Vec<String>> =
self.fields.iter().map(|f| f.to_sql(db_type)).collect();
let fields_sql = fields_sql?;
let mut sql = format!(
"CREATE TABLE {} (\n {}\n);",
self.name.as_str(),
fields_sql.join(",\n ")
);
// 添加索引
for index in &self.indexes {
sql.push_str(&format!(
"\n\n{}",
index.to_sql(self.name.as_str(), db_type)?
));
}
Ok(sql)
}
}
impl Index {
pub fn new(name: &str, fields: Vec<String>, is_unique: bool) -> CustomResult<Self> {
Ok(Self {
name: Identifier::new(name.to_string())?,
fields: fields
.into_iter()
.map(|f| Identifier::new(f))
.collect::<CustomResult<Vec<_>>>()?,
is_unique,
})
}
fn to_sql(&self, table_name: &str, db_type: DatabaseType) -> CustomResult<String> {
let unique = if self.is_unique { "UNIQUE " } else { "" };
Ok(format!(
"CREATE {}INDEX {} ON {} ({});",
unique,
self.name.as_str(),
table_name,
self.fields
.iter()
.map(|f| f.as_str())
.collect::<Vec<_>>()
.join(", ")
))
}
}
// Schema构建器
#[derive(Debug, Default)]
pub struct SchemaBuilder {
tables: Vec<Table>,
}
impl SchemaBuilder {
pub fn new() -> Self {
Self {
tables: Vec::new(),
}
}
pub fn add_table(&mut self, table: Table) -> CustomResult<&mut Self> {
self.tables.push(table);
Ok(self)
}
pub fn build(&self, db_type: DatabaseType) -> CustomResult<String> {
let mut sql = String::new();
for table in &self.tables {
sql.push_str(&table.to_sql(db_type)?);
sql.push_str("\n\n");
}
Ok(sql)
}
}
pub fn generate_schema(db_type: DatabaseType,db_prefix:SafeValue) -> CustomResult<String> {
let db_prefix=db_prefix.to_sql_string()?;
let mut schema = SchemaBuilder::new();
let user_level = "('contributor', 'administrator')";
let content_state = "('draft', 'published', 'private', 'hidden')";
// 用户表
let mut users_table = Table::new(&format!("{}users",db_prefix))?;
users_table
.add_field(Field::new(
"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(
"profile_icon",
FieldType::VarChar(255),
FieldConstraint::new(),
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(WhereClause::Condition(Condition::new(
"role".to_string(),
Operator::In,
Some(SafeValue::Text(user_level.to_string(), ValidationLevel::Relaxed)),
)?)),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"created_at",
FieldType::Timestamp,
FieldConstraint::new().not_null().default(SafeValue::Text(
"CURRENT_TIMESTAMP".to_string(),
ValidationLevel::Strict,
)),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"updated_at",
FieldType::Timestamp,
FieldConstraint::new().not_null().default(SafeValue::Text(
"CURRENT_TIMESTAMP".to_string(),
ValidationLevel::Strict,
)),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"last_login_at",
FieldType::Timestamp,
FieldConstraint::new().default(SafeValue::Text(
"CURRENT_TIMESTAMP".to_string(),
ValidationLevel::Strict,
)),
ValidationLevel::Strict,
)?);
schema.add_table(users_table)?;
// 独立页面表
let mut pages_table = Table::new(&format!("{}pages",db_prefix))?;
pages_table
.add_field(Field::new(
"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(
"meta_keywords",
FieldType::VarChar(255),
FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"meta_description",
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(
"template",
FieldType::VarChar(50),
FieldConstraint::new(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"custom_fields",
FieldType::Text,
FieldConstraint::new(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"status",
FieldType::VarChar(20),
FieldConstraint::new()
.not_null()
.check(WhereClause::Condition(Condition::new(
"status".to_string(),
Operator::In,
Some(SafeValue::Text(content_state.to_string(), ValidationLevel::Standard)),
)?)),
ValidationLevel::Strict,
)?);
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",
FieldType::VarChar(100),
FieldConstraint::new()
.not_null()
.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(
"meta_keywords",
FieldType::VarChar(255),
FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"meta_description",
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(
"status",
FieldType::VarChar(20),
FieldConstraint::new()
.not_null()
.check(WhereClause::Condition(Condition::new(
"status".to_string(),
Operator::In,
Some(SafeValue::Text(content_state.to_string(), ValidationLevel::Standard)),
)?)),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"is_editor",
FieldType::Boolean,
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",
FieldType::Timestamp,
FieldConstraint::new().not_null().default(SafeValue::Text(
"CURRENT_TIMESTAMP".to_string(),
ValidationLevel::Strict,
)),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"updated_at",
FieldType::Timestamp,
FieldConstraint::new().not_null().default(SafeValue::Text(
"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)?;
// 标签表
let mut tags_tables = Table::new(&format!("{}tags",db_prefix))?;
tags_tables
.add_field(Field::new(
"name",
FieldType::VarChar(50),
FieldConstraint::new().primary(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"icon",
FieldType::VarChar(255),
FieldConstraint::new(),
ValidationLevel::Strict,
)?);
schema.add_table(tags_tables)?;
// 文章标签
let mut post_tags_tables = Table::new(&format!("{}post_tags",db_prefix))?;
post_tags_tables
.add_field(Field::new(
"post_id",
FieldType::Integer(false),
FieldConstraint::new()
.not_null()
.foreign_key(format!("{}posts",db_prefix), "id".to_string())
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
ValidationLevel::Strict,
)?).add_field(Field::new(
"tag_id",
FieldType::VarChar(50),
FieldConstraint::new()
.not_null()
.foreign_key(format!("{}tags",db_prefix), "name".to_string())
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
ValidationLevel::Strict,
)?);
post_tags_tables.add_index(Index::new(
"pk_post_tags",
vec!["post_id".to_string(), "tag_id".to_string()],
true,
)?);
schema.add_table(post_tags_tables)?;
// 分类表
let mut categories_table = Table::new(&format!("{}categories",db_prefix))?;
categories_table
.add_field(Field::new(
"name",
FieldType::VarChar(50),
FieldConstraint::new().primary(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"parent_id",
FieldType::VarChar(50),
FieldConstraint::new()
.foreign_key(format!("{}categories",db_prefix), "name".to_string()),
ValidationLevel::Strict,
)?);
schema.add_table(categories_table)?;
// 文章分类关联表
let mut post_categories_table = Table::new(&format!("{}post_categories",db_prefix))?;
post_categories_table
.add_field(Field::new(
"post_id",
FieldType::Integer(false),
FieldConstraint::new()
.not_null()
.foreign_key(format!("{}posts",db_prefix), "id".to_string())
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"category_id",
FieldType::VarChar(50),
FieldConstraint::new()
.not_null()
.foreign_key(format!("{}categories",db_prefix), "name".to_string())
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
ValidationLevel::Strict,
)?);
post_categories_table.add_index(Index::new(
"pk_post_categories",
vec!["post_id".to_string(), "category_id".to_string()],
true,
)?);
schema.add_table(post_categories_table)?;
// 资源库表
let mut resources_table = Table::new(&format!("{}resources",db_prefix))?;
resources_table
.add_field(Field::new(
"id",
FieldType::Integer(true),
FieldConstraint::new().primary(),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
"author_id",
FieldType::VarChar(100),
FieldConstraint::new()
.not_null()
.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(
"file_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",
FieldType::Timestamp,
FieldConstraint::new().not_null().default(SafeValue::Text(
"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)?;
schema.build(db_type)
}

View File

@ -0,0 +1,107 @@
use super::{
builder::{self, SafeValue},
schema, DatabaseTrait,
};
use crate::common::error::{CustomError, CustomErrorInto, CustomResult};
use crate::config;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde_json::Value;
use sqlx::{Column, Executor, SqlitePool, Row, TypeInfo};
use std::collections::HashMap;
use std::env;
#[derive(Clone)]
pub struct Sqlite {
pool: SqlitePool,
}
#[async_trait]
impl DatabaseTrait for Sqlite {
async fn initialization(db_config: config::SqlConfig) -> CustomResult<()> {
let db_prefix = SafeValue::Text(
format!("{}", db_config.db_prefix),
builder::ValidationLevel::Strict,
);
let sqlite_dir = env::current_dir()?.join("assets").join("sqllite");
std::fs::create_dir_all(&sqlite_dir)?;
let db_file = sqlite_dir.join(&db_config.db_name);
std::fs::File::create(&db_file)?;
let path = db_file.to_str().ok_or("Unable to get sqllite path".into_custom_error())?;
let grammar = schema::generate_schema(schema::DatabaseType::SQLite, db_prefix)?;
let connection_str = format!("sqlite:///{}", path);
let pool = SqlitePool::connect(&connection_str).await?;
pool.execute(grammar.as_str()).await?;
Ok(())
}
async fn connect(db_config: &config::SqlConfig) -> CustomResult<Self> {
let db_file = env::current_dir()?
.join("assets")
.join("sqllite")
.join(&db_config.db_name);
if !db_file.exists() {
return Err("SQLite database file does not exist".into_custom_error());
}
let path = db_file.to_str().ok_or("Unable to get sqllite path".into_custom_error())?;
let connection_str = format!("sqlite:///{}", path);
let pool = SqlitePool::connect(&connection_str).await?;
Ok(Sqlite { pool })
}
async fn execute_query<'a>(
&'a self,
builder: &builder::QueryBuilder,
) -> CustomResult<Vec<HashMap<String, Value>>> {
let (query, values) = builder.build()?;
let mut sqlx_query = sqlx::query(&query);
for value in values {
match value {
SafeValue::Null => sqlx_query = sqlx_query.bind(None::<String>),
SafeValue::Bool(b) => sqlx_query = sqlx_query.bind(b),
SafeValue::Integer(i) => sqlx_query = sqlx_query.bind(i),
SafeValue::Float(f) => sqlx_query = sqlx_query.bind(f),
SafeValue::Text(s, _) => sqlx_query = sqlx_query.bind(s),
SafeValue::DateTime(dt) => sqlx_query = sqlx_query.bind(dt.to_rfc3339()),
}
}
let rows = sqlx_query.fetch_all(&self.pool).await?;
Ok(rows
.into_iter()
.map(|row| {
row.columns()
.iter()
.map(|col| {
let value = match col.type_info().name() {
"INTEGER" => Value::Number(
row.try_get::<i64, _>(col.name()).unwrap_or_default().into(),
),
"REAL" => Value::Number(
serde_json::Number::from_f64(
row.try_get::<f64, _>(col.name()).unwrap_or(0.0),
)
.unwrap_or_else(|| 0.into()),
),
"BOOLEAN" => Value::Bool(row.try_get(col.name()).unwrap_or_default()),
_ => Value::String(row.try_get(col.name()).unwrap_or_default()),
};
(col.name().to_string(), value)
})
.collect()
})
.collect())
}
}

10
frontend/app/env.d.ts vendored
View File

@ -7,12 +7,10 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_SERVER_API: string; // 用于访问API的基础URL readonly VITE_INIT_STATUS: string;
readonly VITE_ADDRESS: string; // 前端地址 readonly VITE_SERVER_API: string;
readonly VITE_PORT: number; // 前端系统端口 readonly VITE_PORT: string;
VITE_USERNAME: string; // 前端账号名称 readonly VITE_ADDRESS: string;
VITE_PASSWORD: string; // 前端账号密码
VITE_INIT_STATUS: boolean; // 系统是否进行安装
} }
interface ImportMeta { interface ImportMeta {

View File

@ -1,5 +1,5 @@
import React, { useContext, createContext, useState } from "react"; import React, { createContext, useState, useEffect } from "react";
import { useApi } from "hooks/servicesProvider";
interface SetupContextType { interface SetupContextType {
currentStep: number; currentStep: number;
setCurrentStep: (step: number) => void; setCurrentStep: (step: number) => void;
@ -21,30 +21,22 @@ const StepContainer: React.FC<{ title: string; children: React.ReactNode }> = ({
title, title,
children, children,
}) => ( }) => (
<div className="mx-auto max-w-5xl"> <div className="mx-auto max-w-3xl">
<h2 className="text-2xl font-semibold text-custom-title-light dark:text-custom-title-dark mb-5"> <h2 className="text-xl font-medium text-custom-title-light dark:text-custom-title-dark mb-6 px-4">
{title} {title}
</h2> </h2>
<div className="bg-custom-box-light dark:bg-custom-box-dark rounded-lg shadow-lg p-8"> <div className="space-y-6 px-4">
{children} {children}
</div> </div>
</div> </div>
); );
// 通用的导航按钮组件 // 通用的导航按钮组件
const NavigationButtons: React.FC<StepProps> = ({ onNext, onPrev }) => ( const NavigationButtons: React.FC<StepProps> = ({ onNext }) => (
<div className="flex gap-4 mt-6"> <div className="flex justify-end mt-4">
{onPrev && (
<button
onClick={onPrev}
className="px-6 py-2 rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors"
>
</button>
)}
<button <button
onClick={onNext} onClick={onNext}
className="px-6 py-2 rounded-lg bg-blue-500 hover:bg-blue-600 text-white transition-colors" className="px-6 py-2 rounded-lg bg-blue-500 hover:bg-blue-600 text-white transition-colors font-medium text-sm"
> >
</button> </button>
@ -58,17 +50,17 @@ const InputField: React.FC<{
defaultValue?: string | number; defaultValue?: string | number;
hint?: string; hint?: string;
}> = ({ label, name, defaultValue, hint }) => ( }> = ({ label, name, defaultValue, hint }) => (
<div className="mb-4"> <div className="mb-6">
<h3 className="text-xl text-custom-title-light dark:text-custom-title-dark mb-2"> <h3 className="text-base font-medium text-custom-title-light dark:text-custom-title-dark mb-2">
{label} {label}
</h3> </h3>
<input <input
name={name} name={name}
defaultValue={defaultValue} defaultValue={defaultValue}
className="w-full p-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700" className="w-full p-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
/> />
{hint && ( {hint && (
<p className="text-xs text-custom-p-light dark:text-custom-p-dark mt-1"> <p className="text-xs text-custom-p-light dark:text-custom-p-dark mt-1.5">
{hint} {hint}
</p> </p>
)} )}
@ -78,7 +70,7 @@ const InputField: React.FC<{
const Introduction: React.FC<StepProps> = ({ onNext }) => ( const Introduction: React.FC<StepProps> = ({ onNext }) => (
<StepContainer title="安装说明"> <StepContainer title="安装说明">
<div className="space-y-6"> <div className="space-y-6">
<p className="text-xl text-custom-p-light dark:text-custom-p-dark"> <p className="text-base text-custom-p-light dark:text-custom-p-dark">
使 Echoes 使 Echoes
</p> </p>
<NavigationButtons onNext={onNext} /> <NavigationButtons onNext={onNext} />
@ -86,22 +78,24 @@ const Introduction: React.FC<StepProps> = ({ onNext }) => (
</StepContainer> </StepContainer>
); );
const DatabaseConfig: React.FC<StepProps> = ({ onNext, onPrev }) => { const DatabaseConfig: React.FC<StepProps> = ({ onNext }) => {
const [dbType, setDbType] = useState("postgresql"); const [dbType, setDbType] = useState("postgresql");
return ( return (
<StepContainer title="数据库配置"> <StepContainer title="数据库配置">
<div className="space-y-6"> <div>
<div className="mb-6"> <div className="mb-6">
<h3 className="text-xl text-custom-title-light dark:text-custom-title-dark mb-2"> <h3 className="text-base font-medium text-custom-title-light dark:text-custom-title-dark mb-1.5">
</h3> </h3>
<select <select
value={dbType} value={dbType}
onChange={(e) => setDbType(e.target.value)} onChange={(e) => setDbType(e.target.value)}
className="w-full p-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700" className="w-full p-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
> >
<option value="postgresql">PostgreSQL</option> <option value="postgresql">PostgreSQL</option>
<option value="mysql">MySQL</option>
<option value="sqllite">SQLite</option>
</select> </select>
</div> </div>
@ -113,6 +107,12 @@ const DatabaseConfig: React.FC<StepProps> = ({ onNext, onPrev }) => {
defaultValue="localhost" defaultValue="localhost"
hint="通常使用 localhost" hint="通常使用 localhost"
/> />
<InputField
label="数据库前缀"
name="db_prefix"
defaultValue="echoec_"
hint="通常使用 echoec_"
/>
<InputField <InputField
label="端口" label="端口"
name="db_port" name="db_port"
@ -136,19 +136,71 @@ const DatabaseConfig: React.FC<StepProps> = ({ onNext, onPrev }) => {
/> />
</> </>
)} )}
<NavigationButtons onNext={onNext} onPrev={onPrev} /> {dbType === "mysql" && (
<>
<InputField
label="数据库地址"
name="db_host"
defaultValue="localhost"
hint="通常使用 localhost"
/>
<InputField
label="数据库前缀"
name="db_prefix"
defaultValue="echoec_"
hint="通常使用 echoec_"
/>
<InputField
label="端口"
name="db_port"
defaultValue={3306}
hint="mysql 默认端口为 3306"
/>
<InputField
label="用户名"
name="db_user"
defaultValue="root"
/>
<InputField
label="密码"
name="db_password"
defaultValue="mysql"
/>
<InputField
label="数据库名"
name="db_name"
defaultValue="echoes"
/>
</>
)}
{dbType === "sqllite" && (
<>
<InputField
label="数据库前缀"
name="db_prefix"
defaultValue="echoec_"
hint="通常使用 echoec_"
/>
<InputField
label="数据库名"
name="db_name"
defaultValue="echoes.db"
/>
</>
)}
<NavigationButtons onNext={onNext} />
</div> </div>
</StepContainer> </StepContainer>
); );
}; };
const AdminConfig: React.FC<StepProps> = ({ onNext, onPrev }) => ( const AdminConfig: React.FC<StepProps> = ({ onNext }) => (
<StepContainer title="创建管理员账号"> <StepContainer title="创建管理员账号">
<div className="space-y-6"> <div className="space-y-6">
<InputField label="用户名" name="admin_username" /> <InputField label="用户名" name="admin_username" />
<InputField label="密码" name="admin_password" /> <InputField label="密码" name="admin_password" />
<InputField label="邮箱" name="admin_email" /> <InputField label="邮箱" name="admin_email" />
<NavigationButtons onNext={onNext} onPrev={onPrev} /> <NavigationButtons onNext={onNext} />
</div> </div>
</StepContainer> </StepContainer>
); );
@ -163,31 +215,71 @@ const SetupComplete: React.FC = () => (
</StepContainer> </StepContainer>
); );
export default function SetupPage() { // 修改主题切换按钮组件
const [currentStep, setCurrentStep] = useState(1); const ThemeToggle: React.FC = () => {
const [isDark, setIsDark] = useState(false);
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const isDarkMode = document.documentElement.classList.contains('dark');
setIsDark(isDarkMode);
const handleScroll = () => {
const currentScrollPos = window.scrollY;
setIsVisible(currentScrollPos < 100); // 滚动超过100px就隐藏
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const toggleTheme = () => {
const newIsDark = !isDark;
setIsDark(newIsDark);
document.documentElement.classList.toggle('dark');
};
return ( return (
<div className="min-h-screen w-full bg-custom-bg-light dark:bg-custom-bg-dark"> <button
<div className="container mx-auto px-4 py-4"> onClick={toggleTheme}
<div className="text-center mb-8"> className={`absolute top-4 right-4 p-2.5 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-all duration-300 ${
<h1 className="text-4xl font-bold text-custom-title-light dark:text-custom-title-dark mb-4"> isVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'
Echoes }`}
</h1> >
</div> {isDark ? (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" />
</svg>
) : (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
)}
</button>
);
};
export default function SetupPage() {
let step = Number(import.meta.env.VITE_INIT_STATUS);
const [currentStep, setCurrentStep] = useState(step);
return (
<div className="relative min-h-screen w-full bg-custom-bg-light dark:bg-custom-bg-dark">
<ThemeToggle />
<div className="container mx-auto py-8">
<SetupContext.Provider value={{ currentStep, setCurrentStep }}> <SetupContext.Provider value={{ currentStep, setCurrentStep }}>
{currentStep === 1 && ( {currentStep === 1 && (
<Introduction onNext={() => setCurrentStep(currentStep + 1)} /> <Introduction onNext={() => setCurrentStep(currentStep + 1)} />
)} )}
{currentStep === 2 && ( {currentStep === 2 && (
<DatabaseConfig <DatabaseConfig
onNext={() => setCurrentStep(currentStep + 1)} onNext={() => setCurrentStep(currentStep + 1)}
onPrev={() => setCurrentStep(currentStep - 1)}
/> />
)} )}
{currentStep === 3 && ( {currentStep === 3 && (
<AdminConfig <AdminConfig
onNext={() => setCurrentStep(currentStep + 1)} onNext={() => setCurrentStep(currentStep + 1)}
onPrev={() => setCurrentStep(currentStep - 1)}
/> />
)} )}
{currentStep === 4 && <SetupComplete />} {currentStep === 4 && <SetupComplete />}

12
frontend/core/template.ts Normal file
View File

@ -0,0 +1,12 @@
export interface Template {
name: string;
description?: string;
config: {
layout?: string;
styles?: string[];
scripts?: string[];
};
loader: () => Promise<void>;
element: () => React.ReactNode;
}

View File

@ -1,5 +1,6 @@
import { Configuration, PathDescription } from "common/serializableType"; import { Configuration, PathDescription } from "common/serializableType";
import { ApiService } from "./api"; import { ApiService } from "core/api";
import { Template } from "core/template";
export interface ThemeConfig { export interface ThemeConfig {
name: string; name: string;
@ -25,17 +26,6 @@ export interface ThemeConfig {
}; };
} }
export interface Template {
name: string;
description?: string;
config: {
layout?: string;
styles?: string[];
scripts?: string[];
};
loader: () => Promise<void>;
element: () => React.ReactNode;
}
export class ThemeService { export class ThemeService {
private static instance: ThemeService; private static instance: ThemeService;
@ -56,7 +46,7 @@ export class ThemeService {
public async getCurrentTheme(): Promise<void> { public async getCurrentTheme(): Promise<void> {
try { try {
const themeConfig = await this.api.request<ThemeConfig>( const themeConfig = await this.api.request<ThemeConfig>(
"/theme/current", "/theme",
{ method: "GET" }, { method: "GET" },
); );
this.currentTheme = themeConfig; this.currentTheme = themeConfig;
@ -70,10 +60,10 @@ export class ThemeService {
return this.currentTheme; return this.currentTheme;
} }
public async updateThemeConfig(config: Partial<ThemeConfig>): Promise<void> { public async updateThemeConfig(config: Partial<ThemeConfig>,name:string): Promise<void> {
try { try {
const updatedConfig = await this.api.request<ThemeConfig>( const updatedConfig = await this.api.request<ThemeConfig>(
"/theme/config", `/theme/`,
{ {
method: "PUT", method: "PUT",
headers: { headers: {

View File

@ -17,7 +17,7 @@ export default defineConfig(({ mode }) => {
}, },
routes: (defineRoutes) => { routes: (defineRoutes) => {
return defineRoutes((route) => { return defineRoutes((route) => {
if (!env.VITE_INIT_STATUS) { if (Number(env.VITE_INIT_STATUS??1)<4) {
route("/", "init.tsx", { id: "index-route" }); route("/", "init.tsx", { id: "index-route" });
route("*", "init.tsx", { id: "catch-all-route" }); route("*", "init.tsx", { id: "catch-all-route" });
} else { } else {
@ -30,7 +30,7 @@ export default defineConfig(({ mode }) => {
tsconfigPaths(), tsconfigPaths(),
], ],
define: { define: {
"import.meta.env.VITE_INIT_STATUS": JSON.stringify(false), "import.meta.env.VITE_INIT_STATUS": JSON.stringify(1),
"import.meta.env.VITE_SERVER_API": JSON.stringify("localhost:22000"), "import.meta.env.VITE_SERVER_API": JSON.stringify("localhost:22000"),
"import.meta.env.VITE_PORT": JSON.stringify(22100), "import.meta.env.VITE_PORT": JSON.stringify(22100),
"import.meta.env.VITE_ADDRESS": JSON.stringify("localhost"), "import.meta.env.VITE_ADDRESS": JSON.stringify("localhost"),