This commit is contained in:
2025-08-28 00:55:35 +08:00
commit 410f54a65e
93 changed files with 9863 additions and 0 deletions

50
.gitignore vendored Normal file
View File

@ -0,0 +1,50 @@
# ---> Rust
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/git
**/.DS_Store
**/node_modules
**/dist
**/dist.zip
**/docker/dist
# local env files
**/.env.local
**/.env.*.local
# Log files
**/npm-debug.log*
**/yarn-debug.log*
**/yarn-error.log*
**/pnpm-debug.log*
# Editor directories and files
**/.idea
**/.vscode
**/*.suo
**/*.ntvs*
**/*.njsproj
**/*.sln
**/*.sw?
# lock
**/package-lock.json
**/yarn.lock
**/pnpm-lock.yaml

10
backend/.env Normal file
View File

@ -0,0 +1,10 @@
RUST_LOG=info,udmin=debug
APP_ENV=development
APP_HOST=0.0.0.0
APP_PORT=8080
DB_URL=mysql://root:123456@127.0.0.1:3306/udmin
JWT_SECRET=dev_secret_change_me
JWT_ISS=udmin
JWT_ACCESS_EXP_SECS=1800
JWT_REFRESH_EXP_SECS=1209600
CORS_ALLOW_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175

10
backend/.env.example Normal file
View File

@ -0,0 +1,10 @@
RUST_LOG=info,udmin=debug
APP_ENV=development
APP_HOST=0.0.0.0
APP_PORT=8080
DB_URL=mysql://root:123456@127.0.0.1:3306/udmin
JWT_SECRET=please_change_me
JWT_ISS=udmin
JWT_ACCESS_EXP_SECS=1800
JWT_REFRESH_EXP_SECS=1209600
CORS_ALLOW_ORIGINS=http://localhost:5173

4144
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

39
backend/Cargo.toml Normal file
View File

@ -0,0 +1,39 @@
[package]
name = "udmin"
version = "0.1.0"
edition = "2024" # ✅ 升级到最新 Rust Edition
[dependencies]
axum = "0.8.4"
tokio = { version = "1.47.1", features = ["full"] }
tower = "0.5.0"
tower-http = { version = "0.6.6", features = ["cors", "trace"] }
hyper = { version = "1" }
bytes = "1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_with = "3.14.0"
sea-orm = { version = "1.1.14", features = ["sqlx-mysql", "sqlx-sqlite", "runtime-tokio-rustls", "macros"] }
jsonwebtoken = "9.3.1"
argon2 = "0.5.3" # 或升级到 3.0.0(注意 API 可能不兼容)
uuid = { version = "1.11.0", features = ["serde", "v4"] }
chrono = { version = "0.4", features = ["serde"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
config = "0.14"
dotenvy = "0.15"
thiserror = "1.0"
anyhow = "1.0"
once_cell = "1.19.0"
utoipa = { version = "5.4.0", features = ["axum_extras", "chrono", "uuid"] }
utoipa-swagger-ui = { version = "6.0.0", features = ["axum"] }
sha2 = "0.10"
rand = "0.8"
async-trait = "0.1"
[dependencies.migration]
path = "migration"
[profile.release]
lto = true
codegen-units = 1

5
backend/cookies.txt Normal file
View File

@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1756996792 refresh_token eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsInVpZCI6MSwiaXNzIjoidWRtaW4iLCJleHAiOjE3NTY5OTY3OTIsInR5cCI6InJlZnJlc2gifQ.XllW1VXSni2F548WFm1tjG3gwWPou_QE1JRabz0RZTE

View File

@ -0,0 +1,10 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
[dependencies]
sea-orm-migration = { version = "1.1.14" }
argon2 = "0.5"
chrono = { version = "0.4", features = ["serde"] }
rand = "0.8"

View File

@ -0,0 +1,34 @@
pub use sea_orm_migration::prelude::*;
mod m20220101_000001_create_users;
mod m20220101_000002_create_refresh_tokens;
mod m20220101_000003_create_roles;
mod m20220101_000004_create_permissions;
mod m20220101_000005_create_menus;
mod m20220101_000006_create_rbac_relations;
mod m20220101_000007_create_departments;
mod m20220101_000008_add_keep_alive_to_menus;
mod m20220101_000009_create_request_logs;
// 新增岗位与用户岗位关联
mod m20220101_000010_create_positions;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20220101_000001_create_users::Migration),
Box::new(m20220101_000002_create_refresh_tokens::Migration),
Box::new(m20220101_000003_create_roles::Migration),
Box::new(m20220101_000004_create_permissions::Migration),
Box::new(m20220101_000005_create_menus::Migration),
Box::new(m20220101_000006_create_rbac_relations::Migration),
Box::new(m20220101_000007_create_departments::Migration),
Box::new(m20220101_000008_add_keep_alive_to_menus::Migration),
Box::new(m20220101_000009_create_request_logs::Migration),
// 注册岗位迁移
Box::new(m20220101_000010_create_positions::Migration),
]
}
}

View File

@ -0,0 +1,65 @@
use sea_orm_migration::prelude::*;
use sea_orm_migration::sea_orm::DatabaseBackend;
use argon2::{Argon2, password_hash::{SaltString, PasswordHasher}};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Users::Table)
.if_not_exists()
.col(ColumnDef::new(Users::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(Users::Username).string().not_null().unique_key())
.col(ColumnDef::new(Users::PasswordHash).string().not_null())
.col(ColumnDef::new(Users::Nickname).string().null())
.col(ColumnDef::new(Users::Status).integer().not_null().default(1))
.col(ColumnDef::new(Users::CreatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Users::UpdatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.to_owned()
)
.await?;
// seed admin user (cross-DB)
let salt = SaltString::generate(&mut rand::thread_rng());
let hash = Argon2::default().hash_password("Admin@123".as_bytes(), &salt).unwrap().to_string();
let backend = manager.get_database_backend();
let conn = manager.get_connection();
match backend {
DatabaseBackend::MySql => {
conn.execute_unprepared(&format!(
"INSERT INTO users (username, password_hash, nickname, status) VALUES ('admin', '{}', 'Administrator', 1) ON DUPLICATE KEY UPDATE username=username",
hash
))
.await?;
}
DatabaseBackend::Postgres => {
conn.execute_unprepared(&format!(
"INSERT INTO users (username, password_hash, nickname, status) VALUES ('admin', '{}', 'Administrator', 1) ON CONFLICT (username) DO NOTHING",
hash
))
.await?;
}
DatabaseBackend::Sqlite => {
conn.execute_unprepared(&format!(
"INSERT OR IGNORE INTO users (username, password_hash, nickname, status) VALUES ('admin', '{}', 'Administrator', 1)",
hash
))
.await?;
}
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(Users::Table).to_owned()).await
}
}
#[derive(Iden)]
enum Users { Table, Id, Username, PasswordHash, Nickname, Status, CreatedAt, UpdatedAt }

View File

@ -0,0 +1,31 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(RefreshTokens::Table)
.if_not_exists()
.col(ColumnDef::new(RefreshTokens::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(RefreshTokens::UserId).big_integer().not_null())
.col(ColumnDef::new(RefreshTokens::TokenHash).string().not_null().unique_key())
.col(ColumnDef::new(RefreshTokens::ExpiresAt).timestamp().not_null())
.col(ColumnDef::new(RefreshTokens::CreatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.to_owned()
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(RefreshTokens::Table).to_owned()).await
}
}
#[derive(Iden)]
enum RefreshTokens { Table, Id, UserId, TokenHash, ExpiresAt, CreatedAt }

View File

@ -0,0 +1,33 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Roles::Table)
.if_not_exists()
.col(ColumnDef::new(Roles::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(Roles::Name).string().not_null())
.col(ColumnDef::new(Roles::Code).string().not_null().unique_key())
.col(ColumnDef::new(Roles::Description).string().null())
.col(ColumnDef::new(Roles::Status).integer().not_null().default(1))
.col(ColumnDef::new(Roles::CreatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Roles::UpdatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.to_owned()
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(Roles::Table).to_owned()).await
}
}
#[derive(Iden)]
enum Roles { Table, Id, Name, Code, Description, Status, CreatedAt, UpdatedAt }

View File

@ -0,0 +1,17 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
// 占位迁移:原始迁移文件缺失,但已应用。此处为空实现以满足迁移链完整性。
Ok(())
}
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
// 占位迁移,无需回滚实际变更
Ok(())
}
}

View File

@ -0,0 +1,39 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Menus::Table)
.if_not_exists()
.col(ColumnDef::new(Menus::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(Menus::ParentId).big_integer().null())
.col(ColumnDef::new(Menus::Name).string().not_null())
.col(ColumnDef::new(Menus::Path).string().null())
.col(ColumnDef::new(Menus::Component).string().null())
.col(ColumnDef::new(Menus::Type).integer().not_null())
.col(ColumnDef::new(Menus::Icon).string().null())
.col(ColumnDef::new(Menus::OrderNo).integer().not_null().default(0))
.col(ColumnDef::new(Menus::Visible).boolean().not_null().default(true))
.col(ColumnDef::new(Menus::Status).integer().not_null().default(1))
.col(ColumnDef::new(Menus::Perms).string().null())
.col(ColumnDef::new(Menus::CreatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Menus::UpdatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.to_owned()
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(Menus::Table).to_owned()).await
}
}
#[derive(Iden)]
enum Menus { Table, Id, ParentId, Name, Path, Component, Type, Icon, OrderNo, Visible, Status, Perms, CreatedAt, UpdatedAt }

View File

@ -0,0 +1,46 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// user_roles
manager.create_table(
Table::create()
.table(UserRoles::Table)
.if_not_exists()
.col(ColumnDef::new(UserRoles::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(UserRoles::UserId).big_integer().not_null())
.col(ColumnDef::new(UserRoles::RoleId).big_integer().not_null())
.index(Index::create().name("idx_user_role_uniq").col(UserRoles::UserId).col(UserRoles::RoleId).unique())
.to_owned()
).await?;
// role_menus
manager.create_table(
Table::create()
.table(RoleMenus::Table)
.if_not_exists()
.col(ColumnDef::new(RoleMenus::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(RoleMenus::RoleId).big_integer().not_null())
.col(ColumnDef::new(RoleMenus::MenuId).big_integer().not_null())
.index(Index::create().name("idx_role_menu_uniq").col(RoleMenus::RoleId).col(RoleMenus::MenuId).unique())
.to_owned()
).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(RoleMenus::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(UserRoles::Table).to_owned()).await
}
}
#[derive(Iden)]
enum UserRoles { Table, Id, UserId, RoleId }
#[derive(Iden)]
enum RoleMenus { Table, Id, RoleId, MenuId }

View File

@ -0,0 +1,53 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// departments
manager
.create_table(
Table::create()
.table(Departments::Table)
.if_not_exists()
.col(ColumnDef::new(Departments::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(Departments::ParentId).big_integer().null())
.col(ColumnDef::new(Departments::Name).string().not_null())
.col(ColumnDef::new(Departments::OrderNo).integer().not_null().default(0))
.col(ColumnDef::new(Departments::Status).integer().not_null().default(1))
.col(ColumnDef::new(Departments::CreatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Departments::UpdatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.to_owned()
)
.await?;
// user_departments
manager
.create_table(
Table::create()
.table(UserDepartments::Table)
.if_not_exists()
.col(ColumnDef::new(UserDepartments::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(UserDepartments::UserId).big_integer().not_null())
.col(ColumnDef::new(UserDepartments::DepartmentId).big_integer().not_null())
.index(Index::create().name("idx_user_department_uniq").col(UserDepartments::UserId).col(UserDepartments::DepartmentId).unique())
.to_owned()
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(UserDepartments::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(Departments::Table).to_owned()).await
}
}
#[derive(Iden)]
enum Departments { Table, Id, ParentId, Name, OrderNo, Status, CreatedAt, UpdatedAt }
#[derive(Iden)]
enum UserDepartments { Table, Id, UserId, DepartmentId }

View File

@ -0,0 +1,35 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Menus::Table)
.add_column(ColumnDef::new(Menus::KeepAlive).boolean().not_null().default(true))
.to_owned()
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Menus::Table)
.drop_column(Menus::KeepAlive)
.to_owned()
)
.await
}
}
#[derive(DeriveIden)]
enum Menus {
Table,
KeepAlive,
}

View File

@ -0,0 +1,49 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(RequestLogs::Table)
.if_not_exists()
.col(ColumnDef::new(RequestLogs::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(RequestLogs::Path).string().not_null())
.col(ColumnDef::new(RequestLogs::Method).string().not_null())
.col(ColumnDef::new(RequestLogs::RequestParams).text().null())
.col(ColumnDef::new(RequestLogs::ResponseParams).text().null())
.col(ColumnDef::new(RequestLogs::StatusCode).integer().not_null().default(200))
.col(ColumnDef::new(RequestLogs::UserId).big_integer().null())
.col(ColumnDef::new(RequestLogs::Username).string().null())
.col(ColumnDef::new(RequestLogs::RequestTime).timestamp().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(RequestLogs::DurationMs).big_integer().not_null().default(0))
.col(ColumnDef::new(RequestLogs::CreatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.to_owned()
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(RequestLogs::Table).to_owned()).await
}
}
#[derive(Iden)]
enum RequestLogs {
Table,
Id,
Path,
Method,
RequestParams,
ResponseParams,
StatusCode,
UserId,
Username,
RequestTime,
DurationMs,
CreatedAt,
}

View File

@ -0,0 +1,52 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// positions
manager
.create_table(
Table::create()
.table(Positions::Table)
.if_not_exists()
.col(ColumnDef::new(Positions::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(Positions::Name).string().not_null())
.col(ColumnDef::new(Positions::Code).string().not_null().unique_key())
.col(ColumnDef::new(Positions::Description).string().null())
.col(ColumnDef::new(Positions::Status).integer().not_null().default(1))
.col(ColumnDef::new(Positions::OrderNo).integer().not_null().default(0))
.col(ColumnDef::new(Positions::CreatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Positions::UpdatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.to_owned()
)
.await?;
// user_positions
manager.create_table(
Table::create()
.table(UserPositions::Table)
.if_not_exists()
.col(ColumnDef::new(UserPositions::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(UserPositions::UserId).big_integer().not_null())
.col(ColumnDef::new(UserPositions::PositionId).big_integer().not_null())
.index(Index::create().name("idx_user_position_uniq").col(UserPositions::UserId).col(UserPositions::PositionId).unique())
.to_owned()
).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(UserPositions::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(Positions::Table).to_owned()).await
}
}
#[derive(Iden)]
enum Positions { Table, Id, Name, Code, Description, Status, OrderNo, CreatedAt, UpdatedAt }
#[derive(Iden)]
enum UserPositions { Table, Id, UserId, PositionId }

15
backend/src/db.rs Normal file
View File

@ -0,0 +1,15 @@
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
use std::time::Duration;
pub type Db = DatabaseConnection;
pub async fn init_db() -> anyhow::Result<Db> {
let mut opt = ConnectOptions::new(std::env::var("DB_URL")?);
opt.max_connections(20)
.min_connections(5)
.connect_timeout(Duration::from_secs(8))
.idle_timeout(Duration::from_secs(600))
.sqlx_logging(false);
let conn = Database::connect(opt).await?;
Ok(conn)
}

26
backend/src/error.rs Normal file
View File

@ -0,0 +1,26 @@
use axum::{http::StatusCode, response::IntoResponse, Json};
use crate::response::ApiResponse;
#[derive(thiserror::Error, Debug)]
pub enum AppError {
#[error("unauthorized")] Unauthorized,
#[error("forbidden")] Forbidden,
#[error("bad request: {0}")] BadRequest(String),
#[error("not found")] NotFound,
#[error(transparent)] Db(#[from] sea_orm::DbErr),
#[error(transparent)] Jwt(#[from] jsonwebtoken::errors::Error),
#[error(transparent)] Anyhow(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let (status, code, msg) = match &self {
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, 401, "unauthorized".to_string()),
AppError::Forbidden => (StatusCode::FORBIDDEN, 403, "forbidden".to_string()),
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, 400, m.clone()),
AppError::NotFound => (StatusCode::NOT_FOUND, 404, "not found".into()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, 500, "internal error".into()),
};
(status, Json(ApiResponse::<serde_json::Value> { code, message: msg, data: None })).into_response()
}
}

75
backend/src/main.rs Normal file
View File

@ -0,0 +1,75 @@
mod db;
mod response;
mod error;
pub mod middlewares;
pub mod models;
pub mod services;
pub mod routes;
pub mod utils;
use axum::Router;
use axum::http::{HeaderValue, Method};
use tower_http::cors::{CorsLayer, Any, AllowOrigin};
use migration::MigratorTrait;
use axum::middleware;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::from_default_env()).init();
let db = db::init_db().await?;
// run migrations
migration::Migrator::up(&db, None).await.expect("migration up");
let allow_origins = std::env::var("CORS_ALLOW_ORIGINS").unwrap_or_else(|_| "http://localhost:5173".into());
let origin_values: Vec<HeaderValue> = allow_origins
.split(',')
.filter_map(|s| HeaderValue::from_str(s.trim()).ok())
.collect();
let allowed_methods = [
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
Method::OPTIONS,
];
let cors = if origin_values.is_empty() {
// 当允许任意来源时,不能与 allow_credentials(true) 同时使用
CorsLayer::new()
.allow_origin(Any)
.allow_methods(allowed_methods.clone())
.allow_headers([
axum::http::header::ACCEPT,
axum::http::header::CONTENT_TYPE,
axum::http::header::AUTHORIZATION,
])
.allow_credentials(false)
} else {
CorsLayer::new()
.allow_origin(AllowOrigin::list(origin_values))
.allow_methods(allowed_methods)
.allow_headers([
axum::http::header::ACCEPT,
axum::http::header::CONTENT_TYPE,
axum::http::header::AUTHORIZATION,
])
.allow_credentials(true)
};
let api = routes::api_router().with_state(db.clone());
let app = Router::new()
.nest("/api", api)
.layer(cors)
.layer(middleware::from_fn_with_state(db.clone(), middlewares::logging::request_logger));
let addr = format!("{}:{}", std::env::var("APP_HOST").unwrap_or("0.0.0.0".into()), std::env::var("APP_PORT").unwrap_or("8080".into()));
tracing::info!("listening on {}", addr);
axum::serve(tokio::net::TcpListener::bind(addr).await?, app).await?;
Ok(())
}

View File

@ -0,0 +1,56 @@
use axum::{http::HeaderMap, http::header::AUTHORIZATION};
use chrono::{Utc, Duration as ChronoDuration};
use jsonwebtoken::{EncodingKey, DecodingKey, Header, Validation};
use serde::{Serialize, Deserialize};
use crate::error::AppError;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: String,
pub uid: i64,
pub iss: String,
pub exp: usize,
pub typ: String, // access or refresh
}
pub fn encode_token(claims: &Claims, secret: &str) -> Result<String, AppError> {
let key = EncodingKey::from_secret(secret.as_bytes());
Ok(jsonwebtoken::encode(&Header::default(), claims, &key)?)
}
pub fn decode_token(token: &str, secret: &str) -> Result<Claims, AppError> {
let key = DecodingKey::from_secret(secret.as_bytes());
let data = jsonwebtoken::decode::<Claims>(token, &key, &Validation::default())?;
Ok(data.claims)
}
#[derive(Clone, Debug)]
pub struct AuthUser { pub uid: i64, pub username: String }
impl<S> axum::extract::FromRequestParts<S> for AuthUser where S: Send + Sync + 'static {
type Rejection = AppError;
async fn from_request_parts(parts: &mut axum::http::request::Parts, _state: &S) -> Result<Self, Self::Rejection> {
let headers: &HeaderMap = &parts.headers;
let auth = headers.get(AUTHORIZATION).ok_or(AppError::Unauthorized)?;
let auth = auth.to_str().map_err(|_| AppError::Unauthorized)?;
let token = auth.strip_prefix("Bearer ").ok_or(AppError::Unauthorized)?;
let secret = std::env::var("JWT_SECRET").map_err(|_| AppError::Unauthorized)?;
let claims = decode_token(token, &secret)?;
if claims.typ != "access" { return Err(AppError::Unauthorized); }
Ok(AuthUser { uid: claims.uid, username: claims.sub })
}
}
pub fn new_access_claims(uid: i64, username: &str) -> Claims {
let iss = std::env::var("JWT_ISS").unwrap_or_else(|_| "udmin".into());
let exp_secs: i64 = std::env::var("JWT_ACCESS_EXP_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(1800);
let exp = (Utc::now() + ChronoDuration::seconds(exp_secs)).timestamp() as usize;
Claims { sub: username.to_string(), uid, iss, exp, typ: "access".into() }
}
pub fn new_refresh_claims(uid: i64, username: &str) -> Claims {
let iss = std::env::var("JWT_ISS").unwrap_or_else(|_| "udmin".into());
let exp_secs: i64 = std::env::var("JWT_REFRESH_EXP_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(1209600);
let exp = (Utc::now() + ChronoDuration::seconds(exp_secs)).timestamp() as usize;
Claims { sub: username.to_string(), uid, iss, exp, typ: "refresh".into() }
}

View File

@ -0,0 +1,73 @@
use axum::{extract::State, http::{Request, HeaderMap, header::AUTHORIZATION}, middleware::Next, response::Response, body::Body};
use chrono::{Utc, FixedOffset};
use std::time::Instant;
use crate::{db::Db, services::log_service::{self, CreateLogInput}};
const BODY_LIMIT: usize = 256 * 1024; // 256 KiB 上限
fn parse_user(headers: &HeaderMap) -> (Option<i64>, Option<String>) {
if let Some(auth) = headers.get(AUTHORIZATION) {
if let Ok(s) = auth.to_str() {
if let Some(token) = s.strip_prefix("Bearer ") {
if let Ok(secret) = std::env::var("JWT_SECRET") {
if let Ok(claims) = crate::middlewares::jwt::decode_token(token, &secret) {
return (Some(claims.uid), Some(claims.sub));
}
}
}
}
}
(None, None)
}
pub async fn request_logger(State(db): State<Db>, req: Request<Body>, next: Next) -> Response {
// 跳过 GET 请求的日志记录
if *req.method() == axum::http::Method::GET {
return next.run(req).await;
}
let start = Instant::now();
let request_time = Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap());
let path = req.uri().path().to_string();
let method = req.method().to_string();
let (user_id, username) = parse_user(req.headers());
// capture query and body up to a limit
let query = req.uri().query().unwrap_or("");
let mut req_params = if !query.is_empty() { format!("?{}", query) } else { String::new() };
// 读取并还原请求 Body限制大小
let (parts, body) = req.into_parts();
let body_bytes = match axum::body::to_bytes(body, BODY_LIMIT).await { Ok(b) => b, Err(_) => axum::body::Bytes::new() };
if !body_bytes.is_empty() {
let mut s = String::from_utf8_lossy(&body_bytes).to_string();
if s.len() > 4096 { s.truncate(4096); }
if !req_params.is_empty() { req_params.push_str(" "); }
req_params.push_str(&s);
}
let req = Request::from_parts(parts, Body::from(body_bytes.clone()));
let res = next.run(req).await;
// capture response body
let status = res.status().as_u16() as i32;
let duration_ms = start.elapsed().as_millis() as i64;
let (parts, body) = res.into_parts();
let resp_bytes = match axum::body::to_bytes(body, BODY_LIMIT).await { Ok(b) => b, Err(_) => axum::body::Bytes::new() };
let mut resp_str = String::from_utf8_lossy(&resp_bytes).to_string();
if resp_str.len() > 4096 { resp_str.truncate(4096); }
let res = Response::from_parts(parts, Body::from(resp_bytes));
let _ = log_service::create(&db, CreateLogInput {
path,
method,
request_params: if req_params.is_empty() { None } else { Some(req_params) },
response_params: if resp_str.is_empty() { None } else { Some(resp_str) },
status_code: status,
user_id,
username,
request_time,
duration_ms,
}).await;
res
}

View File

@ -0,0 +1,2 @@
pub mod jwt;
pub mod logging;

View File

@ -0,0 +1,19 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, serde::Serialize, serde::Deserialize)]
#[sea_orm(table_name = "departments")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub parent_id: Option<i64>,
pub name: String,
pub order_no: i32,
pub status: i32,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,27 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, serde::Serialize, serde::Deserialize)]
#[sea_orm(table_name = "menus")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub parent_id: Option<i64>,
pub name: String,
pub path: Option<String>,
pub component: Option<String>,
pub r#type: i32,
pub icon: Option<String>,
pub order_no: i32,
pub visible: bool,
pub status: i32,
// 新增:是否缓存页面
pub keep_alive: bool,
pub perms: Option<String>,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

12
backend/src/models/mod.rs Normal file
View File

@ -0,0 +1,12 @@
pub mod user;
pub mod refresh_token;
pub mod role;
pub mod menu;
pub mod user_role;
pub mod role_menu;
pub mod department;
pub mod user_department;
pub mod request_log;
// 新增岗位与用户岗位关联模型
pub mod position;
pub mod user_position;

View File

@ -0,0 +1,21 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, serde::Serialize, serde::Deserialize)]
#[sea_orm(table_name = "positions")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub name: String,
#[sea_orm(unique)]
pub code: String,
pub description: Option<String>,
pub status: i32,
pub order_no: i32,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,17 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "refresh_tokens")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub user_id: i64,
pub token_hash: String,
pub expires_at: DateTimeWithTimeZone,
pub created_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,23 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, serde::Serialize, serde::Deserialize)]
#[sea_orm(table_name = "request_logs")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub path: String,
pub method: String,
pub request_params: Option<String>,
pub response_params: Option<String>,
pub status_code: i32,
pub user_id: Option<i64>,
pub username: Option<String>,
pub request_time: DateTimeWithTimeZone,
pub duration_ms: i64,
pub created_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,20 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, serde::Serialize, serde::Deserialize)]
#[sea_orm(table_name = "roles")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub name: String,
#[sea_orm(unique)]
pub code: String,
pub description: Option<String>,
pub status: i32,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,15 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "role_menus")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub role_id: i64,
pub menu_id: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,20 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, serde::Serialize, serde::Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
#[sea_orm(unique)]
pub username: String,
pub password_hash: String,
pub nickname: Option<String>,
pub status: i32,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,15 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user_departments")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub user_id: i64,
pub department_id: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,15 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user_positions")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub user_id: i64,
pub position_id: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,15 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user_roles")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub user_id: i64,
pub role_id: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

13
backend/src/response.rs Normal file
View File

@ -0,0 +1,13 @@
use serde::Serialize;
#[derive(Serialize)]
pub struct ApiResponse<T> {
pub code: i32,
pub message: String,
pub data: Option<T>,
}
impl<T> ApiResponse<T> {
pub fn ok(data: T) -> Self { Self { code: 0, message: "ok".into(), data: Some(data) } }
pub fn err(code: i32, message: impl Into<String>) -> Self { Self { code, message: message.into(), data: None } }
}

View File

@ -0,0 +1,65 @@
use axum::{Router, routing::{post, get}, Json, extract::State};
use axum::http::{HeaderMap, HeaderValue};
use serde::{Serialize, Deserialize};
use crate::{db::Db, response::ApiResponse, error::AppError};
use crate::services::{auth_service, role_service, menu_service};
use crate::middlewares::jwt::{AuthUser, decode_token};
use crate::models::user;
#[derive(Deserialize)]
pub struct LoginReq { pub username: String, pub password: String }
#[derive(Serialize)]
pub struct UserInfo { pub id: i64, pub username: String, pub nickname: Option<String>, pub status: i32 }
impl From<user::Model> for UserInfo {
fn from(m: user::Model) -> Self { Self { id: m.id, username: m.username, nickname: m.nickname, status: m.status } }
}
#[derive(Serialize)]
pub struct LoginResp { pub access_token: String, pub user: UserInfo }
pub fn router() -> Router<Db> {
Router::new()
.route("/login", post(login))
.route("/logout", post(logout))
.route("/refresh", get(refresh))
.route("/menus", get(current_user_menus))
}
pub async fn login(State(db): State<Db>, Json(req): Json<LoginReq>) -> Result<(HeaderMap, Json<ApiResponse<LoginResp>>), AppError> {
let res = auth_service::login(&db, req.username, req.password).await?;
let mut headers = HeaderMap::new();
let cookie = format!("refresh_token={}; HttpOnly; SameSite=Lax; Path=/; Max-Age=1209600", res.refresh);
headers.insert(axum::http::header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap());
Ok((headers, Json(ApiResponse::ok(LoginResp { access_token: res.access, user: res.user.into() }))))
}
pub async fn logout(State(db): State<Db>, user: AuthUser) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
auth_service::logout(&db, user.uid).await?;
Ok(Json(ApiResponse::ok(serde_json::json!({"success": true}))))
}
pub async fn refresh(State(db): State<Db>, headers: HeaderMap) -> Result<(HeaderMap, Json<ApiResponse<serde_json::Value>>), AppError> {
let cookie_header = headers.get(axum::http::header::COOKIE).and_then(|v| v.to_str().ok()).unwrap_or("");
let refresh = cookie_header.split(';').find_map(|p| {
let kv: Vec<&str> = p.trim().splitn(2, '=').collect();
if kv.len()==2 && kv[0]=="refresh_token" { Some(kv[1].to_string()) } else { None }
}).ok_or(AppError::Unauthorized)?;
let secret = std::env::var("JWT_SECRET").unwrap();
let claims = decode_token(&refresh, &secret)?;
if claims.typ != "refresh" { return Err(AppError::Unauthorized); }
let (access, new_refresh) = auth_service::rotate_refresh(&db, claims.uid, refresh).await?;
let mut headers = HeaderMap::new();
let cookie = format!("refresh_token={}; HttpOnly; SameSite=Lax; Path=/; Max-Age=1209600", new_refresh);
headers.insert(axum::http::header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap());
Ok((headers, Json(ApiResponse::ok(serde_json::json!({"access_token": access})))) )
}
pub async fn current_user_menus(State(db): State<Db>, user: AuthUser) -> Result<Json<ApiResponse<Vec<menu_service::MenuInfo>>>, AppError> {
let ids = role_service::get_menu_ids_by_user_id(&db, user.uid).await?;
let menus = menu_service::list_by_ids(&db, ids).await?;
Ok(Json(ApiResponse::ok(menus)))
}

View File

@ -0,0 +1,36 @@
use axum::{Router, routing::{get, put}, extract::{Path, Query, State}, Json};
use serde::Deserialize;
use crate::{db::Db, services::department_service, response::ApiResponse, error::AppError};
#[derive(Deserialize)]
struct ListParams { keyword: Option<String> }
pub fn router() -> Router<Db> { Router::new()
.route("/departments", get(list).post(create))
.route("/departments/{id}", put(update).delete(delete_department))
}
async fn list(State(db): State<Db>, Query(p): Query<ListParams>) -> Result<Json<ApiResponse<Vec<department_service::DepartmentInfo>>>, AppError> {
let res = department_service::list_all(&db, p.keyword).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct CreateReq { parent_id: Option<i64>, name: String, order_no: Option<i32>, status: Option<i32> }
async fn create(State(db): State<Db>, Json(req): Json<CreateReq>) -> Result<Json<ApiResponse<department_service::DepartmentInfo>>, AppError> {
let res = department_service::create(&db, department_service::CreateDepartmentInput { parent_id: req.parent_id, name: req.name, order_no: req.order_no, status: req.status }).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct UpdateReq { parent_id: Option<i64>, name: Option<String>, order_no: Option<i32>, status: Option<i32> }
async fn update(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<UpdateReq>) -> Result<Json<ApiResponse<department_service::DepartmentInfo>>, AppError> {
let res = department_service::update(&db, id, department_service::UpdateDepartmentInput { parent_id: req.parent_id, name: req.name, order_no: req.order_no, status: req.status }).await?;
Ok(Json(ApiResponse::ok(res)))
}
async fn delete_department(State(db): State<Db>, Path(id): Path<i64>) -> Result<Json<ApiResponse<bool>>, AppError> {
department_service::delete(&db, id).await?;
Ok(Json(ApiResponse::ok(true)))
}

View File

@ -0,0 +1,9 @@
use axum::{Router, routing::get, extract::{Query, State}, Json};
use crate::{db::Db, response::ApiResponse, services::log_service, error::AppError};
pub fn router() -> Router<Db> { Router::new().route("/logs", get(list)) }
async fn list(State(db): State<Db>, Query(p): Query<log_service::ListParams>) -> Result<Json<ApiResponse<log_service::PageResp<log_service::LogInfo>>>, AppError> {
let res = log_service::list(&db, p).await.map_err(|e| AppError::Anyhow(anyhow::anyhow!(e)))?;
Ok(Json(ApiResponse::ok(res)))
}

View File

@ -0,0 +1,36 @@
use axum::{Router, routing::{get, put}, extract::{Path, Query, State}, Json};
use serde::Deserialize;
use crate::{db::Db, services::menu_service, response::ApiResponse, error::AppError};
#[derive(Deserialize)]
struct ListParams { keyword: Option<String> }
pub fn router() -> Router<Db> { Router::new()
.route("/menus", get(list).post(create))
.route("/menus/{id}", put(update).delete(delete_menu))
}
async fn list(State(db): State<Db>, Query(p): Query<ListParams>) -> Result<Json<ApiResponse<Vec<menu_service::MenuInfo>>>, AppError> {
let res = menu_service::list_all(&db, p.keyword).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct CreateReq { parent_id: Option<i64>, name: String, path: Option<String>, component: Option<String>, r#type: i32, icon: Option<String>, order_no: Option<i32>, visible: Option<bool>, status: Option<i32>, keep_alive: Option<bool>, perms: Option<String> }
async fn create(State(db): State<Db>, Json(req): Json<CreateReq>) -> Result<Json<ApiResponse<menu_service::MenuInfo>>, AppError> {
let res = menu_service::create(&db, menu_service::CreateMenuInput { parent_id: req.parent_id, name: req.name, path: req.path, component: req.component, r#type: req.r#type, icon: req.icon, order_no: req.order_no, visible: req.visible, status: req.status, keep_alive: req.keep_alive, perms: req.perms }).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct UpdateReq { parent_id: Option<i64>, name: Option<String>, path: Option<String>, component: Option<String>, r#type: Option<i32>, icon: Option<String>, order_no: Option<i32>, visible: Option<bool>, status: Option<i32>, keep_alive: Option<bool>, perms: Option<String> }
async fn update(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<UpdateReq>) -> Result<Json<ApiResponse<menu_service::MenuInfo>>, AppError> {
let res = menu_service::update(&db, id, menu_service::UpdateMenuInput { parent_id: req.parent_id, name: req.name, path: req.path, component: req.component, r#type: req.r#type, icon: req.icon, order_no: req.order_no, visible: req.visible, status: req.status, keep_alive: req.keep_alive, perms: req.perms }).await?;
Ok(Json(ApiResponse::ok(res)))
}
async fn delete_menu(State(db): State<Db>, Path(id): Path<i64>) -> Result<Json<ApiResponse<bool>>, AppError> {
menu_service::delete(&db, id).await?;
Ok(Json(ApiResponse::ok(true)))
}

23
backend/src/routes/mod.rs Normal file
View File

@ -0,0 +1,23 @@
pub mod auth;
pub mod users;
pub mod roles;
pub mod menus;
pub mod departments;
pub mod logs;
// 新增岗位
pub mod positions;
use axum::Router;
use crate::db::Db;
pub fn api_router() -> Router<Db> {
Router::new()
.nest("/auth", auth::router())
.merge(users::router())
.merge(roles::router())
.merge(menus::router())
.merge(departments::router())
.merge(logs::router())
// 合并岗位路由
.merge(positions::router())
}

View File

@ -0,0 +1,38 @@
use axum::{Router, routing::{get, put}, extract::{Path, Query, State}, Json};
use serde::Deserialize;
use crate::{db::Db, services::position_service, response::ApiResponse, error::AppError};
#[derive(Deserialize)]
struct PageParams { page: Option<u64>, page_size: Option<u64>, keyword: Option<String> }
pub fn router() -> Router<Db> { Router::new()
.route("/positions", get(list).post(create))
.route("/positions/{id}", put(update).delete(delete_position))
}
async fn list(State(db): State<Db>, Query(p): Query<PageParams>) -> Result<Json<ApiResponse<position_service::PageResp<position_service::PositionInfo>>>, AppError> {
let page = p.page.unwrap_or(1); let page_size = p.page_size.unwrap_or(10);
let res = position_service::list(&db, page, page_size, p.keyword).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct CreateReq { name: String, code: String, description: Option<String>, status: Option<i32>, order_no: Option<i32> }
async fn create(State(db): State<Db>, Json(req): Json<CreateReq>) -> Result<Json<ApiResponse<position_service::PositionInfo>>, AppError> {
let res = position_service::create(&db, position_service::CreatePositionInput { name: req.name, code: req.code, description: req.description, status: req.status, order_no: req.order_no }).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct UpdateReq { name: Option<String>, description: Option<String>, status: Option<i32>, order_no: Option<i32> }
async fn update(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<UpdateReq>) -> Result<Json<ApiResponse<position_service::PositionInfo>>, AppError> {
let res = position_service::update(&db, id, position_service::UpdatePositionInput { name: req.name, description: req.description, status: req.status, order_no: req.order_no }).await?;
Ok(Json(ApiResponse::ok(res)))
}
async fn delete_position(State(db): State<Db>, Path(id): Path<i64>) -> Result<Json<ApiResponse<bool>>, AppError> {
position_service::delete(&db, id).await?;
Ok(Json(ApiResponse::ok(true)))
}

View File

@ -0,0 +1,52 @@
use axum::{Router, routing::{get, put}, extract::{Path, Query, State}, Json};
use serde::Deserialize;
use crate::{db::Db, services::role_service, response::ApiResponse, error::AppError};
#[derive(Deserialize)]
struct PageParams { page: Option<u64>, page_size: Option<u64>, keyword: Option<String> }
pub fn router() -> Router<Db> { Router::new()
.route("/roles", get(list).post(create))
.route("/roles/{id}", put(update).delete(delete_role))
.route("/roles/{id}/menus", get(get_menus).put(set_menus))
}
async fn list(State(db): State<Db>, Query(p): Query<PageParams>) -> Result<Json<ApiResponse<role_service::PageResp<role_service::RoleInfo>>>, AppError> {
let page = p.page.unwrap_or(1); let page_size = p.page_size.unwrap_or(10);
let res = role_service::list(&db, page, page_size, p.keyword).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct CreateReq { name: String, code: String, description: Option<String>, status: Option<i32> }
async fn create(State(db): State<Db>, Json(req): Json<CreateReq>) -> Result<Json<ApiResponse<role_service::RoleInfo>>, AppError> {
let res = role_service::create(&db, role_service::CreateRoleInput { name: req.name, code: req.code, description: req.description, status: req.status }).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct UpdateReq { name: Option<String>, description: Option<String>, status: Option<i32> }
async fn update(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<UpdateReq>) -> Result<Json<ApiResponse<role_service::RoleInfo>>, AppError> {
let res = role_service::update(&db, id, role_service::UpdateRoleInput { name: req.name, description: req.description, status: req.status }).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct IdsReq { ids: Vec<i64> }
async fn get_menus(State(db): State<Db>, Path(id): Path<i64>) -> Result<Json<ApiResponse<Vec<i64>>>, AppError> {
let ids = role_service::get_menu_ids(&db, id).await?;
Ok(Json(ApiResponse::ok(ids)))
}
async fn set_menus(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<IdsReq>) -> Result<Json<ApiResponse<bool>>, AppError> {
role_service::set_menu_ids(&db, id, req.ids).await?;
Ok(Json(ApiResponse::ok(true)))
}
async fn delete_role(State(db): State<Db>, Path(id): Path<i64>) -> Result<Json<ApiResponse<bool>>, AppError> {
role_service::delete(&db, id).await?;
Ok(Json(ApiResponse::ok(true)))
}

View File

@ -0,0 +1,88 @@
use axum::{Router, routing::{get, post, put}, extract::{Path, Query, State}, Json};
use serde::Deserialize;
use crate::{db::Db, services::{user_service, role_service, department_service, position_service}, response::ApiResponse};
#[derive(Deserialize)]
struct PageParams { page: Option<u64>, page_size: Option<u64>, keyword: Option<String> }
pub fn router() -> Router<Db> { Router::new()
.route("/users", get(list).post(create))
.route("/users/{id}", put(update).delete(delete_user))
.route("/users/{id}/reset_password", post(reset_password))
.route("/users/{id}/roles", get(get_roles).put(set_roles))
.route("/users/{id}/departments", get(get_departments).put(set_departments))
// 新增岗位关联
.route("/users/{id}/positions", get(get_positions).put(set_positions))
}
async fn list(State(db): State<Db>, Query(p): Query<PageParams>) -> Result<Json<ApiResponse<user_service::PageResp<user_service::UserInfo>>>, crate::error::AppError> {
let page = p.page.unwrap_or(1); let page_size = p.page_size.unwrap_or(10);
let res = user_service::list(&db, page, page_size, p.keyword).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct CreateReq { username: String, password: String, nickname: Option<String>, status: Option<i32> }
async fn create(State(db): State<Db>, Json(req): Json<CreateReq>) -> Result<Json<ApiResponse<user_service::UserInfo>>, crate::error::AppError> {
let input = user_service::CreateUserInput { username: req.username, password: req.password, nickname: req.nickname, status: req.status };
let res = user_service::create(&db, input).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct UpdateReq { nickname: Option<String>, status: Option<i32> }
async fn update(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<UpdateReq>) -> Result<Json<ApiResponse<user_service::UserInfo>>, crate::error::AppError> {
let res = user_service::update(&db, id, user_service::UpdateUserInput { nickname: req.nickname, status: req.status }).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct IdsReq { ids: Vec<i64> }
async fn get_roles(State(db): State<Db>, Path(id): Path<i64>) -> Result<Json<ApiResponse<Vec<i64>>>, crate::error::AppError> {
let ids = role_service::get_role_ids_by_user_id(&db, id).await?;
Ok(Json(ApiResponse::ok(ids)))
}
async fn set_roles(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<IdsReq>) -> Result<Json<ApiResponse<bool>>, crate::error::AppError> {
role_service::set_role_ids_by_user_id(&db, id, req.ids).await?;
Ok(Json(ApiResponse::ok(true)))
}
// 新增:用户部门分配
async fn get_departments(State(db): State<Db>, Path(id): Path<i64>) -> Result<Json<ApiResponse<Vec<i64>>>, crate::error::AppError> {
let ids = department_service::get_department_ids_by_user_id(&db, id).await?;
Ok(Json(ApiResponse::ok(ids)))
}
async fn set_departments(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<IdsReq>) -> Result<Json<ApiResponse<bool>>, crate::error::AppError> {
department_service::set_department_ids_by_user_id(&db, id, req.ids).await?;
Ok(Json(ApiResponse::ok(true)))
}
// 新增:用户岗位分配
async fn get_positions(State(db): State<Db>, Path(id): Path<i64>) -> Result<Json<ApiResponse<Vec<i64>>>, crate::error::AppError> {
let ids = position_service::get_position_ids_by_user_id(&db, id).await?;
Ok(Json(ApiResponse::ok(ids)))
}
async fn set_positions(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<IdsReq>) -> Result<Json<ApiResponse<bool>>, crate::error::AppError> {
position_service::set_position_ids_by_user_id(&db, id, req.ids).await?;
Ok(Json(ApiResponse::ok(true)))
}
#[derive(Deserialize)]
struct ResetReq { password: String }
async fn reset_password(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<ResetReq>) -> Result<Json<ApiResponse<()>>, crate::error::AppError> {
let input = user_service::ResetPasswordInput { password: req.password };
user_service::reset_password(&db, id, input).await?;
Ok(Json(ApiResponse::ok(())))
}
async fn delete_user(State(db): State<Db>, Path(id): Path<i64>) -> Result<Json<ApiResponse<bool>>, crate::error::AppError> {
user_service::delete(&db, id).await?;
Ok(Json(ApiResponse::ok(true)))
}

View File

@ -0,0 +1,72 @@
use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, ActiveModelTrait, Set};
use crate::{db::Db, models::{user, refresh_token}, utils::password, error::AppError};
use chrono::{Utc, Duration, FixedOffset};
use sha2::{Sha256, Digest};
use sea_orm::ActiveValue::NotSet;
pub struct LoginResult { pub user: user::Model, pub access: String, pub refresh: String }
pub async fn login(db: &Db, username: String, password_plain: String) -> Result<LoginResult, AppError> {
let u = user::Entity::find().filter(user::Column::Username.eq(username.clone())).one(db).await?.ok_or(AppError::Unauthorized)?;
if u.status != 1 { return Err(AppError::Forbidden); }
let ok = password::verify_password(&password_plain, &u.password_hash).map_err(|_| AppError::Unauthorized)?;
if !ok { return Err(AppError::Unauthorized); }
let access_claims = crate::middlewares::jwt::new_access_claims(u.id, &u.username);
let refresh_claims = crate::middlewares::jwt::new_refresh_claims(u.id, &u.username);
let secret = std::env::var("JWT_SECRET").unwrap();
let access = crate::middlewares::jwt::encode_token(&access_claims, &secret)?;
let refresh = crate::middlewares::jwt::encode_token(&refresh_claims, &secret)?;
// persist refresh token hash
let mut hasher = Sha256::new(); hasher.update(refresh.as_bytes());
let token_hash = format!("{:x}", hasher.finalize());
let exp_secs = std::env::var("JWT_REFRESH_EXP_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(1209600);
let expires = (Utc::now() + Duration::seconds(exp_secs as i64)).with_timezone(&FixedOffset::east_opt(0).unwrap());
let created = Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap());
let am = refresh_token::ActiveModel {
id: NotSet,
user_id: Set(u.id),
token_hash: Set(token_hash),
expires_at: Set(expires),
created_at: Set(created),
};
let _ = am.insert(db).await?;
Ok(LoginResult { user: u, access, refresh })
}
pub async fn logout(db: &Db, uid: i64) -> Result<(), AppError> {
let _ = refresh_token::Entity::delete_many().filter(refresh_token::Column::UserId.eq(uid)).exec(db).await?;
Ok(())
}
pub async fn rotate_refresh(db: &Db, uid: i64, old_refresh: String) -> Result<(String, String), AppError> {
let mut hasher = Sha256::new(); hasher.update(old_refresh.as_bytes());
let token_hash = format!("{:x}", hasher.finalize());
let existing = refresh_token::Entity::find().filter(refresh_token::Column::UserId.eq(uid)).filter(refresh_token::Column::TokenHash.eq(token_hash.clone())).one(db).await?;
if existing.is_none() { return Err(AppError::Unauthorized); }
let u = user::Entity::find_by_id(uid).one(db).await?.ok_or(AppError::Unauthorized)?;
let access_claims = crate::middlewares::jwt::new_access_claims(u.id, &u.username);
let refresh_claims = crate::middlewares::jwt::new_refresh_claims(u.id, &u.username);
let secret = std::env::var("JWT_SECRET").unwrap();
let access = crate::middlewares::jwt::encode_token(&access_claims, &secret)?;
let refresh = crate::middlewares::jwt::encode_token(&refresh_claims, &secret)?;
let _ = refresh_token::Entity::delete_many().filter(refresh_token::Column::UserId.eq(uid)).filter(refresh_token::Column::TokenHash.eq(token_hash)).exec(db).await?;
let mut hasher2 = Sha256::new(); hasher2.update(refresh.as_bytes());
let token_hash2 = format!("{:x}", hasher2.finalize());
let exp_secs = std::env::var("JWT_REFRESH_EXP_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(1209600);
let expires = (Utc::now() + Duration::seconds(exp_secs as i64)).with_timezone(&FixedOffset::east_opt(0).unwrap());
let created = Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap());
let am = refresh_token::ActiveModel {
id: NotSet,
user_id: Set(u.id),
token_hash: Set(token_hash2),
expires_at: Set(expires),
created_at: Set(created),
};
let _ = am.insert(db).await?;
Ok((access, refresh))
}

View File

@ -0,0 +1,84 @@
use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, ActiveModelTrait, Set, QueryOrder, TransactionTrait, QuerySelect};
use sea_orm::prelude::DateTimeWithTimeZone;
use crate::{db::Db, models::{department, user_department}, error::AppError};
use sea_orm::PaginatorTrait;
#[derive(Clone, Debug, serde::Serialize)]
pub struct DepartmentInfo { pub id: i64, pub parent_id: Option<i64>, pub name: String, pub order_no: i32, pub status: i32, pub created_at: DateTimeWithTimeZone }
impl From<department::Model> for DepartmentInfo { fn from(m: department::Model) -> Self { Self { id: m.id, parent_id: m.parent_id, name: m.name, order_no: m.order_no, status: m.status, created_at: m.created_at } } }
pub async fn list_all(db: &Db, keyword: Option<String>) -> Result<Vec<DepartmentInfo>, AppError> {
let mut selector = department::Entity::find();
if let Some(k) = keyword { let like = format!("%{}%", k); selector = selector.filter(department::Column::Name.like(like)); }
let models = selector.order_by_asc(department::Column::OrderNo).order_by_asc(department::Column::Id).all(db).await?;
Ok(models.into_iter().map(Into::into).collect())
}
#[derive(serde::Deserialize)]
pub struct CreateDepartmentInput { pub parent_id: Option<i64>, pub name: String, pub order_no: Option<i32>, pub status: Option<i32> }
pub async fn create(db: &Db, input: CreateDepartmentInput) -> Result<DepartmentInfo, AppError> {
let mut am: department::ActiveModel = Default::default();
am.parent_id = Set(input.parent_id);
am.name = Set(input.name);
am.order_no = Set(input.order_no.unwrap_or(0));
am.status = Set(input.status.unwrap_or(1));
let m = am.insert(db).await?;
Ok(m.into())
}
#[derive(serde::Deserialize)]
pub struct UpdateDepartmentInput { pub parent_id: Option<i64>, pub name: Option<String>, pub order_no: Option<i32>, pub status: Option<i32> }
pub async fn update(db: &Db, id: i64, input: UpdateDepartmentInput) -> Result<DepartmentInfo, AppError> {
let m = department::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let mut am: department::ActiveModel = m.into();
if let Some(v) = input.parent_id { am.parent_id = Set(Some(v)); }
if let Some(v) = input.name { am.name = Set(v); }
if let Some(v) = input.order_no { am.order_no = Set(v); }
if let Some(v) = input.status { am.status = Set(v); }
let m = am.update(db).await?;
Ok(m.into())
}
// user <-> departments
pub async fn get_department_ids_by_user_id(db: &Db, user_id: i64) -> Result<Vec<i64>, AppError> {
let ids = user_department::Entity::find()
.filter(user_department::Column::UserId.eq(user_id))
.select_only()
.column(user_department::Column::DepartmentId)
.into_tuple::<i64>()
.all(db)
.await?;
Ok(ids)
}
#[derive(serde::Deserialize)]
pub struct SetIdsInput { pub ids: Vec<i64> }
pub async fn set_department_ids_by_user_id(db: &Db, user_id: i64, ids: Vec<i64>) -> Result<(), AppError> {
let txn = db.begin().await?;
user_department::Entity::delete_many()
.filter(user_department::Column::UserId.eq(user_id))
.exec(&txn)
.await?;
if !ids.is_empty() {
let mut items: Vec<user_department::ActiveModel> = Vec::with_capacity(ids.len());
for did in ids { let mut am: user_department::ActiveModel = Default::default(); am.user_id = Set(user_id); am.department_id = Set(did); items.push(am); }
user_department::Entity::insert_many(items).exec(&txn).await?;
}
txn.commit().await?;
Ok(())
}
// 新增:删除部门(禁止删除存在子部门的项),并清理用户关联
pub async fn delete(db: &Db, id: i64) -> Result<(), AppError> {
let _ = department::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let child_count = department::Entity::find().filter(department::Column::ParentId.eq(Some(id))).count(db).await?;
if child_count > 0 { return Err(AppError::BadRequest("请先删除子部门".into())); }
let txn = db.begin().await?;
user_department::Entity::delete_many().filter(user_department::Column::DepartmentId.eq(id)).exec(&txn).await?;
let _ = department::Entity::delete_by_id(id).exec(&txn).await?;
txn.commit().await?;
Ok(())
}

View File

@ -0,0 +1,71 @@
use crate::{db::Db, models::request_log};
use sea_orm::{ActiveModelTrait, Set, EntityTrait, ColumnTrait, QueryFilter, PaginatorTrait, QueryOrder};
use chrono::{DateTime, FixedOffset, Utc};
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct CreateLogInput {
pub path: String,
pub method: String,
pub request_params: Option<String>,
pub response_params: Option<String>,
pub status_code: i32,
pub user_id: Option<i64>,
pub username: Option<String>,
pub request_time: DateTime<FixedOffset>,
pub duration_ms: i64,
}
pub async fn create(db: &Db, input: CreateLogInput) -> anyhow::Result<i64> {
let am = request_log::ActiveModel {
id: Default::default(),
path: Set(input.path),
method: Set(input.method),
request_params: Set(input.request_params),
response_params: Set(input.response_params),
status_code: Set(input.status_code),
user_id: Set(input.user_id),
username: Set(input.username),
request_time: Set(input.request_time),
duration_ms: Set(input.duration_ms),
created_at: Set(Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap())),
};
let m = am.insert(db).await?;
Ok(m.id)
}
#[derive(serde::Serialize, Clone, Debug)]
pub struct LogInfo {
pub id: i64,
pub path: String,
pub method: String,
pub request_params: Option<String>,
pub response_params: Option<String>,
pub status_code: i32,
pub user_id: Option<i64>,
pub username: Option<String>,
pub request_time: chrono::DateTime<FixedOffset>,
pub duration_ms: i64,
}
impl From<request_log::Model> for LogInfo {
fn from(m: request_log::Model) -> Self {
Self { id: m.id, path: m.path, method: m.method, request_params: m.request_params, response_params: m.response_params, status_code: m.status_code, user_id: m.user_id, username: m.username, request_time: m.request_time, duration_ms: m.duration_ms }
}
}
#[derive(serde::Serialize)]
pub struct PageResp<T> { pub items: Vec<T>, pub total: u64, pub page: u64, pub page_size: u64 }
#[derive(serde::Deserialize)]
pub struct ListParams { pub page: Option<u64>, pub page_size: Option<u64>, pub path: Option<String>, pub start_time: Option<String>, pub end_time: Option<String> }
pub async fn list(db: &Db, p: ListParams) -> anyhow::Result<PageResp<LogInfo>> {
let page = p.page.unwrap_or(1); let page_size = p.page_size.unwrap_or(10);
let mut selector = request_log::Entity::find();
if let Some(path) = p.path { let like = format!("%{}%", path); selector = selector.filter(request_log::Column::Path.like(like)); }
if let Some(start) = p.start_time.as_deref() { if let Ok(dt) = start.parse::<chrono::DateTime<FixedOffset>>() { selector = selector.filter(request_log::Column::RequestTime.gte(dt)); } }
if let Some(end) = p.end_time.as_deref() { if let Ok(dt) = end.parse::<chrono::DateTime<FixedOffset>>() { selector = selector.filter(request_log::Column::RequestTime.lte(dt)); } }
let paginator = selector.order_by_desc(request_log::Column::Id).paginate(db, page_size);
let total = paginator.num_items().await? as u64;
let models = paginator.fetch_page(if page>0 { page-1 } else { 0 }).await?;
Ok(PageResp { items: models.into_iter().map(Into::into).collect(), total, page, page_size })
}

View File

@ -0,0 +1,84 @@
use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, ActiveModelTrait, Set, QueryOrder};
use sea_orm::prelude::DateTimeWithTimeZone;
use crate::{db::Db, models::menu, error::AppError};
use sea_orm::PaginatorTrait;
#[derive(Clone, Debug, serde::Serialize)]
pub struct MenuInfo { pub id: i64, pub parent_id: Option<i64>, pub name: String, pub path: Option<String>, pub component: Option<String>, pub r#type: i32, pub icon: Option<String>, pub order_no: i32, pub visible: bool, pub status: i32, pub keep_alive: bool, pub perms: Option<String>, pub created_at: DateTimeWithTimeZone }
impl From<menu::Model> for MenuInfo { fn from(m: menu::Model) -> Self { Self { id: m.id, parent_id: m.parent_id, name: m.name, path: m.path, component: m.component, r#type: m.r#type, icon: m.icon, order_no: m.order_no, visible: m.visible, status: m.status, keep_alive: m.keep_alive, perms: m.perms, created_at: m.created_at } } }
pub async fn list_all(db: &Db, keyword: Option<String>) -> Result<Vec<MenuInfo>, AppError> {
let mut selector = menu::Entity::find();
if let Some(k) = keyword { let like = format!("%{}%", k); selector = selector.filter(menu::Column::Name.like(like)); }
let models = selector.order_by_asc(menu::Column::OrderNo).order_by_asc(menu::Column::Id).all(db).await?;
Ok(models.into_iter().map(Into::into).collect())
}
#[derive(serde::Deserialize)]
pub struct CreateMenuInput { pub parent_id: Option<i64>, pub name: String, pub path: Option<String>, pub component: Option<String>, pub r#type: i32, pub icon: Option<String>, pub order_no: Option<i32>, pub visible: Option<bool>, pub status: Option<i32>, pub keep_alive: Option<bool>, pub perms: Option<String> }
pub async fn create(db: &Db, input: CreateMenuInput) -> Result<MenuInfo, AppError> {
let mut am: menu::ActiveModel = Default::default();
am.parent_id = Set(input.parent_id);
am.name = Set(input.name);
am.path = Set(input.path);
am.component = Set(input.component);
am.r#type = Set(input.r#type);
am.icon = Set(input.icon);
am.order_no = Set(input.order_no.unwrap_or(0));
am.visible = Set(input.visible.unwrap_or(true));
am.status = Set(input.status.unwrap_or(1));
am.keep_alive = Set(input.keep_alive.unwrap_or(true));
am.perms = Set(input.perms);
let m = am.insert(db).await?;
Ok(m.into())
}
#[derive(serde::Deserialize)]
pub struct UpdateMenuInput { pub parent_id: Option<i64>, pub name: Option<String>, pub path: Option<String>, pub component: Option<String>, pub r#type: Option<i32>, pub icon: Option<String>, pub order_no: Option<i32>, pub visible: Option<bool>, pub status: Option<i32>, pub keep_alive: Option<bool>, pub perms: Option<String> }
pub async fn update(db: &Db, id: i64, input: UpdateMenuInput) -> Result<MenuInfo, AppError> {
let m = menu::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let mut am: menu::ActiveModel = m.into();
if let Some(v) = input.parent_id { am.parent_id = Set(Some(v)); }
if let Some(v) = input.name { am.name = Set(v); }
if let Some(v) = input.path { am.path = Set(Some(v)); }
if let Some(v) = input.component { am.component = Set(Some(v)); }
if let Some(v) = input.r#type { am.r#type = Set(v); }
if let Some(v) = input.icon { am.icon = Set(Some(v)); }
if let Some(v) = input.order_no { am.order_no = Set(v); }
if let Some(v) = input.visible { am.visible = Set(v); }
if let Some(v) = input.status { am.status = Set(v); }
if let Some(v) = input.keep_alive { am.keep_alive = Set(v); }
if let Some(v) = input.perms { am.perms = Set(Some(v)); }
let m = am.update(db).await?;
Ok(m.into())
}
pub async fn list_by_ids(db: &Db, ids: Vec<i64>) -> Result<Vec<MenuInfo>, AppError> {
if ids.is_empty() { return Ok(vec![]); }
let models = menu::Entity::find()
.filter(menu::Column::Id.is_in(ids))
.filter(menu::Column::Visible.eq(true))
.filter(menu::Column::Status.eq(1))
.order_by_asc(menu::Column::OrderNo)
.order_by_asc(menu::Column::Id)
.all(db)
.await?;
Ok(models.into_iter().map(Into::into).collect())
}
// 新增:删除菜单(禁止删除存在子菜单的项),并清理角色关联
use crate::models::role_menu;
use sea_orm::TransactionTrait;
pub async fn delete(db: &Db, id: i64) -> Result<(), AppError> {
let _ = menu::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let child_count = menu::Entity::find().filter(menu::Column::ParentId.eq(Some(id))).count(db).await?;
if child_count > 0 { return Err(AppError::BadRequest("请先删除子菜单".into())); }
let txn = db.begin().await?;
role_menu::Entity::delete_many().filter(role_menu::Column::MenuId.eq(id)).exec(&txn).await?;
let _ = menu::Entity::delete_by_id(id).exec(&txn).await?;
txn.commit().await?;
Ok(())
}

View File

@ -0,0 +1,8 @@
pub mod auth_service;
pub mod user_service;
pub mod role_service;
pub mod menu_service;
pub mod department_service;
pub mod log_service;
// 新增岗位服务
pub mod position_service;

View File

@ -0,0 +1,87 @@
use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, PaginatorTrait, ActiveModelTrait, Set, QueryOrder, TransactionTrait, QuerySelect};
use sea_orm::prelude::DateTimeWithTimeZone;
use crate::{db::Db, models::{position, user_position}, error::AppError};
#[derive(Clone, Debug, serde::Serialize)]
pub struct PositionInfo { pub id: i64, pub name: String, pub code: String, pub description: Option<String>, pub status: i32, pub order_no: i32, pub created_at: DateTimeWithTimeZone }
impl From<position::Model> for PositionInfo { fn from(m: position::Model) -> Self { Self { id: m.id, name: m.name, code: m.code, description: m.description, status: m.status, order_no: m.order_no, created_at: m.created_at } } }
#[derive(Clone, Debug, serde::Serialize)]
pub struct PageResp<T> { pub items: Vec<T>, pub total: u64, pub page: u64, pub page_size: u64 }
pub async fn list(db: &Db, page: u64, page_size: u64, keyword: Option<String>) -> Result<PageResp<PositionInfo>, AppError> {
let mut selector = position::Entity::find();
if let Some(k) = keyword { let like = format!("%{}%", k); selector = selector.filter(position::Column::Name.like(like.clone()).or(position::Column::Code.like(like))); }
let paginator = selector.order_by_desc(position::Column::Id).paginate(db, page_size);
let total = paginator.num_items().await? as u64;
let models = paginator.fetch_page(if page>0 { page-1 } else { 0 }).await?;
Ok(PageResp { items: models.into_iter().map(Into::into).collect(), total, page, page_size })
}
#[derive(serde::Deserialize)]
pub struct CreatePositionInput { pub name: String, pub code: String, pub description: Option<String>, pub status: Option<i32>, pub order_no: Option<i32> }
pub async fn create(db: &Db, input: CreatePositionInput) -> Result<PositionInfo, AppError> {
if position::Entity::find().filter(position::Column::Code.eq(input.code.clone())).one(db).await?.is_some() {
return Err(AppError::BadRequest("position code already exists".into()));
}
let mut am: position::ActiveModel = Default::default();
am.name = Set(input.name);
am.code = Set(input.code);
am.description = Set(input.description);
am.status = Set(input.status.unwrap_or(1));
am.order_no = Set(input.order_no.unwrap_or(0));
let m = am.insert(db).await?;
Ok(m.into())
}
#[derive(serde::Deserialize)]
pub struct UpdatePositionInput { pub name: Option<String>, pub description: Option<String>, pub status: Option<i32>, pub order_no: Option<i32> }
pub async fn update(db: &Db, id: i64, input: UpdatePositionInput) -> Result<PositionInfo, AppError> {
let m = position::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let mut am: position::ActiveModel = m.into();
if let Some(v) = input.name { am.name = Set(v); }
if let Some(v) = input.description { am.description = Set(Some(v)); }
if let Some(v) = input.status { am.status = Set(v); }
if let Some(v) = input.order_no { am.order_no = Set(v); }
let m = am.update(db).await?;
Ok(m.into())
}
pub async fn delete(db: &Db, id: i64) -> Result<(), AppError> {
let _ = position::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let txn = db.begin().await?;
user_position::Entity::delete_many().filter(user_position::Column::PositionId.eq(id)).exec(&txn).await?;
let _ = position::Entity::delete_by_id(id).exec(&txn).await?;
txn.commit().await?;
Ok(())
}
pub async fn get_position_ids_by_user_id(db: &Db, user_id: i64) -> Result<Vec<i64>, AppError> {
let ids = user_position::Entity::find()
.filter(user_position::Column::UserId.eq(user_id))
.select_only()
.column(user_position::Column::PositionId)
.into_tuple::<i64>()
.all(db)
.await?;
Ok(ids)
}
pub async fn set_position_ids_by_user_id(db: &Db, user_id: i64, position_ids: Vec<i64>) -> Result<(), AppError> {
let txn = db.begin().await?;
user_position::Entity::delete_many().filter(user_position::Column::UserId.eq(user_id)).exec(&txn).await?;
if !position_ids.is_empty() {
let mut items: Vec<user_position::ActiveModel> = Vec::with_capacity(position_ids.len());
for pid in position_ids {
let mut am: user_position::ActiveModel = Default::default();
am.user_id = Set(user_id);
am.position_id = Set(pid);
items.push(am);
}
user_position::Entity::insert_many(items).exec(&txn).await?;
}
txn.commit().await?;
Ok(())
}

View File

@ -0,0 +1,136 @@
use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, PaginatorTrait, ActiveModelTrait, Set, QueryOrder};
use sea_orm::prelude::DateTimeWithTimeZone;
use crate::{db::Db, models::role, error::AppError};
#[derive(Clone, Debug, serde::Serialize)]
pub struct RoleInfo { pub id: i64, pub name: String, pub code: String, pub description: Option<String>, pub status: i32, pub created_at: DateTimeWithTimeZone }
impl From<role::Model> for RoleInfo { fn from(m: role::Model) -> Self { Self { id: m.id, name: m.name, code: m.code, description: m.description, status: m.status, created_at: m.created_at } } }
#[derive(Clone, Debug, serde::Serialize)]
pub struct PageResp<T> { pub items: Vec<T>, pub total: u64, pub page: u64, pub page_size: u64 }
pub async fn list(db: &Db, page: u64, page_size: u64, keyword: Option<String>) -> Result<PageResp<RoleInfo>, AppError> {
let mut selector = role::Entity::find();
if let Some(k) = keyword { let like = format!("%{}%", k); selector = selector.filter(role::Column::Name.like(like.clone()).or(role::Column::Code.like(like))); }
let paginator = selector.order_by_desc(role::Column::Id).paginate(db, page_size);
let total = paginator.num_items().await? as u64;
let models = paginator.fetch_page(if page>0 { page-1 } else { 0 }).await?;
Ok(PageResp { items: models.into_iter().map(Into::into).collect(), total, page, page_size })
}
#[derive(serde::Deserialize)]
pub struct CreateRoleInput { pub name: String, pub code: String, pub description: Option<String>, pub status: Option<i32> }
pub async fn create(db: &Db, input: CreateRoleInput) -> Result<RoleInfo, AppError> {
if role::Entity::find().filter(role::Column::Code.eq(input.code.clone())).one(db).await?.is_some() {
return Err(AppError::BadRequest("role code already exists".into()));
}
let mut am: role::ActiveModel = Default::default();
am.name = Set(input.name);
am.code = Set(input.code);
am.description = Set(input.description);
am.status = Set(input.status.unwrap_or(1));
let m = am.insert(db).await?;
Ok(m.into())
}
#[derive(serde::Deserialize)]
pub struct UpdateRoleInput { pub name: Option<String>, pub description: Option<String>, pub status: Option<i32> }
pub async fn update(db: &Db, id: i64, input: UpdateRoleInput) -> Result<RoleInfo, AppError> {
let m = role::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let mut am: role::ActiveModel = m.into();
if let Some(v) = input.name { am.name = Set(v); }
if let Some(v) = input.description { am.description = Set(Some(v)); }
if let Some(v) = input.status { am.status = Set(v); }
let m = am.update(db).await?;
Ok(m.into())
}
// --- Role assignments: menus only ---
use crate::models::{role_menu, user_role};
use sea_orm::{TransactionTrait, QuerySelect};
pub async fn get_menu_ids(db: &Db, role_id: i64) -> Result<Vec<i64>, AppError> {
let ids = role_menu::Entity::find()
.filter(role_menu::Column::RoleId.eq(role_id))
.select_only()
.column(role_menu::Column::MenuId)
.into_tuple::<i64>()
.all(db)
.await?;
Ok(ids)
}
pub async fn set_menu_ids(db: &Db, role_id: i64, ids: Vec<i64>) -> Result<(), AppError> {
let txn = db.begin().await?;
role_menu::Entity::delete_many()
.filter(role_menu::Column::RoleId.eq(role_id))
.exec(&txn)
.await?;
if !ids.is_empty() {
let mut items: Vec<role_menu::ActiveModel> = Vec::with_capacity(ids.len());
for mid in ids { let mut am: role_menu::ActiveModel = Default::default(); am.role_id = Set(role_id); am.menu_id = Set(mid); items.push(am); }
role_menu::Entity::insert_many(items).exec(&txn).await?;
}
txn.commit().await?;
Ok(())
}
pub async fn get_role_ids_by_user_id(db: &Db, user_id: i64) -> Result<Vec<i64>, AppError> {
let ids = user_role::Entity::find()
.filter(user_role::Column::UserId.eq(user_id))
.select_only()
.column(user_role::Column::RoleId)
.into_tuple::<i64>()
.all(db)
.await?;
Ok(ids)
}
pub async fn get_menu_ids_by_user_id(db: &Db, user_id: i64) -> Result<Vec<i64>, AppError> {
let role_ids = get_role_ids_by_user_id(db, user_id).await?;
if role_ids.is_empty() { return Ok(vec![]); }
let mut ids = role_menu::Entity::find()
.filter(role_menu::Column::RoleId.is_in(role_ids))
.select_only()
.column(role_menu::Column::MenuId)
.into_tuple::<i64>()
.all(db)
.await?;
ids.sort_unstable();
ids.dedup();
Ok(ids)
}
pub async fn set_role_ids_by_user_id(db: &Db, user_id: i64, role_ids: Vec<i64>) -> Result<(), AppError> {
let txn = db.begin().await?;
user_role::Entity::delete_many()
.filter(user_role::Column::UserId.eq(user_id))
.exec(&txn)
.await?;
if !role_ids.is_empty() {
let mut items: Vec<user_role::ActiveModel> = Vec::with_capacity(role_ids.len());
for rid in role_ids {
let mut am: user_role::ActiveModel = Default::default();
am.user_id = Set(user_id);
am.role_id = Set(rid);
items.push(am);
}
user_role::Entity::insert_many(items).exec(&txn).await?;
}
txn.commit().await?;
Ok(())
}
// 新增:删除角色(清理用户与权限、菜单关联)
pub async fn delete(db: &Db, id: i64) -> Result<(), AppError> {
let _ = role::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let txn = db.begin().await?;
role_menu::Entity::delete_many().filter(role_menu::Column::RoleId.eq(id)).exec(&txn).await?;
user_role::Entity::delete_many().filter(user_role::Column::RoleId.eq(id)).exec(&txn).await?;
let _ = role::Entity::delete_by_id(id).exec(&txn).await?;
txn.commit().await?;
Ok(())
}

View File

@ -0,0 +1,74 @@
use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, PaginatorTrait, ActiveModelTrait, Set, QueryOrder};
use sea_orm::prelude::DateTimeWithTimeZone;
use crate::{db::Db, models::user, utils::password, error::AppError};
#[derive(Clone, Debug, serde::Serialize)]
pub struct UserInfo { pub id: i64, pub username: String, pub nickname: Option<String>, pub status: i32, pub created_at: DateTimeWithTimeZone }
impl From<user::Model> for UserInfo { fn from(m: user::Model) -> Self { Self { id: m.id, username: m.username, nickname: m.nickname, status: m.status, created_at: m.created_at } } }
#[derive(Clone, Debug, serde::Serialize)]
pub struct PageResp<T> { pub items: Vec<T>, pub total: u64, pub page: u64, pub page_size: u64 }
pub async fn list(db: &Db, page: u64, page_size: u64, keyword: Option<String>) -> Result<PageResp<UserInfo>, AppError> {
let mut selector = user::Entity::find();
if let Some(k) = keyword { let like = format!("%{}%", k); selector = selector.filter(user::Column::Username.like(like.clone()).or(user::Column::Nickname.like(like))); }
let paginator = selector.order_by_desc(user::Column::Id).paginate(db, page_size);
let total = paginator.num_items().await? as u64;
let models = paginator.fetch_page(if page>0 { page-1 } else { 0 }).await?;
Ok(PageResp { items: models.into_iter().map(Into::into).collect(), total, page, page_size })
}
#[derive(serde::Deserialize)]
pub struct CreateUserInput { pub username: String, pub password: String, pub nickname: Option<String>, pub status: Option<i32> }
pub async fn create(db: &Db, input: CreateUserInput) -> Result<UserInfo, AppError> {
if user::Entity::find().filter(user::Column::Username.eq(input.username.clone())).one(db).await?.is_some() {
return Err(AppError::BadRequest("username already exists".into()));
}
let hash = password::hash_password(&input.password)?;
let mut am: user::ActiveModel = Default::default();
am.username = Set(input.username);
am.password_hash = Set(hash);
am.nickname = Set(input.nickname);
am.status = Set(input.status.unwrap_or(1));
let m = am.insert(db).await?;
Ok(m.into())
}
#[derive(serde::Deserialize)]
pub struct UpdateUserInput { pub nickname: Option<String>, pub status: Option<i32> }
pub async fn update(db: &Db, id: i64, input: UpdateUserInput) -> Result<UserInfo, AppError> {
let m = user::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let mut am: user::ActiveModel = m.into();
if let Some(n) = input.nickname { am.nickname = Set(Some(n)); }
if let Some(s) = input.status { am.status = Set(s); }
let m = am.update(db).await?;
Ok(m.into())
}
#[derive(serde::Deserialize)]
pub struct ResetPasswordInput { pub password: String }
pub async fn reset_password(db: &Db, id: i64, input: ResetPasswordInput) -> Result<(), AppError> {
let m = user::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let mut am: user::ActiveModel = m.into();
am.password_hash = Set(password::hash_password(&input.password)?);
let _ = am.update(db).await?;
Ok(())
}
// 新增:删除用户(包含清理关联关系)
use crate::models::{user_role, user_department, refresh_token};
use sea_orm::TransactionTrait;
pub async fn delete(db: &Db, id: i64) -> Result<(), AppError> {
let _ = user::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let txn = db.begin().await?;
user_role::Entity::delete_many().filter(user_role::Column::UserId.eq(id)).exec(&txn).await?;
user_department::Entity::delete_many().filter(user_department::Column::UserId.eq(id)).exec(&txn).await?;
refresh_token::Entity::delete_many().filter(refresh_token::Column::UserId.eq(id)).exec(&txn).await?;
let _ = user::Entity::delete_by_id(id).exec(&txn).await?;
txn.commit().await?;
Ok(())
}

1
backend/src/utils/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod password;

View File

@ -0,0 +1,15 @@
pub fn hash_password(plain: &str) -> anyhow::Result<String> {
use argon2::{password_hash::{SaltString, PasswordHasher}, Argon2};
let salt = SaltString::generate(&mut rand::thread_rng());
let hashed = Argon2::default()
.hash_password(plain.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!(e.to_string()))?
.to_string();
Ok(hashed)
}
pub fn verify_password(plain: &str, hashed: &str) -> anyhow::Result<bool> {
use argon2::{password_hash::{PasswordHash, PasswordVerifier}, Argon2};
let parsed = PasswordHash::new(&hashed).map_err(|e| anyhow::anyhow!(e.to_string()))?;
Ok(Argon2::default().verify_password(plain.as_bytes(), &parsed).is_ok())
}

BIN
backend/udmin.db Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
body_login.json Normal file
View File

@ -0,0 +1 @@
{"code":0,"message":"ok","data":{"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsInVpZCI6MSwiaXNzIjoidWRtaW4iLCJleHAiOjE3NTU3OTAwMjUsInR5cCI6ImFjY2VzcyJ9.Jp9I3Q6eCUHWe7TM4Xwzy6iLd-Y_It-izRgLNPRC7XE","user":{"id":1,"username":"admin","nickname":"Administrator","status":1}}}

5
cookies.txt Normal file
View File

@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1756997209 refresh_token eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsInVpZCI6MSwiaXNzIjoidWRtaW4iLCJleHAiOjE3NTY5OTcyMDksInR5cCI6InJlZnJlc2gifQ.1zYcf-hETforh0jdyTGhuTA7_8U9EUASYaeGXfC-Jkw

5
cookies_admin.txt Normal file
View File

@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1756997825 refresh_token eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsInVpZCI6MSwiaXNzIjoidWRtaW4iLCJleHAiOjE3NTY5OTc4MjUsInR5cCI6InJlZnJlc2gifQ.2w3R0i3ShF-ypbya6UN5ZZ19kBNv1hf_whP-RXGn3cc

1
frontend/.env Normal file
View File

@ -0,0 +1 @@
VITE_API_BASE=http://localhost:8080

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Udmin Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

28
frontend/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "frontend",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview --port 5173"
},
"dependencies": {
"@ant-design/icons": "^5.4.0",
"antd": "^5.17.0",
"axios": "^1.7.2",
"highlight.js": "^11.11.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-json-view-lite": "^2.4.2",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@types/react": "^18.3.24",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.2.0",
"typescript": "^5.4.0",
"vite": "^5.2.0"
}
}

40
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,40 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import Users from './pages/Users'
import MainLayout from './layouts/MainLayout'
import { getToken } from './utils/token'
import Roles from './pages/Roles'
import Menus from './pages/Menus'
import Permissions from './pages/Permissions'
import Departments from './pages/Departments'
import Logs from './pages/Logs'
// 移除不存在的 Layout/RequireAuth 组件导入
// 新增
import Positions from './pages/Positions'
function RequireAuth({ children }: { children: any }) {
const token = getToken()
if (!token) return <Navigate to="/login" replace />
return children
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<RequireAuth><MainLayout /></RequireAuth>}>
<Route index element={<Dashboard />} />
<Route path="/users" element={<Users />} />
<Route path="/roles" element={<Roles />} />
<Route path="/menus" element={<Menus />} />
<Route path="/permissions" element={<Permissions />} />
<Route path="/departments" element={<Departments />} />
<Route path="/logs" element={<Logs />} />
{/* 新增 */}
<Route path="/positions" element={<Positions />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none"><rect width="120" height="120" rx="20" fill="#1677FF"/><path d="M30 72c10-2 18-8 30-8s20 6 30 8v8c-10-2-18-8-30-8s-20 6-30 8v-8z" fill="#fff" opacity=".8"/><circle cx="60" cy="44" r="14" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 305 B

View File

@ -0,0 +1,21 @@
import React from 'react'
import { Breadcrumb } from 'antd'
interface PageHeaderProps {
items: string[]
title: string
style?: React.CSSProperties
extra?: React.ReactNode
}
export default function PageHeader({ items, title, style, extra }: PageHeaderProps) {
return (
<div style={style}>
<Breadcrumb style={{ marginBottom: 12 }} items={items.map(t => ({ title: t }))} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h2 style={{ margin: 0, fontSize: 18 }}>{title}</h2>
{extra ? <div>{extra}</div> : null}
</div>
</div>
)
}

View File

@ -0,0 +1,556 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Layout, Menu, theme, Avatar, Dropdown, Space, Modal, Form, Input, message, Breadcrumb, ConfigProvider, Tabs } from 'antd'
import { useNavigate, useLocation, useOutlet } from 'react-router-dom'
import { HomeOutlined, LogoutOutlined, TeamOutlined, SettingOutlined, AppstoreOutlined, KeyOutlined, UserOutlined, DashboardOutlined, FileOutlined, LockOutlined, MenuOutlined, PieChartOutlined, BarChartOutlined, TableOutlined, CalendarOutlined, FormOutlined, SearchOutlined, ToolOutlined, ShoppingCartOutlined, ShopOutlined, FolderOpenOutlined, FolderOutlined, CloudOutlined, DatabaseOutlined, ApiOutlined, CodeOutlined, BugOutlined, BellOutlined, PlusOutlined, EditOutlined, DeleteOutlined, UploadOutlined, DownloadOutlined, EyeOutlined, EyeInvisibleOutlined, StarOutlined, HeartOutlined, LikeOutlined, DislikeOutlined, SmileOutlined, FrownOutlined, PhoneOutlined, MailOutlined, EnvironmentOutlined, GlobalOutlined, AimOutlined, CompassOutlined, CameraOutlined, VideoCameraOutlined, SoundOutlined, WifiOutlined, RocketOutlined, ThunderboltOutlined, ExperimentOutlined, BulbOutlined, GiftOutlined, BankOutlined, WalletOutlined, MoneyCollectOutlined, BookOutlined, ReadOutlined, ProfileOutlined, CloudUploadOutlined, CloudDownloadOutlined, InboxOutlined, FolderAddOutlined, SlidersOutlined, FilterOutlined, AlertOutlined, ClockCircleOutlined, FieldTimeOutlined, HistoryOutlined, ContactsOutlined, SolutionOutlined, IdcardOutlined, QrcodeOutlined, ScanOutlined, SafetyOutlined, SecurityScanOutlined, UnlockOutlined, HddOutlined, CopyOutlined, ScissorOutlined, SnippetsOutlined, FileProtectOutlined, DesktopOutlined, LaptopOutlined, MobileOutlined, TabletOutlined, ClusterOutlined, AppstoreAddOutlined, PlusSquareOutlined, SyncOutlined, ReloadOutlined } from '@ant-design/icons'
import { clearToken, getUser as getUserLocal, setUser as setUserLocal } from '../utils/token'
import api from '../utils/axios'
import './layout.css'
import { PermissionProvider } from '../utils/permission'
const { Header, Sider, Content } = Layout
interface MenuItemResp { id: number; parent_id?: number | null; name: string; path?: string | null; component?: string | null; type: number; icon?: string | null; order_no: number; visible: boolean; status: number; perms?: string | null; keep_alive?: boolean }
import zhCN from 'antd/locale/zh_CN'
import enUS from 'antd/locale/en_US'
export default function MainLayout() {
const navigate = useNavigate()
const loc = useLocation()
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken()
// 简易语言切换(存储到 localStorage
const [lang, setLang] = useState<'zh' | 'en'>(() => (localStorage.getItem('lang') === 'en' ? 'en' : 'zh'))
const localeObj = lang === 'zh' ? zhCN : enUS
const [user, setUser] = useState(() => getUserLocal())
const [profileOpen, setProfileOpen] = useState(false)
const [form] = Form.useForm()
const [rawMenus, setRawMenus] = useState([] as MenuItemResp[])
const [menuItems, setMenuItems] = useState([] as any[])
// 从菜单提取权限编码集合(后端通过菜单返回 perms包含页面/按钮权限)
const permissionCodes = useMemo(() => {
const set = new Set<string>()
rawMenus.forEach((m: MenuItemResp) => {
const p = m.perms
if (p && typeof p === 'string') {
p.split(',').map(s => s.trim()).filter(Boolean).forEach(code => set.add(code))
}
})
return set
}, [rawMenus])
const iconMap: Record<string, any> = useMemo(() => ({
HomeOutlined: <HomeOutlined />,
UserOutlined: <UserOutlined />,
TeamOutlined: <TeamOutlined />,
SettingOutlined: <SettingOutlined />,
AppstoreOutlined: <AppstoreOutlined />,
KeyOutlined: <KeyOutlined />,
DashboardOutlined: <DashboardOutlined />,
FileOutlined: <FileOutlined />,
LockOutlined: <LockOutlined />,
MenuOutlined: <MenuOutlined />,
PieChartOutlined: <PieChartOutlined />,
BarChartOutlined: <BarChartOutlined />,
TableOutlined: <TableOutlined />,
CalendarOutlined: <CalendarOutlined />,
FormOutlined: <FormOutlined />,
SearchOutlined: <SearchOutlined />,
ToolOutlined: <ToolOutlined />,
ShoppingCartOutlined: <ShoppingCartOutlined />,
ShopOutlined: <ShopOutlined />,
FolderOpenOutlined: <FolderOpenOutlined />,
FolderOutlined: <FolderOutlined />,
CloudOutlined: <CloudOutlined />,
DatabaseOutlined: <DatabaseOutlined />,
ApiOutlined: <ApiOutlined />,
CodeOutlined: <CodeOutlined />,
BugOutlined: <BugOutlined />,
BellOutlined: <BellOutlined />,
// 新增常用图标映射
PlusOutlined: <PlusOutlined />,
EditOutlined: <EditOutlined />,
DeleteOutlined: <DeleteOutlined />,
UploadOutlined: <UploadOutlined />,
DownloadOutlined: <DownloadOutlined />,
EyeOutlined: <EyeOutlined />,
EyeInvisibleOutlined: <EyeInvisibleOutlined />,
StarOutlined: <StarOutlined />,
HeartOutlined: <HeartOutlined />,
LikeOutlined: <LikeOutlined />,
DislikeOutlined: <DislikeOutlined />,
SmileOutlined: <SmileOutlined />,
FrownOutlined: <FrownOutlined />,
PhoneOutlined: <PhoneOutlined />,
MailOutlined: <MailOutlined />,
EnvironmentOutlined: <EnvironmentOutlined />,
GlobalOutlined: <GlobalOutlined />,
AimOutlined: <AimOutlined />,
CompassOutlined: <CompassOutlined />,
CameraOutlined: <CameraOutlined />,
VideoCameraOutlined: <VideoCameraOutlined />,
SoundOutlined: <SoundOutlined />,
WifiOutlined: <WifiOutlined />,
RocketOutlined: <RocketOutlined />,
ThunderboltOutlined: <ThunderboltOutlined />,
ExperimentOutlined: <ExperimentOutlined />,
BulbOutlined: <BulbOutlined />,
GiftOutlined: <GiftOutlined />,
BankOutlined: <BankOutlined />,
WalletOutlined: <WalletOutlined />,
MoneyCollectOutlined: <MoneyCollectOutlined />,
BookOutlined: <BookOutlined />,
ReadOutlined: <ReadOutlined />,
ProfileOutlined: <ProfileOutlined />,
CloudUploadOutlined: <CloudUploadOutlined />,
CloudDownloadOutlined: <CloudDownloadOutlined />,
InboxOutlined: <InboxOutlined />,
FolderAddOutlined: <FolderAddOutlined />,
SlidersOutlined: <SlidersOutlined />,
FilterOutlined: <FilterOutlined />,
AlertOutlined: <AlertOutlined />,
ClockCircleOutlined: <ClockCircleOutlined />,
FieldTimeOutlined: <FieldTimeOutlined />,
HistoryOutlined: <HistoryOutlined />,
ContactsOutlined: <ContactsOutlined />,
SolutionOutlined: <SolutionOutlined />,
IdcardOutlined: <IdcardOutlined />,
QrcodeOutlined: <QrcodeOutlined />,
ScanOutlined: <ScanOutlined />,
SafetyOutlined: <SafetyOutlined />,
SecurityScanOutlined: <SecurityScanOutlined />,
UnlockOutlined: <UnlockOutlined />,
HddOutlined: <HddOutlined />,
CopyOutlined: <CopyOutlined />,
ScissorOutlined: <ScissorOutlined />,
SnippetsOutlined: <SnippetsOutlined />,
FileProtectOutlined: <FileProtectOutlined />,
DesktopOutlined: <DesktopOutlined />,
LaptopOutlined: <LaptopOutlined />,
MobileOutlined: <MobileOutlined />,
TabletOutlined: <TabletOutlined />,
ClusterOutlined: <ClusterOutlined />,
AppstoreAddOutlined: <AppstoreAddOutlined />,
PlusSquareOutlined: <PlusSquareOutlined />,
SyncOutlined: <SyncOutlined />,
ReloadOutlined: <ReloadOutlined />,
}), [])
useEffect(() => {
const fetchMenus = async () => {
try {
const { data } = await api.get('/auth/menus')
if (data?.code === 0) {
const list: MenuItemResp[] = data.data || []
setRawMenus(list)
}
} catch (e) {
// ignore
}
}
fetchMenus()
}, [])
useEffect(() => {
// 前端移除不再需要的“权限”相关菜单项
const filtered = rawMenus.filter((m: MenuItemResp) => m.path !== '/permissions' && m.path !== '/demo/perms')
// build tree items from filtered
const map = new Map<number, any>()
const byId = new Map<number, MenuItemResp>()
filtered.forEach((m: MenuItemResp) => byId.set(m.id, m))
filtered
.filter((m: MenuItemResp) => m.type !== 3) // skip buttons
.forEach((m: MenuItemResp) => {
const key = m.path && m.path.startsWith('/') ? m.path : `m-${m.id}`
const item: any = {
key,
label: m.name,
icon: m.icon ? iconMap[m.icon] : undefined,
// NOTE: 不要预先放 children: [],否则在 antd Menu 中会被当成可展开项
}
map.set(m.id, item)
})
const roots: any[] = []
filtered
.filter((m: MenuItemResp) => m.type !== 3)
.forEach((m: MenuItemResp) => {
const node = map.get(m.id)
const pid = m.parent_id || undefined
// 只有当父级不是按钮时,才允许挂载,避免将节点放到按钮下
if (pid && map.has(pid) && byId.get(pid)?.type !== 3) {
const parent = map.get(pid)
if (!parent.children) parent.children = []
parent.children.push(node)
} else {
roots.push(node)
}
})
setMenuItems(roots)
}, [rawMenus, iconMap])
const handleLogout = async () => {
try {
await api.post('/auth/logout')
} catch (_) {}
clearToken()
navigate('/login', { replace: true })
}
const openProfile = () => {
const u = getUserLocal()
setUser(u)
form.setFieldsValue({ username: u?.username, nickname: u?.nickname, avatar: (u as any)?.avatar || '' })
setProfileOpen(true)
}
const handleSaveProfile = async () => {
try{
const values = await form.validateFields()
const uid = (user as any)?.id
if (uid) {
const { data } = await api.put(`/users/${uid}`, { nickname: values.nickname })
if (data?.code !== 0) throw new Error(data?.message || '更新失败')
}
const merged = { ...(user as any), nickname: values.nickname, avatar: values.avatar }
setUserLocal(merged)
setUser(merged)
message.success('资料已更新')
setProfileOpen(false)
}catch(e: any){ if(e?.errorFields) return; message.error(e?.message || '更新失败') }
}
const onMenuClick = (info: any) => {
const key = info?.key as string
if (key && key.startsWith('/')) navigate(key)
}
const selectedKeys = useMemo(() => {
const k = loc.pathname
return [k]
}, [loc.pathname])
// 根据当前路径计算需要展开的父级菜单 keys不包含叶子自身
const [openKeys, setOpenKeys] = useState<string[]>(() => {
try {
const v = localStorage.getItem('layout:menuOpenKeys')
const arr = v ? JSON.parse(v) : []
return Array.isArray(arr) ? (arr as string[]) : []
} catch { return [] }
})
// 持久化用户手动展开/收起的子菜单状态,刷新后保持
useEffect(() => {
try { localStorage.setItem('layout:menuOpenKeys', JSON.stringify(openKeys)) } catch {}
}, [openKeys])
// 确保当前路由对应的父级菜单也处于展开状态,但不覆盖用户手动展开的其他菜单(合并而非替换)
const currentAncestors = useMemo(() => {
const list = rawMenus.filter((m: MenuItemResp) => m.type !== 3 && m.path !== '/permissions' && m.path !== '/demo/perms')
const idMap = new Map<number, MenuItemResp>()
const pathToId = new Map<string, number>()
list.forEach((m: MenuItemResp) => { idMap.set(m.id, m); if (m.path) pathToId.set(m.path, m.id) })
const currentPath = loc.pathname
let foundId: number | undefined = pathToId.get(currentPath)
if (!foundId) {
const candidates = list.filter((m: MenuItemResp) => m.path && currentPath.startsWith(m.path))
if (candidates.length) {
candidates.sort((a: MenuItemResp, b: MenuItemResp) => (b.path?.length || 0) - (a.path?.length || 0))
foundId = candidates[0].id
}
}
if (!foundId) return [] as string[]
const chain: MenuItemResp[] = []
let cur: MenuItemResp | undefined = idMap.get(foundId)
let guard = 0
while (cur && guard++ < 50) {
chain.push(cur)
const pid = cur.parent_id || undefined
cur = pid ? idMap.get(pid) : undefined
}
chain.reverse()
const ancestors = chain.slice(0, Math.max(0, chain.length - 1))
return ancestors.map((m: MenuItemResp) => (m.path && m.path.startsWith('/') ? m.path : `m-${m.id}`))
}, [rawMenus, loc.pathname])
useEffect(() => {
setOpenKeys(prev => {
const s = new Set(prev)
currentAncestors.forEach(k => s.add(k))
return Array.from(s)
})
}, [currentAncestors])
const dropdownMenuItems = useMemo(() => ([
{ key: 'profile', label: '个人信息' },
{ type: 'divider' as any },
{ key: 'logout', label: '退出登录', icon: <LogoutOutlined /> },
]), [])
// 基于菜单树与当前路径计算面包屑
const breadcrumbItems = useMemo(() => {
// 过滤掉不需要显示的菜单与按钮type 3
const list = rawMenus.filter((m: MenuItemResp) => m.type !== 3 && m.path !== '/permissions' && m.path !== '/demo/perms')
const idMap = new Map<number, MenuItemResp>()
const pathToId = new Map<string, number>()
list.forEach((m: MenuItemResp) => { idMap.set(m.id, m); if (m.path) pathToId.set(m.path, m.id) })
const currentPath = loc.pathname
let foundId: number | undefined
if (pathToId.has(currentPath)) {
foundId = pathToId.get(currentPath)!
} else {
// 兜底:取路径前缀最长匹配(例如 /users/detail/123 匹配 /users
const candidates = list.filter((m: MenuItemResp) => m.path && currentPath.startsWith(m.path))
if (candidates.length) {
candidates.sort((a: MenuItemResp, b: MenuItemResp) => (b.path?.length || 0) - (a.path?.length || 0))
foundId = candidates[0].id
}
}
if (!foundId) return [] as any[]
const chain: MenuItemResp[] = []
let cur: MenuItemResp | undefined = idMap.get(foundId)
let guard = 0
while (cur && guard++ < 50) {
chain.push(cur)
const pid = cur.parent_id || undefined
cur = pid ? idMap.get(pid) : undefined
}
chain.reverse()
return chain.map((m: MenuItemResp, idx: number) => ({
title: (m.path && idx !== chain.length - 1)
? <a onClick={(e: any) => { e.preventDefault(); navigate(m.path!) }}>{m.name}</a>
: m.name
}))
}, [rawMenus, loc.pathname, navigate])
// 路由页签与缓存(放在 return 之前的顶层)
type TabItem = { key: string; title: string; keepAlive: boolean }
const [tabs, setTabs] = useState<TabItem[]>([])
const [cache, setCache] = useState<Record<string, React.ReactNode>>({})
const outlet = useOutlet()
// 右侧菜单折叠状态(默认收起)
// 左侧菜单折叠状态:持久化到 localStorage刷新不丢失
const [leftCollapsed, setLeftCollapsed] = useState<boolean>(() => {
try {
const v = localStorage.getItem('layout:leftCollapsed')
return v ? v === '1' : false
} catch { return false }
})
// 断点状态:仅用于小屏时临时强制折叠,不写入持久化
const [isBroken, setIsBroken] = useState(false)
useEffect(() => {
try { localStorage.setItem('layout:leftCollapsed', leftCollapsed ? '1' : '0') } catch {}
}, [leftCollapsed])
// 取消自动展开:尊重用户的折叠偏好(刷新后保持折叠/展开状态)
// 取消自动展开:尊重用户的折叠偏好(刷新后保持折叠/展开状态)
// path -> menu 映射,便于取标题与 keep_alive
const pathToMenu = useMemo(() => {
const map = new Map<string, MenuItemResp>()
rawMenus.forEach((m: MenuItemResp) => { if (m.path) map.set(m.path, m) })
return map
}, [rawMenus])
// 当前路由激活 key
const activeKey = useMemo(() => loc.pathname, [loc.pathname])
// 根据菜单变更,修正已存在 tabs 的标题与 keepAlive解决刷新后标题变为路径的问题
useEffect(() => {
setTabs(prev => {
let changed = false
const next = prev.map(t => {
// 特殊处理首页:无菜单映射时也应显示“首页”
if (t.key === '/') {
const nextTitle = '首页'
const nextKeep = true
if (t.title !== nextTitle || t.keepAlive !== nextKeep) {
changed = true
return { ...t, title: nextTitle, keepAlive: nextKeep }
}
return t
}
const m = pathToMenu.get(t.key)
if (!m) return t
const nextKeep = (typeof m.keep_alive === 'boolean') ? !!m.keep_alive : true
const nextTitle = m.name || t.title
if (t.title !== nextTitle || t.keepAlive !== nextKeep) {
changed = true
return { ...t, title: nextTitle, keepAlive: nextKeep }
}
return t
})
return changed ? next : prev
})
}, [pathToMenu])
// 根据当前路由补齐 tabs
useEffect(() => {
const curPath = loc.pathname
if (!curPath.startsWith('/')) return
const m = pathToMenu.get(curPath)
const title = m?.name || (curPath === '/' ? '首页' : curPath)
const keepAlive = (m && typeof m.keep_alive === 'boolean') ? !!m.keep_alive : true
setTabs(prev => (prev.some(t => t.key === curPath) ? prev : [...prev, { key: curPath, title, keepAlive }]))
}, [loc.pathname, pathToMenu])
// 确保缓存当前激活页(若 keepAlive 打开)
useEffect(() => {
const cur = tabs.find(t => t.key === activeKey)
if (!cur) return
if (cur.keepAlive && outlet && !cache[activeKey]) {
setCache(prev => ({ ...prev, [activeKey]: outlet }))
}
}, [activeKey, outlet, tabs, cache])
const onTabChange = (key: string) => { if (key && key !== activeKey) navigate(key) }
const onTabEdit = (targetKey: any, action: 'add' | 'remove') => {
if (action === 'remove') {
const tKey = String(targetKey)
if (tKey === '/') return
setTabs(prev => {
const idx = prev.findIndex(t => t.key === tKey)
if (idx === -1) return prev
const next = prev.filter(t => t.key !== tKey)
// 清理缓存
setCache(c => { const n = { ...c }; delete n[tKey]; return n })
// 如果关闭的是当前激活页,跳转到相邻页
if (tKey === activeKey) {
const fallback = next[idx - 1] || next[idx] || { key: '/' }
if (fallback.key) navigate(fallback.key)
}
return next
})
}
}
// 确保“首页”始终存在且固定(仅初始化时补齐一次)
useEffect(() => {
setTabs(prev => (prev.some(t => t.key === '/') ? prev : [{ key: '/', title: '首页', keepAlive: true }, ...prev]))
}, [])
return (
<PermissionProvider codes={permissionCodes}>
<ConfigProvider locale={localeObj}>
<Layout style={{ minHeight: '100vh' }}>
{/* 其余内容保持不变 */}
<Sider
collapsible
collapsed={isBroken ? true : leftCollapsed}
onCollapse={(collapsed, type) => {
// 仅在用户点击触发器时更新用户偏好;响应式折叠不写入
if (type === 'clickTrigger') {
setLeftCollapsed(collapsed)
}
}}
breakpoint="lg"
onBreakpoint={(broken) => setIsBroken(broken)}
trigger={null}
collapsedWidth={56}
width={220}
style={{ background: colorBgContainer }}
>
<div
style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}
onClick={() => setLeftCollapsed(c => !c)}
title={leftCollapsed ? '展开菜单' : '收起菜单'}
>
{leftCollapsed ? 'U' : 'Udmin'}
</div>
<Menu
mode="inline"
selectedKeys={selectedKeys}
openKeys={openKeys}
onOpenChange={(keys) => setOpenKeys(keys as string[])}
inlineCollapsed={isBroken ? true : leftCollapsed}
items={menuItems}
onClick={onMenuClick}
style={{ borderInlineEnd: 0 }}
/>
</Sider>
<Layout>
<Header style={{ padding: '8px 16px', background: colorBgContainer }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 12 }}>
{/* 用户要求:隐藏展开/收起图标,保留名称点击收起/展开 */}
{tabs.length > 0 && (
<Tabs
className="top-tabs"
hideAdd
type="editable-card"
items={tabs.map(t => ({ key: t.key, label: t.title, closable: t.key !== '/' }))}
activeKey={activeKey}
onChange={onTabChange}
onEdit={onTabEdit as any}
/>
)}
</div>
<Space size={16} align="center">
<Dropdown
menu={{
items: [
{ key: 'zh', label: '中文' },
{ key: 'en', label: 'English' },
],
onClick: ({ key }) => { const v = key as 'zh'|'en'; setLang(v); localStorage.setItem('lang', v) }
}}
placement="bottomLeft"
>
<Space style={{ cursor: 'pointer', userSelect: 'none' }}>
<GlobalOutlined />
<span>{lang === 'zh' ? '中文' : 'English'}</span>
</Space>
</Dropdown>
<Dropdown
menu={{ items: dropdownMenuItems as any, onClick: (e: { key: string }) => { if (e.key === 'logout') handleLogout(); if (e.key === 'profile') openProfile() } }}
placement="bottomRight"
>
<Space style={{ cursor: 'pointer' }}>
<Avatar size={32} icon={<UserOutlined />} />
<span>{(user as any)?.nickname || (user as any)?.username || '用户'}</span>
</Space>
</Dropdown>
</Space>
</div>
</Header>
<Content style={{ margin: '16px', padding: 16, background: colorBgContainer, borderRadius: borderRadiusLG }}>
{/* 缓存的标签页:非激活隐藏 */}
{tabs.filter(t => t.keepAlive).map(t => (
<div key={t.key} style={{ display: t.key === activeKey ? 'block' : 'none' }}>
{cache[t.key]}
</div>
))}
{/* 未开启缓存:只渲染当前路由 */}
{(() => {
const cur = tabs.find(t => t.key === activeKey)
if (!cur) return null
if (cur.keepAlive) return null
return <div key={activeKey}>{outlet}</div>
})()}
</Content>
</Layout>
</Layout>
<Modal title="个人信息" open={profileOpen} onOk={handleSaveProfile} onCancel={() => setProfileOpen(false)} okText="保存" width={840}>
<Form form={form} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="username" label="用户名">
<Input disabled />
</Form.Item>
<Form.Item name="nickname" label="昵称" rules={[{ required: true, message: '请输入昵称' }]}>
<Input placeholder="请输入昵称" />
</Form.Item>
<Form.Item name="avatar" label="头像 URL">
<Input placeholder="请输入头像图片地址URL" />
</Form.Item>
</Form>
</Modal>
</ConfigProvider>
</PermissionProvider>
)
}

View File

@ -0,0 +1 @@
.logo{height:48px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:18px}

12
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,12 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import 'antd/dist/reset.css'
import './styles/global.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<App />
</BrowserRouter>
)

View File

@ -0,0 +1,171 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Card, Col, Progress, Row, Statistic, Table, Tag, Typography } from 'antd'
import api from '../utils/axios'
import { formatDateTime } from '../utils/datetime'
import PageHeader from '../components/PageHeader'
interface UserItem { id: number; username: string; nickname?: string; status?: number; created_at?: string }
export default function Dashboard() {
const [loading, setLoading] = useState(false)
const [userTotal, setUserTotal] = useState(0)
const [roleTotal, setRoleTotal] = useState(0)
const [deptTotal, setDeptTotal] = useState(0)
const [menuTotal, setMenuTotal] = useState(0)
const [userSample, setUserSample] = useState([] as UserItem[])
useEffect(() => {
const fetchAll = async () => {
setLoading(true)
try {
const [usersRes, rolesRes, deptsRes, menusRes, usersSampleRes] = await Promise.all([
api.get('/users', { params: { page: 1, page_size: 1 } }),
api.get('/roles', { params: { page: 1, page_size: 1 } }),
api.get('/departments', { params: { keyword: '' } }),
api.get('/menus'),
api.get('/users', { params: { page: 1, page_size: 200 } }),
])
if (usersRes.data?.code === 0) setUserTotal(Number(usersRes.data?.data?.total || 0))
if (rolesRes.data?.code === 0) setRoleTotal(Number(rolesRes.data?.data?.total || 0))
if (deptsRes.data?.code === 0) setDeptTotal(Array.isArray(deptsRes.data?.data) ? deptsRes.data.data.length : 0)
if (menusRes.data?.code === 0) setMenuTotal(Array.isArray(menusRes.data?.data) ? menusRes.data.data.length : 0)
if (usersSampleRes.data?.code === 0) setUserSample(Array.isArray(usersSampleRes.data?.data?.items) ? usersSampleRes.data.data.items : [])
} catch (e) {
// ignore on dashboard
} finally { setLoading(false) }
}
fetchAll()
}, [])
// 用户状态分布(基于 sample 数据近似统计)
const statusDist = useMemo(() => {
const enabled = userSample.reduce((acc, u) => acc + (u.status === 1 ? 1 : 0), 0)
const total = userSample.length || 1
const percentEnabled = Math.round((enabled / total) * 100)
return { enabled, disabled: total - enabled, percentEnabled }
}, [userSample])
// 近7天新增用户基于 sample 的 created_at 统计)
const last7Days = useMemo(() => {
const today = new Date()
const days: string[] = []
for (let i = 6; i >= 0; i--) {
const d = new Date(today)
d.setDate(today.getDate() - i)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const da = String(d.getDate()).padStart(2, '0')
days.push(`${y}-${m}-${da}`)
}
const counter: Record<string, number> = Object.fromEntries(days.map(d => [d, 0]))
userSample.forEach(u => {
if (!u.created_at) return
const dt = new Date(u.created_at)
if (Number.isNaN(dt.getTime())) return
const y = dt.getFullYear()
const m = String(dt.getMonth() + 1).padStart(2, '0')
const da = String(dt.getDate()).padStart(2, '0')
const key = `${y}-${m}-${da}`
if (key in counter) counter[key] += 1
})
return days.map(d => ({ date: d, value: counter[d] || 0 }))
}, [userSample])
const userColumns = useMemo(() => [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '用户名', dataIndex: 'username' },
{ title: '昵称', dataIndex: 'nickname' },
{ title: '状态', dataIndex: 'status', render: (v: number) => v === 1 ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: '创建时间', dataIndex: 'created_at', render: (v: any) => formatDateTime(v) },
], [])
const recentUsers = useMemo(() => {
const withTime = userSample.filter(u => !!u.created_at)
withTime.sort((a, b) => (new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()))
return withTime.slice(0, 8)
}, [userSample])
const maxDaily = Math.max(...last7Days.map(d => d.value), 1)
return (
<div>
<PageHeader items={["首页"]} title="首页" />
<Row gutter={[16, 16]}>
<Col xs={12} sm={12} md={6}>
<Card loading={loading}>
<Statistic title="用户总数" value={userTotal} />
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card loading={loading}>
<Statistic title="角色总数" value={roleTotal} />
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card loading={loading}>
<Statistic title="部门总数" value={deptTotal} />
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card loading={loading}>
<Statistic title="菜单总数" value={menuTotal} />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} md={12}>
<Card title="用户状态分布(样本)" loading={loading}>
<Row gutter={24}>
<Col span={12} style={{ textAlign: 'center' }}>
<Progress type="dashboard" percent={statusDist.percentEnabled} />
<div style={{ marginTop: 8 }}></div>
</Col>
<Col span={12}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div><Tag color="green">{statusDist.enabled}</Tag></div>
<div><Tag>{statusDist.disabled}</Tag></div>
<div>{userSample.length}</div>
</div>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} md={12}>
<Card title="近7天新增用户样本" loading={loading}>
<div style={{ height: 180, display: 'flex', alignItems: 'flex-end', gap: 8 }}>
{last7Days.map(d => (
<div key={d.date} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ width: 20, background: '#1677ff', height: Math.max(4, (d.value / maxDaily) * 140) }} />
<div style={{ marginTop: 6, fontSize: 12 }}>{d.value}</div>
<div style={{ marginTop: 2, fontSize: 12, color: '#999' }}>{d.date.slice(5)}</div>
</div>
))}
</div>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col span={24}>
<Card title="最近注册用户(样本)">
<Table<UserItem>
rowKey="id"
size="small"
pagination={false}
dataSource={recentUsers}
columns={userColumns as any}
/>
</Card>
</Col>
</Row>
<Card style={{ marginTop: 16 }}>
<Typography.Paragraph>
使 Udmin
</Typography.Paragraph>
</Card>
</div>
)
}

View File

@ -0,0 +1,257 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Button, Form, Input, Modal, Select, Space, Table, Tag, message, Popconfirm, TreeSelect } from 'antd'
import api from '../utils/axios'
import type { ColumnsType } from 'antd/es/table'
import { formatDateTime } from '../utils/datetime'
import { EditOutlined, DeleteOutlined, ApartmentOutlined, FolderOutlined, FileOutlined } from '@ant-design/icons'
import PageHeader from '../components/PageHeader'
interface DeptItem { id: number; parent_id?: number; name: string; order_no: number; status: number; created_at?: string; children?: DeptItem[] }
export default function Departments(){
const [loading, setLoading] = useState(false)
const [data, setData] = useState([] as DeptItem[])
const [keyword, setKeyword] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [current, setCurrent] = useState(null as DeptItem | null)
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const [searchForm] = Form.useForm()
const fetchList = async (kw: string = keyword) => {
setLoading(true)
try{
const { data } = await api.get('/departments', { params: { keyword: kw } })
if(data?.code === 0){ setData(data.data || []) } else { throw new Error(data?.message || '获取部门失败') }
}catch(e: any){ message.error(e.message || '获取部门失败') } finally { setLoading(false) }
}
const didInitFetchRef = useRef(false)
useEffect(()=>{
if(didInitFetchRef.current) return
didInitFetchRef.current = true
fetchList('')
}, [])
// 构建部门树用于树形选择
type DeptTreeNode = { title: string; value: number; key: number; children?: DeptTreeNode[] }
const deptTreeData: DeptTreeNode[] = useMemo(() => {
const map = new Map<number, DeptTreeNode & { children: DeptTreeNode[] }>()
data.forEach((d: DeptItem) => map.set(d.id, { title: d.name, value: d.id, key: d.id, children: [] }))
const roots: (DeptTreeNode & { children: DeptTreeNode[] })[] = []
data.forEach((d: DeptItem) => {
const node = map.get(d.id)!
if (d.parent_id && map.has(d.parent_id)) {
const parent = map.get(d.parent_id)
if (parent) parent.children.push(node)
} else {
roots.push(node)
}
})
return roots
}, [data])
const getDescendantIds = (list: DeptItem[], id: number): Set<number> => {
const childrenMap = new Map<number, number[]>()
list.forEach(d => {
if(d.parent_id){
if(!childrenMap.has(d.parent_id)) childrenMap.set(d.parent_id, [])
childrenMap.get(d.parent_id)!.push(d.id)
}
})
const res = new Set<number>()
const stack = (childrenMap.get(id) || []).slice()
while(stack.length){
const cur = stack.pop()!
if(!res.has(cur)){
res.add(cur)
const next = childrenMap.get(cur) || []
next.forEach(n => stack.push(n))
}
}
return res
}
const filterTree = (nodes: DeptTreeNode[], blocked: Set<number>): DeptTreeNode[] => {
const recur = (arr: DeptTreeNode[]): DeptTreeNode[] => arr
.filter(n => !blocked.has(n.key as number))
.map(n => ({ ...n, children: n.children ? recur(n.children) : undefined }))
return recur(nodes)
}
const editTreeData = useMemo(() => {
if(!current) return deptTreeData
const blocked = getDescendantIds(data, current.id)
blocked.add(current.id)
return filterTree(deptTreeData, blocked)
}, [current, deptTreeData, data])
// 构建用于表格展示的部门树
const treeDataForTable: DeptItem[] = useMemo(() => {
const map = new Map<number, DeptItem>()
const roots: DeptItem[] = []
// 初始化节点(不预置 children避免叶子显示展开图标
data.forEach((d: DeptItem) => map.set(d.id, { ...d }))
data.forEach((d: DeptItem) => {
const node = map.get(d.id)!
if (d.parent_id && map.has(d.parent_id)) {
const parent = map.get(d.parent_id)!
if (!parent.children) parent.children = []
parent.children.push(node)
} else {
roots.push(node)
}
})
return roots
}, [data])
// 默认展开顶层且有子节点的部门
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([])
useEffect(() => {
const rootsWithChildren = treeDataForTable
.filter((n: DeptItem) => Array.isArray(n.children) && n.children.length > 0)
.map((n: DeptItem) => n.id as React.Key)
setExpandedRowKeys(rootsWithChildren)
}, [treeDataForTable]);
// (已移除)一键展开/收起工具
// 已根据你的要求删除“展开顶层 / 展开全部 / 收起全部”相关函数与按钮
// 新增/编辑处理
const onCreate = () => { form.resetFields(); setCreateOpen(true) }
const handleCreate = async () => {
try{
const values = await form.validateFields()
const payload = {
...values,
parent_id: values.parent_id != null && values.parent_id !== '' ? Number(values.parent_id) : undefined,
order_no: values.order_no !== undefined && values.order_no !== '' ? Number(values.order_no) : 0,
status: values.status !== undefined ? Number(values.status) : 1,
}
const { data } = await api.post('/departments', payload)
if(data?.code === 0){ message.success('创建成功'); setCreateOpen(false); fetchList(keyword) } else { throw new Error(data?.message || '创建失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '创建失败') }
}
const onEdit = (record: DeptItem) => { setCurrent(record); editForm.setFieldsValue({ parent_id: record.parent_id, name: record.name, order_no: record.order_no, status: record.status }); setEditOpen(true) }
const handleEdit = async () => {
try{
const values = await editForm.validateFields()
const payload = {
...values,
parent_id: values.parent_id != null && values.parent_id !== '' ? Number(values.parent_id) : undefined,
order_no: values.order_no !== undefined && values.order_no !== '' ? Number(values.order_no) : undefined,
status: values.status !== undefined ? Number(values.status) : undefined,
}
const { data } = await api.put(`/departments/${current!.id}`, payload)
if(data?.code === 0){ message.success('更新成功'); setEditOpen(false); fetchList(keyword) } else { throw new Error(data?.message || '更新失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '更新失败') }
}
const columns: ColumnsType<DeptItem> = useMemo(() => [
{ title: '名称', dataIndex: 'name', render: (_: any, r: DeptItem) => {
const isRoot = r.parent_id == null
const hasChildren = Array.isArray(r.children) && r.children.length > 0
const Icon = isRoot ? ApartmentOutlined : hasChildren ? FolderOutlined : FileOutlined
const color = isRoot ? '#1677ff' : hasChildren ? '#faad14' : '#999'
return (
<Space size={6}>
<Icon style={{ color }} />
<span>{r.name}</span>
</Space>
)
}},
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '排序', dataIndex: 'order_no', width: 100 },
{ title: '状态', dataIndex: 'status', width: 120, render: (v: number) => v === 1 ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: '创建时间', dataIndex: 'created_at', width: 200, render: (v: any) => formatDateTime(v) },
{ title: '操作', key: 'actions', width: 300, render: (_: any, r: DeptItem) => (
<Space>
<a className="action-link" onClick={() => onEdit(r)}>
<EditOutlined />
<span></span>
</a>
<Popconfirm title={`确认删除部门「${r.name}」?(若存在子部门将无法删除)`} onConfirm={async ()=>{ try{ const { data } = await api.delete(`/departments/${r.id}`); if(data?.code===0){ message.success('删除成功'); fetchList(keyword) } else { throw new Error(data?.message||'删除失败') } }catch(e:any){ message.error(e.message||'删除失败') } }}>
<a className="action-link action-danger">
<DeleteOutlined />
<span></span>
</a>
</Popconfirm>
</Space>
)},
], [keyword])
return (
<div>
<PageHeader items={["系统管理","部门管理"]} title="" />
<Form form={searchForm} layout="inline" onFinish={(vals: any)=>{ const kw = String(vals?.keyword ?? '').trim(); setKeyword(kw); fetchList(kw) }} style={{ marginBottom: 12 }}>
<Form.Item name="keyword">
<Input allowClear placeholder="搜索部门" style={{ width: 320 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={()=>{ searchForm.resetFields(); setKeyword(''); fetchList('') }}></Button>
<Button type="primary" onClick={onCreate}></Button>
</Space>
</Form.Item>
</Form>
<Table<DeptItem>
rowKey="id"
loading={loading}
dataSource={treeDataForTable}
columns={columns}
expandable={{ expandedRowKeys, onExpandedRowsChange: (keys) => setExpandedRowKeys(keys as React.Key[]), indentSize: 18, rowExpandable: (record: DeptItem) => Array.isArray(record.children) && record.children.length > 0 }}
pagination={false}
/>
<Modal title="新增部门" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="创建" width={840}>
<Form form={form} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="parent_id" label="上级部门">
<TreeSelect
allowClear
placeholder="选择上级(可为空,表示顶级部门)"
style={{ width: '100%' }}
treeData={deptTreeData}
treeDefaultExpandAll
showSearch
filterTreeNode={(input: string, node: any) => String(node?.title ?? '').toLowerCase().includes(String(input).toLowerCase())}
/>
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="请输入名称" />
</Form.Item>
<Form.Item name="order_no" label="排序" initialValue={0}>
<Input type="number" placeholder="数字越小越靠前" />
</Form.Item>
<Form.Item name="status" label="状态" initialValue={1}>
<Select options={[{label:'启用', value:1},{label:'禁用', value:0}]} />
</Form.Item>
</Form>
</Modal>
<Modal title="编辑部门" open={editOpen} onOk={handleEdit} onCancel={() => setEditOpen(false)} okText="保存" width={840}>
<Form form={editForm} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="parent_id" label="上级部门">
<TreeSelect
allowClear
placeholder="选择上级(不可选择自身及其子部门)"
style={{ width: '100%' }}
treeData={editTreeData}
treeDefaultExpandAll
showSearch
filterTreeNode={(input: string, node: any) => String(node?.title ?? '').toLowerCase().includes(String(input).toLowerCase())}
/>
</Form.Item>
<Form.Item name="name" label="名称">
<Input placeholder="请输入名称" />
</Form.Item>
<Form.Item name="order_no" label="排序">
<Input type="number" placeholder="数字越小越靠前" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={[{label:'启用', value:1},{label:'禁用', value:0}]} />
</Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@ -0,0 +1,47 @@
import { LockOutlined, UserOutlined } from '@ant-design/icons'
import { Button, Card, Form, Input, message, Typography } from 'antd'
import { useNavigate } from 'react-router-dom'
import api from '../utils/axios'
import { setToken, setUser } from '../utils/token'
import './login.css'
export default function Login() {
const navigate = useNavigate()
const [form] = Form.useForm()
const onFinish = async (values: any) => {
try {
const { data } = await api.post('/auth/login', values)
if (data?.code === 0) {
const token = data.data.access_token
setToken(token)
setUser(data.data.user)
message.success('登录成功')
navigate('/', { replace: true })
} else {
throw new Error(data?.message || '登录失败')
}
} catch (e: any) {
message.error(e.message || '登录失败')
}
}
return (
<div className="login-wrap">
<Card className="login-card" variant="outlined">
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>Udmin </Typography.Title>
<Form form={form} onFinish={onFinish} layout="vertical" initialValues={{ username: 'admin', password: 'Admin@123' }}>
<Form.Item name="username" label="用户名" rules={[{ required: true }]}>
<Input size="large" prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true }]}>
<Input.Password size="large" prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block></Button>
</Form.Item>
</Form>
</Card>
</div>
)
}

172
frontend/src/pages/Logs.tsx Normal file
View File

@ -0,0 +1,172 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Button, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, message, Descriptions, DatePicker, Drawer, Typography } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import api, { ApiResp } from '../utils/axios'
import dayjs from 'dayjs'
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'
import { EyeOutlined } from '@ant-design/icons'
import PageHeader from '../components/PageHeader'
interface LogInfo {
id: number
path: string
method: string
request_params?: string
response_params?: string
status_code: number
user_id?: number
username?: string
request_time: string
duration_ms: number
}
interface PageResp<T> { items: T[]; total: number; page: number; page_size: number }
export default function Logs() {
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [data, setData] = useState<LogInfo[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [detailOpen, setDetailOpen] = useState(false)
const [detail, setDetail] = useState<LogInfo | null>(null)
const fetchData = async (p = page, ps = pageSize) => {
const v = form.getFieldsValue()
const params: any = { page: p, page_size: ps }
if (v.path) params.path = v.path
if (v.time && Array.isArray(v.time) && v.time.length === 2) {
params.start_time = (v.time[0] as dayjs.Dayjs).toISOString()
params.end_time = (v.time[1] as dayjs.Dayjs).toISOString()
}
setLoading(true)
try {
const { data } = await api.get<ApiResp<PageResp<LogInfo>>>('/logs', { params })
if (data?.code === 0) {
setData(data.data?.items || [])
setTotal(data.data?.total || 0)
setPage(data.data?.page || p)
setPageSize(data.data?.page_size || ps)
}
} finally { setLoading(false) }
}
useEffect(() => { fetchData(1, 10) }, [])
const openDetail = (record: LogInfo) => { setDetail(record); setDetailOpen(true) }
const closeDetail = () => setDetailOpen(false)
const columns = useMemo(() => [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '时间', dataIndex: 'request_time', width: 180, render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss') },
{ title: '路径', dataIndex: 'path', width: 240 },
{ title: '方法', dataIndex: 'method', width: 90, render: (m: string) => <Tag>{m}</Tag> },
{ title: '用户', dataIndex: 'username', width: 140, render: (_: any, r: LogInfo) => r.username || (r.user_id ? `UID:${r.user_id}` : '-') },
{ title: '状态码', dataIndex: 'status_code', width: 100 },
{ title: '耗时(ms)', dataIndex: 'duration_ms', width: 100 },
{ title: '请求参数', dataIndex: 'request_params', width: 260, ellipsis: true },
{ title: '响应参数', dataIndex: 'response_params', width: 260, ellipsis: true },
{ title: '操作', key: 'action', fixed: 'right' as any, width: 120, render: (_: any, r: LogInfo) => (
<Space>
<a className="action-link" onClick={() => openDetail(r)}>
<EyeOutlined />
<span></span>
</a>
</Space>
)},
], [])
const tryParse = (s: string) => { try { return JSON.parse(s) } catch { return undefined } }
const prettyJson = (raw?: string) => {
if (!raw) return ''
let text = raw
// First try direct JSON
const p1 = tryParse(text)
if (p1 !== undefined) {
if (typeof p1 === 'string') {
const p2 = tryParse(p1)
text = JSON.stringify(p2 !== undefined ? p2 : p1, null, 2)
} else {
text = JSON.stringify(p1, null, 2)
}
return text
}
// Try URI-decoded then JSON
try {
const decoded = decodeURIComponent(text)
const p3 = tryParse(decoded)
if (p3 !== undefined) return JSON.stringify(p3, null, 2)
} catch {}
return text
}
const highlightCode = (raw?: string) => {
const code = prettyJson(raw)
try { return hljs.highlight(code, { language: 'json' }).value } catch { return hljs.highlightAuto(code).value }
}
const reqHtml = useMemo(() => highlightCode(detail?.request_params), [detail?.request_params])
const respHtml = useMemo(() => highlightCode(detail?.response_params), [detail?.response_params])
return (
<div>
<PageHeader items={["系统管理","日志管理"]} title="" />
<Form form={form} layout="inline" onFinish={() => fetchData(1, pageSize)} style={{ marginBottom: 12 }}>
<Form.Item label="请求路径" name="path">
<Input placeholder="like /users" allowClear style={{ width: 260 }} />
</Form.Item>
<Form.Item label="发起时间" name="time">
<DatePicker.RangePicker showTime allowClear />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => { form.resetFields(); fetchData(1, 10) }}></Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
dataSource={data}
columns={columns as any}
scroll={{ x: 2000 }}
pagination={{ current: page, pageSize, total, onChange: (p, ps) => fetchData(p, ps) }}
/>
<Drawer title="日志详情" width={720} open={detailOpen} onClose={closeDetail} destroyOnClose placement="right">
{detail && (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="时间">{dayjs(detail.request_time).format('YYYY-MM-DD HH:mm:ss')}</Descriptions.Item>
<Descriptions.Item label="路径">{detail.path}</Descriptions.Item>
<Descriptions.Item label="方法"><Tag>{detail.method}</Tag></Descriptions.Item>
<Descriptions.Item label="用户">{detail.username || (detail.user_id ? `UID:${detail.user_id}` : '-')}</Descriptions.Item>
<Descriptions.Item label="状态码">{detail.status_code}</Descriptions.Item>
<Descriptions.Item label="耗时(ms)">{detail.duration_ms}</Descriptions.Item>
</Descriptions>
<div>
<Typography.Text strong></Typography.Text>
<pre className="hljs" style={{ background: '#f6f6f6', padding: 12, borderRadius: 6, maxHeight: 240, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
<code dangerouslySetInnerHTML={{ __html: reqHtml }} />
</pre>
</div>
<div>
<Typography.Text strong></Typography.Text>
<pre className="hljs" style={{ background: '#f6f6f6', padding: 12, borderRadius: 6, maxHeight: 520, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
<code dangerouslySetInnerHTML={{ __html: respHtml }} />
</pre>
</div>
</Space>
)}
</Drawer>
</div>
)
}

View File

@ -0,0 +1,538 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Button, Form, Input, Modal, Select, Space, Table, Tag, message, Popconfirm, Popover, Switch, TreeSelect } from 'antd'
import api from '../utils/axios'
import { HomeOutlined, UserOutlined, TeamOutlined, SettingOutlined, AppstoreOutlined, KeyOutlined, DashboardOutlined, FileOutlined, LockOutlined, MenuOutlined, PieChartOutlined, BarChartOutlined, TableOutlined, CalendarOutlined, FormOutlined, SearchOutlined, ToolOutlined, ShoppingCartOutlined, ShopOutlined, FolderOpenOutlined, FolderOutlined, CloudOutlined, DatabaseOutlined, ApiOutlined, CodeOutlined, BugOutlined, BellOutlined, PlusOutlined, EditOutlined, DeleteOutlined, UploadOutlined, DownloadOutlined, EyeOutlined, EyeInvisibleOutlined, StarOutlined, HeartOutlined, LikeOutlined, DislikeOutlined, SmileOutlined, FrownOutlined, PhoneOutlined, MailOutlined, EnvironmentOutlined, GlobalOutlined, AimOutlined, CompassOutlined, CameraOutlined, VideoCameraOutlined, SoundOutlined, WifiOutlined, RocketOutlined, ThunderboltOutlined, ExperimentOutlined, BulbOutlined, GiftOutlined, BankOutlined, WalletOutlined, MoneyCollectOutlined, BookOutlined, ReadOutlined, ProfileOutlined, CloudUploadOutlined, CloudDownloadOutlined, InboxOutlined, FolderAddOutlined, SlidersOutlined, FilterOutlined, AlertOutlined, ClockCircleOutlined, FieldTimeOutlined, HistoryOutlined, ContactsOutlined, SolutionOutlined, IdcardOutlined, QrcodeOutlined, ScanOutlined, SafetyOutlined, SecurityScanOutlined, UnlockOutlined, HddOutlined, CopyOutlined, ScissorOutlined, SnippetsOutlined, FileProtectOutlined, DesktopOutlined, LaptopOutlined, MobileOutlined, TabletOutlined, ClusterOutlined, AppstoreAddOutlined, PlusSquareOutlined, SyncOutlined, ReloadOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import { formatDateTime } from '../utils/datetime'
import { usePermission } from '../utils/permission'
import PageHeader from '../components/PageHeader'
// 定义图标项类型,避免隐式 any
interface IconItem { name: string; node: any }
interface MenuItem {
id: number
parent_id?: number
name: string
code?: string
path?: string
component?: string
type: number // 1:目录 2:菜单 3:按钮
icon?: string
order_no?: number
visible?: boolean
status?: number
keep_alive?: boolean
perms?: string
created_at?: string
// 支持树形展示
children?: MenuItem[]
}
// 平铺图标选择器(悬停展开 + 可滚动)
interface IconPickerProps { value?: string; onChange?: (v?: string) => void; items: IconItem[] }
const IconPicker = ({ value, onChange, items }: IconPickerProps) => {
const current = items.find((i: IconItem) => i.name === value)
const content = (
<div style={{ width: 520 }}>
<div style={{ maxHeight: 260, overflowY: 'auto', padding: 8 }}>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 8,
}}
>
<div
onClick={() => onChange && onChange(undefined)}
style={{
border: value ? '1px solid #f0f0f0' : '1px solid #1677ff',
borderRadius: 6,
padding: 10,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 8,
justifyContent: 'center',
}}
>
<span style={{ fontSize: 12 }}></span>
</div>
{items.map((it: IconItem) => {
const active = value === it.name
return (
<div
key={it.name}
onClick={() => onChange && onChange(it.name)}
style={{
border: active ? '1px solid #1677ff' : '1px solid #f0f0f0',
borderRadius: 6,
padding: 10,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 8,
justifyContent: 'center',
}}
title={it.name}
>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>{it.node}</span>
<span style={{ fontSize: 12 }}>{it.name}</span>
</div>
)
})}
</div>
</div>
</div>
)
return (
<Popover trigger="hover" placement="bottomLeft" mouseEnterDelay={0.05} content={content}
overlayInnerStyle={{ padding: 0 }}
>
<div
style={{
border: '1px solid #d9d9d9',
borderRadius: 6,
height: 36,
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '0 12px',
cursor: 'pointer',
minWidth: 160,
}}
>
{current ? (
<>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>{current.node}</span>
<span style={{ fontSize: 12, color: '#555' }}>{current.name}</span>
</>
) : (
<span style={{ fontSize: 12, color: '#999' }}></span>
)}
</div>
</Popover>
)
}
export default function Menus(){
const [loading, setLoading] = useState(false)
const [data, setData] = useState([] as MenuItem[])
const [parents, setParents] = useState([] as MenuItem[])
const [total, setTotal] = useState(0)
const [keyword, setKeyword] = useState('')
// 是否显示按钮type=3
const [showButtons, setShowButtons] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [current, setCurrent] = useState(null as MenuItem | null)
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const [searchForm] = Form.useForm()
// 权限判断
const { has } = usePermission()
// 可选图标列表(名称需与 MainLayout.iconMap 保持一致)
const iconItems: IconItem[] = useMemo(() => [
{ name: 'HomeOutlined', node: <HomeOutlined /> },
{ name: 'UserOutlined', node: <UserOutlined /> },
{ name: 'TeamOutlined', node: <TeamOutlined /> },
{ name: 'SettingOutlined', node: <SettingOutlined /> },
{ name: 'AppstoreOutlined', node: <AppstoreOutlined /> },
{ name: 'KeyOutlined', node: <KeyOutlined /> },
{ name: 'DashboardOutlined', node: <DashboardOutlined /> },
{ name: 'FileOutlined', node: <FileOutlined /> },
{ name: 'LockOutlined', node: <LockOutlined /> },
{ name: 'MenuOutlined', node: <MenuOutlined /> },
{ name: 'PieChartOutlined', node: <PieChartOutlined /> },
{ name: 'BarChartOutlined', node: <BarChartOutlined /> },
{ name: 'TableOutlined', node: <TableOutlined /> },
{ name: 'CalendarOutlined', node: <CalendarOutlined /> },
{ name: 'FormOutlined', node: <FormOutlined /> },
{ name: 'SearchOutlined', node: <SearchOutlined /> },
{ name: 'ToolOutlined', node: <ToolOutlined /> },
{ name: 'ShoppingCartOutlined', node: <ShoppingCartOutlined /> },
{ name: 'ShopOutlined', node: <ShopOutlined /> },
{ name: 'FolderOpenOutlined', node: <FolderOpenOutlined /> },
{ name: 'FolderOutlined', node: <FolderOutlined /> },
{ name: 'CloudOutlined', node: <CloudOutlined /> },
{ name: 'DatabaseOutlined', node: <DatabaseOutlined /> },
{ name: 'ApiOutlined', node: <ApiOutlined /> },
{ name: 'CodeOutlined', node: <CodeOutlined /> },
{ name: 'BugOutlined', node: <BugOutlined /> },
{ name: 'BellOutlined', node: <BellOutlined /> },
// 新增常用图标
{ name: 'PlusOutlined', node: <PlusOutlined /> },
{ name: 'EditOutlined', node: <EditOutlined /> },
{ name: 'DeleteOutlined', node: <DeleteOutlined /> },
{ name: 'UploadOutlined', node: <UploadOutlined /> },
{ name: 'DownloadOutlined', node: <DownloadOutlined /> },
{ name: 'EyeOutlined', node: <EyeOutlined /> },
{ name: 'EyeInvisibleOutlined', node: <EyeInvisibleOutlined /> },
{ name: 'StarOutlined', node: <StarOutlined /> },
{ name: 'HeartOutlined', node: <HeartOutlined /> },
{ name: 'LikeOutlined', node: <LikeOutlined /> },
{ name: 'DislikeOutlined', node: <DislikeOutlined /> },
{ name: 'SmileOutlined', node: <SmileOutlined /> },
{ name: 'FrownOutlined', node: <FrownOutlined /> },
{ name: 'PhoneOutlined', node: <PhoneOutlined /> },
{ name: 'MailOutlined', node: <MailOutlined /> },
{ name: 'EnvironmentOutlined', node: <EnvironmentOutlined /> },
{ name: 'GlobalOutlined', node: <GlobalOutlined /> },
{ name: 'AimOutlined', node: <AimOutlined /> },
{ name: 'CompassOutlined', node: <CompassOutlined /> },
{ name: 'CameraOutlined', node: <CameraOutlined /> },
{ name: 'VideoCameraOutlined', node: <VideoCameraOutlined /> },
{ name: 'SoundOutlined', node: <SoundOutlined /> },
{ name: 'WifiOutlined', node: <WifiOutlined /> },
{ name: 'RocketOutlined', node: <RocketOutlined /> },
{ name: 'ThunderboltOutlined', node: <ThunderboltOutlined /> },
{ name: 'ExperimentOutlined', node: <ExperimentOutlined /> },
{ name: 'BulbOutlined', node: <BulbOutlined /> },
{ name: 'GiftOutlined', node: <GiftOutlined /> },
{ name: 'BankOutlined', node: <BankOutlined /> },
{ name: 'WalletOutlined', node: <WalletOutlined /> },
{ name: 'MoneyCollectOutlined', node: <MoneyCollectOutlined /> },
{ name: 'BookOutlined', node: <BookOutlined /> },
{ name: 'ReadOutlined', node: <ReadOutlined /> },
{ name: 'ProfileOutlined', node: <ProfileOutlined /> },
{ name: 'CloudUploadOutlined', node: <CloudUploadOutlined /> },
{ name: 'CloudDownloadOutlined', node: <CloudDownloadOutlined /> },
{ name: 'InboxOutlined', node: <InboxOutlined /> },
{ name: 'FolderAddOutlined', node: <FolderAddOutlined /> },
{ name: 'SlidersOutlined', node: <SlidersOutlined /> },
{ name: 'FilterOutlined', node: <FilterOutlined /> },
{ name: 'AlertOutlined', node: <AlertOutlined /> },
{ name: 'ClockCircleOutlined', node: <ClockCircleOutlined /> },
{ name: 'FieldTimeOutlined', node: <FieldTimeOutlined /> },
{ name: 'HistoryOutlined', node: <HistoryOutlined /> },
{ name: 'ContactsOutlined', node: <ContactsOutlined /> },
{ name: 'SolutionOutlined', node: <SolutionOutlined /> },
{ name: 'IdcardOutlined', node: <IdcardOutlined /> },
{ name: 'QrcodeOutlined', node: <QrcodeOutlined /> },
{ name: 'ScanOutlined', node: <ScanOutlined /> },
{ name: 'SafetyOutlined', node: <SafetyOutlined /> },
{ name: 'SecurityScanOutlined', node: <SecurityScanOutlined /> },
{ name: 'UnlockOutlined', node: <UnlockOutlined /> },
{ name: 'HddOutlined', node: <HddOutlined /> },
{ name: 'CopyOutlined', node: <CopyOutlined /> },
{ name: 'ScissorOutlined', node: <ScissorOutlined /> },
{ name: 'SnippetsOutlined', node: <SnippetsOutlined /> },
{ name: 'FileProtectOutlined', node: <FileProtectOutlined /> },
{ name: 'DesktopOutlined', node: <DesktopOutlined /> },
{ name: 'LaptopOutlined', node: <LaptopOutlined /> },
{ name: 'MobileOutlined', node: <MobileOutlined /> },
{ name: 'TabletOutlined', node: <TabletOutlined /> },
{ name: 'ClusterOutlined', node: <ClusterOutlined /> },
{ name: 'AppstoreAddOutlined', node: <AppstoreAddOutlined /> },
{ name: 'PlusSquareOutlined', node: <PlusSquareOutlined /> },
{ name: 'SyncOutlined', node: <SyncOutlined /> },
{ name: 'ReloadOutlined', node: <ReloadOutlined /> },
], [])
const fetchMenus = async (kw: string = keyword) => {
setLoading(true)
try{
const { data } = await api.get(`/menus`, { params: { keyword: kw } })
if(data?.code === 0){ setData(data.data || []); setParents((data.data || []).filter((m: MenuItem) => m.type !== 3)) }
else { throw new Error(data?.message || '获取菜单失败') }
}catch(e: any){ message.error(e.message || '获取菜单失败') } finally { setLoading(false) }
}
useEffect(() => { fetchMenus(keyword) }, [])
const onCreate = () => { form.resetFields(); setCreateOpen(true) }
const handleCreate = async () => {
try{
const values = await form.validateFields()
if (!values.icon) delete values.icon // 选择“无”或未选择,不提交字段
const payload = { ...values, status: values.status === true ? 1 : 0, parent_id: values.parent_id != null && values.parent_id !== '' ? Number(values.parent_id) : undefined }
const { data } = await api.post('/menus', payload)
if(data?.code === 0){ message.success('创建成功'); setCreateOpen(false); fetchMenus() }
else { throw new Error(data?.message || '创建失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '创建失败') }
}
const onEdit = (record: MenuItem) => { setCurrent(record); editForm.setFieldsValue({ ...record, status: record.status === 1, type: record.type === 1 ? 2 : record.type }); setEditOpen(true) }
const handleEdit = async () => {
try{
const values = await editForm.validateFields()
if (!values.icon) delete values.icon // 选择“无”或未选择,不提交字段
const payload = { ...values, status: values.status === true ? 1 : 0, parent_id: values.parent_id != null && values.parent_id !== '' ? Number(values.parent_id) : undefined }
const { data } = await api.put(`/menus/${current!.id}`, payload)
if(data?.code === 0){ message.success('更新成功'); setEditOpen(false); fetchMenus() }
else { throw new Error(data?.message || '更新失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '更新失败') }
}
// 一键添加预设按钮(用户/角色/菜单页面的操作按钮)
const ensurePresetButtons = async () => {
const presets = [
{ parentName: '用户管理', buttons: [
{ name: '新增', perms: 'system:user:create' },
{ name: '编辑', perms: 'system:user:update' },
{ name: '重置密码', perms: 'system:user:reset' },
{ name: '删除', perms: 'system:user:delete' },
]},
{ parentName: '角色管理', buttons: [
{ name: '新增', perms: 'system:role:create' },
{ name: '编辑', perms: 'system:role:update' },
{ name: '分配菜单', perms: 'system:role:assign' },
{ name: '删除', perms: 'system:role:delete' },
]},
{ parentName: '菜单管理', buttons: [
{ name: '新增', perms: 'system:menu:create' },
{ name: '编辑', perms: 'system:menu:update' },
{ name: '删除', perms: 'system:menu:delete' },
]},
]
const menuByName: Record<string, MenuItem | undefined> = {}
data.forEach((m: MenuItem) => {
if (m.type !== 3 && (m.name === '用户管理' || m.name === '角色管理' || m.name === '菜单管理')) {
menuByName[m.name] = m
}
})
let created = 0
const missingParents: string[] = []
setLoading(true)
try {
for (const group of presets) {
const parent = menuByName[group.parentName]
if (!parent) { missingParents.push(group.parentName); continue }
for (const btn of group.buttons) {
const exists = data.some((m: MenuItem) => m.type === 3 && m.parent_id === parent.id && m.perms === btn.perms)
if (exists) continue
try {
const payload: Partial<MenuItem> = { parent_id: parent.id, name: btn.name, type: 3, perms: btn.perms, visible: true, status: 1 }
const { data: resp } = await api.post('/menus', payload)
if (resp?.code === 0) created++
} catch (e) {
// 单个失败忽略,继续后续
}
}
}
await fetchMenus(keyword)
if (created > 0) message.success(`已添加 ${created} 个按钮`)
else message.info('没有需要新增的按钮')
if (missingParents.length) message.warning(`未找到父菜单:${missingParents.join('、')}`)
} finally {
setLoading(false)
}
}
// 构建「上级菜单」树(非按钮均可作为上级,按钮不可作为上级)
type MenuTreeNode = { title: string; value: number; key: number; children?: MenuTreeNode[] }
const dirTreeData: MenuTreeNode[] = useMemo(() => {
const parents = data.filter((m: MenuItem) => m.type !== 3)
const map = new Map<number, MenuTreeNode & { children: MenuTreeNode[] }>()
parents.forEach((m: MenuItem) => map.set(m.id, { title: m.name, value: m.id, key: m.id, children: [] }))
const roots: (MenuTreeNode & { children: MenuTreeNode[] })[] = []
parents.forEach((m: MenuItem) => {
const node = map.get(m.id)!
if (m.parent_id && map.has(m.parent_id)) {
const parent = map.get(m.parent_id)
if (parent) parent.children.push(node)
} else {
roots.push(node)
}
})
return roots
}, [data])
// 计算某非按钮节点的所有子孙(编辑时用于禁选自身及其子孙)
const getNonButtonDescendantIds = (list: MenuItem[], id: number): Set<number> => {
const childrenMap = new Map<number, number[]>()
list.filter(m => m.type !== 3).forEach((m: MenuItem) => {
if (m.parent_id) {
if (!childrenMap.has(m.parent_id)) childrenMap.set(m.parent_id, [])
childrenMap.get(m.parent_id)!.push(m.id)
}
})
const res = new Set<number>()
const stack = (childrenMap.get(id) || []).slice()
while (stack.length) {
const cur = stack.pop()!
if (!res.has(cur)) {
res.add(cur)
const next = childrenMap.get(cur) || []
next.forEach(n => stack.push(n))
}
}
return res
}
const filterDirTree = (nodes: MenuTreeNode[], blocked: Set<number>): MenuTreeNode[] => {
const recur = (arr: MenuTreeNode[]): MenuTreeNode[] => arr
.filter(n => !blocked.has(n.key))
.map(n => ({ ...n, children: n.children ? recur(n.children) : undefined }))
return recur(nodes)
}
const editDirTreeData = useMemo(() => {
if (!current || current.type === 3) return dirTreeData
const blocked = getNonButtonDescendantIds(data, current.id)
blocked.add(current.id)
return filterDirTree(dirTreeData, blocked)
}, [current, data, dirTreeData])
// 将平铺数据构造成树形(用于表格展示)
const treeDataForTable: MenuItem[] = useMemo(() => {
const list = showButtons ? data : data.filter((m: MenuItem) => m.type !== 3)
const map = new Map<number, MenuItem>()
const roots: MenuItem[] = []
// 初始化节点(不预置 children避免叶子显示展开图标
list.forEach((m: MenuItem) => map.set(m.id, { ...m }))
list.forEach((m: MenuItem) => {
const node = map.get(m.id)!
if (m.parent_id && map.has(m.parent_id)) {
const parent = map.get(m.parent_id)!
// 按钮不可作为父级,若数据上挂在按钮下,则提升为根节点
if (parent.type !== 3) {
if (!parent.children) parent.children = []
parent.children.push(node)
} else {
roots.push(node)
}
} else {
roots.push(node)
}
})
return roots
}, [data, showButtons])
// 默认展开最顶层菜单
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([])
useEffect(() => {
// 仅展开拥有子节点的顶层菜单
const rootsWithChildren = treeDataForTable
.filter((n: MenuItem) => Array.isArray(n.children) && n.children.length > 0)
.map((n: MenuItem) => n.id as React.Key)
setExpandedRowKeys(rootsWithChildren)
}, [treeDataForTable])
const columns: ColumnsType<MenuItem> = useMemo(() => [
// { title: 'ID', dataIndex: 'id', width: 80 },
{ title: '名称', dataIndex: 'name' },
{ title: '编码', dataIndex: 'code' },
{ title: '类型', dataIndex: 'type', render: (v: number) => v === 3 ? <Tag></Tag> : <Tag color="green"></Tag> },
{ title: '创建时间', dataIndex: 'created_at', render: (v: any) => formatDateTime(v) },
{ title: '操作', key: 'actions', width: 360, render: (_: any, r: MenuItem) => (
<Space>
{has('system:menu:update') && (
<a className="action-link" onClick={() => onEdit(r)}>
<EditOutlined />
<span></span>
</a>
)}
{has('system:menu:delete') && (
<Popconfirm title={`确认删除菜单「${r.name}」?(若存在子菜单将无法删除)`} onConfirm={async ()=>{ try{ const { data } = await api.delete(`/menus/${r.id}`); if(data?.code===0){ message.success('删除成功'); fetchMenus(keyword) } else { throw new Error(data?.message||'删除失败') } }catch(e:any){ message.error(e.message||'删除失败') } }}>
<a className="action-link action-danger">
<DeleteOutlined />
<span></span>
</a>
</Popconfirm>
)}
</Space>
)},
], [keyword, has])
return (
<div>
<PageHeader items={["系统管理","菜单管理"]} title="" />
<Form form={searchForm} layout="inline" onFinish={(vals: any)=>{ const kw = String(vals?.keyword ?? '').trim(); setKeyword(kw); fetchMenus(kw) }} style={{ marginBottom: 12 }}>
<Form.Item name="keyword">
<Input allowClear placeholder="搜索菜单名称/路径/权限" style={{ width: 360 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={()=>{ searchForm.resetFields(); setKeyword(''); fetchMenus('') }}></Button>
{has('system:menu:create') && (
<Button type="primary" onClick={onCreate}></Button>
)}
</Space>
</Form.Item>
</Form>
<Table<MenuItem>
rowKey="id"
loading={loading}
dataSource={treeDataForTable}
columns={columns}
expandable={{ expandedRowKeys, onExpandedRowsChange: (keys) => setExpandedRowKeys(keys as React.Key[]), indentSize: 18, rowExpandable: (record: MenuItem) => Array.isArray(record.children) && record.children.length > 0 }}
pagination={{ pageSize: 9999, hideOnSinglePage: true }}
/>
<Modal title="新增菜单" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="创建" width={840}>
<Form form={form} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right" initialValues={{ type: 2, visible: true, status: true, keep_alive: true }}>
<Form.Item name="parent_id" label="上级菜单">
<TreeSelect
allowClear
placeholder="选择上级菜单(按钮不可作为上级,可为空表示顶级)"
style={{ width: '100%' }}
treeData={dirTreeData}
treeDefaultExpandAll
showSearch
filterTreeNode={(input: string, node: any) => String(node?.title ?? '').toLowerCase().includes(String(input).toLowerCase())}
/>
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="请输入名称" />
</Form.Item>
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
<Select options={[{label:'菜单',value:2},{label:'按钮',value:3}]} />
</Form.Item>
<Form.Item name="path" label="路径"><Input placeholder="/path" /></Form.Item>
<Form.Item name="component" label="组件"><Input placeholder="页面组件路径" /></Form.Item>
<Form.Item name="icon" label="图标">
<IconPicker items={iconItems} />
</Form.Item>
<Form.Item name="order_no" label="排序"><Input placeholder="数字越小越靠前" /></Form.Item>
<Form.Item name="visible" label="是否显示"><Select options={[{label:'显示',value:true},{label:'隐藏',value:false}]} /></Form.Item>
<Form.Item name="status" label="状态" valuePropName="checked"><Switch checkedChildren="启用" unCheckedChildren="禁用" /></Form.Item>
<Form.Item name="keep_alive" label="缓存"><Switch checkedChildren="开" unCheckedChildren="关" defaultChecked />
</Form.Item>
<Form.Item name="perms" label="权限标识"><Input placeholder="如 system:user:list" /></Form.Item>
</Form>
</Modal>
<Modal title="编辑菜单" open={editOpen} onOk={handleEdit} onCancel={() => setEditOpen(false)} okText="保存" width={840}>
<Form form={editForm} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="parent_id" label="上级菜单">
<TreeSelect
allowClear
placeholder="选择上级菜单(不可选择自身及其子层,且按钮不可作为上级)"
style={{ width: '100%' }}
treeData={editDirTreeData}
treeDefaultExpandAll
showSearch
filterTreeNode={(input: string, node: any) => String(node?.title ?? '').toLowerCase().includes(String(input).toLowerCase())}
/>
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="请输入名称" />
</Form.Item>
<Form.Item name="type" label="类型"><Select options={[{label:'菜单',value:2},{label:'按钮',value:3}]} /></Form.Item>
<Form.Item name="path" label="路径"><Input placeholder="/path" /></Form.Item>
<Form.Item name="component" label="组件"><Input placeholder="页面组件路径" /></Form.Item>
<Form.Item name="icon" label="图标">
<IconPicker items={iconItems} />
</Form.Item>
<Form.Item name="order_no" label="排序"><Input placeholder="数字越小越靠前" /></Form.Item>
<Form.Item name="visible" label="是否显示" valuePropName="checked"><Switch checkedChildren="显示" unCheckedChildren="隐藏" /></Form.Item>
<Form.Item name="status" label="状态" valuePropName="checked"><Switch checkedChildren="启用" unCheckedChildren="禁用" /></Form.Item>
<Form.Item name="keep_alive" label="缓存" valuePropName="checked"><Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
<Form.Item name="perms" label="权限标识"><Input placeholder="如 system:user:list" /></Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@ -0,0 +1,148 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, message } from 'antd'
import api from '../utils/axios'
import type { ColumnsType } from 'antd/es/table'
import { formatDateTime } from '../utils/datetime'
import { EditOutlined, DeleteOutlined } from '@ant-design/icons'
import { usePermission } from '../utils/permission'
import PageHeader from '../components/PageHeader'
interface PermissionItem {
id: number
code: string
name: string
description?: string
created_at?: string
}
export default function Permissions(){
const [loading, setLoading] = useState(false)
const [data, setData] = useState([] as PermissionItem[])
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [keyword, setKeyword] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [current, setCurrent] = useState(null as PermissionItem | null)
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const [searchForm] = Form.useForm()
// 权限判断
const { has } = usePermission()
const fetchPermissions = async (p: number = page, ps: number = pageSize, kw: string = keyword) => {
setLoading(true)
try{
const { data } = await api.get(`/permissions`, { params: { page: p, page_size: ps, keyword: kw } })
if(data?.code === 0){
const resp = data.data
setData(resp.items || [])
setTotal(resp.total || 0)
setPage(resp.page || p)
setPageSize(resp.page_size || ps)
}else{
throw new Error(data?.message || '获取权限失败')
}
}catch(e: any){
message.error(e.message || '获取权限失败')
}finally{ setLoading(false) }
}
useEffect(() => { fetchPermissions(1, pageSize, keyword) }, [])
const onCreate = () => { form.resetFields(); setCreateOpen(true) }
const handleCreate = async () => {
try{
const values = await form.validateFields()
const { data } = await api.post('/permissions', values)
if(data?.code === 0){ message.success('创建成功'); setCreateOpen(false); fetchPermissions(1, pageSize) }
else { throw new Error(data?.message || '创建失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '创建失败') }
}
const onEdit = (record: PermissionItem) => { setCurrent(record); editForm.setFieldsValue({ name: record.name, description: record.description }); setEditOpen(true) }
const handleEdit = async () => {
try{
const values = await editForm.validateFields()
const { data } = await api.put(`/permissions/${current!.id}`, values)
if(data?.code === 0){ message.success('更新成功'); setEditOpen(false); fetchPermissions(page, pageSize) }
else { throw new Error(data?.message || '更新失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '更新失败') }
}
const columns: ColumnsType<PermissionItem> = useMemo(() => [
{ title: 'ID', dataIndex: 'id' },
{ title: '名称', dataIndex: 'name' },
{ title: '权限标识', dataIndex: 'code' },
{ title: '创建时间', dataIndex: 'created_at', render: (v: any) => formatDateTime(v) },
{ title: '操作', key: 'actions', width: 280, render: (_: any, r: PermissionItem) => (
<Space>
<a className="action-link" onClick={() => onEdit(r)}>
<EditOutlined />
<span></span>
</a>
<Popconfirm title={`确认删除权限「${r.name}」?`} onConfirm={async ()=>{ try{ const { data } = await api.delete(`/permissions/${r.id}`); if(data?.code===0){ message.success('删除成功'); fetchPermissions(page, pageSize, keyword) } else { throw new Error(data?.message||'删除失败') } }catch(e:any){ message.error(e.message||'删除失败') } }}>
<a className="action-link action-danger">
<DeleteOutlined />
<span></span>
</a>
</Popconfirm>
</Space>
)},
], [page, pageSize, keyword])
return (
<div>
<PageHeader items={["系统管理","权限管理"]} title="" />
<Form form={searchForm} layout="inline" onFinish={(vals: any)=>{ const kw = String(vals?.keyword ?? '').trim(); setKeyword(kw); fetchPermissions(1, pageSize, kw) }} style={{ marginBottom: 12 }}>
<Form.Item name="keyword">
<Input allowClear placeholder="搜索权限编码/名称" style={{ width: 320 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={()=>{ searchForm.resetFields(); setKeyword(''); fetchPermissions(1, pageSize, '') }}></Button>
{has('system:permission:create') && (
<Button type="primary" onClick={onCreate}></Button>
)}
</Space>
</Form.Item>
</Form>
<Table<PermissionItem>
rowKey="id"
loading={loading}
dataSource={data}
columns={columns}
pagination={{ current: page, pageSize, total, showSizeChanger: true, onChange: (p: number, ps?: number) => fetchPermissions(p, ps ?? pageSize, keyword) }}
/>
<Modal title="新增权限" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="创建" width={840}>
<Form form={form} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
<Input placeholder="唯一编码,如 system:user:list" />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="权限名称" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input placeholder="权限描述" />
</Form.Item>
</Form>
</Modal>
<Modal title="编辑权限" open={editOpen} onOk={handleEdit} onCancel={() => setEditOpen(false)} okText="保存" width={840}>
<Form form={editForm} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="权限名称" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input placeholder="权限描述" />
</Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@ -0,0 +1,169 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Button, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import api from '../utils/axios'
import { formatDateTime } from '../utils/datetime'
import { EditOutlined, DeleteOutlined } from '@ant-design/icons'
import { usePermission } from '../utils/permission'
import PageHeader from '../components/PageHeader'
interface PositionItem {
id: number
name: string
code: string
description?: string
status?: number
order_no?: number
created_at?: string
}
export default function Positions(){
const [loading, setLoading] = useState(false)
const [data, setData] = useState([] as PositionItem[])
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [keyword, setKeyword] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [current, setCurrent] = useState(null as PositionItem | null)
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const [searchForm] = Form.useForm()
const { has } = usePermission()
const fetchPositions = async (p: number = page, ps: number = pageSize, kw: string = keyword) => {
setLoading(true)
try{
const { data } = await api.get(`/positions`, { params: { page: p, page_size: ps, keyword: kw } })
if(data?.code === 0){
const resp = data.data
setData(resp.items || [])
setTotal(resp.total || 0)
setPage(resp.page || p)
setPageSize(resp.page_size || ps)
}else{
throw new Error(data?.message || '获取岗位失败')
}
}catch(e: any){
message.error(e.message || '获取岗位失败')
}finally{ setLoading(false) }
}
useEffect(() => { fetchPositions(1, pageSize, keyword) }, [])
const onCreate = () => { form.resetFields(); setCreateOpen(true) }
const handleCreate = async () => {
try{
const values = await form.validateFields()
const { data } = await api.post('/positions', values)
if(data?.code === 0){ message.success('创建成功'); setCreateOpen(false); fetchPositions(1, pageSize) }
else { throw new Error(data?.message || '创建失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '创建失败') }
}
const onEdit = (record: PositionItem) => { setCurrent(record); editForm.setFieldsValue({ name: record.name, description: record.description, status: record.status, order_no: record.order_no }); setEditOpen(true) }
const handleEdit = async () => {
try{
const values = await editForm.validateFields()
const { data } = await api.put(`/positions/${current!.id}`, values)
if(data?.code === 0){ message.success('更新成功'); setEditOpen(false); fetchPositions(page, pageSize) }
else { throw new Error(data?.message || '更新失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '更新失败') }
}
const columns: ColumnsType<PositionItem> = useMemo(() => [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '岗位名', dataIndex: 'name' },
{ title: '编码', dataIndex: 'code' },
{ title: '排序', dataIndex: 'order_no' },
{ title: '描述', dataIndex: 'description' },
{ title: '状态', dataIndex: 'status', render: (v: number) => v === 1 ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: '创建时间', dataIndex: 'created_at', render: (v: any) => formatDateTime(v) },
{ title: '操作', key: 'actions', width: 280, render: (_: any, r: PositionItem) => (
<Space>
{has('system:position:update') && (
<a className="action-link" onClick={() => onEdit(r)}>
<EditOutlined />
<span></span>
</a>
)}
{has('system:position:delete') && (
<Popconfirm title={`确认删除岗位「${r.name}」?`} onConfirm={async ()=>{ try{ const { data } = await api.delete(`/positions/${r.id}`); if(data?.code===0){ message.success('删除成功'); fetchPositions(page, pageSize, keyword) } else { throw new Error(data?.message||'删除失败') } }catch(e: any){ message.error(e.message||'删除失败') } }}>
<a className="action-link action-danger">
<DeleteOutlined />
<span></span>
</a>
</Popconfirm>
)}
</Space>
)},
], [page, pageSize, keyword, has])
return (
<div>
<PageHeader items={["系统管理","岗位管理"]} title="" />
<Form form={searchForm} layout="inline" onFinish={(vals: any)=>{ const kw = String(vals?.keyword ?? '').trim(); setKeyword(kw); fetchPositions(1, pageSize, kw) }} style={{ marginBottom: 12 }}>
<Form.Item name="keyword">
<Input allowClear placeholder="搜索岗位名/编码" style={{ width: 320 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={()=>{ searchForm.resetFields(); setKeyword(''); fetchPositions(1, pageSize, '') }}></Button>
{has('system:position:create') && (
<Button type="primary" onClick={onCreate}></Button>
)}
</Space>
</Form.Item>
</Form>
<Table<PositionItem>
rowKey="id"
loading={loading}
dataSource={data}
columns={columns}
pagination={{ current: page, pageSize, total, showSizeChanger: true, onChange: (p: number, ps?: number) => fetchPositions(p, ps ?? pageSize, keyword) }}
/>
<Modal title="新增岗位" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="创建" width={640}>
<Form form={form} layout="horizontal" labelCol={{ span: 3 }} wrapperCol={{ span: 21 }} labelAlign="right">
<Form.Item name="name" label="岗位名" rules={[{ required: true }]}>
<Input placeholder="请输入岗位名" />
</Form.Item>
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
<Input placeholder="唯一编码,如 hr:manager" />
</Form.Item>
<Form.Item name="order_no" label="排序" initialValue={0}>
<Input type="number" placeholder="数值越大越靠前" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input placeholder="岗位描述" />
</Form.Item>
<Form.Item name="status" label="状态" initialValue={1}>
<Select options={[{label:'启用', value:1},{label:'禁用', value:0}]} />
</Form.Item>
</Form>
</Modal>
<Modal title="编辑岗位" open={editOpen} onOk={handleEdit} onCancel={() => setEditOpen(false)} okText="保存" width={640}>
<Form form={editForm} layout="horizontal" labelCol={{ span: 3 }} wrapperCol={{ span: 21 }} labelAlign="right">
<Form.Item name="name" label="岗位名" rules={[{ required: true }]}>
<Input placeholder="请输入岗位名" />
</Form.Item>
<Form.Item name="order_no" label="排序">
<Input type="number" placeholder="数值越大越靠前" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input placeholder="岗位描述" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={[{label:'启用', value:1},{label:'禁用', value:0}]} />
</Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@ -0,0 +1,296 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Button, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, message, Tree } from 'antd'
import type { DataNode } from 'antd/es/tree'
import type { ColumnsType } from 'antd/es/table'
import api from '../utils/axios'
import { formatDateTime } from '../utils/datetime'
import { EditOutlined, DeleteOutlined, AppstoreOutlined } from '@ant-design/icons'
import { usePermission } from '../utils/permission'
import PageHeader from '../components/PageHeader'
interface RoleItem {
id: number
name: string
code: string
description?: string
status?: number
created_at?: string
}
interface MenuItem { id: number; parent_id?: number; name: string; type: number }
// 放宽后端返回的字段类型number 或可转为数字的 string并统一收敛为 MenuItem
const toMenuItem = (o: unknown): MenuItem | null => {
if (typeof o !== 'object' || o === null) return null
const r = o as Record<string, unknown>
const id = Number(r.id)
const type = Number(r.type)
const name = r.name != null ? String(r.name) : ''
if (!Number.isFinite(id) || !Number.isFinite(type) || !name) return null
const rawPid = (r as any).parent_id
const pid = rawPid == null || rawPid === '' ? undefined : Number(rawPid)
if (pid !== undefined && !Number.isFinite(pid)) return null
return { id, type, name, parent_id: pid }
}
export default function Roles(){
const [loading, setLoading] = useState(false)
const [data, setData] = useState([] as RoleItem[])
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [keyword, setKeyword] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [current, setCurrent] = useState(null as RoleItem | null)
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const [searchForm] = Form.useForm()
// 权限判断
const { has } = usePermission()
// assignment states
const [assignMenuOpen, setAssignMenuOpen] = useState(false)
const [allMenus, setAllMenus] = useState([] as MenuItem[])
const [checkedMenuIds, setCheckedMenuIds] = useState([] as number[])
const [menuSearch, setMenuSearch] = useState('')
// 打开分配菜单:加载菜单树与已勾选
const onAssignMenus = async (record: RoleItem) => {
setCurrent(record)
setAssignMenuOpen(true)
try{
const [allRes, checkedRes] = await Promise.all([
api.get('/menus', { params: { keyword: '' } }),
api.get(`/roles/${record.id}/menus`),
])
if(allRes.data?.code === 0){
const arr = Array.isArray(allRes.data?.data) ? (allRes.data.data as unknown[]) : []
const normalized: MenuItem[] = arr.map(toMenuItem).filter((x): x is MenuItem => x !== null)
setAllMenus(normalized)
}
if(checkedRes.data?.code === 0){
const ids = Array.isArray(checkedRes.data?.data) ? (checkedRes.data.data as unknown[]).map(v => Number(v)).filter(n => Number.isFinite(n)) as number[] : []
setCheckedMenuIds(ids)
}
}catch(e: any){ message.error(e.message || '加载菜单失败') }
}
// 保存分配的菜单
const handleSaveMenus = async () => {
if(!current) return
try{
const { data } = await api.put(`/roles/${current.id}/menus`, { ids: checkedMenuIds })
if(data?.code === 0){ message.success('保存成功'); setAssignMenuOpen(false) }
else { throw new Error(data?.message || '保存失败') }
}catch(e: any){ message.error(e.message || '保存失败') }
}
const fetchRoles = async (p: number = page, ps: number = pageSize, kw: string = keyword) => {
setLoading(true)
try{
const { data } = await api.get(`/roles`, { params: { page: p, page_size: ps, keyword: kw } })
if(data?.code === 0){
const resp = data.data
setData(resp.items || [])
setTotal(resp.total || 0)
setPage(resp.page || p)
setPageSize(resp.page_size || ps)
}else{
throw new Error(data?.message || '获取角色失败')
}
}catch(e: any){
message.error(e.message || '获取角色失败')
}finally{ setLoading(false) }
}
useEffect(() => { fetchRoles(1, pageSize, keyword) }, [])
const onCreate = () => { form.resetFields(); setCreateOpen(true) }
const handleCreate = async () => {
try{
const values = await form.validateFields()
const { data } = await api.post('/roles', values)
if(data?.code === 0){ message.success('创建成功'); setCreateOpen(false); fetchRoles(1, pageSize) }
else { throw new Error(data?.message || '创建失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '创建失败') }
}
const onEdit = (record: RoleItem) => { setCurrent(record); editForm.setFieldsValue({ name: record.name, description: record.description, status: record.status }); setEditOpen(true) }
const handleEdit = async () => {
try{
const values = await editForm.validateFields()
const { data } = await api.put(`/roles/${current!.id}`, values)
if(data?.code === 0){ message.success('更新成功'); setEditOpen(false); fetchRoles(page, pageSize) }
else { throw new Error(data?.message || '更新失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '更新失败') }
}
// build menu tree包含按钮 type=3一并展示
const menuTreeData: DataNode[] = useMemo(() => {
const list = allMenus
const map = new Map<number, DataNode & { children: DataNode[] }>()
const byId = new Map<number, MenuItem>()
list.forEach((m: MenuItem) => byId.set(m.id, m))
list.forEach((m: MenuItem) => map.set(m.id, { key: m.id, title: m.name, children: [] }))
const roots: (DataNode & { children: DataNode[] })[] = []
list.forEach((m: MenuItem) => {
const node = map.get(m.id)!
const pid = m.parent_id
if (pid && map.has(pid) && byId.get(pid)?.type !== 3) {
const parent = map.get(pid)
if (parent) parent.children.push(node)
} else {
// 父节点不存在、或父节点为按钮type=3作为根节点处理避免把子节点挂到按钮下
roots.push(node)
}
})
// 简单按标题排序,增强视觉一致性
const titleText = (n: DataNode): string => {
const t = (n as any).title
return typeof t === 'function' ? '' : String(t ?? '')
}
const sortNodes = (nodes: (DataNode & { children?: DataNode[] })[]) => {
nodes.sort((a, b) => titleText(a).localeCompare(titleText(b)))
nodes.forEach(n => { if (Array.isArray(n.children)) sortNodes(n.children as any) })
}
sortNodes(roots)
return roots
}, [allMenus])
// 基于标题的树过滤,匹配到的节点与其祖先均保留
const filteredMenuTreeData: DataNode[] = useMemo(() => {
const kw = menuSearch.trim().toLowerCase()
if (!kw) return menuTreeData
const titleText = (n: DataNode): string => {
const t = (n as any).title
return typeof t === 'function' ? '' : String(t ?? '')
}
const match = (n: DataNode) => titleText(n).toLowerCase().includes(kw)
const dfs = (nodes: DataNode[]): DataNode[] => {
const res: DataNode[] = []
nodes.forEach(n => {
const kids = (n.children ? dfs(n.children as DataNode[]) : [])
if (match(n) || kids.length > 0) {
res.push({ ...n, children: kids.length ? kids : undefined })
}
})
return res
}
return dfs(menuTreeData)
}, [menuTreeData, menuSearch])
const columns: ColumnsType<RoleItem> = useMemo(() => [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '角色名', dataIndex: 'name' },
{ title: '编码', dataIndex: 'code' },
{ title: '描述', dataIndex: 'description' },
{ title: '状态', dataIndex: 'status', render: (v: number) => v === 1 ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: '创建时间', dataIndex: 'created_at', render: (v: any) => formatDateTime(v) },
{ title: '操作', key: 'actions', width: 360, render: (_: any, r: RoleItem) => (
<Space>
{has('system:role:update') && (
<a className="action-link" onClick={() => onEdit(r)}>
<EditOutlined />
<span></span>
</a>
)}
{has('system:role:assign') && (
<a className="action-link" onClick={() => onAssignMenus(r)}>
<AppstoreOutlined />
<span></span>
</a>
)}
{has('system:role:delete') && (
<Popconfirm title={`确认删除角色「${r.name}」?`} onConfirm={async ()=>{ try{ const { data } = await api.delete(`/roles/${r.id}`); if(data?.code===0){ message.success('删除成功'); fetchRoles(page, pageSize, keyword) } else { throw new Error(data?.message||'删除失败') } }catch(e: any){ message.error(e.message||'删除失败') } }}>
<a className="action-link action-danger">
<DeleteOutlined />
<span></span>
</a>
</Popconfirm>
)}
</Space>
)},
], [page, pageSize, keyword, has])
return (
<div>
<PageHeader items={["系统管理","角色管理"]} title="" />
<Form form={searchForm} layout="inline" onFinish={(vals: any)=>{ const kw = String(vals?.keyword ?? '').trim(); setKeyword(kw); fetchRoles(1, pageSize, kw) }} style={{ marginBottom: 12 }}>
<Form.Item name="keyword">
<Input allowClear placeholder="搜索角色名/编码" style={{ width: 320 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={()=>{ searchForm.resetFields(); setKeyword(''); fetchRoles(1, pageSize, '') }}></Button>
{has('system:role:create') && (
<Button type="primary" onClick={onCreate}></Button>
)}
</Space>
</Form.Item>
</Form>
<Table<RoleItem>
rowKey="id"
loading={loading}
dataSource={data}
columns={columns}
pagination={{ current: page, pageSize, total, showSizeChanger: true, onChange: (p: number, ps?: number) => fetchRoles(p, ps ?? pageSize, keyword) }}
/>
<Modal title="新增角色" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="创建" width={840}>
<Form form={form} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="name" label="角色名" rules={[{ required: true }]}>
<Input placeholder="请输入角色名" />
</Form.Item>
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
<Input placeholder="唯一编码,如 system:admin" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input placeholder="角色描述" />
</Form.Item>
<Form.Item name="status" label="状态" initialValue={1}>
<Select options={[{label:'启用', value:1},{label:'禁用', value:0}]} />
</Form.Item>
</Form>
</Modal>
<Modal title="编辑角色" open={editOpen} onOk={handleEdit} onCancel={() => setEditOpen(false)} okText="保存" width={840}>
<Form form={editForm} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="name" label="角色名" rules={[{ required: true }]}>
<Input placeholder="请输入角色名" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input placeholder="角色描述" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={[{label:'启用', value:1},{label:'禁用', value:0}]} />
</Form.Item>
</Form>
</Modal>
{/* 移除分配权限模态框,仅保留分配菜单 */}
<Modal title={`分配菜单${current ? ' - ' + current.name : ''}`} open={assignMenuOpen} onOk={handleSaveMenus} onCancel={() => setAssignMenuOpen(false)} okText="保存" width={600}>
<Input allowClear placeholder="搜索菜单(名称)" value={menuSearch} onChange={(e)=>setMenuSearch(e.target.value)} style={{ marginBottom: 8 }} />
<Tree
checkable
showLine
treeData={filteredMenuTreeData}
checkedKeys={checkedMenuIds}
onCheck={(k: any) => {
const arr = Array.isArray(k) ? k : (k?.checked ?? [])
const next = (arr as (string|number)[]).map(v => Number(v))
setCheckedMenuIds(next)
}}
defaultExpandAll
style={{ maxHeight: 420, overflow: 'auto', padding: 8, border: '1px solid #f0f0f0', borderRadius: 6 }}
/>
<div style={{ marginTop: 12, color: '#888' }}>perms</div>
</Modal>
</div>
)
}

View File

@ -0,0 +1,482 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Button, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, TreeSelect } from 'antd'
import api from '../utils/axios'
import type { ColumnsType } from 'antd/es/table'
import { formatDateTime } from '../utils/datetime'
import { EditOutlined, DeleteOutlined, KeyOutlined, UserOutlined } from '@ant-design/icons'
import { usePermission } from '../utils/permission'
import PageHeader from '../components/PageHeader'
interface UserItem {
id: number
username: string
nickname?: string
status?: number
created_at?: string
}
// 简单的 {id, name} 类型守卫,便于从未知数组安全映射
type IdName = { id: number; name: string }
const isIdName = (o: unknown): o is IdName => {
if (typeof o !== 'object' || o === null) return false
const rec = o as Record<string, unknown>
return typeof rec.id === 'number' && typeof rec.name === 'string'
}
// 部门基础类型与守卫(包含 parent_id 便于构建树)
type DeptBasic = { id: number; name: string; parent_id?: number }
const isDeptBasic = (o: unknown): o is DeptBasic => {
if (typeof o !== 'object' || o === null) return false
const rec = o as Record<string, unknown>
const okId = typeof rec.id === 'number'
const okName = typeof rec.name === 'string'
const okPid = rec.parent_id === undefined || typeof rec.parent_id === 'number'
return okId && okName && okPid
}
export default function Users(){
const [loading, setLoading] = useState(false)
const [data, setData] = useState([] as UserItem[])
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [keyword, setKeyword] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [pwdOpen, setPwdOpen] = useState(false)
const [positionsOpen, setPositionsOpen] = useState(false)
const [current, setCurrent] = useState(null as UserItem | null)
const [currentUserId, setCurrentUserId] = useState(null as number | null)
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const [pwdForm] = Form.useForm()
const [searchForm] = Form.useForm()
// 分配角色(移到编辑弹窗内)
const [selectedRoleIds, setSelectedRoleIds] = useState([] as number[])
const [allRoles, setAllRoles] = useState([] as { id: number; name: string }[])
// 分配部门(移到编辑弹窗内)
const [selectedDeptIds, setSelectedDeptIds] = useState([] as number[])
const [allDepts, setAllDepts] = useState([] as DeptBasic[])
// 岗位分配相关状态
const [positionOptions, setPositionOptions] = useState([] as { label: string; value: number }[])
const [userPositions, setUserPositions] = useState([] as number[])
// 新增/编辑弹窗内的岗位选择
const [createPositionIds, setCreatePositionIds] = useState([] as number[])
const [editPositionIds, setEditPositionIds] = useState([] as number[])
// 权限判断
const { has } = usePermission()
// 根据 allDepts 构建部门树(用于选择多个部门)
type DeptTreeNode = { title: string; value: number; key: number; children?: DeptTreeNode[] }
const deptTreeData: DeptTreeNode[] = useMemo(() => {
const map = new Map<number, DeptTreeNode & { children: DeptTreeNode[] }>()
allDepts.forEach((d: DeptBasic) => map.set(d.id, { title: d.name, value: d.id, key: d.id, children: [] }))
const roots: (DeptTreeNode & { children: DeptTreeNode[] })[] = []
allDepts.forEach((d: DeptBasic) => {
const node = map.get(d.id)!
if (d.parent_id && map.has(d.parent_id)) {
const parent = map.get(d.parent_id)
if (parent) parent.children.push(node)
} else {
roots.push(node)
}
})
return roots
}, [allDepts])
// 获取岗位选项
const fetchPositionOptions = async () => {
try {
const { data } = await api.get('/positions', { params: { page: 1, page_size: 1000 } })
if (data?.code === 0) {
setPositionOptions((data.data.items || []).map((it: any) => ({ label: it.name, value: it.id })))
}
} catch (e) {
console.error('获取岗位列表失败:', e)
}
}
useEffect(() => {
fetchPositionOptions()
}, [])
const fetchUsers = async (p: number = page, ps: number = pageSize, kw: string = keyword) => {
setLoading(true)
try{
const { data } = await api.get(`/users`, { params: { page: p, page_size: ps, keyword: kw } })
if(data?.code === 0){
const resp = data.data
setData(resp.items || [])
setTotal(resp.total || 0)
setPage(resp.page || p)
setPageSize(resp.page_size || ps)
}else{
throw new Error(data?.message || '获取用户失败')
}
}catch(e: any){
message.error(e.message || '获取用户失败')
}finally{
setLoading(false)
}
}
useEffect(() => { fetchUsers(1, pageSize, keyword) }, [])
const onCreate = async () => {
form.resetFields()
// 新增用户:预置清空角色与部门选择,并加载候选数据
setSelectedRoleIds([])
setSelectedDeptIds([])
setCreatePositionIds([])
setCreateOpen(true)
try {
await fetchPositionOptions()
const [rolesRes, deptsRes] = await Promise.all([
api.get('/roles', { params: { page: 1, page_size: 1000 } }),
api.get('/departments', { params: { keyword: '' } })
])
if (rolesRes.data?.code === 0) {
const rolesItemsSrc = Array.isArray(rolesRes.data?.data?.items) ? (rolesRes.data.data.items as unknown[]) : []
const roleItems = rolesItemsSrc.filter(isIdName).map(({ id, name }) => ({ id, name }))
setAllRoles(roleItems)
}
if (deptsRes.data?.code === 0) {
const deptsSrc = Array.isArray(deptsRes.data?.data) ? (deptsRes.data.data as unknown[]) : []
const deptBasics = deptsSrc.filter(isDeptBasic)
setAllDepts(deptBasics)
}
} catch (e: any) {
message.error(e.message || '加载角色/部门失败')
}
}
const handleCreate = async () => {
try{
const values = await form.validateFields()
const { data } = await api.post('/users', values)
if(data?.code !== 0){ throw new Error(data?.message || '创建失败') }
const uid = typeof data?.data?.id === 'number' ? data.data.id : undefined
if (uid) {
const [rolesSave, deptsSave, posSave] = await Promise.all([
api.put(`/users/${uid}/roles`, { ids: selectedRoleIds }),
api.put(`/users/${uid}/departments`, { ids: selectedDeptIds }),
api.put(`/users/${uid}/positions`, { ids: createPositionIds })
])
if (rolesSave.data?.code !== 0) throw new Error(rolesSave.data?.message || '保存角色失败')
if (deptsSave.data?.code !== 0) throw new Error(deptsSave.data?.message || '保存部门失败')
if (posSave.data?.code !== 0) throw new Error(posSave.data?.message || '保存岗位失败')
} else {
message.warning('创建成功但未获取到用户ID未能分配角色/部门/岗位')
}
message.success('创建成功')
setCreateOpen(false)
fetchUsers(1, pageSize)
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '创建失败') }
}
const onEdit = async (record: UserItem) => {
setCurrent(record)
editForm.setFieldsValue({ nickname: record.nickname, status: record.status })
setEditOpen(true)
// 加载该用户已分配的角色/部门/岗位及候选列表
try{
await fetchPositionOptions()
const [roleIdsRes, rolesRes, deptIdsRes, deptsRes, posIdsRes] = await Promise.all([
api.get(`/users/${record.id}/roles`),
api.get('/roles', { params: { page: 1, page_size: 1000 } }),
api.get(`/users/${record.id}/departments`),
api.get('/departments', { params: { keyword: '' } }),
api.get(`/users/${record.id}/positions`)
])
if(roleIdsRes.data?.code !== 0) throw new Error(roleIdsRes.data?.message || '获取用户角色失败')
if(rolesRes.data?.code !== 0) throw new Error(rolesRes.data?.message || '获取角色列表失败')
if(deptIdsRes.data?.code !== 0) throw new Error(deptIdsRes.data?.message || '获取用户部门失败')
if(deptsRes.data?.code !== 0) throw new Error(deptsRes.data?.message || '获取部门列表失败')
if(posIdsRes.data?.code !== 0) throw new Error(posIdsRes.data?.message || '获取用户岗位失败')
const roleIds = Array.isArray(roleIdsRes.data?.data) ? (roleIdsRes.data.data as unknown[]).filter((n): n is number => typeof n === 'number') : []
setSelectedRoleIds(roleIds)
const rolesItemsSrc = Array.isArray(rolesRes.data?.data?.items) ? rolesRes.data.data.items as unknown[] : []
const roleItems = rolesItemsSrc.filter(isIdName).map(({ id, name }) => ({ id, name }))
setAllRoles(roleItems)
const deptIds = Array.isArray(deptIdsRes.data?.data) ? (deptIdsRes.data.data as unknown[]).filter((n): n is number => typeof n === 'number') : []
setSelectedDeptIds(deptIds)
const deptsSrc = Array.isArray(deptsRes.data?.data) ? (deptsRes.data.data as unknown[]) : []
const deptBasics = deptsSrc.filter(isDeptBasic)
setAllDepts(deptBasics)
const posIds = Array.isArray(posIdsRes.data?.data) ? (posIdsRes.data.data as unknown[]).filter((n): n is number => typeof n === 'number') : []
setEditPositionIds(posIds)
}catch(e: any){ message.error(e.message || '加载编辑数据失败') }
}
const handleEdit = async () => {
if(!current) return
try{
const values = await editForm.validateFields()
// 先保存基础信息
const { data: upd } = await api.put(`/users/${current!.id}`, values)
if(upd?.code !== 0) throw new Error(upd?.message || '更新失败')
// 再保存角色与部门
const [rolesSave, deptsSave] = await Promise.all([
api.put(`/users/${current.id}/roles`, { ids: selectedRoleIds }),
api.put(`/users/${current.id}/departments`, { ids: selectedDeptIds }),
api.put(`/users/${current.id}/positions`, { ids: editPositionIds })
])
if(rolesSave.data?.code !== 0) throw new Error(rolesSave.data?.message || '保存角色失败')
if(deptsSave.data?.code !== 0) throw new Error(deptsSave.data?.message || '保存部门失败')
message.success('更新成功')
setEditOpen(false)
fetchUsers(page, pageSize)
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '更新失败') }
}
const onResetPwd = (record: UserItem) => {
setCurrent(record)
pwdForm.resetFields()
setPwdOpen(true)
}
const handleResetPwd = async () => {
try{
const values = await pwdForm.validateFields()
const { data } = await api.post(`/users/${current!.id}/reset_password`, values)
if(data?.code === 0){
message.success('密码已重置')
setPwdOpen(false)
}else{ throw new Error(data?.message || '重置失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '重置失败') }
}
// 岗位分配相关方法
const openPositions = async (userId: number) => {
setCurrentUserId(userId)
try {
const { data } = await api.get(`/users/${userId}/positions`)
if (data?.code === 0) {
setUserPositions(data.data || [])
} else {
throw new Error(data?.message || '获取用户岗位失败')
}
} catch (e: any) {
message.error(e.message || '获取用户岗位失败')
setUserPositions([])
}
setPositionsOpen(true)
}
const savePositions = async () => {
if (!currentUserId) return
try {
const { data } = await api.put(`/users/${currentUserId}/positions`, { ids: userPositions })
if (data?.code === 0) {
message.success('岗位分配成功')
setPositionsOpen(false)
} else {
throw new Error(data?.message || '保存岗位失败')
}
} catch (e: any) {
message.error(e.message || '保存岗位失败')
}
}
const columns: ColumnsType<UserItem> = useMemo(() => [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '用户名', dataIndex: 'username' },
{ title: '昵称', dataIndex: 'nickname' },
{ title: '状态', dataIndex: 'status', render: (v: number) => v === 1 ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: '创建时间', dataIndex: 'created_at', render: (v: any) => formatDateTime(v) },
{ title: '操作', key: 'actions', width: 360, render: (_: any, r: UserItem) => (
<Space>
{has('system:user:update') && (
<a className="action-link" onClick={() => onEdit(r)}>
<EditOutlined />
<span></span>
</a>
)}
{has('system:user:reset') && (
<a className="action-link" onClick={() => onResetPwd(r)}>
<KeyOutlined />
<span></span>
</a>
)}
{has('system:user:assignPosition') && (
<a className="action-link" onClick={() => openPositions(r.id)}>
<UserOutlined />
<span></span>
</a>
)}
{has('system:user:delete') && (
<Popconfirm title={`确认删除用户「${r.username}」?`} onConfirm={async ()=>{ try{ const { data } = await api.delete(`/users/${r.id}`); if(data?.code===0){ message.success('删除成功'); fetchUsers(page, pageSize, keyword) } else { throw new Error(data?.message||'删除失败') } }catch(e:any){ message.error(e.message||'删除失败') } }}>
<a className="action-link action-danger">
<DeleteOutlined />
<span></span>
</a>
</Popconfirm>
)}
</Space>
)},
], [page, pageSize, keyword, has])
return (
<div>
<PageHeader items={["系统管理","用户管理"]} title="" />
<Form form={searchForm} layout="inline" onFinish={(vals: any)=>{ const kw = String(vals?.keyword ?? '').trim(); setKeyword(kw); fetchUsers(1, pageSize, kw) }} style={{ marginBottom: 12 }}>
<Form.Item name="keyword">
<Input allowClear placeholder="搜索用户名/昵称" style={{ width: 320 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={()=>{ searchForm.resetFields(); setKeyword(''); fetchUsers(1, pageSize, '') }}></Button>
{has('system:user:create') && (
<Button type="primary" onClick={onCreate}></Button>
)}
</Space>
</Form.Item>
</Form>
<Table<UserItem>
rowKey="id"
loading={loading}
dataSource={data}
columns={columns}
pagination={{ current: page, pageSize, total, showSizeChanger: true, onChange: (p: number, ps?: number) => fetchUsers(p, ps ?? pageSize, keyword) }}
/>
<Typography.Paragraph type="secondary" style={{ marginTop: 12 }}>
</Typography.Paragraph>
<Modal title="新增用户" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="创建" width={840}>
<Form form={form} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="username" label="用户名" rules={[{ required: true }]}>
<Input placeholder="请输入用户名" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true }]}>
<Input.Password placeholder="请输入密码" />
</Form.Item>
<Form.Item name="nickname" label="昵称">
<Input placeholder="请输入昵称" />
</Form.Item>
<Form.Item name="status" label="状态" initialValue={1}>
<Select options={[{label:'启用', value:1},{label:'禁用', value:0}]} />
</Form.Item>
<Form.Item label="角色">
<Select
mode="multiple"
style={{ width: '100%' }}
placeholder="选择角色"
value={selectedRoleIds}
options={allRoles.map((r: { id: number; name: string }) => ({ label: r.name, value: r.id }))}
onChange={(vals: number[]) => setSelectedRoleIds(vals)}
/>
</Form.Item>
<Form.Item label="部门">
<TreeSelect
allowClear
multiple
treeCheckable
placeholder="选择部门(支持多选)"
style={{ width: '100%' }}
treeData={deptTreeData}
treeDefaultExpandAll
showSearch
filterTreeNode={(input: string, node: any) => String(node?.title ?? '').toLowerCase().includes(String(input).toLowerCase())}
value={selectedDeptIds}
onChange={(vals) => setSelectedDeptIds(Array.isArray(vals) ? (vals as (string | number)[]).map(v => Number(v)) : [])}
/>
</Form.Item>
{has('system:user:assignPosition') && (
<Form.Item label="岗位">
<Select
mode="multiple"
allowClear
placeholder="选择岗位"
value={createPositionIds}
options={positionOptions}
style={{ width: '100%' }}
onChange={setCreatePositionIds}
/>
</Form.Item>
)}
</Form>
</Modal>
<Modal title="编辑用户" open={editOpen} onOk={handleEdit} onCancel={() => setEditOpen(false)} okText="保存" width={840}>
<Form form={editForm} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="nickname" label="昵称">
<Input placeholder="请输入昵称" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={[{label:'启用', value:1},{label:'禁用', value:0}]} />
</Form.Item>
<Form.Item label="角色">
<Select
mode="multiple"
style={{ width: '100%' }}
placeholder="选择角色"
value={selectedRoleIds}
options={allRoles.map((r: { id: number; name: string }) => ({ label: r.name, value: r.id }))}
onChange={(vals: number[]) => setSelectedRoleIds(vals)}
/>
</Form.Item>
<Form.Item label="部门">
<TreeSelect
allowClear
multiple
treeCheckable
placeholder="选择部门(支持多选)"
style={{ width: '100%' }}
treeData={deptTreeData}
treeDefaultExpandAll
showSearch
filterTreeNode={(input: string, node: any) => String(node?.title ?? '').toLowerCase().includes(String(input).toLowerCase())}
value={selectedDeptIds}
onChange={(vals) => setSelectedDeptIds(Array.isArray(vals) ? (vals as (string | number)[]).map(v => Number(v)) : [])}
/>
</Form.Item>
{has('system:user:assignPosition') && (
<Form.Item label="岗位">
<Select
mode="multiple"
allowClear
placeholder="选择岗位"
value={editPositionIds}
options={positionOptions}
style={{ width: '100%' }}
onChange={setEditPositionIds}
/>
</Form.Item>
)}
</Form>
</Modal>
<Modal title={`重置密码${current ? `${current.username}` : ''}`} open={pwdOpen} onOk={handleResetPwd} onCancel={() => setPwdOpen(false)} okText="重置">
<Form form={pwdForm} layout="vertical">
<Form.Item name="password" label="新密码" rules={[{ required: true }]}>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
</Form>
</Modal>
<Modal title="分配岗位" open={positionsOpen} onOk={savePositions} onCancel={() => setPositionsOpen(false)} okText="保存">
<Form layout="vertical">
<Form.Item label="选择岗位">
<Select
mode="multiple"
allowClear
value={userPositions}
onChange={setUserPositions}
options={positionOptions}
style={{ width: '100%' }}
placeholder="选择岗位"
/>
</Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@ -0,0 +1,2 @@
.login-wrap{min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#e3f2fd,#e8f5e9)}
.login-card{width:420px}

View File

@ -0,0 +1,27 @@
html,body,#root{height:100%}
*{box-sizing:border-box}
/* 顶部多窗口 Tabs去掉默认底部横线并去掉额外间距避免影响右侧个人中心图标 */
.top-tabs .ant-tabs-nav::before { border-bottom: 0 !important; }
.top-tabs .ant-tabs-nav { margin: 0 !important; }
/* Enlarge action buttons in table action columns */
.ant-table .ant-space .ant-btn,
.ant-table .ant-btn {
height: 32px; /* middle size height */
padding: 4px 12px;
font-size: 14px;
}
/* Ensure icon-only buttons are also larger */
.ant-table .ant-btn .anticon { font-size: 16px; }
/* Slightly increase space gap in action columns */
.ant-table .ant-space { gap: 8px !important; }
/* Icon + text action link styles in tables */
.ant-table .action-link { display: inline-flex; align-items: center; gap: 6px; color: #1677ff; cursor: pointer; font-size: 14px; }
.ant-table .action-link .anticon { font-size: 16px; }
.ant-table .action-link:hover { color: #0958d9; }
.ant-table .action-danger { color: #ff4d4f; }
.ant-table .action-danger:hover { color: #d9363e; }

View File

@ -0,0 +1,71 @@
import axios, { type AxiosError, type AxiosInstance, type AxiosRequestHeaders, type AxiosResponse, type InternalAxiosRequestConfig, AxiosHeaders } from 'axios'
import { getToken, setToken, clearToken } from './token'
// 统一的接口返回泛型
export type ApiResp<T> = { code: number; message?: string; data?: T }
// 在请求配置上携带一次性重试标记
type RetryConfig = InternalAxiosRequestConfig & { _retry?: boolean }
// 使用 Vite 的环境变量类型
const isDev = import.meta.env.DEV
const configuredBase = import.meta.env?.VITE_API_BASE || ''
const baseURL = isDev ? '' : configuredBase
const api: AxiosInstance = axios.create({ baseURL: baseURL ? `${baseURL}/api` : '/api', withCredentials: true })
let isRefreshing = false
let pendingQueue: { resolve: () => void; reject: (e: unknown) => void; config: RetryConfig }[] = []
api.interceptors.request.use((config: RetryConfig) => {
const token = getToken()
if (token) {
const h = config.headers
const value = `Bearer ${token}`
if (h instanceof AxiosHeaders) {
h.set('Authorization', value)
} else {
// 兼容对象形式的 headers
config.headers = { ...(h as Record<string, any>), Authorization: value } as AxiosRequestHeaders
}
}
return config
})
api.interceptors.response.use(
(r: AxiosResponse) => r,
async (error: AxiosError<ApiResp<unknown>>) => {
const original = (error.config || {}) as RetryConfig
if (error.response?.status === 401 && !original._retry) {
original._retry = true
if (!isRefreshing) {
isRefreshing = true
try {
const { data } = await api.get<ApiResp<{ access_token: string }>>('/auth/refresh')
if (data?.code === 0) {
const access = data.data?.access_token
if (access) setToken(access)
pendingQueue.forEach(p => p.resolve())
pendingQueue = []
return api(original)
}
} catch (e) {
pendingQueue.forEach(p => p.reject(e))
pendingQueue = []
clearToken()
if (typeof window !== 'undefined') {
window.location.href = '/login'
}
return Promise.reject(e)
} finally {
isRefreshing = false
}
}
return new Promise<void>((resolve, reject) => {
pendingQueue.push({ resolve: () => resolve(), reject: (e: unknown) => reject(e as unknown), config: original })
}).then(() => api(original))
}
return Promise.reject(error)
}
)
export default api

View File

@ -0,0 +1,31 @@
export function formatDateTime(value: unknown): string {
if (value === null || value === undefined) return ''
try {
if (typeof value === 'string') {
const s = value.trim()
if (s.length >= 19) {
const core = s.replace('T', ' ').slice(0, 19)
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(core)) return core
}
const d = new Date(s)
if (!isNaN(d.getTime())) return formatFromDate(d)
} else if (typeof value === 'number') {
const d = new Date(value)
if (!isNaN(d.getTime())) return formatFromDate(d)
} else if (value instanceof Date) {
if (!isNaN(value.getTime())) return formatFromDate(value)
}
} catch (_) {}
return String(value)
}
function pad(n: number): string { return n < 10 ? '0' + n : '' + n }
function formatFromDate(d: Date): string {
const y = d.getFullYear()
const m = pad(d.getMonth() + 1)
const day = pad(d.getDate())
const hh = pad(d.getHours())
const mm = pad(d.getMinutes())
const ss = pad(d.getSeconds())
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
}

View File

@ -0,0 +1,40 @@
import React, { createContext, useContext, useMemo } from 'react'
// 权限上下文,存储后端返回的权限编码集合(如 system:user:create 等)
export type PermissionSet = Set<string>
interface PermissionContextValue {
codes: PermissionSet
}
const PermissionContext = createContext<PermissionContextValue>({ codes: new Set<string>() })
export function PermissionProvider({ codes, children }: { codes: PermissionSet; children: React.ReactNode }) {
// 统一将编码小写化,避免大小写不一致
const normalized = useMemo(() => {
const s = new Set<string>()
codes.forEach((c) => { if (c) s.add(String(c).trim().toLowerCase()) })
return s
}, [codes])
const value = useMemo(() => ({ codes: normalized }), [normalized])
return <PermissionContext.Provider value={value}>{children}</PermissionContext.Provider>
}
export function usePermission() {
const { codes } = useContext(PermissionContext)
const has = (code?: string | null) => {
if (!code) return false
return codes.has(String(code).trim().toLowerCase())
}
const anyOf = (list: (string | undefined | null)[]) => list.some((c) => has(c || undefined))
const allOf = (list: (string | undefined | null)[]) => list.every((c) => has(c || undefined))
return { has, anyOf, allOf, codes }
}
// 便捷组件:具备指定权限才渲染子节点;否则什么也不渲染
export function Perm({ code, children }: { code: string; children: React.ReactNode }) {
const { has } = usePermission()
if (!has(code)) return null
return <>{children}</>
}

View File

@ -0,0 +1,20 @@
const KEY = 'udmin_access_token'
const UKEY = 'udmin_user'
export function getToken(){
return localStorage.getItem(KEY)
}
export function setToken(t: string){
localStorage.setItem(KEY, t)
}
export function clearToken(){
localStorage.removeItem(KEY)
localStorage.removeItem(UKEY)
}
export function setUser(u: any){
localStorage.setItem(UKEY, JSON.stringify(u))
}
export function getUser(){
const s = localStorage.getItem(UKEY)
return s ? JSON.parse(s) : null
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

18
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": "."
},
"include": ["src"]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/layouts/mainlayout.tsx","./src/pages/dashboard.tsx","./src/pages/departments.tsx","./src/pages/login.tsx","./src/pages/logs.tsx","./src/pages/menus.tsx","./src/pages/permissions.tsx","./src/pages/roles.tsx","./src/pages/users.tsx","./src/utils/axios.ts","./src/utils/datetime.ts","./src/utils/permission.tsx","./src/utils/token.ts"],"version":"5.9.2"}

17
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
open: true,
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true
}
}
}
})

443
scripts/setup_demo.sh Executable file
View File

@ -0,0 +1,443 @@
#!/usr/bin/env bash
set -euo pipefail
API="http://127.0.0.1:8080/api"
extract_id() { sed -n -E 's/.*"id":([0-9]+).*/\1/p'; }
# 使用变量承载 JSON避免直接使用管道导致的 broken pipe
get_menu_id_by_parent_and_path(){
local parent_id="$1"; local path="$2"
local MENUS
MENUS=$(curl -s -X GET "$API/menus" -H "$H_AUTH")
echo "$MENUS" | P="$path" PID="$parent_id" python3 - <<'PY'
import json,sys,os
try:
data=json.load(sys.stdin)
except Exception:
sys.exit(0)
menus=data.get("data", [])
P=os.environ.get("P")
PID=os.environ.get("PID")
try:
pid_int=None if PID in (None, "", "null") else int(PID)
except Exception:
pid_int=None
for m in menus:
if m.get("path")==P and ((pid_int is None and m.get("parent_id") is None) or (pid_int is not None and m.get("parent_id")==pid_int)):
print(m.get("id"), end="")
break
PY
}
get_menu_id_by_parent_and_name(){
local parent_id="$1"; local name="$2"
local MENUS
MENUS=$(curl -s -X GET "$API/menus" -H "$H_AUTH")
echo "$MENUS" | N="$name" PID="$parent_id" python3 - <<'PY'
import json,sys,os
try:
data=json.load(sys.stdin)
except Exception:
sys.exit(0)
menus=data.get("data", [])
N=os.environ.get("N")
PID=os.environ.get("PID")
try:
pid_int=None if PID in (None, "", "null") else int(PID)
except Exception:
pid_int=None
for m in menus:
if m.get("name")==N and ((pid_int is None and m.get("parent_id") is None) or (pid_int is not None and m.get("parent_id")==pid_int)):
print(m.get("id"), end="")
break
PY
}
create_or_get_menu(){
# parent_id name type path component icon order_no [visible] [status] [keep_alive]
local parent_id="$1"; local name="$2"; local mtype="$3"; local path="$4"; local component="$5"; local icon="$6"; local order_no="$7"; local visible="${8:-true}"; local status="${9:-1}"; local keep_alive="${10:-true}"
local id=""
if [ -n "$path" ]; then id=$(get_menu_id_by_parent_and_path "$parent_id" "$path") || true; fi
if [ -z "$id" ]; then id=$(get_menu_id_by_parent_and_name "$parent_id" "$name") || true; fi
if [ -n "$id" ]; then echo "$id"; return 0; fi
# 规范化可选字段为 JSON 合法值
local j_parent j_path j_comp j_icon j_order
if [ -n "${parent_id:-}" ]; then j_parent="$parent_id"; else j_parent="null"; fi
if [ -n "${path:-}" ]; then j_path="\"$path\""; else j_path="null"; fi
if [ -n "${component:-}" ]; then j_comp="\"$component\""; else j_comp="null"; fi
if [ -n "${icon:-}" ]; then j_icon="\"$icon\""; else j_icon="null"; fi
j_order="${order_no:-0}"
local payload
payload=$(cat <<JSON
{"parent_id":$j_parent,"name":"$name","path":$j_path,"component":$j_comp,"type":$mtype,"icon":$j_icon,"order_no":$j_order,"visible":$visible,"status":$status,"keep_alive":$keep_alive}
JSON
)
local resp; resp=$(echo "$payload" | curl -s -X POST "$API/menus" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary @-)
id=$(echo "$resp" | extract_id)
echo "$id"
}
# 1) Admin login
LOGIN_JSON=$(printf '{"username":"admin","password":"Admin@123"}' | curl -s -X POST "$API/auth/login" -H "Content-Type: application/json" --data-binary @-)
TOKEN=$(echo "$LOGIN_JSON" | sed -n -E 's/.*"access_token":"([^"]*)".*/\1/p')
if [ -z "$TOKEN" ]; then echo "[ERROR] Admin 登录失败: $LOGIN_JSON"; exit 1; fi
H_AUTH="Authorization: Bearer $TOKEN"
echo "[OK] Admin 登录成功"
# 1b) 去重清理:仅保留最早创建的根菜单,删除其余重复的根及子树
cleanup_duplicate_roots(){
local ROOT_NAME="$1"; local ROOT_TYPE="$2"
local MENUS JSON
MENUS=$(curl -s -X GET "$API/menus" -H "$H_AUTH")
# 计算应保留的 root_id 与需要删除的 root_ids
JSON=$(echo "$MENUS" | NAME="$ROOT_NAME" RTYPE="$ROOT_TYPE" python3 - <<'PY'
import json,sys,os
try:
data=json.load(sys.stdin)
menus=data.get("data", [])
except Exception:
menus=[]
NAME=os.environ.get("NAME")
RTYPE=int(os.environ.get("RTYPE") or 0)
roots=[m for m in menus if m.get("name")==NAME and m.get("parent_id") is None and m.get("type")==RTYPE]
roots=sorted(roots, key=lambda m: m.get("id") or 1<<30)
keep_id=str(roots[0].get("id")) if roots else ""
duplicates=[str(m.get("id")) for m in roots[1:]]
# 构建子树映射
children={}
for m in menus:
pid=m.get("parent_id")
if pid is None: continue
children.setdefault(str(pid), []).append(str(m.get("id")))
# 计算每个重复根的删除顺序(自底向上)
orders={}
from collections import deque
for rid in duplicates:
order=[]
# BFS 收集所有节点
q=deque([rid])
seen=set()
while q:
x=q.popleft()
if x in seen: continue
seen.add(x)
for c in children.get(x, []):
q.append(c)
order.append(x)
# 逆序删除(叶 -> 根)
orders[rid]=list(reversed(order))
print(json.dumps({"keep": keep_id, "dups": duplicates, "orders": orders}))
PY
)
local KEEP_ID; KEEP_ID=$(echo "$JSON" | sed -n -E 's/.*"keep":"?([0-9]+)"?.*/\1/p')
local DUPS; DUPS=$(echo "$JSON" | sed -n -E 's/.*"dups":\[([^\]]*)\].*/\1/p' | tr -d '" ')
if [ -n "${DUPS:-}" ]; then
echo "[INFO] 发现重复根菜单: $ROOT_NAME (type=$ROOT_TYPE)保留ID=$KEEP_ID,待清理: [$DUPS]"
# 逐个根依次删除其子树
for rid in $(echo "$DUPS" | tr ',' ' '); do
local ORDER; ORDER=$(echo "$JSON" | RID="$rid" python3 - <<'PY'
import json,sys,os
obj=json.load(sys.stdin)
rid=os.environ.get("RID")
print(",".join(obj.get("orders", {}).get(rid, [])), end="")
PY
)
for id in $(echo "$ORDER" | tr ',' ' '); do
# 逐个删除(叶优先),失败重试一次
RES=$(curl -s -X DELETE "$API/menus/$id" -H "$H_AUTH" || true)
echo "$RES" | grep -q '"code":0' || {
sleep 0.2
RES=$(curl -s -X DELETE "$API/menus/$id" -H "$H_AUTH" || true)
echo "$RES" | grep -q '"code":0' || echo "[WARN] 删除菜单失败(id=$id): $RES"
}
done
done
echo "[OK] 已清理 $ROOT_NAME 重复根菜单"
fi
}
# 如数据库已存在相同根菜单,先做去重清理
cleanup_duplicate_roots "演示-系统管理" 0 || true
cleanup_duplicate_roots "系统管理" 1 || true
# 2) Create demo menus可通过环境变量 SKIP_DEMO=1 跳过)
if [ "${SKIP_DEMO:-0}" != "1" ]; then
# 幂等:优先查找已存在的“演示-系统管理”,否则创建(基于 Python 解析)
MENUS_JSON=$(curl -s -X GET "$API/menus" -H "$H_AUTH")
DEMO_ROOT_ID=$(echo "$MENUS_JSON" | python3 - <<'PY'
import json,sys
try:
data=json.load(sys.stdin)
menus=data.get("data", [])
except Exception:
menus=[]
for m in menus:
if m.get("name")=="演示-系统管理" and m.get("type")==0 and m.get("parent_id") is None:
print(m.get("id"), end="")
break
PY
)
if [ -z "${DEMO_ROOT_ID:-}" ]; then
DEMO_ROOT_JSON=$(printf '{"name":"演示-系统管理","type":0,"order_no":100,"visible":true,"status":1}' | curl -s -X POST "$API/menus" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary @-)
DEMO_ROOT_ID=$(echo "$DEMO_ROOT_JSON" | extract_id)
fi
if [ -z "${DEMO_ROOT_ID:-}" ]; then echo "[ERROR] 创建/获取 演示-系统管理 根菜单失败"; exit 1; fi
USERS_ID=$(create_or_get_menu "$DEMO_ROOT_ID" "演示-用户管理" 1 "/demo/users" "Users" "" 10)
ROLES_ID=$(create_or_get_menu "$DEMO_ROOT_ID" "演示-角色管理" 1 "/demo/roles" "Roles" "" 20)
MENUS_ID=$(create_or_get_menu "$DEMO_ROOT_ID" "演示-菜单管理" 1 "/demo/menus" "Menus" "" 30)
PERMS_ID=$(create_or_get_menu "$DEMO_ROOT_ID" "演示-权限管理" 1 "/demo/perms" "Permissions" "" 40)
DEPT_ID=$(create_or_get_menu "$DEMO_ROOT_ID" "演示-部门管理" 1 "/demo/departments" "Departments" "" 50)
POS_ID=$(create_or_get_menu "$DEMO_ROOT_ID" "演示-岗位管理" 1 "/demo/positions" "Positions" "" 60)
echo "[OK] 演示菜单准备完成: ROOT=$DEMO_ROOT_ID USERS=$USERS_ID ROLES=$ROLES_ID MENUS=$MENUS_ID PERMS=$PERMS_ID DEPT=$DEPT_ID POS=$POS_ID"
else
echo "[INFO] 已设置 SKIP_DEMO=1跳过演示菜单创建"
fi
# [INFO] 演示菜单已通过幂等逻辑创建/获取,避免重复
# 3) Create demo role
TS=$(date +%s)
ROLE_NAME="演示-经理-${TS}"
ROLE_CODE="demo_manager_${TS}"
ROLE_JSON=$(printf '{"name":"%s","code":"%s","status":1}' "$ROLE_NAME" "$ROLE_CODE" | curl -s -X POST "$API/roles" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary @-)
ROLE_ID=$(echo "$ROLE_JSON" | extract_id); if [ -z "$ROLE_ID" ]; then echo "[ERROR] 创建角色失败: $ROLE_JSON"; exit 1; fi
echo "[OK] 创建角色成功: $ROLE_NAME (id=$ROLE_ID)"
# 4) Bind menus to the role
IDS=""
for v in "${USERS_ID:-}" "${ROLES_ID:-}" "${MENUS_ID:-}" "${PERMS_ID:-}" "${DEPT_ID:-}" "${POS_ID:-}"; do
if [ -n "$v" ]; then
if [ -n "$IDS" ]; then IDS="$IDS,$v"; else IDS="$v"; fi
fi
done
if [ -n "$IDS" ]; then
BIND_MENU_JSON=$(printf '{"ids":[%s]}' "$IDS" | curl -s -X PUT "$API/roles/$ROLE_ID/menus" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary @-)
echo "$BIND_MENU_JSON" | grep -q '"code":0' || { echo "[ERROR] 绑定角色菜单失败: $BIND_MENU_JSON"; exit 1; }
echo "[OK] 绑定角色菜单成功"
else
echo "[WARN] 无可绑定的演示菜单,跳过绑定"
fi
# 5) Create a user and bind the role
USER_NAME="alice_${TS}"
USER_PASS="Password@123"
USER_JSON=$(printf '{"username":"%s","password":"%s","nickname":"Alice","status":1}' "$USER_NAME" "$USER_PASS" | curl -s -X POST "$API/users" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary @-)
USER_ID=$(echo "$USER_JSON" | extract_id); if [ -z "$USER_ID" ]; then echo "[ERROR] 创建用户失败: $USER_JSON"; exit 1; fi
echo "[OK] 创建用户成功: $USER_NAME (id=$USER_ID)"
BIND_ROLE_JSON=$(printf '{"ids":[%s]}' "$ROLE_ID" | curl -s -X PUT "$API/users/$USER_ID/roles" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary @-)
echo "$BIND_ROLE_JSON" | grep -q '"code":0' || { echo "[ERROR] 绑定用户角色失败: $BIND_ROLE_JSON"; exit 1; }
echo "[OK] 绑定用户角色成功"
# 5b) Bind demo role to admin and verify menus
ADMIN_JSON=$(curl -s -X GET "$API/users?page=1&page_size=50&keyword=admin" -H "$H_AUTH")
ADMIN_ID=$(echo "$ADMIN_JSON" | sed -n -E 's/.*"id":([0-9]+).*"username":"admin".*/\1/p')
if [ -z "$ADMIN_ID" ]; then
echo "[WARN] 未找到 admin 用户ID"
else
BIND_ADMIN_JSON=$(printf '{"ids":[%s]}' "$ROLE_ID" | curl -s -X PUT "$API/users/$ADMIN_ID/roles" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary @-)
echo "$BIND_ADMIN_JSON" | grep -q '"code":0' && echo "[OK] 已为 admin 绑定演示角色(id=$ROLE_ID)" || echo "[WARN] 为 admin 绑定角色可能失败: $BIND_ADMIN_JSON"
MENUS_ADMIN=$(curl -s -X GET "$API/auth/menus" -H "$H_AUTH")
echo "[RESULT] Admin 可见菜单响应: $MENUS_ADMIN"
fi
# 6) Login as new user and fetch menus
LOGIN2_JSON=$(printf '{"username":"%s","password":"%s"}' "$USER_NAME" "$USER_PASS" | curl -s -X POST "$API/auth/login" -H "Content-Type: application/json" --data-binary @-)
TOKEN2=$(echo "$LOGIN2_JSON" | sed -n -E 's/.*"access_token":"([^\"]*)".*/\1/p')
if [ -z "$TOKEN2" ]; then echo "[ERROR] 新用户登录失败: $LOGIN2_JSON"; exit 1; fi
MENUS2=$(curl -s -X GET "$API/auth/menus" -H "Authorization: Bearer $TOKEN2")
echo "[RESULT] 新用户可见菜单响应: $MENUS2"
# 7) Check permissions and seed defaults if empty
PERMS_LIST=$(curl -s -X GET "$API/permissions?page=1&page_size=100" -H "$H_AUTH") || true
PERMS_TOTAL=$(echo "$PERMS_LIST" | sed -n -E 's/.*"total":([0-9]+).*/\1/p') || true
[ -z "${PERMS_TOTAL:-}" ] && PERMS_TOTAL=0
echo "[RESULT] 当前权限总数: $PERMS_TOTAL"
if [ "$PERMS_TOTAL" = "0" ]; then
echo "[INFO] 权限为空,开始初始化默认权限"
curl -s -X POST "$API/permissions" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary '{"code":"users:view","name":"用户查看","description":"查看用户"}' >/dev/null || true
curl -s -X POST "$API/permissions" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary '{"code":"users:edit","name":"用户编辑","description":"编辑用户"}' >/dev/null || true
curl -s -X POST "$API/permissions" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary '{"code":"roles:view","name":"角色查看","description":"查看角色"}' >/dev/null || true
curl -s -X POST "$API/permissions" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary '{"code":"roles:edit","name":"角色编辑","description":"编辑角色"}' >/dev/null || true
curl -s -X POST "$API/permissions" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary '{"code":"menus:view","name":"菜单查看","description":"查看菜单"}' >/dev/null || true
curl -s -X POST "$API/permissions" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary '{"code":"menus:edit","name":"菜单编辑","description":"编辑菜单"}' >/dev/null || true
curl -s -X POST "$API/permissions" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary '{"code":"permissions:view","name":"权限查看","description":"查看权限"}' >/dev/null || true
curl -s -X POST "$API/permissions" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary '{"code":"permissions:edit","name":"权限编辑","description":"编辑权限"}' >/dev/null || true
curl -s -X POST "$API/permissions" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary '{"code":"departments:view","name":"部门查看","description":"查看部门"}' >/dev/null || true
curl -s -X POST "$API/permissions" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary '{"code":"departments:edit","name":"部门编辑","description":"编辑部门"}' >/dev/null || true
PERMS_LIST=$(curl -s -X GET "$API/permissions?page=1&page_size=100" -H "$H_AUTH") || true
PERMS_TOTAL=$(echo "$PERMS_LIST" | sed -n -E 's/.*"total":([0-9]+).*/\1/p') || true
[ -z "${PERMS_TOTAL:-}" ] && PERMS_TOTAL=0
echo "[RESULT] 初始化后权限总数: $PERMS_TOTAL"
fi
echo "[SUMMARY] DEMO_ROOT=${DEMO_ROOT_ID:-} USERS=${USERS_ID:-} ROLES=${ROLES_ID:-} MENUS=${MENUS_ID:-} PERMS=${PERMS_ID:-} ROLE=$ROLE_ID USER=$USER_ID USERNAME=$USER_NAME"
# 8) Seed formal "系统管理" menus and Super Admin role
# 获取或创建 系统管理 根菜单:查找时不依赖字段顺序
MENUS_JSON=$(curl -s -X GET "$API/menus" -H "$H_AUTH")
SYS_ROOT_ID=$(echo "$MENUS_JSON" | python3 - <<'PY'
import json,sys
try:
data=json.load(sys.stdin)
menus=data.get("data", [])
except Exception:
menus=[]
for m in menus:
if m.get("name")=="系统管理" and m.get("type")==1 and m.get("parent_id") is None:
print(m.get("id"), end="")
break
PY
)
if [ -z "${SYS_ROOT_ID:-}" ]; then
SYS_ROOT_JSON=$(printf '{"name":"系统管理","type":1,"icon":"SettingOutlined","order_no":10,"visible":true,"status":1}' | curl -s -X POST "$API/menus" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary @-)
SYS_ROOT_ID=$(echo "$SYS_ROOT_JSON" | extract_id)
fi
if [ -z "${SYS_ROOT_ID:-}" ]; then echo "[ERROR] 创建/获取 系统管理 根菜单失败"; exit 1; fi
# helper to create child menu under 系统管理
create_menu_under_sys(){
local name="$1"; local path="$2"; local component="$3"; local icon="$4"; local order_no="$5"
local id
id=$(get_menu_id_by_parent_and_path "$SYS_ROOT_ID" "$path") || true
if [ -z "$id" ]; then id=$(get_menu_id_by_parent_and_name "$SYS_ROOT_ID" "$name") || true; fi
if [ -n "$id" ]; then echo "$id"; return 0; fi
id=$(create_or_get_menu "$SYS_ROOT_ID" "$name" 1 "$path" "$component" "$icon" "$order_no")
echo "$id"
}
USERS2_ID=$(create_menu_under_sys "用户管理" "/users" "Users" "UserOutlined" 20); [ -z "$USERS2_ID" ] && echo "[WARN] 用户管理 菜单ID获取失败"
ROLES2_ID=$(create_menu_under_sys "角色管理" "/roles" "Roles" "TeamOutlined" 30); [ -z "$ROLES2_ID" ] && echo "[WARN] 角色管理 菜单ID获取失败"
MENUS2_ID=$(create_menu_under_sys "菜单管理" "/menus" "Menus" "AppstoreOutlined" 40); [ -z "$MENUS2_ID" ] && echo "[WARN] 菜单管理 菜单ID获取失败"
PERMS2_ID=$(create_menu_under_sys "权限管理" "/permissions" "Permissions" "KeyOutlined" 50); [ -z "$PERMS2_ID" ] && echo "[WARN] 权限管理 菜单ID获取失败"
DEPTS2_ID=$(create_menu_under_sys "部门管理" "/departments" "Departments" "AppstoreOutlined" 60); [ -z "$DEPTS2_ID" ] && echo "[WARN] 部门管理 菜单ID获取失败"
POSITIONS2_ID=$(create_menu_under_sys "岗位管理" "/positions" "Positions" "IdcardOutlined" 65); [ -z "$POSITIONS2_ID" ] && echo "[WARN] 岗位管理 菜单ID获取失败"
echo "[OK] 正式系统菜单准备完成: ROOT=$SYS_ROOT_ID USERS=$USERS2_ID ROLES=$ROLES2_ID MENUS=$MENUS2_ID PERMS=$PERMS2_ID DEPTS=$DEPTS2_ID POSITIONS=$POSITIONS2_ID"
# 工具函数:根据 parent_id + perms 查找或创建按钮菜单(使用 Python 解析)
create_or_get_button(){
local parent_id="$1"; local name="$2"; local perms="$3"; local order_no="${4:-0}"
local id MENUS
MENUS=$(curl -s -X GET "$API/menus" -H "$H_AUTH")
id=$(echo "$MENUS" | PERM="$perms" PID="$parent_id" python3 - <<'PY'
import json,sys,os
try:
data=json.load(sys.stdin)
menus=data.get("data", [])
except Exception:
menus=[]
PERM=os.environ.get("PERM")
PID=os.environ.get("PID")
pid_int=None
try:
pid_int=int(PID)
except Exception:
pass
for m in menus:
if m.get("type")==3 and m.get("perms")==PERM and m.get("parent_id")==pid_int:
print(m.get("id"), end="")
break
PY
) || true
if [ -n "$id" ]; then echo "$id"; return 0; fi
local resp; resp=$(printf '{"parent_id":%s,"name":"%s","type":3,"order_no":%s,"visible":false,"status":1,"keep_alive":false,"perms":"%s"}' \
"$parent_id" "$name" "${order_no:-0}" "$perms" | curl -s -X POST "$API/menus" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary @-)
id=$(echo "$resp" | extract_id)
echo "$id"
}
# 为系统管理下添加按钮权限(幂等)
[ -n "${USERS2_ID:-}" ] && {
create_or_get_button "$USERS2_ID" "新增" "system:user:create" 10 >/dev/null || true
create_or_get_button "$USERS2_ID" "编辑" "system:user:update" 20 >/dev/null || true
create_or_get_button "$USERS2_ID" "重置密码" "system:user:reset" 30 >/dev/null || true
create_or_get_button "$USERS2_ID" "删除" "system:user:delete" 40 >/dev/null || true
create_or_get_button "$USERS2_ID" "分配岗位" "system:user:assignPosition" 50 >/dev/null || true
}
[ -n "${ROLES2_ID:-}" ] && {
create_or_get_button "$ROLES2_ID" "新增" "system:role:create" 10 >/dev/null || true
create_or_get_button "$ROLES2_ID" "编辑" "system:role:update" 20 >/dev/null || true
create_or_get_button "$ROLES2_ID" "分配菜单" "system:role:assign" 30 >/dev/null || true
create_or_get_button "$ROLES2_ID" "删除" "system:role:delete" 40 >/dev/null || true
}
[ -n "${MENUS2_ID:-}" ] && {
create_or_get_button "$MENUS2_ID" "新增" "system:menu:create" 10 >/dev/null || true
create_or_get_button "$MENUS2_ID" "编辑" "system:menu:update" 20 >/dev/null || true
create_or_get_button "$MENUS2_ID" "删除" "system:menu:delete" 30 >/dev/null || true
}
[ -n "${POSITIONS2_ID:-}" ] && {
create_or_get_button "$POSITIONS2_ID" "新增" "system:position:create" 10 >/dev/null || true
create_or_get_button "$POSITIONS2_ID" "编辑" "system:position:update" 20 >/dev/null || true
create_or_get_button "$POSITIONS2_ID" "删除" "system:position:delete" 30 >/dev/null || true
}
# 9) Create Super Admin role if not exists (code=super_admin)
SUPER_JSON=$(curl -s -X GET "$API/roles?page=1&page_size=100&keyword=super_admin" -H "$H_AUTH")
SUPER_ROLE_ID=$(echo "$SUPER_JSON" | sed -n -E 's/.*"id":([0-9]+).*"code":"super_admin".*/\1/p')
if [ -z "${SUPER_ROLE_ID:-}" ]; then
SUPER_CREATE_JSON=$(printf '{"name":"超级管理员","code":"super_admin","status":1}' | curl -s -X POST "$API/roles" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary @-)
SUPER_ROLE_ID=$(echo "$SUPER_CREATE_JSON" | extract_id)
fi
if [ -z "${SUPER_ROLE_ID:-}" ]; then echo "[ERROR] 创建/获取 超级管理员 角色失败"; exit 1; fi
echo "[OK] 超级管理员 角色ID=$SUPER_ROLE_ID"
# 10) Bind ALL menus to Super Admin (幂等)
ALL_MENU_IDS=$(curl -s -X GET "$API/menus" -H "$H_AUTH" | python3 - <<'PY'
import json,sys
try:
data=json.load(sys.stdin)
menus=data.get("data", [])
except Exception:
menus=[]
ids=[str(m.get("id")) for m in menus if isinstance(m.get("id"), int)]
print(",".join(ids), end="")
PY
)
BIND_SUPER_JSON=$(printf '{"ids":[%s]}' "$ALL_MENU_IDS" | curl -s -X PUT "$API/roles/$SUPER_ROLE_ID/menus" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary @-)
echo "$BIND_SUPER_JSON" | grep -q '"code":0' || { echo "[ERROR] 绑定全部菜单到 超级管理员 失败: $BIND_SUPER_JSON"; exit 1; }
echo "[OK] 已将全部菜单绑定到 超级管理员 角色"
# 11) Assign Super Admin role to admin (保留演示角色)
if [ -z "${ADMIN_ID:-}" ]; then
ADMIN_JSON=$(curl -s -X GET "$API/users?page=1&page_size=50&keyword=admin" -H "$H_AUTH")
ADMIN_ID=$(echo "$ADMIN_JSON" | sed -n -E 's/.*"id":([0-9]+).*"username":"admin".*/\1/p')
fi
if [ -n "${ADMIN_ID:-}" ]; then
CUR_ROLES_JSON=$(curl -s -X GET "$API/users/$ADMIN_ID/roles" -H "$H_AUTH")
NEW_ROLE_IDS=$(echo "$CUR_ROLES_JSON" | SUPER_ROLE_ID="$SUPER_ROLE_ID" python3 -c 'import json,sys,os
try:
data=json.load(sys.stdin)
except Exception:
data={}
ids=[]
for it in data.get("data", []):
try:
i=int(it.get("id"))
s=str(i)
if s not in ids:
ids.append(s)
except Exception:
pass
sid=os.environ.get("SUPER_ROLE_ID")
if sid and sid not in ids:
ids.append(sid)
print("".join([",".join(ids)]), end="")')
if [ -z "$NEW_ROLE_IDS" ]; then NEW_ROLE_IDS="$SUPER_ROLE_ID"; fi
ASSIGN_ADMIN_JSON=$(printf '{"ids":[%s]}' "$NEW_ROLE_IDS" | curl -s -X PUT "$API/users/$ADMIN_ID/roles" -H "$H_AUTH" -H "Content-Type: application/json" --data-binary @-)
echo "$ASSIGN_ADMIN_JSON" | grep -q '"code":0' && echo "[OK] 已为 admin 分配 超级管理员 角色(id=$SUPER_ROLE_ID) 合并现有角色: [$NEW_ROLE_IDS]" || echo "[WARN] 为 admin 分配 超级管理员 可能失败: $ASSIGN_ADMIN_JSON"
MENUS_ADMIN2=$(curl -s -X GET "$API/auth/menus" -H "$H_AUTH")
echo "[RESULT] Admin 可见菜单(包含正式菜单): $MENUS_ADMIN2"
else
echo "[WARN] 未能获取 admin 用户ID跳过角色分配"
fi
echo "[SUMMARY] DEMO_ROOT=${DEMO_ROOT_ID:-} USERS=${USERS_ID:-} ROLES=${ROLES_ID:-} MENUS=${MENUS_ID:-} PERMS=${PERMS_ID:-}"

39
scripts/verify_admin_menus.sh Executable file
View File

@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
API=${API:-http://127.0.0.1:8080/api}
USERNAME=${USERNAME:-admin}
PASSWORD=${PASSWORD:-Admin@123}
json() { python3 -c 'import sys,json; print(json.dumps(json.load(sys.stdin), ensure_ascii=False, indent=2))'; }
say() { printf "\n==== %s ====\n" "$*"; }
say "1) 登录获取 Token"
LOGIN_JSON=$(curl -s -X POST "$API/auth/login" -H 'Content-Type: application/json' --data-binary "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\"}")
TOKEN=$(echo "$LOGIN_JSON" | python3 -c 'import sys,json; j=json.load(sys.stdin); print(j.get("data",{}).get("access_token",""))')
if [[ -z "$TOKEN" ]]; then echo "[ERROR] 登录失败:"; echo "$LOGIN_JSON"; exit 1; fi
H_AUTH="Authorization: Bearer $TOKEN"
echo "[OK] 登录成功"
say "2) 查询 admin 当前菜单 (/auth/menus)"
MENUS_JSON=$(curl -s "$API/auth/menus" -H "$H_AUTH")
printf '%s' "$MENUS_JSON" | python3 -c 'import sys,json; j=json.load(sys.stdin); print("admin 可见菜单数量:", len(j.get("data") or [])); print("admin 菜单名称:", ", ".join([x.get("name") for x in (j.get("data") or []) if isinstance(x, dict)]))'
say "3) 查询 super_admin 角色ID 和 admin 用户ID"
SUPER_ROLE_ID=$(curl -s "$API/roles?page=1&page_size=1000" -H "$H_AUTH" | python3 -c 'import sys,json; j=json.load(sys.stdin); print(next((it.get("id") for it in (j.get("data",{}).get("items") or []) if it.get("code")=="super_admin"), ""))')
ADMIN_ID=$(curl -s "$API/users?page=1&page_size=1000&keyword=admin" -H "$H_AUTH" | python3 -c 'import sys,json; j=json.load(sys.stdin); print(next((it.get("id") for it in (j.get("data",{}).get("items") or []) if it.get("username")=="admin"), ""))')
echo "super_admin 角色ID: ${SUPER_ROLE_ID:-<未找到>}"
echo "admin 用户ID: ${ADMIN_ID:-<未找到>}"
say "4) 拉取 admin 当前角色ID"
ROLE_IDS_JSON=$(curl -s "$API/users/${ADMIN_ID}/roles" -H "$H_AUTH")
printf '%s' "$ROLE_IDS_JSON" | json || true
say "5) 合并 super_admin 角色并回写"
ASSIGN_BODY=$(echo "$ROLE_IDS_JSON" | SUPER_ROLE_ID="${SUPER_ROLE_ID:-0}" python3 -c 'import sys,os,json; d=json.load(sys.stdin); ids=list(d.get("data") or []); sid=int(os.environ.get("SUPER_ROLE_ID","0")); (sid and (sid not in ids) and ids.append(sid)); print(json.dumps({"ids": ids}))')
echo "$ASSIGN_BODY" | curl -s -X PUT "$API/users/${ADMIN_ID}/roles" -H "$H_AUTH" -H 'Content-Type: application/json' --data-binary @- | json || true
say "6) 重新验证 admin 菜单"
MENUS_JSON=$(curl -s "$API/auth/menus" -H "$H_AUTH")
printf '%s' "$MENUS_JSON" | python3 -c 'import sys,json; j=json.load(sys.stdin); print("admin 可见菜单数量:", len(j.get("data") or [])); print("admin 菜单名称:", ", ".join([x.get("name") for x in (j.get("data") or []) if isinstance(x, dict)]))'