数据库:由后端动态构建
后端:更新数据库配置,支持MySQL和SQLite,重构SQL查询构建,重构数据库初始化操作; 前端:调整初始化状态逻辑,改进主题切换功能。
This commit is contained in:
parent
bc42edd38e
commit
3daf6280a7
@ -3,13 +3,19 @@ name = "echoes"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
|
||||
[dependencies]
|
||||
rocket = { version = "0.5", features = ["json"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
toml = "0.8.19"
|
||||
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"
|
||||
jwt-compact = { version = "0.8.0", features = ["ed25519-dalek"] }
|
||||
ed25519-dalek = "2.1.1"
|
||||
@ -17,5 +23,4 @@ rand = "0.8.5"
|
||||
chrono = "0.4"
|
||||
regex = "1.11.1"
|
||||
bcrypt = "0.16"
|
||||
uuid = { version = "1.11.0", features = ["v4", "serde"] }
|
||||
hex = "0.4.3"
|
@ -23,8 +23,12 @@ pub async fn token_system(
|
||||
state: &State<Arc<AppState>>,
|
||||
data: Json<TokenSystemData>,
|
||||
) -> AppResult<String> {
|
||||
let sql = state
|
||||
.sql_get()
|
||||
.await
|
||||
.into_app_result()?;
|
||||
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()?;
|
||||
builder
|
||||
.add_field("password_hash".to_string())
|
||||
@ -56,9 +60,8 @@ pub async fn token_system(
|
||||
builder::Condition::new(
|
||||
"role".to_string(),
|
||||
builder::Operator::Eq,
|
||||
Some(builder::SafeValue::Enum(
|
||||
Some(builder::SafeValue::Text(
|
||||
"administrator".into(),
|
||||
"user_role".into(),
|
||||
builder::ValidationLevel::Standard,
|
||||
)),
|
||||
)
|
||||
@ -66,10 +69,7 @@ pub async fn token_system(
|
||||
),
|
||||
]));
|
||||
|
||||
let values = state
|
||||
.sql_get()
|
||||
.await
|
||||
.into_app_result()?
|
||||
let values = sql
|
||||
.get_db()
|
||||
.execute_query(&builder)
|
||||
.await
|
||||
@ -83,6 +83,8 @@ pub async fn token_system(
|
||||
status::Custom(Status::NotFound, "Invalid system user or password".into())
|
||||
})?;
|
||||
|
||||
println!("{}\n{}",&data.password,password.clone());
|
||||
|
||||
security::bcrypt::verify_hash(&data.password, password)
|
||||
.map_err(|_| status::Custom(Status::Forbidden, "Invalid password".into()))?;
|
||||
|
||||
|
@ -2,6 +2,7 @@ use super::SystemToken;
|
||||
use crate::storage::{sql, sql::builder};
|
||||
use crate::common::error::{AppResult, AppResultInto, CustomResult};
|
||||
use crate::AppState;
|
||||
use rocket::data;
|
||||
use rocket::{
|
||||
get,
|
||||
http::Status,
|
||||
@ -40,7 +41,7 @@ pub async fn get_setting(
|
||||
name: String,
|
||||
) -> CustomResult<Json<Value>> {
|
||||
let name_condition = builder::Condition::new(
|
||||
"key".to_string(),
|
||||
"name".to_string(),
|
||||
builder::Operator::Eq,
|
||||
Some(builder::SafeValue::Text(
|
||||
format!("{}_{}", comfig_type, name),
|
||||
@ -51,7 +52,7 @@ pub async fn get_setting(
|
||||
let where_clause = builder::WhereClause::Condition(name_condition);
|
||||
|
||||
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
|
||||
.add_condition(where_clause)
|
||||
@ -69,9 +70,9 @@ pub async fn insert_setting(
|
||||
data: Json<Value>,
|
||||
) -> CustomResult<()> {
|
||||
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(
|
||||
"key".to_string(),
|
||||
"name".to_string(),
|
||||
builder::SafeValue::Text(
|
||||
format!("{}_{}", comfig_type, name).to_string(),
|
||||
builder::ValidationLevel::Strict,
|
||||
@ -79,7 +80,7 @@ pub async fn insert_setting(
|
||||
)?;
|
||||
builder.set_value(
|
||||
"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?;
|
||||
Ok(())
|
||||
@ -91,7 +92,7 @@ pub async fn system_config_get(
|
||||
_token: SystemToken,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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
|
||||
.into_app_result()?;
|
||||
Ok(settings)
|
||||
|
@ -1,35 +1,22 @@
|
||||
use super::{settings, users};
|
||||
use crate::common::config;
|
||||
use crate::common::error::{AppResult, AppResultInto};
|
||||
use crate::common::helpers;
|
||||
use crate::security;
|
||||
use crate::storage::sql;
|
||||
use crate::common::error::{AppResult, AppResultInto};
|
||||
use crate::AppState;
|
||||
use crate::common::config;
|
||||
use crate::common::helpers;
|
||||
use chrono::Duration;
|
||||
use rocket::data;
|
||||
use rocket::{http::Status, post, response::status, serde::json::Json, State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Deserialize, Serialize,Debug)]
|
||||
pub struct InstallData {
|
||||
username: String,
|
||||
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>,
|
||||
#[post("/sql", format = "application/json", data = "<sql_config>")]
|
||||
pub async fn setup_sql(
|
||||
sql_config: Json<config::SqlConfig>,
|
||||
state: &State<Arc<AppState>>,
|
||||
) -> AppResult<status::Custom<Json<InstallReplyData>>> {
|
||||
) -> AppResult<String> {
|
||||
let mut config = config::Config::read().unwrap_or_default();
|
||||
if config.init.sql {
|
||||
return Err(status::Custom(
|
||||
@ -37,19 +24,57 @@ pub async fn steup_sql(
|
||||
"Database already initialized".to_string(),
|
||||
));
|
||||
}
|
||||
let data = data.into_inner();
|
||||
let sql = {
|
||||
|
||||
let sql_config = sql_config.into_inner();
|
||||
|
||||
config.init.sql = true;
|
||||
config.sql_config = data.sql_config.clone();
|
||||
sql::Database::initial_setup(data.sql_config.clone())
|
||||
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()?;
|
||||
state.sql_link(&data.sql_config).await.into_app_result()?;
|
||||
|
||||
let data = data.into_inner();
|
||||
|
||||
let sql = {
|
||||
state.sql_link(&config.sql_config).await.into_app_result()?;
|
||||
state.sql_get().await.into_app_result()?
|
||||
};
|
||||
|
||||
|
||||
let system_credentials = (
|
||||
helpers::generate_random_string(20),
|
||||
helpers::generate_random_string(20),
|
||||
@ -79,7 +104,6 @@ pub async fn steup_sql(
|
||||
.await
|
||||
.into_app_result()?;
|
||||
|
||||
|
||||
settings::insert_setting(
|
||||
&sql,
|
||||
"system".to_string(),
|
||||
@ -93,11 +117,13 @@ pub async fn steup_sql(
|
||||
.into_app_result()?;
|
||||
|
||||
let token = security::jwt::generate_jwt(
|
||||
security::jwt::CustomClaims { name: data.username },
|
||||
security::jwt::CustomClaims {
|
||||
name: data.username,
|
||||
},
|
||||
Duration::days(7),
|
||||
)
|
||||
.into_app_result()?;
|
||||
|
||||
config.init.administrator=true;
|
||||
config::Config::write(config).into_app_result()?;
|
||||
state.trigger_restart().await.into_app_result()?;
|
||||
|
||||
|
@ -20,37 +20,32 @@ pub struct RegisterData {
|
||||
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() {
|
||||
"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 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
|
||||
.set_value(
|
||||
"username".to_string(),
|
||||
builder::SafeValue::Text(data.username, builder::ValidationLevel::Relaxed),
|
||||
builder::SafeValue::Text(data.username, builder::ValidationLevel::Standard),
|
||||
)?
|
||||
.set_value(
|
||||
"email".to_string(),
|
||||
builder::SafeValue::Text(data.email, builder::ValidationLevel::Relaxed),
|
||||
builder::SafeValue::Text(data.email, builder::ValidationLevel::Standard),
|
||||
)?
|
||||
.set_value(
|
||||
"password_hash".to_string(),
|
||||
builder::SafeValue::Text(
|
||||
bcrypt::generate_hash(&data.password)?,
|
||||
builder::ValidationLevel::Relaxed,
|
||||
),
|
||||
builder::SafeValue::Text(password_hash, builder::ValidationLevel::Relaxed),
|
||||
)?
|
||||
.set_value(
|
||||
"role".to_string(),
|
||||
builder::SafeValue::Enum(
|
||||
role,
|
||||
"user_role".to_string(),
|
||||
builder::ValidationLevel::Standard,
|
||||
),
|
||||
builder::SafeValue::Text(role, builder::ValidationLevel::Strict),
|
||||
)?;
|
||||
|
||||
sql.get_db().execute_query(&builder).await?;
|
||||
|
@ -47,17 +47,19 @@ pub struct SqlConfig {
|
||||
pub user: String,
|
||||
pub password: String,
|
||||
pub db_name: String,
|
||||
pub db_prefix:String,
|
||||
}
|
||||
|
||||
impl Default for SqlConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
db_type: "postgresql".to_string(),
|
||||
address: "localhost".to_string(),
|
||||
port: 5432,
|
||||
user: "postgres".to_string(),
|
||||
password: "postgres".to_string(),
|
||||
db_type: "sqllite".to_string(),
|
||||
address: "".to_string(),
|
||||
port: 0,
|
||||
user: "".to_string(),
|
||||
password: "".to_string(),
|
||||
db_name: "echoes".to_string(),
|
||||
db_prefix: "echoes_".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,12 +3,12 @@ mod common;
|
||||
mod storage;
|
||||
mod api;
|
||||
|
||||
use storage::sql;
|
||||
use crate::common::config;
|
||||
use common::error::{CustomErrorInto, CustomResult};
|
||||
use rocket::Shutdown;
|
||||
use std::sync::Arc;
|
||||
use storage::sql;
|
||||
use tokio::sync::Mutex;
|
||||
use crate::common::config;
|
||||
pub struct AppState {
|
||||
db: Arc<Mutex<Option<sql::Database>>>,
|
||||
shutdown: Arc<Mutex<Option<Shutdown>>>,
|
||||
@ -25,11 +25,7 @@ impl AppState {
|
||||
}
|
||||
|
||||
pub async fn sql_get(&self) -> CustomResult<sql::Database> {
|
||||
self.db
|
||||
.lock()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or_else(|| "数据库未连接".into_custom_error())
|
||||
self.db.lock().await.clone().ok_or_else(|| "数据库未连接".into_custom_error())
|
||||
}
|
||||
|
||||
pub async fn sql_link(&self, config: &config::SqlConfig) -> CustomResult<()> {
|
||||
@ -37,57 +33,53 @@ impl AppState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
pub async fn set_shutdown(&self, shutdown: Shutdown) {
|
||||
*self.shutdown.lock().await = Some(shutdown);
|
||||
}
|
||||
|
||||
pub async fn trigger_restart(&self) -> CustomResult<()> {
|
||||
*self.restart_progress.lock().await = true;
|
||||
self.shutdown
|
||||
.lock()
|
||||
.await
|
||||
.take()
|
||||
.ok_or_else(|| "未能获取rocket的shutdown".into_custom_error())?
|
||||
.notify();
|
||||
self.shutdown.lock().await.take().ok_or_else(|| "未能获取rocket的shutdown".into_custom_error())?.notify();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::main]
|
||||
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 rocket_config = rocket::Config::figment()
|
||||
.merge(("address", config.address))
|
||||
.merge(("port", config.port));
|
||||
let rocket_config = rocket::Config::figment().merge(("address", config.address)).merge(("port", config.port));
|
||||
|
||||
let mut rocket_builder = rocket::build()
|
||||
.configure(rocket_config)
|
||||
.manage(state.clone());
|
||||
let mut rocket_builder = rocket::build().configure(rocket_config).manage(state.clone());
|
||||
|
||||
if !config.info.install {
|
||||
rocket_builder = rocket_builder.mount("/", rocket::routes![api::setup::install]);
|
||||
if !config.init.sql {
|
||||
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 {
|
||||
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()).mount("/config", api::configure_routes());
|
||||
}
|
||||
|
||||
let rocket = rocket_builder.ignite().await?;
|
||||
|
||||
rocket
|
||||
.state::<Arc<AppState>>()
|
||||
.ok_or_else(|| "未能获取AppState".into_custom_error())?
|
||||
.set_shutdown(rocket.shutdown())
|
||||
.await;
|
||||
rocket.state::<Arc<AppState>>().ok_or_else(|| "未能获取AppState".into_custom_error())?.set_shutdown(rocket.shutdown()).await;
|
||||
|
||||
rocket.launch().await?;
|
||||
|
||||
if *state.restart_progress.lock().await {
|
||||
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);
|
||||
|
@ -2,16 +2,16 @@ use crate::common::error::{CustomErrorInto, CustomResult};
|
||||
use chrono::{DateTime, Utc};
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::Hash;
|
||||
use uuid::Uuid;
|
||||
use crate::sql::schema::DatabaseType;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Copy, Serialize)]
|
||||
pub enum ValidationLevel {
|
||||
Strict,
|
||||
Standard,
|
||||
Relaxed,
|
||||
Raw,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@ -28,6 +28,7 @@ impl Default for TextValidator {
|
||||
(ValidationLevel::Strict, 100),
|
||||
(ValidationLevel::Standard, 1000),
|
||||
(ValidationLevel::Relaxed, 100000),
|
||||
(ValidationLevel::Raw, usize::MAX),
|
||||
]);
|
||||
|
||||
let level_allowed_chars = HashMap::from([
|
||||
@ -43,6 +44,7 @@ impl Default for TextValidator {
|
||||
'}', '@', '#', '$', '%', '^', '&', '*', '+', '=', '<', '>', '/', '\\',
|
||||
],
|
||||
),
|
||||
(ValidationLevel::Raw, vec![]),
|
||||
]);
|
||||
|
||||
TextValidator {
|
||||
@ -74,6 +76,9 @@ impl Default for TextValidator {
|
||||
|
||||
impl TextValidator {
|
||||
pub fn validate(&self, text: &str, level: ValidationLevel) -> CustomResult<()> {
|
||||
if level == ValidationLevel::Raw {
|
||||
return self.validate_sql_patterns(text);
|
||||
}
|
||||
let max_length = self
|
||||
.level_max_lengths
|
||||
.get(&level)
|
||||
@ -140,6 +145,9 @@ impl TextValidator {
|
||||
pub fn validate_strict(&self, text: &str) -> CustomResult<()> {
|
||||
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> {
|
||||
self.validate_relaxed(text)?;
|
||||
@ -155,77 +163,31 @@ pub enum SafeValue {
|
||||
Float(f64),
|
||||
Text(String, ValidationLevel),
|
||||
DateTime(DateTime<Utc>),
|
||||
Uuid(Uuid),
|
||||
Binary(Vec<u8>),
|
||||
Array(Vec<SafeValue>),
|
||||
Json(JsonValue),
|
||||
Enum(String, String, ValidationLevel),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SafeValue {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
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 {
|
||||
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> {
|
||||
let sql_type = match self {
|
||||
SafeValue::Null => "NULL",
|
||||
SafeValue::Bool(_) => "boolean",
|
||||
SafeValue::Integer(_) => "bigint",
|
||||
SafeValue::Float(_) => "double precision",
|
||||
SafeValue::Text(_, _) => "text",
|
||||
SafeValue::DateTime(_) => "timestamp with time zone",
|
||||
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('\'', "''"));
|
||||
}
|
||||
SafeValue::Bool(_) => "BOOLEAN",
|
||||
SafeValue::Integer(_) => "INTEGER",
|
||||
SafeValue::Float(_) => "REAL",
|
||||
SafeValue::Text(_, _) => "TEXT",
|
||||
SafeValue::DateTime(_) => "TEXT",
|
||||
};
|
||||
Ok(sql_type.to_string())
|
||||
}
|
||||
@ -233,37 +195,27 @@ impl SafeValue {
|
||||
pub fn to_sql_string(&self) -> CustomResult<String> {
|
||||
match self {
|
||||
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::Float(f) => Ok(f.to_string()),
|
||||
SafeValue::Text(s, level) => {
|
||||
TextValidator::default().validate(s, *level)?;
|
||||
Ok(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())
|
||||
Ok(format!("{}", s.replace('\'', "''")))
|
||||
}
|
||||
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) {
|
||||
Ok("NULL".to_string())
|
||||
} else {
|
||||
Ok(format!("${}::{}", param_index, self.get_sql_type()?))
|
||||
return Ok("NULL".to_string());
|
||||
}
|
||||
|
||||
// 根据数据库类型返回不同的参数占位符
|
||||
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,
|
||||
IsNull,
|
||||
IsNotNull,
|
||||
JsonContains,
|
||||
JsonExists,
|
||||
}
|
||||
|
||||
impl Operator {
|
||||
fn as_str(&self) -> &'static str {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Operator::Eq => "=",
|
||||
Operator::Ne => "!=",
|
||||
@ -322,17 +272,15 @@ impl Operator {
|
||||
Operator::In => "IN",
|
||||
Operator::IsNull => "IS NULL",
|
||||
Operator::IsNotNull => "IS NOT NULL",
|
||||
Operator::JsonContains => "@>",
|
||||
Operator::JsonExists => "?",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Condition {
|
||||
field: Identifier,
|
||||
operator: Operator,
|
||||
value: Option<SafeValue>,
|
||||
pub field: Identifier,
|
||||
pub operator: Operator,
|
||||
pub value: Option<SafeValue>,
|
||||
}
|
||||
|
||||
impl Condition {
|
||||
@ -362,10 +310,11 @@ pub struct QueryBuilder {
|
||||
order_by: Option<Identifier>,
|
||||
limit: Option<i32>,
|
||||
offset: Option<i32>,
|
||||
db_type: DatabaseType,
|
||||
}
|
||||
|
||||
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 {
|
||||
operation,
|
||||
table: Identifier::new(table)?,
|
||||
@ -375,6 +324,7 @@ impl QueryBuilder {
|
||||
order_by: None,
|
||||
limit: None,
|
||||
offset: None,
|
||||
db_type,
|
||||
})
|
||||
}
|
||||
|
||||
@ -438,7 +388,7 @@ impl QueryBuilder {
|
||||
if matches!(value, SafeValue::Null) {
|
||||
placeholders.push("NULL".to_string());
|
||||
} 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());
|
||||
}
|
||||
}
|
||||
@ -458,11 +408,13 @@ impl QueryBuilder {
|
||||
|
||||
let mut updates = Vec::new();
|
||||
for (field, value) in &self.values {
|
||||
let set_sql = format!(
|
||||
"{} = {}",
|
||||
field.as_str(),
|
||||
value.to_param_sql(params.len() + 1)?
|
||||
);
|
||||
let placeholder = if matches!(value, SafeValue::Null) {
|
||||
"NULL".to_string()
|
||||
} else {
|
||||
value.to_param_sql(params.len() + 1, self.db_type)?
|
||||
};
|
||||
|
||||
let set_sql = format!("{} = {}", field.as_str(), placeholder);
|
||||
if !matches!(value, SafeValue::Null) {
|
||||
params.push(value.clone());
|
||||
}
|
||||
@ -542,11 +494,17 @@ impl QueryBuilder {
|
||||
) -> CustomResult<String> {
|
||||
match &condition.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!(
|
||||
"{} {} {}",
|
||||
condition.field.as_str(),
|
||||
condition.operator.as_str(),
|
||||
value.to_param_sql(param_index)?
|
||||
placeholder
|
||||
);
|
||||
if !matches!(value, SafeValue::Null) {
|
||||
params.push(value.clone());
|
||||
|
@ -1,9 +1,14 @@
|
||||
mod postgresql;
|
||||
mod mysql;
|
||||
mod sqllite;
|
||||
pub mod builder;
|
||||
mod schema;
|
||||
|
||||
use crate::config;
|
||||
use crate::common::error::{CustomErrorInto, CustomResult};
|
||||
use async_trait::async_trait;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
pub mod builder;
|
||||
use schema::DatabaseType;
|
||||
|
||||
#[async_trait]
|
||||
pub trait DatabaseTrait: Send + Sync {
|
||||
@ -22,6 +27,8 @@ pub trait DatabaseTrait: Send + Sync {
|
||||
#[derive(Clone)]
|
||||
pub struct Database {
|
||||
pub db: Arc<Box<dyn DatabaseTrait>>,
|
||||
pub prefix: Arc<String>,
|
||||
pub db_type: Arc<String>
|
||||
}
|
||||
|
||||
impl Database {
|
||||
@ -29,22 +36,45 @@ impl Database {
|
||||
&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> {
|
||||
let db = match database.db_type.as_str() {
|
||||
"postgresql" => postgresql::Postgresql::connect(database).await?,
|
||||
let db: Box<dyn DatabaseTrait> = match database.db_type.as_str() {
|
||||
"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()),
|
||||
};
|
||||
|
||||
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<()> {
|
||||
match database.db_type.as_str() {
|
||||
"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()),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
}
|
||||
|
110
backend/src/storage/sql/mysql.rs
Normal file
110
backend/src/storage/sql/mysql.rs
Normal 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())
|
||||
}
|
||||
|
||||
}
|
@ -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::common::error::CustomResult;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_json::Value;
|
||||
use sqlx::{Column, Executor, PgPool, Row, TypeInfo};
|
||||
use std::collections::HashMap;
|
||||
use std::{env, fs};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Postgresql {
|
||||
pool: PgPool,
|
||||
@ -14,13 +18,11 @@ pub struct Postgresql {
|
||||
#[async_trait]
|
||||
impl DatabaseTrait for Postgresql {
|
||||
async fn initialization(db_config: config::SqlConfig) -> CustomResult<()> {
|
||||
let path = env::current_dir()?
|
||||
.join("src")
|
||||
.join("storage")
|
||||
.join("sql")
|
||||
.join("postgresql")
|
||||
.join("schema.sql");
|
||||
let grammar = fs::read_to_string(&path)?;
|
||||
let db_prefix = SafeValue::Text(
|
||||
format!("{}", db_config.db_prefix),
|
||||
builder::ValidationLevel::Strict,
|
||||
);
|
||||
let grammar = schema::generate_schema(schema::DatabaseType::PostgreSQL, db_prefix)?;
|
||||
|
||||
let connection_str = format!(
|
||||
"postgres://{}:{}@{}:{}",
|
||||
@ -70,7 +72,14 @@ impl DatabaseTrait for Postgresql {
|
||||
let mut sqlx_query = sqlx::query(&query);
|
||||
|
||||
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?;
|
||||
@ -92,7 +101,6 @@ impl DatabaseTrait for Postgresql {
|
||||
.unwrap_or_else(|| 0.into()),
|
||||
),
|
||||
"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()),
|
||||
};
|
||||
(col.name().to_string(), value)
|
||||
@ -102,3 +110,9 @@ impl DatabaseTrait for Postgresql {
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Postgresql {
|
||||
fn get_sdb(&self){
|
||||
let a=self.pool;
|
||||
}
|
||||
}
|
@ -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
|
||||
);
|
812
backend/src/storage/sql/schema.rs
Normal file
812
backend/src/storage/sql/schema.rs
Normal 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)
|
||||
}
|
107
backend/src/storage/sql/sqllite.rs
Normal file
107
backend/src/storage/sql/sqllite.rs
Normal 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
10
frontend/app/env.d.ts
vendored
@ -7,12 +7,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_SERVER_API: string; // 用于访问API的基础URL
|
||||
readonly VITE_ADDRESS: string; // 前端地址
|
||||
readonly VITE_PORT: number; // 前端系统端口
|
||||
VITE_USERNAME: string; // 前端账号名称
|
||||
VITE_PASSWORD: string; // 前端账号密码
|
||||
VITE_INIT_STATUS: boolean; // 系统是否进行安装
|
||||
readonly VITE_INIT_STATUS: string;
|
||||
readonly VITE_SERVER_API: string;
|
||||
readonly VITE_PORT: string;
|
||||
readonly VITE_ADDRESS: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
@ -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 {
|
||||
currentStep: number;
|
||||
setCurrentStep: (step: number) => void;
|
||||
@ -21,30 +21,22 @@ const StepContainer: React.FC<{ title: string; children: React.ReactNode }> = ({
|
||||
title,
|
||||
children,
|
||||
}) => (
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<h2 className="text-2xl font-semibold text-custom-title-light dark:text-custom-title-dark mb-5">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h2 className="text-xl font-medium text-custom-title-light dark:text-custom-title-dark mb-6 px-4">
|
||||
{title}
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 通用的导航按钮组件
|
||||
const NavigationButtons: React.FC<StepProps> = ({ onNext, onPrev }) => (
|
||||
<div className="flex gap-4 mt-6">
|
||||
{onPrev && (
|
||||
<button
|
||||
onClick={onPrev}
|
||||
className="px-6 py-2 rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors"
|
||||
>
|
||||
上一步
|
||||
</button>
|
||||
)}
|
||||
const NavigationButtons: React.FC<StepProps> = ({ onNext }) => (
|
||||
<div className="flex justify-end mt-4">
|
||||
<button
|
||||
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>
|
||||
@ -58,17 +50,17 @@ const InputField: React.FC<{
|
||||
defaultValue?: string | number;
|
||||
hint?: string;
|
||||
}> = ({ label, name, defaultValue, hint }) => (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xl text-custom-title-light dark:text-custom-title-dark mb-2">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-base font-medium text-custom-title-light dark:text-custom-title-dark mb-2">
|
||||
{label}
|
||||
</h3>
|
||||
<input
|
||||
name={name}
|
||||
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 && (
|
||||
<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}
|
||||
</p>
|
||||
)}
|
||||
@ -78,7 +70,7 @@ const InputField: React.FC<{
|
||||
const Introduction: React.FC<StepProps> = ({ onNext }) => (
|
||||
<StepContainer title="安装说明">
|
||||
<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
|
||||
</p>
|
||||
<NavigationButtons onNext={onNext} />
|
||||
@ -86,22 +78,24 @@ const Introduction: React.FC<StepProps> = ({ onNext }) => (
|
||||
</StepContainer>
|
||||
);
|
||||
|
||||
const DatabaseConfig: React.FC<StepProps> = ({ onNext, onPrev }) => {
|
||||
const DatabaseConfig: React.FC<StepProps> = ({ onNext }) => {
|
||||
const [dbType, setDbType] = useState("postgresql");
|
||||
|
||||
return (
|
||||
<StepContainer title="数据库配置">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<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>
|
||||
<select
|
||||
value={dbType}
|
||||
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="mysql">MySQL</option>
|
||||
<option value="sqllite">SQLite</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@ -113,6 +107,12 @@ const DatabaseConfig: React.FC<StepProps> = ({ onNext, onPrev }) => {
|
||||
defaultValue="localhost"
|
||||
hint="通常使用 localhost"
|
||||
/>
|
||||
<InputField
|
||||
label="数据库前缀"
|
||||
name="db_prefix"
|
||||
defaultValue="echoec_"
|
||||
hint="通常使用 echoec_"
|
||||
/>
|
||||
<InputField
|
||||
label="端口"
|
||||
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>
|
||||
</StepContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminConfig: React.FC<StepProps> = ({ onNext, onPrev }) => (
|
||||
const AdminConfig: React.FC<StepProps> = ({ onNext }) => (
|
||||
<StepContainer title="创建管理员账号">
|
||||
<div className="space-y-6">
|
||||
<InputField label="用户名" name="admin_username" />
|
||||
<InputField label="密码" name="admin_password" />
|
||||
<InputField label="邮箱" name="admin_email" />
|
||||
<NavigationButtons onNext={onNext} onPrev={onPrev} />
|
||||
<NavigationButtons onNext={onNext} />
|
||||
</div>
|
||||
</StepContainer>
|
||||
);
|
||||
@ -163,17 +215,59 @@ const SetupComplete: React.FC = () => (
|
||||
</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 (
|
||||
<div className="min-h-screen w-full bg-custom-bg-light dark:bg-custom-bg-dark">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-custom-title-light dark:text-custom-title-dark mb-4">
|
||||
Echoes
|
||||
</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
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 ${
|
||||
isVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
{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 }}>
|
||||
{currentStep === 1 && (
|
||||
<Introduction onNext={() => setCurrentStep(currentStep + 1)} />
|
||||
@ -181,13 +275,11 @@ export default function SetupPage() {
|
||||
{currentStep === 2 && (
|
||||
<DatabaseConfig
|
||||
onNext={() => setCurrentStep(currentStep + 1)}
|
||||
onPrev={() => setCurrentStep(currentStep - 1)}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 3 && (
|
||||
<AdminConfig
|
||||
onNext={() => setCurrentStep(currentStep + 1)}
|
||||
onPrev={() => setCurrentStep(currentStep - 1)}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 4 && <SetupComplete />}
|
||||
|
12
frontend/core/template.ts
Normal file
12
frontend/core/template.ts
Normal 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;
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Configuration, PathDescription } from "common/serializableType";
|
||||
import { ApiService } from "./api";
|
||||
import { ApiService } from "core/api";
|
||||
import { Template } from "core/template";
|
||||
|
||||
export interface ThemeConfig {
|
||||
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 {
|
||||
private static instance: ThemeService;
|
||||
@ -56,7 +46,7 @@ export class ThemeService {
|
||||
public async getCurrentTheme(): Promise<void> {
|
||||
try {
|
||||
const themeConfig = await this.api.request<ThemeConfig>(
|
||||
"/theme/current",
|
||||
"/theme",
|
||||
{ method: "GET" },
|
||||
);
|
||||
this.currentTheme = themeConfig;
|
||||
@ -70,10 +60,10 @@ export class ThemeService {
|
||||
return this.currentTheme;
|
||||
}
|
||||
|
||||
public async updateThemeConfig(config: Partial<ThemeConfig>): Promise<void> {
|
||||
public async updateThemeConfig(config: Partial<ThemeConfig>,name:string): Promise<void> {
|
||||
try {
|
||||
const updatedConfig = await this.api.request<ThemeConfig>(
|
||||
"/theme/config",
|
||||
`/theme/`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
|
@ -17,7 +17,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
routes: (defineRoutes) => {
|
||||
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: "catch-all-route" });
|
||||
} else {
|
||||
@ -30,7 +30,7 @@ export default defineConfig(({ mode }) => {
|
||||
tsconfigPaths(),
|
||||
],
|
||||
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_PORT": JSON.stringify(22100),
|
||||
"import.meta.env.VITE_ADDRESS": JSON.stringify("localhost"),
|
||||
|
Loading…
Reference in New Issue
Block a user