commit 410f54a65e98d3191cc02291db8bd7098443cfe1 Author: ayou <550244300@qq.com> Date: Thu Aug 28 00:55:35 2025 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c0d811 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..1c6cf71 --- /dev/null +++ b/backend/.env @@ -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 \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..0627495 --- /dev/null +++ b/backend/.env.example @@ -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 \ No newline at end of file diff --git a/backend/Cargo.lock b/backend/Cargo.lock new file mode 100644 index 0000000..8d83840 --- /dev/null +++ b/backend/Cargo.lock @@ -0,0 +1,4144 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "anyhow" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core 0.4.5", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +dependencies = [ + "axum-core 0.5.2", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "bigdecimal" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a22f228ab7a1b23027ccc6c350b72868017af7ea8356fbdf19f8d991c690013" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "bitflags" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29" +dependencies = [ + "serde", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd1d3c0c2f5833f22386f252fe8ed005c7f59fdcddeef025c01b4c3b9fd9ac3" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee0f8803222ba5a7e2777dd72ca451868909b1ac410621b676adf07280e9b5f" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc0e74a703892159f5ae7d3aac52c8e6c392f5ae5f359c70b5881d60aaac318" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" +dependencies = [ + "async-trait", + "convert_case", + "json5", + "nom", + "pathdiff", + "ron", + "rust-ini", + "serde", + "serde_json", + "toml", + "yaml-rust2", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown 0.15.5", + "serde", +] + +[[package]] +name = "inherent" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c38228f24186d9cc68c729accb4d413be9eaed6ad07ff79e0270d9e56f3de13" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "io-uring" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +dependencies = [ + "bitflags", + "libc", + "redox_syscall", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "migration" +version = "0.1.0" +dependencies = [ + "argon2", + "chrono", + "rand", + "sea-orm-migration", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +dependencies = [ + "base64 0.22.1", + "serde", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" +dependencies = [ + "memchr", + "thiserror 2.0.16", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb056d9e8ea77922845ec74a1c4e8fb17e7c218cc4fc11a15c5d25e189aa40bc" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e404e638f781eb3202dc82db6760c8ae8a1eeef7fb3fa8264b2ef280504966" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "pest_meta" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd1101f170f5903fde0914f899bb503d9ff5271d7ba76bbb70bea63690cc0d5" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pgvector" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc58e2d255979a31caa7cabfa7aac654af0354220719ab7a68520ae7a91e8c0b" +dependencies = [ + "serde", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "version_check", + "yansi", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ron" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +dependencies = [ + "base64 0.21.7", + "bitflags", + "serde", + "serde_derive", +] + +[[package]] +name = "rsa" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-embed" +version = "8.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.106", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] +name = "rust-ini" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0698206bcb8882bf2a9ecb4c1e7785db57ff052297085a6efd4fe42302068a" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rust_decimal" +version = "1.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b203a6425500a03e0919c42d3c47caca51e79f1132046626d2c8871c5092035d" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustls" +version = "0.23.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sea-bae" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f694a6ab48f14bc063cfadff30ab551d3c7e46d8f81836c51989d548f44a2a25" +dependencies = [ + "heck 0.4.1", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "sea-orm" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34963b2d68331ef5fbc8aa28a53781471c15f90ba1ad4f2689d21ce8b9a9d1f1" +dependencies = [ + "async-stream", + "async-trait", + "bigdecimal", + "chrono", + "futures-util", + "log", + "ouroboros", + "pgvector", + "rust_decimal", + "sea-orm-macros", + "sea-query", + "sea-query-binder", + "serde", + "serde_json", + "sqlx", + "strum", + "thiserror 2.0.16", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sea-orm-cli" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc17cb2b24e93fc1d56de7751a12222f2303c06e83ed4d7a1e929e39f30c7d7" +dependencies = [ + "chrono", + "clap", + "dotenvy", + "glob", + "regex", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "sea-orm-macros" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a489127c872766445b4e28f846825f89a076ac3af2591d1365503a68f93e974c" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "sea-bae", + "syn 2.0.106", + "unicode-ident", +] + +[[package]] +name = "sea-orm-migration" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695e830a1332a4e3e57b5972eee00574a36060e1938afca7041a524e0955d5ba" +dependencies = [ + "async-trait", + "clap", + "dotenvy", + "sea-orm", + "sea-orm-cli", + "sea-schema", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sea-query" +version = "0.32.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c" +dependencies = [ + "bigdecimal", + "chrono", + "inherent", + "ordered-float", + "rust_decimal", + "sea-query-derive", + "serde_json", + "time", + "uuid", +] + +[[package]] +name = "sea-query-binder" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608" +dependencies = [ + "bigdecimal", + "chrono", + "rust_decimal", + "sea-query", + "serde_json", + "sqlx", + "time", + "uuid", +] + +[[package]] +name = "sea-query-derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab" +dependencies = [ + "darling", + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.106", + "thiserror 2.0.16", +] + +[[package]] +name = "sea-schema" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2239ff574c04858ca77485f112afea1a15e53135d3097d0c86509cef1def1338" +dependencies = [ + "futures", + "sea-query", + "sea-schema-derive", +] + +[[package]] +name = "sea-schema-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdc8729c37fdbf88472f97fd470393089f997a909e535ff67c544d18cfccf0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serde_json" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.10.0", + "schemars 0.9.0", + "schemars 1.0.4", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.16", + "time", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bigdecimal", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink 0.10.0", + "indexmap 2.10.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rust_decimal", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.16", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.106", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.106", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bigdecimal", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "rust_decimal", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.16", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bigdecimal", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "num-bigint", + "once_cell", + "rand", + "rust_decimal", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.16", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.16", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl 2.0.16", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "slab", + "socket2", + "tokio-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.10.0", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "udmin" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "async-trait", + "axum 0.8.4", + "bytes", + "chrono", + "config", + "dotenvy", + "hyper", + "jsonwebtoken", + "migration", + "once_cell", + "rand", + "sea-orm", + "serde", + "serde_json", + "serde_with", + "sha2", + "thiserror 1.0.69", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "utoipa 5.4.0", + "utoipa-swagger-ui", + "uuid", +] + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec961601b32b6f5d14ae8dabd35ff2ff2e2c6cb4c0e6641845ff105abe96d958" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utoipa" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23" +dependencies = [ + "indexmap 2.10.0", + "serde", + "serde_json", + "utoipa-gen 4.3.1", +] + +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap 2.10.0", + "serde", + "serde_json", + "utoipa-gen 5.4.0", +] + +[[package]] +name = "utoipa-gen" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.106", + "uuid", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b39868d43c011961e04b41623e050aedf2cc93652562ff7935ce0f819aaf2da" +dependencies = [ + "axum 0.7.9", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "utoipa 4.2.3", + "zip", +] + +[[package]] +name = "uuid" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.2", +] + +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yaml-rust2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink 0.8.4", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..2e0daa5 --- /dev/null +++ b/backend/Cargo.toml @@ -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 \ No newline at end of file diff --git a/backend/cookies.txt b/backend/cookies.txt new file mode 100644 index 0000000..fbe5d02 --- /dev/null +++ b/backend/cookies.txt @@ -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 diff --git a/backend/migration/Cargo.toml b/backend/migration/Cargo.toml new file mode 100644 index 0000000..e7fa36b --- /dev/null +++ b/backend/migration/Cargo.toml @@ -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" \ No newline at end of file diff --git a/backend/migration/src/lib.rs b/backend/migration/src/lib.rs new file mode 100644 index 0000000..702dcf6 --- /dev/null +++ b/backend/migration/src/lib.rs @@ -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> { + 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), + ] + } +} \ No newline at end of file diff --git a/backend/migration/src/m20220101_000001_create_users.rs b/backend/migration/src/m20220101_000001_create_users.rs new file mode 100644 index 0000000..5107c30 --- /dev/null +++ b/backend/migration/src/m20220101_000001_create_users.rs @@ -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 } \ No newline at end of file diff --git a/backend/migration/src/m20220101_000002_create_refresh_tokens.rs b/backend/migration/src/m20220101_000002_create_refresh_tokens.rs new file mode 100644 index 0000000..219e611 --- /dev/null +++ b/backend/migration/src/m20220101_000002_create_refresh_tokens.rs @@ -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 } \ No newline at end of file diff --git a/backend/migration/src/m20220101_000003_create_roles.rs b/backend/migration/src/m20220101_000003_create_roles.rs new file mode 100644 index 0000000..8bb0cfe --- /dev/null +++ b/backend/migration/src/m20220101_000003_create_roles.rs @@ -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 } \ No newline at end of file diff --git a/backend/migration/src/m20220101_000004_create_permissions.rs b/backend/migration/src/m20220101_000004_create_permissions.rs new file mode 100644 index 0000000..f98d5e1 --- /dev/null +++ b/backend/migration/src/m20220101_000004_create_permissions.rs @@ -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(()) + } +} \ No newline at end of file diff --git a/backend/migration/src/m20220101_000005_create_menus.rs b/backend/migration/src/m20220101_000005_create_menus.rs new file mode 100644 index 0000000..4c28c57 --- /dev/null +++ b/backend/migration/src/m20220101_000005_create_menus.rs @@ -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 } \ No newline at end of file diff --git a/backend/migration/src/m20220101_000006_create_rbac_relations.rs b/backend/migration/src/m20220101_000006_create_rbac_relations.rs new file mode 100644 index 0000000..888932c --- /dev/null +++ b/backend/migration/src/m20220101_000006_create_rbac_relations.rs @@ -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 } \ No newline at end of file diff --git a/backend/migration/src/m20220101_000007_create_departments.rs b/backend/migration/src/m20220101_000007_create_departments.rs new file mode 100644 index 0000000..186a194 --- /dev/null +++ b/backend/migration/src/m20220101_000007_create_departments.rs @@ -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 } \ No newline at end of file diff --git a/backend/migration/src/m20220101_000008_add_keep_alive_to_menus.rs b/backend/migration/src/m20220101_000008_add_keep_alive_to_menus.rs new file mode 100644 index 0000000..f32c016 --- /dev/null +++ b/backend/migration/src/m20220101_000008_add_keep_alive_to_menus.rs @@ -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, +} \ No newline at end of file diff --git a/backend/migration/src/m20220101_000009_create_request_logs.rs b/backend/migration/src/m20220101_000009_create_request_logs.rs new file mode 100644 index 0000000..4f3b0ac --- /dev/null +++ b/backend/migration/src/m20220101_000009_create_request_logs.rs @@ -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, +} \ No newline at end of file diff --git a/backend/migration/src/m20220101_000010_create_positions.rs b/backend/migration/src/m20220101_000010_create_positions.rs new file mode 100644 index 0000000..74349de --- /dev/null +++ b/backend/migration/src/m20220101_000010_create_positions.rs @@ -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 } \ No newline at end of file diff --git a/backend/src/db.rs b/backend/src/db.rs new file mode 100644 index 0000000..143a063 --- /dev/null +++ b/backend/src/db.rs @@ -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 { + 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) +} \ No newline at end of file diff --git a/backend/src/error.rs b/backend/src/error.rs new file mode 100644 index 0000000..4d61288 --- /dev/null +++ b/backend/src/error.rs @@ -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:: { code, message: msg, data: None })).into_response() + } +} \ No newline at end of file diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..26d80e8 --- /dev/null +++ b/backend/src/main.rs @@ -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 = 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(()) +} \ No newline at end of file diff --git a/backend/src/middlewares/jwt.rs b/backend/src/middlewares/jwt.rs new file mode 100644 index 0000000..da6d469 --- /dev/null +++ b/backend/src/middlewares/jwt.rs @@ -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 { + let key = EncodingKey::from_secret(secret.as_bytes()); + Ok(jsonwebtoken::encode(&Header::default(), claims, &key)?) +} + +pub fn decode_token(token: &str, secret: &str) -> Result { + let key = DecodingKey::from_secret(secret.as_bytes()); + let data = jsonwebtoken::decode::(token, &key, &Validation::default())?; + Ok(data.claims) +} + +#[derive(Clone, Debug)] +pub struct AuthUser { pub uid: i64, pub username: String } + +impl axum::extract::FromRequestParts for AuthUser where S: Send + Sync + 'static { + type Rejection = AppError; + async fn from_request_parts(parts: &mut axum::http::request::Parts, _state: &S) -> Result { + 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() } +} \ No newline at end of file diff --git a/backend/src/middlewares/logging.rs b/backend/src/middlewares/logging.rs new file mode 100644 index 0000000..42eafcc --- /dev/null +++ b/backend/src/middlewares/logging.rs @@ -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, Option) { + 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, req: Request, 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 +} \ No newline at end of file diff --git a/backend/src/middlewares/mod.rs b/backend/src/middlewares/mod.rs new file mode 100644 index 0000000..4c04569 --- /dev/null +++ b/backend/src/middlewares/mod.rs @@ -0,0 +1,2 @@ +pub mod jwt; +pub mod logging; \ No newline at end of file diff --git a/backend/src/models/department.rs b/backend/src/models/department.rs new file mode 100644 index 0000000..72cd17a --- /dev/null +++ b/backend/src/models/department.rs @@ -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, + 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 {} \ No newline at end of file diff --git a/backend/src/models/menu.rs b/backend/src/models/menu.rs new file mode 100644 index 0000000..1e0ca77 --- /dev/null +++ b/backend/src/models/menu.rs @@ -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, + pub name: String, + pub path: Option, + pub component: Option, + pub r#type: i32, + pub icon: Option, + pub order_no: i32, + pub visible: bool, + pub status: i32, + // 新增:是否缓存页面 + pub keep_alive: bool, + pub perms: Option, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs new file mode 100644 index 0000000..bed7e2a --- /dev/null +++ b/backend/src/models/mod.rs @@ -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; \ No newline at end of file diff --git a/backend/src/models/position.rs b/backend/src/models/position.rs new file mode 100644 index 0000000..c48f813 --- /dev/null +++ b/backend/src/models/position.rs @@ -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, + 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 {} \ No newline at end of file diff --git a/backend/src/models/refresh_token.rs b/backend/src/models/refresh_token.rs new file mode 100644 index 0000000..09aacc3 --- /dev/null +++ b/backend/src/models/refresh_token.rs @@ -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 {} \ No newline at end of file diff --git a/backend/src/models/request_log.rs b/backend/src/models/request_log.rs new file mode 100644 index 0000000..31a1cb8 --- /dev/null +++ b/backend/src/models/request_log.rs @@ -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, + pub response_params: Option, + pub status_code: i32, + pub user_id: Option, + pub username: Option, + 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 {} \ No newline at end of file diff --git a/backend/src/models/role.rs b/backend/src/models/role.rs new file mode 100644 index 0000000..434c8e9 --- /dev/null +++ b/backend/src/models/role.rs @@ -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, + pub status: i32, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file diff --git a/backend/src/models/role_menu.rs b/backend/src/models/role_menu.rs new file mode 100644 index 0000000..96e0f22 --- /dev/null +++ b/backend/src/models/role_menu.rs @@ -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 {} \ No newline at end of file diff --git a/backend/src/models/user.rs b/backend/src/models/user.rs new file mode 100644 index 0000000..52b787c --- /dev/null +++ b/backend/src/models/user.rs @@ -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, + pub status: i32, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file diff --git a/backend/src/models/user_department.rs b/backend/src/models/user_department.rs new file mode 100644 index 0000000..935e8c3 --- /dev/null +++ b/backend/src/models/user_department.rs @@ -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 {} \ No newline at end of file diff --git a/backend/src/models/user_position.rs b/backend/src/models/user_position.rs new file mode 100644 index 0000000..14fafa8 --- /dev/null +++ b/backend/src/models/user_position.rs @@ -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 {} \ No newline at end of file diff --git a/backend/src/models/user_role.rs b/backend/src/models/user_role.rs new file mode 100644 index 0000000..d545690 --- /dev/null +++ b/backend/src/models/user_role.rs @@ -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 {} \ No newline at end of file diff --git a/backend/src/response.rs b/backend/src/response.rs new file mode 100644 index 0000000..14dc40a --- /dev/null +++ b/backend/src/response.rs @@ -0,0 +1,13 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub struct ApiResponse { + pub code: i32, + pub message: String, + pub data: Option, +} + +impl ApiResponse { + pub fn ok(data: T) -> Self { Self { code: 0, message: "ok".into(), data: Some(data) } } + pub fn err(code: i32, message: impl Into) -> Self { Self { code, message: message.into(), data: None } } +} \ No newline at end of file diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs new file mode 100644 index 0000000..6823b72 --- /dev/null +++ b/backend/src/routes/auth.rs @@ -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, pub status: i32 } +impl From 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 { + 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, Json(req): Json) -> Result<(HeaderMap, Json>), 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, user: AuthUser) -> Result>, AppError> { + auth_service::logout(&db, user.uid).await?; + Ok(Json(ApiResponse::ok(serde_json::json!({"success": true})))) +} + +pub async fn refresh(State(db): State, headers: HeaderMap) -> Result<(HeaderMap, Json>), 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, user: AuthUser) -> Result>>, 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))) +} \ No newline at end of file diff --git a/backend/src/routes/departments.rs b/backend/src/routes/departments.rs new file mode 100644 index 0000000..1d1d4ea --- /dev/null +++ b/backend/src/routes/departments.rs @@ -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 } + +pub fn router() -> Router { Router::new() + .route("/departments", get(list).post(create)) + .route("/departments/{id}", put(update).delete(delete_department)) +} + +async fn list(State(db): State, Query(p): Query) -> Result>>, AppError> { + let res = department_service::list_all(&db, p.keyword).await?; + Ok(Json(ApiResponse::ok(res))) +} + +#[derive(Deserialize)] +struct CreateReq { parent_id: Option, name: String, order_no: Option, status: Option } + +async fn create(State(db): State, Json(req): Json) -> Result>, 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, name: Option, order_no: Option, status: Option } + +async fn update(State(db): State, Path(id): Path, Json(req): Json) -> Result>, 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, Path(id): Path) -> Result>, AppError> { + department_service::delete(&db, id).await?; + Ok(Json(ApiResponse::ok(true))) +} \ No newline at end of file diff --git a/backend/src/routes/logs.rs b/backend/src/routes/logs.rs new file mode 100644 index 0000000..5a63ba7 --- /dev/null +++ b/backend/src/routes/logs.rs @@ -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 { Router::new().route("/logs", get(list)) } + +async fn list(State(db): State, Query(p): Query) -> Result>>, AppError> { + let res = log_service::list(&db, p).await.map_err(|e| AppError::Anyhow(anyhow::anyhow!(e)))?; + Ok(Json(ApiResponse::ok(res))) +} \ No newline at end of file diff --git a/backend/src/routes/menus.rs b/backend/src/routes/menus.rs new file mode 100644 index 0000000..5d3b913 --- /dev/null +++ b/backend/src/routes/menus.rs @@ -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 } + +pub fn router() -> Router { Router::new() + .route("/menus", get(list).post(create)) + .route("/menus/{id}", put(update).delete(delete_menu)) +} + +async fn list(State(db): State, Query(p): Query) -> Result>>, AppError> { + let res = menu_service::list_all(&db, p.keyword).await?; + Ok(Json(ApiResponse::ok(res))) +} + +#[derive(Deserialize)] +struct CreateReq { parent_id: Option, name: String, path: Option, component: Option, r#type: i32, icon: Option, order_no: Option, visible: Option, status: Option, keep_alive: Option, perms: Option } + +async fn create(State(db): State, Json(req): Json) -> Result>, 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, name: Option, path: Option, component: Option, r#type: Option, icon: Option, order_no: Option, visible: Option, status: Option, keep_alive: Option, perms: Option } + +async fn update(State(db): State, Path(id): Path, Json(req): Json) -> Result>, 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, Path(id): Path) -> Result>, AppError> { + menu_service::delete(&db, id).await?; + Ok(Json(ApiResponse::ok(true))) +} \ No newline at end of file diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs new file mode 100644 index 0000000..08e2621 --- /dev/null +++ b/backend/src/routes/mod.rs @@ -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 { + Router::new() + .nest("/auth", auth::router()) + .merge(users::router()) + .merge(roles::router()) + .merge(menus::router()) + .merge(departments::router()) + .merge(logs::router()) + // 合并岗位路由 + .merge(positions::router()) +} \ No newline at end of file diff --git a/backend/src/routes/positions.rs b/backend/src/routes/positions.rs new file mode 100644 index 0000000..1bfaf9b --- /dev/null +++ b/backend/src/routes/positions.rs @@ -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, page_size: Option, keyword: Option } + +pub fn router() -> Router { Router::new() + .route("/positions", get(list).post(create)) + .route("/positions/{id}", put(update).delete(delete_position)) +} + +async fn list(State(db): State, Query(p): Query) -> Result>>, 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, status: Option, order_no: Option } + +async fn create(State(db): State, Json(req): Json) -> Result>, 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, description: Option, status: Option, order_no: Option } + +async fn update(State(db): State, Path(id): Path, Json(req): Json) -> Result>, 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, Path(id): Path) -> Result>, AppError> { + position_service::delete(&db, id).await?; + Ok(Json(ApiResponse::ok(true))) +} \ No newline at end of file diff --git a/backend/src/routes/roles.rs b/backend/src/routes/roles.rs new file mode 100644 index 0000000..9682cb6 --- /dev/null +++ b/backend/src/routes/roles.rs @@ -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, page_size: Option, keyword: Option } + +pub fn router() -> Router { 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, Query(p): Query) -> Result>>, 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, status: Option } + +async fn create(State(db): State, Json(req): Json) -> Result>, 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, description: Option, status: Option } + +async fn update(State(db): State, Path(id): Path, Json(req): Json) -> Result>, 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 } + + +async fn get_menus(State(db): State, Path(id): Path) -> Result>>, AppError> { + let ids = role_service::get_menu_ids(&db, id).await?; + Ok(Json(ApiResponse::ok(ids))) +} + +async fn set_menus(State(db): State, Path(id): Path, Json(req): Json) -> Result>, AppError> { + role_service::set_menu_ids(&db, id, req.ids).await?; + Ok(Json(ApiResponse::ok(true))) +} +async fn delete_role(State(db): State, Path(id): Path) -> Result>, AppError> { + role_service::delete(&db, id).await?; + Ok(Json(ApiResponse::ok(true))) +} \ No newline at end of file diff --git a/backend/src/routes/users.rs b/backend/src/routes/users.rs new file mode 100644 index 0000000..0322632 --- /dev/null +++ b/backend/src/routes/users.rs @@ -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, page_size: Option, keyword: Option } + +pub fn router() -> Router { 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, Query(p): Query) -> Result>>, 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, status: Option } + +async fn create(State(db): State, Json(req): Json) -> Result>, 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, status: Option } + +async fn update(State(db): State, Path(id): Path, Json(req): Json) -> Result>, 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 } + +async fn get_roles(State(db): State, Path(id): Path) -> Result>>, 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, Path(id): Path, Json(req): Json) -> Result>, 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, Path(id): Path) -> Result>>, 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, Path(id): Path, Json(req): Json) -> Result>, 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, Path(id): Path) -> Result>>, 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, Path(id): Path, Json(req): Json) -> Result>, 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, Path(id): Path, Json(req): Json) -> Result>, 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, Path(id): Path) -> Result>, crate::error::AppError> { + user_service::delete(&db, id).await?; + Ok(Json(ApiResponse::ok(true))) +} \ No newline at end of file diff --git a/backend/src/services/auth_service.rs b/backend/src/services/auth_service.rs new file mode 100644 index 0000000..9f6fdab --- /dev/null +++ b/backend/src/services/auth_service.rs @@ -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 { + 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)) +} \ No newline at end of file diff --git a/backend/src/services/department_service.rs b/backend/src/services/department_service.rs new file mode 100644 index 0000000..b01bcb9 --- /dev/null +++ b/backend/src/services/department_service.rs @@ -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, pub name: String, pub order_no: i32, pub status: i32, pub created_at: DateTimeWithTimeZone } +impl From 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) -> Result, 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, pub name: String, pub order_no: Option, pub status: Option } + +pub async fn create(db: &Db, input: CreateDepartmentInput) -> Result { + 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, pub name: Option, pub order_no: Option, pub status: Option } + +pub async fn update(db: &Db, id: i64, input: UpdateDepartmentInput) -> Result { + 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, AppError> { + let ids = user_department::Entity::find() + .filter(user_department::Column::UserId.eq(user_id)) + .select_only() + .column(user_department::Column::DepartmentId) + .into_tuple::() + .all(db) + .await?; + Ok(ids) +} + +#[derive(serde::Deserialize)] +pub struct SetIdsInput { pub ids: Vec } + +pub async fn set_department_ids_by_user_id(db: &Db, user_id: i64, ids: Vec) -> 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 = 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(()) +} \ No newline at end of file diff --git a/backend/src/services/log_service.rs b/backend/src/services/log_service.rs new file mode 100644 index 0000000..46b4e2f --- /dev/null +++ b/backend/src/services/log_service.rs @@ -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, + pub response_params: Option, + pub status_code: i32, + pub user_id: Option, + pub username: Option, + pub request_time: DateTime, + pub duration_ms: i64, +} + +pub async fn create(db: &Db, input: CreateLogInput) -> anyhow::Result { + 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, + pub response_params: Option, + pub status_code: i32, + pub user_id: Option, + pub username: Option, + pub request_time: chrono::DateTime, + pub duration_ms: i64, +} +impl From 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 { pub items: Vec, pub total: u64, pub page: u64, pub page_size: u64 } + +#[derive(serde::Deserialize)] +pub struct ListParams { pub page: Option, pub page_size: Option, pub path: Option, pub start_time: Option, pub end_time: Option } + +pub async fn list(db: &Db, p: ListParams) -> anyhow::Result> { + 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::>() { selector = selector.filter(request_log::Column::RequestTime.gte(dt)); } } + if let Some(end) = p.end_time.as_deref() { if let Ok(dt) = end.parse::>() { 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 }) +} \ No newline at end of file diff --git a/backend/src/services/menu_service.rs b/backend/src/services/menu_service.rs new file mode 100644 index 0000000..070e802 --- /dev/null +++ b/backend/src/services/menu_service.rs @@ -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, pub name: String, pub path: Option, pub component: Option, pub r#type: i32, pub icon: Option, pub order_no: i32, pub visible: bool, pub status: i32, pub keep_alive: bool, pub perms: Option, pub created_at: DateTimeWithTimeZone } +impl From 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) -> Result, 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, pub name: String, pub path: Option, pub component: Option, pub r#type: i32, pub icon: Option, pub order_no: Option, pub visible: Option, pub status: Option, pub keep_alive: Option, pub perms: Option } + +pub async fn create(db: &Db, input: CreateMenuInput) -> Result { + 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, pub name: Option, pub path: Option, pub component: Option, pub r#type: Option, pub icon: Option, pub order_no: Option, pub visible: Option, pub status: Option, pub keep_alive: Option, pub perms: Option } + +pub async fn update(db: &Db, id: i64, input: UpdateMenuInput) -> Result { + 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) -> Result, 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(()) +} \ No newline at end of file diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs new file mode 100644 index 0000000..6bf0d9b --- /dev/null +++ b/backend/src/services/mod.rs @@ -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; \ No newline at end of file diff --git a/backend/src/services/position_service.rs b/backend/src/services/position_service.rs new file mode 100644 index 0000000..77afdb9 --- /dev/null +++ b/backend/src/services/position_service.rs @@ -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, pub status: i32, pub order_no: i32, pub created_at: DateTimeWithTimeZone } +impl From 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 { pub items: Vec, pub total: u64, pub page: u64, pub page_size: u64 } + +pub async fn list(db: &Db, page: u64, page_size: u64, keyword: Option) -> Result, 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, pub status: Option, pub order_no: Option } + +pub async fn create(db: &Db, input: CreatePositionInput) -> Result { + 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, pub description: Option, pub status: Option, pub order_no: Option } + +pub async fn update(db: &Db, id: i64, input: UpdatePositionInput) -> Result { + 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, AppError> { + let ids = user_position::Entity::find() + .filter(user_position::Column::UserId.eq(user_id)) + .select_only() + .column(user_position::Column::PositionId) + .into_tuple::() + .all(db) + .await?; + Ok(ids) +} + +pub async fn set_position_ids_by_user_id(db: &Db, user_id: i64, position_ids: Vec) -> 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 = 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(()) +} \ No newline at end of file diff --git a/backend/src/services/role_service.rs b/backend/src/services/role_service.rs new file mode 100644 index 0000000..c71e8bc --- /dev/null +++ b/backend/src/services/role_service.rs @@ -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, pub status: i32, pub created_at: DateTimeWithTimeZone } +impl From 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 { pub items: Vec, pub total: u64, pub page: u64, pub page_size: u64 } + +pub async fn list(db: &Db, page: u64, page_size: u64, keyword: Option) -> Result, 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, pub status: Option } + +pub async fn create(db: &Db, input: CreateRoleInput) -> Result { + 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, pub description: Option, pub status: Option } + +pub async fn update(db: &Db, id: i64, input: UpdateRoleInput) -> Result { + 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, AppError> { + let ids = role_menu::Entity::find() + .filter(role_menu::Column::RoleId.eq(role_id)) + .select_only() + .column(role_menu::Column::MenuId) + .into_tuple::() + .all(db) + .await?; + Ok(ids) +} + +pub async fn set_menu_ids(db: &Db, role_id: i64, ids: Vec) -> 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 = 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, AppError> { + let ids = user_role::Entity::find() + .filter(user_role::Column::UserId.eq(user_id)) + .select_only() + .column(user_role::Column::RoleId) + .into_tuple::() + .all(db) + .await?; + Ok(ids) +} + +pub async fn get_menu_ids_by_user_id(db: &Db, user_id: i64) -> Result, 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::() + .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) -> 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 = 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(()) +} \ No newline at end of file diff --git a/backend/src/services/user_service.rs b/backend/src/services/user_service.rs new file mode 100644 index 0000000..2a81ea0 --- /dev/null +++ b/backend/src/services/user_service.rs @@ -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, pub status: i32, pub created_at: DateTimeWithTimeZone } +impl From 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 { pub items: Vec, pub total: u64, pub page: u64, pub page_size: u64 } + +pub async fn list(db: &Db, page: u64, page_size: u64, keyword: Option) -> Result, 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, pub status: Option } + +pub async fn create(db: &Db, input: CreateUserInput) -> Result { + 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, pub status: Option } + +pub async fn update(db: &Db, id: i64, input: UpdateUserInput) -> Result { + 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(()) +} \ No newline at end of file diff --git a/backend/src/utils/mod.rs b/backend/src/utils/mod.rs new file mode 100644 index 0000000..5b4e5bc --- /dev/null +++ b/backend/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod password; \ No newline at end of file diff --git a/backend/src/utils/password.rs b/backend/src/utils/password.rs new file mode 100644 index 0000000..6dc0490 --- /dev/null +++ b/backend/src/utils/password.rs @@ -0,0 +1,15 @@ +pub fn hash_password(plain: &str) -> anyhow::Result { + 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 { + 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()) +} \ No newline at end of file diff --git a/backend/udmin.db b/backend/udmin.db new file mode 100644 index 0000000..4c6c682 Binary files /dev/null and b/backend/udmin.db differ diff --git a/backend/udmin.db.bak.1756028054 b/backend/udmin.db.bak.1756028054 new file mode 100644 index 0000000..2f6e2ff Binary files /dev/null and b/backend/udmin.db.bak.1756028054 differ diff --git a/backend/udmin.db.bak2.1756028098 b/backend/udmin.db.bak2.1756028098 new file mode 100644 index 0000000..2f6e2ff Binary files /dev/null and b/backend/udmin.db.bak2.1756028098 differ diff --git a/backend/udmin.db.bak3.1756028170 b/backend/udmin.db.bak3.1756028170 new file mode 100644 index 0000000..4c6c682 Binary files /dev/null and b/backend/udmin.db.bak3.1756028170 differ diff --git a/body_login.json b/body_login.json new file mode 100644 index 0000000..8fb734c --- /dev/null +++ b/body_login.json @@ -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}}} \ No newline at end of file diff --git a/cookies.txt b/cookies.txt new file mode 100644 index 0000000..fb56082 --- /dev/null +++ b/cookies.txt @@ -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 diff --git a/cookies_admin.txt b/cookies_admin.txt new file mode 100644 index 0000000..f64a6e2 --- /dev/null +++ b/cookies_admin.txt @@ -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 diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..99fa403 --- /dev/null +++ b/frontend/.env @@ -0,0 +1 @@ +VITE_API_BASE=http://localhost:8080 \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..aae83aa --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Udmin Admin + + +
+ + + \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5b6f82e --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..b9b07ac --- /dev/null +++ b/frontend/src/App.tsx @@ -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 + return children +} + +export default function App() { + return ( + + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* 新增 */} + } /> + + } /> + + ) +} \ No newline at end of file diff --git a/frontend/src/assets/logo.svg b/frontend/src/assets/logo.svg new file mode 100644 index 0000000..51170e2 --- /dev/null +++ b/frontend/src/assets/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx new file mode 100644 index 0000000..2d2c21d --- /dev/null +++ b/frontend/src/components/PageHeader.tsx @@ -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 ( +
+ ({ title: t }))} /> +
+

{title}

+ {extra ?
{extra}
: null} +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx new file mode 100644 index 0000000..600dd68 --- /dev/null +++ b/frontend/src/layouts/MainLayout.tsx @@ -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() + 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 = useMemo(() => ({ + 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: , + }), []) + + 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() + const byId = new Map() + 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(() => { + 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() + const pathToId = new Map() + 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: }, + ]), []) + + // 基于菜单树与当前路径计算面包屑 + 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() + const pathToId = new Map() + 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) + ? { e.preventDefault(); navigate(m.path!) }}>{m.name} + : m.name + })) + }, [rawMenus, loc.pathname, navigate]) + + // 路由页签与缓存(放在 return 之前的顶层) + type TabItem = { key: string; title: string; keepAlive: boolean } + const [tabs, setTabs] = useState([]) + const [cache, setCache] = useState>({}) + const outlet = useOutlet() + // 右侧菜单折叠状态(默认收起) + // 左侧菜单折叠状态:持久化到 localStorage,刷新不丢失 + const [leftCollapsed, setLeftCollapsed] = useState(() => { + 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() + 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 ( + + + + {/* 其余内容保持不变 */} + { + // 仅在用户点击触发器时更新用户偏好;响应式折叠不写入 + if (type === 'clickTrigger') { + setLeftCollapsed(collapsed) + } + }} + breakpoint="lg" + onBreakpoint={(broken) => setIsBroken(broken)} + trigger={null} + collapsedWidth={56} + width={220} + style={{ background: colorBgContainer }} + > +
setLeftCollapsed(c => !c)} + title={leftCollapsed ? '展开菜单' : '收起菜单'} + > + {leftCollapsed ? 'U' : 'Udmin'} +
+ setOpenKeys(keys as string[])} + inlineCollapsed={isBroken ? true : leftCollapsed} + items={menuItems} + onClick={onMenuClick} + style={{ borderInlineEnd: 0 }} + /> + + +
+
+
+ {/* 用户要求:隐藏展开/收起图标,保留名称点击收起/展开 */} + {tabs.length > 0 && ( + ({ key: t.key, label: t.title, closable: t.key !== '/' }))} + activeKey={activeKey} + onChange={onTabChange} + onEdit={onTabEdit as any} + /> + )} +
+ + { const v = key as 'zh'|'en'; setLang(v); localStorage.setItem('lang', v) } + }} + placement="bottomLeft" + > + + + {lang === 'zh' ? '中文' : 'English'} + + + { if (e.key === 'logout') handleLogout(); if (e.key === 'profile') openProfile() } }} + placement="bottomRight" + > + + } /> + {(user as any)?.nickname || (user as any)?.username || '用户'} + + + +
+
+ + {/* 缓存的标签页:非激活隐藏 */} + {tabs.filter(t => t.keepAlive).map(t => ( +
+ {cache[t.key]} +
+ ))} + {/* 未开启缓存:只渲染当前路由 */} + {(() => { + const cur = tabs.find(t => t.key === activeKey) + if (!cur) return null + if (cur.keepAlive) return null + return
{outlet}
+ })()} +
+
+ + + setProfileOpen(false)} okText="保存" width={840}> +
+ + + + + + + + + +
+
+ + + ) + } \ No newline at end of file diff --git a/frontend/src/layouts/layout.css b/frontend/src/layouts/layout.css new file mode 100644 index 0000000..2342a38 --- /dev/null +++ b/frontend/src/layouts/layout.css @@ -0,0 +1 @@ +.logo{height:48px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:18px} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..fea8390 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + +) \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..7b012a9 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -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 = 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 ? 启用 : 禁用 }, + { 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 ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
启用占比
+ + +
+
启用:{statusDist.enabled}
+
禁用:{statusDist.disabled}
+
样本:{userSample.length}
+
+ +
+
+ + + +
+ {last7Days.map(d => ( +
+
+
{d.value}
+
{d.date.slice(5)}
+
+ ))} +
+ + + + + + + + + rowKey="id" + size="small" + pagination={false} + dataSource={recentUsers} + columns={userColumns as any} + /> + + + + + + + 欢迎使用 Udmin。当前首页展示了若干概览与示例报表,真实数据以接口返回为准。 + + +
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/Departments.tsx b/frontend/src/pages/Departments.tsx new file mode 100644 index 0000000..16d9e70 --- /dev/null +++ b/frontend/src/pages/Departments.tsx @@ -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() + 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 => { + const childrenMap = new Map() + 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() + 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): 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() + 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([]) + 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 = 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 ( + + + {r.name} + + ) + }}, + { title: 'ID', dataIndex: 'id', width: 80 }, + { title: '排序', dataIndex: 'order_no', width: 100 }, + { title: '状态', dataIndex: 'status', width: 120, render: (v: number) => v === 1 ? 启用 : 禁用 }, + { title: '创建时间', dataIndex: 'created_at', width: 200, render: (v: any) => formatDateTime(v) }, + { title: '操作', key: 'actions', width: 300, render: (_: any, r: DeptItem) => ( + + onEdit(r)}> + + 编辑 + + { 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||'删除失败') } }}> + + + 删除 + + + + )}, + ], [keyword]) + + return ( +
+ +
{ const kw = String(vals?.keyword ?? '').trim(); setKeyword(kw); fetchList(kw) }} style={{ marginBottom: 12 }}> + + + + + + + + + + +
+ + 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} + /> + + setCreateOpen(false)} okText="创建" width={840}> +
+ + String(node?.title ?? '').toLowerCase().includes(String(input).toLowerCase())} + /> + + + + + + + + + + + + + + + } placeholder="用户名" /> + + + } placeholder="密码" /> + + + + +
+ +
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/Logs.tsx b/frontend/src/pages/Logs.tsx new file mode 100644 index 0000000..59102b5 --- /dev/null +++ b/frontend/src/pages/Logs.tsx @@ -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 { 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([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + + const [detailOpen, setDetailOpen] = useState(false) + const [detail, setDetail] = useState(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>>('/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) => {m} }, + { 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) => ( + + openDetail(r)}> + + 详情 + + + )}, + ], []) + + 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 ( +
+ +
fetchData(1, pageSize)} style={{ marginBottom: 12 }}> + + + + + + + + + + + + +
+ fetchData(p, ps) }} + /> + + + {detail && ( + + + {detail.id} + {dayjs(detail.request_time).format('YYYY-MM-DD HH:mm:ss')} + {detail.path} + {detail.method} + {detail.username || (detail.user_id ? `UID:${detail.user_id}` : '-')} + {detail.status_code} + {detail.duration_ms} + + +
+ 请求参数 +
+                
+              
+
+ +
+ 响应参数 +
+                
+              
+
+
+ )} +
+ + ) +} \ No newline at end of file diff --git a/frontend/src/pages/Menus.tsx b/frontend/src/pages/Menus.tsx new file mode 100644 index 0000000..5f1d747 --- /dev/null +++ b/frontend/src/pages/Menus.tsx @@ -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 = ( +
+
+
+
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', + }} + > + +
+ {items.map((it: IconItem) => { + const active = value === it.name + return ( +
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} + > + {it.node} + {it.name} +
+ ) + })} +
+
+
+ ) + + return ( + +
+ {current ? ( + <> + {current.node} + {current.name} + + ) : ( + 将鼠标移入选择图标(可不选) + )} +
+
+ ) +} + +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: }, + { name: 'UserOutlined', node: }, + { name: 'TeamOutlined', node: }, + { name: 'SettingOutlined', node: }, + { name: 'AppstoreOutlined', node: }, + { name: 'KeyOutlined', node: }, + { name: 'DashboardOutlined', node: }, + { name: 'FileOutlined', node: }, + { name: 'LockOutlined', node: }, + { name: 'MenuOutlined', node: }, + { name: 'PieChartOutlined', node: }, + { name: 'BarChartOutlined', node: }, + { name: 'TableOutlined', node: }, + { name: 'CalendarOutlined', node: }, + { name: 'FormOutlined', node: }, + { name: 'SearchOutlined', node: }, + { name: 'ToolOutlined', node: }, + { name: 'ShoppingCartOutlined', node: }, + { name: 'ShopOutlined', node: }, + { name: 'FolderOpenOutlined', node: }, + { name: 'FolderOutlined', node: }, + { name: 'CloudOutlined', node: }, + { name: 'DatabaseOutlined', node: }, + { name: 'ApiOutlined', node: }, + { name: 'CodeOutlined', node: }, + { name: 'BugOutlined', node: }, + { name: 'BellOutlined', node: }, + // 新增常用图标 + { name: 'PlusOutlined', node: }, + { name: 'EditOutlined', node: }, + { name: 'DeleteOutlined', node: }, + { name: 'UploadOutlined', node: }, + { name: 'DownloadOutlined', node: }, + { name: 'EyeOutlined', node: }, + { name: 'EyeInvisibleOutlined', node: }, + { name: 'StarOutlined', node: }, + { name: 'HeartOutlined', node: }, + { name: 'LikeOutlined', node: }, + { name: 'DislikeOutlined', node: }, + { name: 'SmileOutlined', node: }, + { name: 'FrownOutlined', node: }, + { name: 'PhoneOutlined', node: }, + { name: 'MailOutlined', node: }, + { name: 'EnvironmentOutlined', node: }, + { name: 'GlobalOutlined', node: }, + { name: 'AimOutlined', node: }, + { name: 'CompassOutlined', node: }, + { name: 'CameraOutlined', node: }, + { name: 'VideoCameraOutlined', node: }, + { name: 'SoundOutlined', node: }, + { name: 'WifiOutlined', node: }, + { name: 'RocketOutlined', node: }, + { name: 'ThunderboltOutlined', node: }, + { name: 'ExperimentOutlined', node: }, + { name: 'BulbOutlined', node: }, + { name: 'GiftOutlined', node: }, + { name: 'BankOutlined', node: }, + { name: 'WalletOutlined', node: }, + { name: 'MoneyCollectOutlined', node: }, + { name: 'BookOutlined', node: }, + { name: 'ReadOutlined', node: }, + { name: 'ProfileOutlined', node: }, + { name: 'CloudUploadOutlined', node: }, + { name: 'CloudDownloadOutlined', node: }, + { name: 'InboxOutlined', node: }, + { name: 'FolderAddOutlined', node: }, + { name: 'SlidersOutlined', node: }, + { name: 'FilterOutlined', node: }, + { name: 'AlertOutlined', node: }, + { name: 'ClockCircleOutlined', node: }, + { name: 'FieldTimeOutlined', node: }, + { name: 'HistoryOutlined', node: }, + { name: 'ContactsOutlined', node: }, + { name: 'SolutionOutlined', node: }, + { name: 'IdcardOutlined', node: }, + { name: 'QrcodeOutlined', node: }, + { name: 'ScanOutlined', node: }, + { name: 'SafetyOutlined', node: }, + { name: 'SecurityScanOutlined', node: }, + { name: 'UnlockOutlined', node: }, + { name: 'HddOutlined', node: }, + { name: 'CopyOutlined', node: }, + { name: 'ScissorOutlined', node: }, + { name: 'SnippetsOutlined', node: }, + { name: 'FileProtectOutlined', node: }, + { name: 'DesktopOutlined', node: }, + { name: 'LaptopOutlined', node: }, + { name: 'MobileOutlined', node: }, + { name: 'TabletOutlined', node: }, + { name: 'ClusterOutlined', node: }, + { name: 'AppstoreAddOutlined', node: }, + { name: 'PlusSquareOutlined', node: }, + { name: 'SyncOutlined', node: }, + { name: 'ReloadOutlined', node: }, + ], []) + + 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 = {} + 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 = { 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() + 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 => { + const childrenMap = new Map() + 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() + 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): 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() + 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([]) + 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 = useMemo(() => [ + // { title: 'ID', dataIndex: 'id', width: 80 }, + { title: '名称', dataIndex: 'name' }, + { title: '编码', dataIndex: 'code' }, + { title: '类型', dataIndex: 'type', render: (v: number) => v === 3 ? 按钮 : 菜单 }, + { title: '创建时间', dataIndex: 'created_at', render: (v: any) => formatDateTime(v) }, + { title: '操作', key: 'actions', width: 360, render: (_: any, r: MenuItem) => ( + + {has('system:menu:update') && ( + onEdit(r)}> + + 编辑 + + )} + {has('system:menu:delete') && ( + { 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||'删除失败') } }}> + + + 删除 + + + )} + + )}, + ], [keyword, has]) + + return ( +
+ + +
{ const kw = String(vals?.keyword ?? '').trim(); setKeyword(kw); fetchMenus(kw) }} style={{ marginBottom: 12 }}> + + + + + + + + {has('system:menu:create') && ( + + )} + + + + + 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 }} + /> + + setCreateOpen(false)} okText="创建" width={840}> +
+ + String(node?.title ?? '').toLowerCase().includes(String(input).toLowerCase())} + /> + + + + + + + + + + + + + +
+ + setEditOpen(false)} okText="保存" width={840}> +
+ + String(node?.title ?? '').toLowerCase().includes(String(input).toLowerCase())} + /> + + + + + + + + + + + + + + + + +
+
+ ) +} + diff --git a/frontend/src/pages/Permissions.tsx b/frontend/src/pages/Permissions.tsx new file mode 100644 index 0000000..6b2a713 --- /dev/null +++ b/frontend/src/pages/Permissions.tsx @@ -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 = 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) => ( + + onEdit(r)}> + + 编辑 + + { 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||'删除失败') } }}> + + + 删除 + + + + )}, + ], [page, pageSize, keyword]) + + return ( +
+ +
{ const kw = String(vals?.keyword ?? '').trim(); setKeyword(kw); fetchPermissions(1, pageSize, kw) }} style={{ marginBottom: 12 }}> + + + + + + + + {has('system:permission:create') && ( + + )} + + + + + 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) }} + /> + + setCreateOpen(false)} okText="创建" width={840}> +
+ + + + + + + + + + +
+ + setEditOpen(false)} okText="保存" width={840}> +
+ + + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/Positions.tsx b/frontend/src/pages/Positions.tsx new file mode 100644 index 0000000..90ed02e --- /dev/null +++ b/frontend/src/pages/Positions.tsx @@ -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 = 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 ? 启用 : 禁用 }, + { title: '创建时间', dataIndex: 'created_at', render: (v: any) => formatDateTime(v) }, + { title: '操作', key: 'actions', width: 280, render: (_: any, r: PositionItem) => ( + + {has('system:position:update') && ( + onEdit(r)}> + + 编辑 + + )} + {has('system:position:delete') && ( + { 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||'删除失败') } }}> + + + 删除 + + + )} + + )}, + ], [page, pageSize, keyword, has]) + + return ( +
+ +
{ const kw = String(vals?.keyword ?? '').trim(); setKeyword(kw); fetchPositions(1, pageSize, kw) }} style={{ marginBottom: 12 }}> + + + + + + + + {has('system:position:create') && ( + + )} + + + + + + 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) }} + /> + + setCreateOpen(false)} okText="创建" width={640}> +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + {has('system:role:create') && ( + + )} + + + + + 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) }} + /> + + setCreateOpen(false)} okText="创建" width={840}> +
+ + + + + + + + + + + + + + + + + setMenuSearch(e.target.value)} style={{ marginBottom: 8 }} /> + { + 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 }} + /> +
说明:权限来自菜单项的权限标识(perms),无需单独分配权限。
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx new file mode 100644 index 0000000..5d6289b --- /dev/null +++ b/frontend/src/pages/Users.tsx @@ -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 + 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 + 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() + 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 = useMemo(() => [ + { title: 'ID', dataIndex: 'id', width: 80 }, + { title: '用户名', dataIndex: 'username' }, + { title: '昵称', dataIndex: 'nickname' }, + { title: '状态', dataIndex: 'status', render: (v: number) => v === 1 ? 启用 : 禁用 }, + { title: '创建时间', dataIndex: 'created_at', render: (v: any) => formatDateTime(v) }, + { title: '操作', key: 'actions', width: 360, render: (_: any, r: UserItem) => ( + + {has('system:user:update') && ( + onEdit(r)}> + + 编辑 + + )} + {has('system:user:reset') && ( + onResetPwd(r)}> + + 重置密码 + + )} + {has('system:user:assignPosition') && ( + openPositions(r.id)}> + + 分配岗位 + + )} + {has('system:user:delete') && ( + { 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||'删除失败') } }}> + + + 删除 + + + )} + + )}, + ], [page, pageSize, keyword, has]) + + return ( +
+ + { const kw = String(vals?.keyword ?? '').trim(); setKeyword(kw); fetchUsers(1, pageSize, kw) }} style={{ marginBottom: 12 }}> + + + + + + + + {has('system:user:create') && ( + + )} + + + + + 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) }} + /> + + 提示:此页面已支持分页、创建、编辑与重置密码。 + + + setCreateOpen(false)} okText="创建" width={840}> +
+ + + + + + + + + + + ({ label: r.name, value: r.id }))} + onChange={(vals: number[]) => setSelectedRoleIds(vals)} + /> + + + String(node?.title ?? '').toLowerCase().includes(String(input).toLowerCase())} + value={selectedDeptIds} + onChange={(vals) => setSelectedDeptIds(Array.isArray(vals) ? (vals as (string | number)[]).map(v => Number(v)) : [])} + /> + + {has('system:user:assignPosition') && ( + + + + + ({ label: r.name, value: r.id }))} + onChange={(vals: number[]) => setSelectedRoleIds(vals)} + /> + + + String(node?.title ?? '').toLowerCase().includes(String(input).toLowerCase())} + value={selectedDeptIds} + onChange={(vals) => setSelectedDeptIds(Array.isArray(vals) ? (vals as (string | number)[]).map(v => Number(v)) : [])} + /> + + {has('system:user:assignPosition') && ( + + + + +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/login.css b/frontend/src/pages/login.css new file mode 100644 index 0000000..00f3924 --- /dev/null +++ b/frontend/src/pages/login.css @@ -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} \ No newline at end of file diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css new file mode 100644 index 0000000..f51f302 --- /dev/null +++ b/frontend/src/styles/global.css @@ -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; } diff --git a/frontend/src/utils/axios.ts b/frontend/src/utils/axios.ts new file mode 100644 index 0000000..57c1f1a --- /dev/null +++ b/frontend/src/utils/axios.ts @@ -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 = { 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), Authorization: value } as AxiosRequestHeaders + } + } + return config +}) + +api.interceptors.response.use( + (r: AxiosResponse) => r, + async (error: AxiosError>) => { + 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>('/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((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 \ No newline at end of file diff --git a/frontend/src/utils/datetime.ts b/frontend/src/utils/datetime.ts new file mode 100644 index 0000000..49e3da9 --- /dev/null +++ b/frontend/src/utils/datetime.ts @@ -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}` +} \ No newline at end of file diff --git a/frontend/src/utils/permission.tsx b/frontend/src/utils/permission.tsx new file mode 100644 index 0000000..f6ca80a --- /dev/null +++ b/frontend/src/utils/permission.tsx @@ -0,0 +1,40 @@ +import React, { createContext, useContext, useMemo } from 'react' + +// 权限上下文,存储后端返回的权限编码集合(如 system:user:create 等) +export type PermissionSet = Set + +interface PermissionContextValue { + codes: PermissionSet +} + +const PermissionContext = createContext({ codes: new Set() }) + +export function PermissionProvider({ codes, children }: { codes: PermissionSet; children: React.ReactNode }) { + // 统一将编码小写化,避免大小写不一致 + const normalized = useMemo(() => { + const s = new Set() + codes.forEach((c) => { if (c) s.add(String(c).trim().toLowerCase()) }) + return s + }, [codes]) + + const value = useMemo(() => ({ codes: normalized }), [normalized]) + return {children} +} + +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} +} \ No newline at end of file diff --git a/frontend/src/utils/token.ts b/frontend/src/utils/token.ts new file mode 100644 index 0000000..a44bb8f --- /dev/null +++ b/frontend/src/utils/token.ts @@ -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 +} \ No newline at end of file diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..151aa68 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..06c968e --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +} \ No newline at end of file diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..3a6c062 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} \ No newline at end of file diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo new file mode 100644 index 0000000..8889b38 --- /dev/null +++ b/frontend/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..d22e897 --- /dev/null +++ b/frontend/vite.config.ts @@ -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 + } + } + } +}) \ No newline at end of file diff --git a/scripts/setup_demo.sh b/scripts/setup_demo.sh new file mode 100755 index 0000000..28db511 --- /dev/null +++ b/scripts/setup_demo.sh @@ -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 < 根) + 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:-}" \ No newline at end of file diff --git a/scripts/verify_admin_menus.sh b/scripts/verify_admin_menus.sh new file mode 100755 index 0000000..01ba925 --- /dev/null +++ b/scripts/verify_admin_menus.sh @@ -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)]))' \ No newline at end of file