Init
This commit is contained in:
commit
061de4a1ee
|
@ -0,0 +1,7 @@
|
||||||
|
WEB.ADDR=0.0.0.0:9527
|
||||||
|
|
||||||
|
MYSQL.MAX_CONS=5
|
||||||
|
MYSQL.DSN=mysql://root:mysql123!%40%23@127.0.0.1:3306/study
|
||||||
|
|
||||||
|
# 日志级别
|
||||||
|
RUST_LOG=DEBUG
|
|
@ -0,0 +1 @@
|
||||||
|
/target
|
|
@ -0,0 +1,8 @@
|
||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="EMPTY_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="DuplicatedCode" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||||
|
<Languages>
|
||||||
|
<language minSize="50" name="Rust" />
|
||||||
|
</Languages>
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/axum-sqlx.iml" filepath="$PROJECT_DIR$/.idea/axum-sqlx.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,19 @@
|
||||||
|
[package]
|
||||||
|
name = "axum-sqlx"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
askama = "0.12.1"
|
||||||
|
axum = "0.7.4"
|
||||||
|
chrono = { version = "0.4.33", features = ["serde"] }
|
||||||
|
config = "0.13.4"
|
||||||
|
dotenvy = "0.15.7"
|
||||||
|
serde = { version = "1.0.196", features = ["derive"] }
|
||||||
|
serde_json = "1.0.113"
|
||||||
|
sqlx = { version = "0.7.3", features = ["mysql", "runtime-tokio", "tls-rustls", "chrono", "macros"] }
|
||||||
|
tokio = { version = "1.35.1", features = ["full"] }
|
||||||
|
tracing = "0.1.40"
|
||||||
|
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
|
@ -0,0 +1,33 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
use crate::{err::Error, Result};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct MysqlConfig {
|
||||||
|
pub dsn: String,
|
||||||
|
pub max_cons: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct WebConfig {
|
||||||
|
pub addr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub mysql: MysqlConfig,
|
||||||
|
pub web: WebConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
pub fn from_env() -> Result<Self> {
|
||||||
|
config::Config::builder()
|
||||||
|
.add_source(config::Environment::default())
|
||||||
|
.build()
|
||||||
|
.map_err(Error::from)?
|
||||||
|
.try_deserialize()
|
||||||
|
.map_err(Error::from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
[web]
|
||||||
|
addr = "0.0.0.0:9527"
|
||||||
|
|
||||||
|
[mysql]
|
||||||
|
max_cons = 5
|
||||||
|
dns = "mysql://root:mysql123!%40%23@127.0.0.1:3306/study"
|
|
@ -0,0 +1,155 @@
|
||||||
|
use crate::{err::Error, model, Result};
|
||||||
|
|
||||||
|
use super::{DEFAULT_PAGE_SIZE, Paginate};
|
||||||
|
|
||||||
|
pub async fn list(
|
||||||
|
conn: &sqlx::MySqlPool,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<Paginate<Vec<model::member::Member>>> {
|
||||||
|
let count: (i64, ) = sqlx::query_as("select count(*) from member")
|
||||||
|
.fetch_one(conn)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?;
|
||||||
|
|
||||||
|
let mut q = sqlx::QueryBuilder::new("SELECT * FROM member ORDER BY id DESC");
|
||||||
|
q.push(" LIMIT ")
|
||||||
|
.push_bind(DEFAULT_PAGE_SIZE)
|
||||||
|
.push(" OFFSET ")
|
||||||
|
.push_bind(page * DEFAULT_PAGE_SIZE);
|
||||||
|
|
||||||
|
let data = q
|
||||||
|
.build_query_as()
|
||||||
|
.fetch_all(conn)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?;
|
||||||
|
|
||||||
|
Ok(Paginate::new(count.0 as u32, page, data))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn find(conn: &sqlx::MySqlPool, id: u32) -> Result<Option<model::member::Member>> {
|
||||||
|
let m = sqlx::query_as("select * from member where id=?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(conn)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?;
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn exists(conn: &sqlx::MySqlPool, name: &str, id: Option<u32>) -> Result<bool> {
|
||||||
|
let mut q = sqlx::QueryBuilder::new("SELECT COUNT(*) FROM member WHERE name=");
|
||||||
|
q.push_bind(name);
|
||||||
|
|
||||||
|
if let Some(id) = id {
|
||||||
|
q.push(" AND id!=").push_bind(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
let count: (i64, ) = q.build_query_as().fetch_one(conn).await.map_err(Error::from)?;
|
||||||
|
|
||||||
|
Ok(count.0 > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn add(
|
||||||
|
conn: &sqlx::MySqlPool,
|
||||||
|
m: &model::member::Member,
|
||||||
|
) -> Result<u32> {
|
||||||
|
if exists(conn, &m.name, None).await? {
|
||||||
|
return Err(Error::exists("同名的会员已存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = sqlx::query(
|
||||||
|
"INSERT INTO `member` (name, dateline, balance, types,is_del) VALUES(?,?,?,?,?)",
|
||||||
|
)
|
||||||
|
.bind(&m.name)
|
||||||
|
.bind(&m.dateline)
|
||||||
|
.bind(&m.balance)
|
||||||
|
.bind(&m.types)
|
||||||
|
.bind(&m.is_del)
|
||||||
|
.execute(conn)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?
|
||||||
|
.last_insert_id();
|
||||||
|
|
||||||
|
Ok(id as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn edit(conn: &sqlx::MySqlPool, m: &model::member::Member) -> Result<u64> {
|
||||||
|
if exists(conn, &m.name, Some(m.id)).await? {
|
||||||
|
return Err(Error::not_found("同名的会员已存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let aff = sqlx::query("UPDATE `member` SET name=?,balance=?,types=? WHERE id=?")
|
||||||
|
.bind(&m.name)
|
||||||
|
.bind(&m.balance)
|
||||||
|
.bind(&m.types)
|
||||||
|
.bind(&m.id)
|
||||||
|
.execute(conn)
|
||||||
|
.await.map_err(Error::from)?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
Ok(aff)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn del(conn: &sqlx::MySqlPool, id: u32) -> Result<u64> {
|
||||||
|
let aff = sqlx::query("update member set is_del=true where id=?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(conn)
|
||||||
|
.await.map_err(Error::from)?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
Ok(aff)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn real_del(conn: &sqlx::MySqlPool, id: u32) -> Result<u64> {
|
||||||
|
let aff = sqlx::query("delete from member where id=?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(conn)
|
||||||
|
.await.map_err(Error::from)?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
Ok(aff)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn tran(conn: &sqlx::MySqlPool, t: &model::member::Tran) -> Result<(u64, u64)> {
|
||||||
|
let mut tx = conn.begin().await.map_err(Error::from)?;
|
||||||
|
|
||||||
|
let from_query = sqlx::query("UPDATE member SET balance=balance-? WHERE name=? AND balance>=?")
|
||||||
|
.bind(&t.amount)
|
||||||
|
.bind(&t.from_member)
|
||||||
|
.bind(&t.amount)
|
||||||
|
.execute(&mut *tx) // https://github.com/launchbadge/sqlx/issues/2606
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let from_aff = match from_query {
|
||||||
|
Ok(r) => r.rows_affected(),
|
||||||
|
Err(err) => {
|
||||||
|
tx.rollback().await.map_err(Error::from)?;
|
||||||
|
return Err(Error::from(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if from_aff < 1 {
|
||||||
|
tx.rollback().await.map_err(Error::from)?;
|
||||||
|
return Err(Error::tran("转账失败,请检查转出账户是否有足够余额"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let to_aff = match sqlx::query("UPDATE member SET balance=balance+? WHERE name=?")
|
||||||
|
.bind(&t.amount)
|
||||||
|
.bind(&t.to_member)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => r.rows_affected(),
|
||||||
|
Err(err) => {
|
||||||
|
tx.rollback().await.map_err(Error::from)?;
|
||||||
|
return Err(Error::from(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tx.commit().await.map_err(Error::from)?;
|
||||||
|
|
||||||
|
Ok((from_aff, to_aff))
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
pub mod member;
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE: u32 = 30;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Paginate<T: Serialize> {
|
||||||
|
pub total: u32,
|
||||||
|
pub total_page: u32,
|
||||||
|
pub page: u32,
|
||||||
|
pub page_size: u32,
|
||||||
|
pub data: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Serialize> Paginate<T> {
|
||||||
|
pub fn new(total: u32, page: u32, data: T) -> Self {
|
||||||
|
let total_page = f64::ceil(total as f64 / DEFAULT_PAGE_SIZE as f64) as u32;
|
||||||
|
Self { total, total_page, page, page_size: DEFAULT_PAGE_SIZE, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_prev(&self) -> bool {
|
||||||
|
self.page > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_next(&self) -> bool {
|
||||||
|
self.page < self.last_page()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn last_page(&self) -> u32 {
|
||||||
|
self.total_page - 1
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,74 @@
|
||||||
|
use std::fmt::{Debug, Display, Formatter};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ErrorKind {
|
||||||
|
Database,
|
||||||
|
Config,
|
||||||
|
Template,
|
||||||
|
NotFound,
|
||||||
|
Exists,
|
||||||
|
Tran,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Error {
|
||||||
|
pub kind: ErrorKind,
|
||||||
|
pub message: String,
|
||||||
|
pub cause: Option<Box<dyn std::error::Error>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl axum::response::IntoResponse for Error {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
self.message.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
pub fn new(kind: ErrorKind, message: String, cause: Option<Box<dyn std::error::Error>>) -> Self {
|
||||||
|
Self { kind, message, cause }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_cause(cause: Box<dyn std::error::Error>, kind: ErrorKind) -> Self {
|
||||||
|
Self::new(kind, cause.to_string(), Some(cause))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn not_found(msg: &str) -> Self {
|
||||||
|
Self::new(ErrorKind::NotFound, msg.to_string(), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn exists(msg: &str) -> Self {
|
||||||
|
Self::new(ErrorKind::Exists, msg.to_string(), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tran(msg: &str) -> Self {
|
||||||
|
Self::new(ErrorKind::Tran, msg.to_string(), None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Error {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{:?}", self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for Error {
|
||||||
|
fn from(err: sqlx::Error) -> Self {
|
||||||
|
Self::with_cause(Box::new(err), ErrorKind::Database)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl From<config::ConfigError> for Error {
|
||||||
|
fn from(err: config::ConfigError) -> Self {
|
||||||
|
Self::with_cause(Box::new(err), ErrorKind::Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<askama::Error> for Error {
|
||||||
|
fn from(err: askama::Error) -> Self {
|
||||||
|
Self::with_cause(Box::new(err), ErrorKind::Template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::model::member::MemberTypes;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AddAndEdit {
|
||||||
|
pub name: String,
|
||||||
|
pub balance: u32,
|
||||||
|
pub types: MemberTypes,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Tran {
|
||||||
|
pub from_member: String,
|
||||||
|
pub to_member: String,
|
||||||
|
pub amount: u32,
|
||||||
|
}
|
|
@ -0,0 +1,171 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use askama::Template;
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::Form;
|
||||||
|
use axum::http::{header, HeaderMap, StatusCode};
|
||||||
|
use axum::response::Html;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{db::member, err::Error, form, model, model::state::AppState, Result, view};
|
||||||
|
|
||||||
|
fn get_conn(state: &AppState) -> Arc<sqlx::MySqlPool> {
|
||||||
|
state.pool.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn redirect(url: &str) -> Result<(StatusCode, HeaderMap, ())> {
|
||||||
|
let mut header = HeaderMap::new();
|
||||||
|
header.insert(header::LOCATION, url.parse().unwrap());
|
||||||
|
|
||||||
|
Ok((StatusCode::FOUND, header, ()))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PageQuery {
|
||||||
|
pub page: Option<u32>,
|
||||||
|
pub msg: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn index(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(q): Query<PageQuery>,
|
||||||
|
) -> Result<Html<String>> {
|
||||||
|
let conn = get_conn(&state);
|
||||||
|
|
||||||
|
let p = member::list(&conn, q.page.unwrap_or(0)).await?;
|
||||||
|
|
||||||
|
let tpl = view::Home { p, msg: q.msg };
|
||||||
|
let html = tpl.render().map_err(Error::from)?;
|
||||||
|
Ok(Html(html))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn detail(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<u32>,
|
||||||
|
) -> Result<Html<String>> {
|
||||||
|
let conn = get_conn(&state);
|
||||||
|
|
||||||
|
let m = member::find(&conn, id).await?;
|
||||||
|
|
||||||
|
match m {
|
||||||
|
None => Err(Error::not_found("不存在的会员")),
|
||||||
|
Some(m) => {
|
||||||
|
let tpl = view::Detail { m };
|
||||||
|
let html = tpl.render().map_err(Error::from)?;
|
||||||
|
Ok(Html(html))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn add_ui() -> Result<Html<String>> {
|
||||||
|
let tpl = view::Add;
|
||||||
|
let html = tpl.render().map_err(Error::from)?;
|
||||||
|
Ok(Html(html))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn add(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Form(frm): Form<form::AddAndEdit>,
|
||||||
|
) -> Result<(StatusCode, HeaderMap, ())> {
|
||||||
|
let conn = get_conn(&state);
|
||||||
|
|
||||||
|
member::add(&conn, &model::member::Member {
|
||||||
|
name: frm.name,
|
||||||
|
balance: frm.balance,
|
||||||
|
types: frm.types,
|
||||||
|
dateline: chrono::Local::now(),
|
||||||
|
..Default::default()
|
||||||
|
}).await?;
|
||||||
|
|
||||||
|
redirect("/?msg=会员添加成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn edit_ui(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<u32>,
|
||||||
|
) -> Result<Html<String>> {
|
||||||
|
let conn = get_conn(&state);
|
||||||
|
|
||||||
|
let m = member::find(&conn, id).await?;
|
||||||
|
|
||||||
|
match m {
|
||||||
|
Some(m) => {
|
||||||
|
let tpl = view::Edit { m };
|
||||||
|
let html = tpl.render().map_err(Error::from)?;
|
||||||
|
Ok(Html(html))
|
||||||
|
}
|
||||||
|
None => Err(Error::not_found("不存在的会员")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn edit(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<u32>,
|
||||||
|
Form(frm): Form<form::AddAndEdit>,
|
||||||
|
) -> Result<(StatusCode, HeaderMap, ())> {
|
||||||
|
let conn = get_conn(&state);
|
||||||
|
|
||||||
|
member::edit(&conn, &model::member::Member {
|
||||||
|
id,
|
||||||
|
name: frm.name,
|
||||||
|
balance: frm.balance,
|
||||||
|
types: frm.types,
|
||||||
|
..Default::default()
|
||||||
|
}).await?;
|
||||||
|
|
||||||
|
redirect("/?msg=会员修改成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn del(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<u32>,
|
||||||
|
) -> Result<(StatusCode, HeaderMap, ())> {
|
||||||
|
let conn = get_conn(&state);
|
||||||
|
|
||||||
|
member::del(&conn, id).await?;
|
||||||
|
|
||||||
|
redirect("/?msg=逻辑删除成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn real_del(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<u32>,
|
||||||
|
) -> Result<(StatusCode, HeaderMap, ())> {
|
||||||
|
let conn = get_conn(&state);
|
||||||
|
|
||||||
|
member::real_del(&conn, id).await?;
|
||||||
|
|
||||||
|
redirect("/?msg=物理删除成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn tran_ui() -> Result<Html<String>> {
|
||||||
|
let tpl = view::Tran;
|
||||||
|
let html = tpl.render().map_err(Error::from)?;
|
||||||
|
Ok(Html(html))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn tran(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Form(frm): Form<form::Tran>,
|
||||||
|
) -> Result<(StatusCode, HeaderMap, ())> {
|
||||||
|
let conn = get_conn(&state);
|
||||||
|
|
||||||
|
let aff = member::tran(&conn, &model::member::Tran {
|
||||||
|
from_member: frm.from_member,
|
||||||
|
to_member: frm.to_member,
|
||||||
|
amount: frm.amount,
|
||||||
|
}).await?;
|
||||||
|
|
||||||
|
tracing::debug!("{:?}", aff);
|
||||||
|
|
||||||
|
redirect("/?msg=转账成功")
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
pub mod config;
|
||||||
|
pub mod db;
|
||||||
|
pub mod err;
|
||||||
|
pub mod handler;
|
||||||
|
pub mod model;
|
||||||
|
pub mod view;
|
||||||
|
pub mod form;
|
||||||
|
pub type Result<T> = std::result::Result<T, crate::err::Error>;
|
|
@ -0,0 +1,46 @@
|
||||||
|
#![allow(unused, dead_code)]
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use axum::routing::get;
|
||||||
|
use sqlx::mysql::MySqlPoolOptions;
|
||||||
|
|
||||||
|
use axum_sqlx::{config::AppConfig, handler, model::state::AppState};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// 解析 .env 文件
|
||||||
|
dotenvy::dotenv().expect("解析.env文件失败");
|
||||||
|
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
let cfg = AppConfig::from_env()
|
||||||
|
.map_err(|e| tracing::error!("初始化配置失败:{}", e.to_string()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let pool = MySqlPoolOptions::new()
|
||||||
|
.max_connections(cfg.mysql.max_cons)
|
||||||
|
.connect(&cfg.mysql.dsn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| tracing::error!("数据库连接失败:{}", e.to_string()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(handler::index))
|
||||||
|
.route("/detail/:id", get(handler::detail))
|
||||||
|
.route("/add", get(handler::add_ui).post(handler::add))
|
||||||
|
.route("/edit/:id", get(handler::edit_ui).post(handler::edit))
|
||||||
|
.route("/del/:id", get(handler::del))
|
||||||
|
.route("/real_del/:id", get(handler::real_del))
|
||||||
|
.route("/tran", get(handler::tran_ui).post(handler::tran))
|
||||||
|
.with_state(AppState {
|
||||||
|
pool: Arc::new(pool)
|
||||||
|
});
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(&cfg.web.addr).await.unwrap();
|
||||||
|
|
||||||
|
tracing::info!("服务器运行于: {}", listener.local_addr().unwrap());
|
||||||
|
|
||||||
|
axum::serve(listener, app).await.unwrap()
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize, Serialize, sqlx::Type, Clone)]
|
||||||
|
#[repr(u8)]
|
||||||
|
pub enum MemberTypes {
|
||||||
|
#[default]
|
||||||
|
/// 普通会员
|
||||||
|
Normal,
|
||||||
|
/// 白银会员
|
||||||
|
Silver,
|
||||||
|
/// 黄金会员
|
||||||
|
Gold,
|
||||||
|
/// 钻石会员
|
||||||
|
Diamond,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for MemberTypes {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let s = match self {
|
||||||
|
&Self::Normal => String::from("普通会员"),
|
||||||
|
&Self::Silver => String::from("白银会员"),
|
||||||
|
&Self::Gold => String::from("黄金会员"),
|
||||||
|
&Self::Diamond => String::from("钻石会员"),
|
||||||
|
};
|
||||||
|
|
||||||
|
write!(f, "{s}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct Member {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub dateline: chrono::DateTime<chrono::Local>,
|
||||||
|
pub balance: u32,
|
||||||
|
pub types: MemberTypes,
|
||||||
|
pub is_del: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub struct Tran {
|
||||||
|
pub from_member: String,
|
||||||
|
pub to_member: String,
|
||||||
|
pub amount: u32,
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod member;
|
||||||
|
pub mod state;
|
|
@ -0,0 +1,7 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
// 最外层的state必须实现Clone
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub pool: Arc<sqlx::MySqlPool>,
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
use askama::Template;
|
||||||
|
use crate::{db::Paginate, model};
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "index.html")]
|
||||||
|
pub struct Home {
|
||||||
|
pub p: Paginate<Vec<model::member::Member>>,
|
||||||
|
pub msg: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "detail.html")]
|
||||||
|
pub struct Detail {
|
||||||
|
pub m: model::member::Member,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "add.html")]
|
||||||
|
pub struct Add;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "edit.html")]
|
||||||
|
pub struct Edit {
|
||||||
|
pub m: model::member::Member,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "tran.html")]
|
||||||
|
pub struct Tran;
|
|
@ -0,0 +1,22 @@
|
||||||
|
{% extends "./base.html" %} {%block title%}添加{%endblock%} {%block content%}
|
||||||
|
<form method="post" autocomplete="off" action="/add">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label">会员名称</label>
|
||||||
|
<input type="text" class="form-control" id="name" name="name" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="balance" class="form-label">账户余额</label>
|
||||||
|
<input type="number" class="form-control" id="balance" name="balance" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="types" class="form-label">会员类型</label>
|
||||||
|
<select class="form-select" name="types" id="types">
|
||||||
|
<option value="Normal">普通会员</option>
|
||||||
|
<option value="Silver">白银会员</option>
|
||||||
|
<option value="Gold">黄金会员</option>
|
||||||
|
<option value="Diamond">钻石会员</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">提交</button>
|
||||||
|
</form>
|
||||||
|
{%endblock%}
|
|
@ -0,0 +1,67 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-Hans">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>AXUM使用SQLx - AXUM中文网</title>
|
||||||
|
<link
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav
|
||||||
|
class="navbar bg-dark border-bottom border-bottom-dark navbar-expand-lg"
|
||||||
|
data-bs-theme="dark"
|
||||||
|
>
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="https://axum.rs" target="_blank">
|
||||||
|
<img
|
||||||
|
src="https://file.axum.rs/asset/logo.png"
|
||||||
|
alt="Logo"
|
||||||
|
width="30"
|
||||||
|
class="d-inline-block align-text-middle"
|
||||||
|
/>
|
||||||
|
AXUM.RS
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
class="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navbarSupportedContent"
|
||||||
|
aria-controls="navbarSupportedContent"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
>
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" aria-current="page" href="/">列表</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/add">添加</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/tran">转账</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container my-3">
|
||||||
|
<h1>{%block title%}{%endblock%}</h1>
|
||||||
|
<div>{%block content%}{%endblock%}</div>
|
||||||
|
</div>
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"
|
||||||
|
integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,25 @@
|
||||||
|
{% extends "./base.html" %} {%block title%}详情{%endblock%} {%block content%}
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<tr>
|
||||||
|
<th>会员名称</th>
|
||||||
|
<td>{{m.name}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>账户余额</th>
|
||||||
|
<td>{{m.balance}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>会员类型</th>
|
||||||
|
<td>{{m.types}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>加入时间</th>
|
||||||
|
<td>{{m.dateline}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="my-3 text-center">
|
||||||
|
<button class="btn btn-primary" onclick="history.back()">返回</button>
|
||||||
|
</div>
|
||||||
|
{%endblock%}
|
|
@ -0,0 +1,50 @@
|
||||||
|
{% extends "./base.html" %} {%block title%}修改{%endblock%} {%block content%}
|
||||||
|
<form method="post" autocomplete="off" action="/edit/{{m.id}}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label">会员名称</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
value="{{m.name}}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="balance" class="form-label">账户余额</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="form-control"
|
||||||
|
id="balance"
|
||||||
|
name="balance"
|
||||||
|
value="{{m.balance}}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="types" class="form-label">会员类型</label>
|
||||||
|
<select class="form-select" name="types" id="types">
|
||||||
|
{%match m.types %} {%when crate::model::member::MemberTypes::Normal%}
|
||||||
|
<option value="Normal" selected>普通会员</option>
|
||||||
|
{%else%}
|
||||||
|
<option value="Normal">普通会员</option>
|
||||||
|
{%endmatch%} {%match m.types %} {%when
|
||||||
|
crate::model::member::MemberTypes::Silver%}
|
||||||
|
<option value="Silver" selected>白银会员</option>
|
||||||
|
{%else%}
|
||||||
|
<option value="Silver">白银会员</option>
|
||||||
|
{%endmatch%} {%match m.types %} {%when
|
||||||
|
crate::model::member::MemberTypes::Gold%}
|
||||||
|
<option value="Gold" selected>黄金会员</option>
|
||||||
|
{%else%}
|
||||||
|
<option value="Gold">黄金会员</option>
|
||||||
|
{%endmatch%} {%match m.types %} {%when
|
||||||
|
crate::model::member::MemberTypes::Diamond%}
|
||||||
|
<option value="Diamond" selected>钻石会员</option>
|
||||||
|
{%else%}
|
||||||
|
<option value="Diamond">钻石会员</option>
|
||||||
|
{%endmatch%}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">提交</button>
|
||||||
|
</form>
|
||||||
|
{%endblock%}
|
|
@ -0,0 +1,94 @@
|
||||||
|
{% extends "./base.html" %} {%block title%}列表{%endblock%} {%block content%}
|
||||||
|
{%match msg%} {%when Some with (msg)%}
|
||||||
|
<div class="alert alert-success" role="alert">{{msg}}</div>
|
||||||
|
{%when None%} {%endmatch%}
|
||||||
|
|
||||||
|
<div class="my-3">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">#</th>
|
||||||
|
<th scope="col">名称</th>
|
||||||
|
<th scope="col">余额</th>
|
||||||
|
<th scope="col">类型</th>
|
||||||
|
<th scope="col">时间</th>
|
||||||
|
<th scope="col">已删除</th>
|
||||||
|
<th scope="col">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{%for m in p.data%}
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{{ m.id }}</th>
|
||||||
|
<td>{{ m.name }}</td>
|
||||||
|
<td>{{ m.balance }}</td>
|
||||||
|
<td>{{ m.types }}</td>
|
||||||
|
<td>{{ m.dateline }}</td>
|
||||||
|
<td>
|
||||||
|
{% if m.is_del %}
|
||||||
|
<span class="badge text-bg-danger">是</span> {%else%}
|
||||||
|
<span class="badge text-bg-success">否</span> {%endif%}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a class="btn btn-secondary btn-sm" href="/detail/{{m.id}}">详情</a>
|
||||||
|
<a class="btn btn-primary btn-sm" href="/edit/{{m.id}}">修改</a>
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-danger dropdown-toggle"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="dropdown-item"
|
||||||
|
href="/del/{{m.id}}"
|
||||||
|
onclick="if(!confirm('确定删除?')){return false;}"
|
||||||
|
>逻辑删除</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="dropdown-item"
|
||||||
|
href="/real_del/{{m.id}}"
|
||||||
|
onclick="if(!confirm('确定删除?')){return false;}"
|
||||||
|
>物理删除</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{%endfor%}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if p.total_page > 0 %}
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination">
|
||||||
|
<li class="page-item">
|
||||||
|
{%if p.has_prev()%}
|
||||||
|
<a class="page-link" href="?page={{p.page-1}}">上一页</a>
|
||||||
|
{%else%}
|
||||||
|
<a class="page-link" href="?page=0">上一页</a>
|
||||||
|
{%endif%}
|
||||||
|
</li>
|
||||||
|
{%for i in 0..p.total_page%}
|
||||||
|
<li class="page-item{%if p.page==i%} active{%endif%}">
|
||||||
|
<a class="page-link" href="?page={{i}}">{{i+1}}</a>
|
||||||
|
</li>
|
||||||
|
{%endfor%}
|
||||||
|
<li class="page-item">
|
||||||
|
{%if p.has_next()%}
|
||||||
|
<a class="page-link" href="?page={{p.page+1}}">下一页</a>
|
||||||
|
{%else%}
|
||||||
|
<a class="page-link" href="?page={{p.last_page()}}">下一页</a>
|
||||||
|
{%endif%}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{%endif%} {%endblock%}
|
|
@ -0,0 +1,31 @@
|
||||||
|
{% extends "./base.html" %} {%block title%}转账{%endblock%} {%block content%}
|
||||||
|
<form method="post" autocomplete="off" action="/tran">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="from_member" class="form-label">转账人</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="from_member"
|
||||||
|
name="from_member"
|
||||||
|
placeholder="输入转账人的会员名称,比如:张三"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="to_member" class="form-label">收款人</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="to_member"
|
||||||
|
name="to_member"
|
||||||
|
placeholder="输入收款人的会员名称,比如:李四"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="amount" class="form-label">转账金额</label>
|
||||||
|
<input type="number" class="form-control" id="amount" name="amount" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">提交</button>
|
||||||
|
</form>
|
||||||
|
{%endblock%}
|
Loading…
Reference in New Issue