This commit is contained in:
胡天 2024-02-01 18:16:16 +08:00
commit 061de4a1ee
28 changed files with 3521 additions and 0 deletions

7
.env Normal file
View File

@ -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

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

8
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

11
.idea/axum-sqlx.iml Normal file
View File

@ -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>

View File

@ -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>

8
.idea/modules.xml Normal file
View File

@ -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>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

2533
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

19
Cargo.toml Normal file
View File

@ -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"] }

33
src/config.rs Normal file
View File

@ -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)
}
}

6
src/config/default.toml Normal file
View File

@ -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"

155
src/db/member.rs Normal file
View File

@ -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))
}

33
src/db/mod.rs Normal file
View File

@ -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
}
}

74
src/err.rs Normal file
View File

@ -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)
}
}

17
src/form.rs Normal file
View File

@ -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,
}

171
src/handler.rs Normal file
View File

@ -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=转账成功")
}

8
src/lib.rs Normal file
View File

@ -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>;

46
src/main.rs Normal file
View File

@ -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()
}

48
src/model/member.rs Normal file
View File

@ -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,
}

2
src/model/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod member;
pub mod state;

7
src/model/state.rs Normal file
View File

@ -0,0 +1,7 @@
use std::sync::Arc;
// 最外层的state必须实现Clone
#[derive(Clone)]
pub struct AppState {
pub pool: Arc<sqlx::MySqlPool>,
}

29
src/view.rs Normal file
View File

@ -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;

22
templates/add.html Normal file
View File

@ -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%}

67
templates/base.html Normal file
View File

@ -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>

25
templates/detail.html Normal file
View File

@ -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%}

50
templates/edit.html Normal file
View File

@ -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%}

94
templates/index.html Normal file
View File

@ -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%}

31
templates/tran.html Normal file
View File

@ -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%}