Compare commits

..
238 Commits
Author SHA1 Message Date
baorong 95110511f9 feat: HTTP层增强(token刷新/重试) + ErrorBoundary全局异常 + 通知/日志/个人中心/管理员/菜单页面 + ExportButton/FileUpload通用组件 2026-04-26 14:03:50 +08:00
zuiidea 2d46d7bf27 chore(create): rename npm package to init-antd-admin
Replace antd-admin-cli (name taken on npm). Bin init-antd-admin;
release tags init-antd-admin-v*; workflow publish-init-antd-admin.yml.

Made-with: Cursor
2026-04-15 14:11:49 +08:00
zuiidea 0ecfefcd97 chore(create): rename npm package to antd-admin-cli
Unscoped name avoids npm similarity block with create-antd-admin.
Bin command antd-admin-cli; release tags antd-admin-cli-v*; workflow
publish-antd-admin-cli.yml.

Made-with: Cursor
2026-04-15 12:05:19 +08:00
zuiidea 57cd84b5ce chore(create): rename npm package to create-antdadmin
Use bin/create-antdadmin, npm create antdadmin, and tag prefix
create-antdadmin-v*. Workflow file publish-create-antdadmin.yml.
Env vars CREATE_ANTD_ADMIN_* unchanged.

Made-with: Cursor
2026-04-15 11:58:45 +08:00
zuiidea ca88d6567d fix(create): allow local npm publish without provenance
Remove publishConfig.provenance; CI passes --provenance on npm publish so
GitHub Actions still gets attestations with id-token: write.

Made-with: Cursor
2026-04-15 11:42:49 +08:00
zuiidea f7de07dba6 chore(create): bump to 1.0.3, fix npm bin and publish files
Raise version above registry 1.0.2, set bin to dist/cli.js, and limit npm
files to dist/cli.js and dist/examples.json.

Made-with: Cursor
2026-04-15 11:38:22 +08:00
zuiidea e99220d619 docs: update root README layout and tables
Made-with: Cursor
2026-04-15 11:24:22 +08:00
zuiidea 73e53ea3ba ci: add GitHub Action to publish create-antd-admin to npm
Made-with: Cursor
2026-04-15 11:15:12 +08:00
zuiidea 70ee2eaa41 docs(create): add README for create-antd-admin CLI
Made-with: Cursor
2026-04-15 10:48:10 +08:00
zuiidea 70beec4ec8 merge: feat/create-antd-admin-cli (create-antd-admin CLI)
Made-with: Cursor
2026-04-15 10:45:29 +08:00
zuiidea fa163c9456 docs: update Nextra docs implementation plan
Made-with: Cursor
2026-04-15 10:01:16 +08:00
zuiidea a54300c588 fix(docs): intercept mockServiceWorker.js before Nextra catch-all
Made-with: Cursor
2026-04-15 09:51:01 +08:00
zuiidea 2fce0a3e2a fix(docs): remove package type module for Next build compatibility
Made-with: Cursor
2026-04-14 23:19:03 +08:00
zuiidea d4706457c8 chore(docs): fix tsbuildinfo gitignore pattern
Made-with: Cursor
2026-04-14 23:17:56 +08:00
zuiidea fe322fae17 chore(docs): gitignore Next build output and tsbuildinfo
Made-with: Cursor
2026-04-14 23:17:41 +08:00
zuiidea ab43016511 chore(docs): eslint flat config and verify next build
Made-with: Cursor
2026-04-14 23:17:07 +08:00
zuiidea a2f23fbbe9 chore: wire docs app into turbo and root README
Made-with: Cursor
2026-04-14 23:17:07 +08:00
zuiidea bef1ea8f18 docs(docs): add Chinese content skeleton for Nextra
Made-with: Cursor
2026-04-14 23:17:07 +08:00
zuiidea bd26b035ac feat(docs): add Nextra catch-all MDX page
Made-with: Cursor
2026-04-14 23:17:07 +08:00
zuiidea 3730c874b5 feat(docs): Nextra docs theme root layout (zh-CN)
Made-with: Cursor
2026-04-14 23:17:07 +08:00
zuiidea b186628cb6 feat(docs): add mdx-components for Nextra theme
Made-with: Cursor
2026-04-14 23:17:07 +08:00
zuiidea 72e01b9641 chore(docs): add Next and TypeScript config for Nextra
Made-with: Cursor
2026-04-14 23:17:07 +08:00
zuiidea 0c3619f684 chore(docs): scaffold antd-admin-docs package with Nextra deps
Made-with: Cursor
2026-04-14 23:17:06 +08:00
zuiidea ca3cc71054 test(create): integration test for transforms without network
Made-with: Cursor
2026-04-14 22:37:01 +08:00
zuiidea 3579246c24 docs: document create-antd-admin dlx usage; verify turbo build
Made-with: Cursor
2026-04-14 22:36:56 +08:00
zuiidea c872f80e55 feat(create): wire CLI entry and create command pipeline
Made-with: Cursor
2026-04-14 22:36:11 +08:00
zuiidea a26e7f717e feat(create): interactive prompts for example, dir, name, pm
Made-with: Cursor
2026-04-14 22:33:56 +08:00
zuiidea 9eb67a091f feat(create): transform package.json name
Made-with: Cursor
2026-04-14 22:33:05 +08:00
zuiidea 56943a3a95 feat(create): optional git init and initial commit
Made-with: Cursor
2026-04-14 22:33:01 +08:00
zuiidea 3e630785bb feat(create): detect package managers and run install
Made-with: Cursor
2026-04-14 22:32:59 +08:00
zuiidea db696409d3 feat(create): reject non-empty target directories
Made-with: Cursor
2026-04-14 22:32:21 +08:00
zuiidea 6031d691ea feat(create): download templates with giget
Made-with: Cursor
2026-04-14 22:32:16 +08:00
zuiidea 26b002e80a feat(create): resolve giget source for examples and URLs
Made-with: Cursor
2026-04-14 22:31:24 +08:00
zuiidea 831aef8a7b test(create): vitest and package name normalization
Made-with: Cursor
2026-04-14 22:30:41 +08:00
zuiidea 0185201cbd feat(create): load examples manifest from dist/examples.json
Made-with: Cursor
2026-04-14 22:29:50 +08:00
zuiidea 578f7f5082 feat(create): add constants, CliError, and git ref resolver
Made-with: Cursor
2026-04-14 22:29:10 +08:00
zuiidea 9b8fd96a7a docs(specs): fix write-examples-json repoRoot depth for scripts/
Made-with: Cursor
2026-04-14 22:28:36 +08:00
zuiidea 02150a61a7 build(create): tsup bundle and postbuild examples manifest
Made-with: Cursor
2026-04-14 22:28:11 +08:00
zuiidea cc1a803bdb chore(create): scaffold create-antd-admin package
Made-with: Cursor
2026-04-14 22:27:04 +08:00
zuiidea 3938263979 docs(specs): add create-antd-admin implementation plan
Made-with: Cursor
2026-04-14 22:21:55 +08:00
zuiidea 3dd61607b5 docs(specs): add create-antd-admin CLI design (packages/create)
Made-with: Cursor
2026-04-14 22:18:47 +08:00
zuiidea 67f12dbf2c docs: add apps/docs Nextra implementation plan
Made-with: Cursor
2026-04-14 21:19:42 +08:00
zuiidea bff4c9f905 docs: add apps/docs Nextra 4 design spec
Made-with: Cursor
2026-04-14 21:15:15 +08:00
zuiidea 81bb9e996c feat(with-lingui): dynamic load locale message catalogs
Made-with: Cursor
2026-04-14 21:06:01 +08:00
zuiidea d188afadb0 chore: ignore docs/superpowers; update basic template spec
Made-with: Cursor
2026-04-14 21:01:06 +08:00
zuiidea 432d67b28e fix(basic): e2e script paths and template docs
Made-with: Cursor
2026-04-14 20:57:24 +08:00
zuiidea b6f98b71b1 feat(basic): remove Lingui bootstrap and English shell (batch A)
Made-with: Cursor
2026-04-14 20:55:52 +08:00
zuiidea b47e932d92 chore(basic): remove Lingui vite and swc plugins
Made-with: Cursor
2026-04-14 19:23:06 +08:00
zuiidea a5d6189b53 chore(basic): drop Lingui deps and rename package
Made-with: Cursor
2026-04-14 19:22:27 +08:00
zuiidea b08775bad8 chore: scaffold apps/basic from with-lingui
Made-with: Cursor
2026-04-14 19:21:30 +08:00
zuiidea 5913cd2f43 docs: add apps/basic implementation plan
Made-with: Cursor
2026-04-14 19:17:55 +08:00
zuiidea 29888fd1ad refactor(with-lingui): move theme and session, merge mock utils
- Theme hooks: tokenBuilders + useAppTheme under hooks; drop theme barrel
- Session fetch helper under utils; update entry/login imports
- Mock filter + avatar helpers in mocks/utils; avatars from API in UI
- README tree updates

Made-with: Cursor
2026-04-14 19:12:48 +08:00
zuiidea 47fd1aea90 docs: add apps/basic template design spec (English-only fork of with-lingui)
Made-with: Cursor
2026-04-14 19:08:23 +08:00
zuiidea ab24405469 chore: add workspace instruction files 2026-04-14 18:39:21 +08:00
zuiidea 1b4c8e2895 chore: add vercel SPA rewrite config 2026-04-14 18:35:02 +08:00
zuiidea 3994caea8e feat: support enabling mock in build via env 2026-04-14 18:33:34 +08:00
zuiidea dd91ffcecd chore: sync package metadata 2026-04-14 18:05:22 +08:00
zuiidea 426518c70a chore: initial import 2026-04-14 18:04:41 +08:00
zuiidea 458d63c020 Reinitialize the project 2026-04-14 17:16:40 +08:00
baorong 6d4e1289a3 fix: npm install fail need --legacy-peer-deps 2026-03-15 15:43:23 +08:00
baorong ccaf67076e bump version: 6.0.0 2026-03-14 14:20:35 +08:00
baorong 8f7dd82e40 refactor: umi v4 support 2026-03-13 23:07:16 +08:00
baorong 9989aa4de5 bump version: 5.5.0 2026-02-19 22:06:42 +08:00
baorong f983533d59 chore dep: use puppeteer-core because npm install easy to fail 2026-02-19 22:03:32 +08:00
baorong 2182759d47 chore dep: little mistake 2026-02-19 21:43:23 +08:00
baorong 4d1851ddd2 chore deps: upgrade axios to 1.x 2026-02-19 21:30:25 +08:00
baorong 0d82eca735 chore lint: code sth. 2026-02-19 21:28:07 +08:00
baorong cfb6b271ee chore code: clear some warnings and remove MapChart & AirportCoord 2026-02-19 21:19:40 +08:00
baorong 0b97964457 fix i18n: lingui not work 2026-02-18 19:28:50 +08:00
baorong edfbfcf5db chore(code): adapt code to antd v6 2026-02-18 19:28:00 +08:00
baorong e5172432df chore dep: use highcharts-react-official 2026-02-18 19:04:53 +08:00
baorong 741b04f120 chore deps: upgrade lingui to v5 2026-02-18 15:53:34 +08:00
baorong 83f2cc1344 chore dep: upgrade antd v6 2026-02-13 17:56:40 +08:00
baorong 152556d582 chore dep: upgrade to lingui v4 2026-02-13 17:39:53 +08:00
Baorong Li 67fc31a008 Merge pull request #1165 from baocang/codex/find-and-fix-two-bugs
fix: correct typos in build config and demo
2025-05-29 22:23:18 +08:00
Baocang Nie 23eace8350 fix: correct typos in build config and demo 2025-05-28 11:13:32 +08:00
Baorong 93817e4840 fix: dep mis with pnpm 2024-04-13 12:50:58 +08:00
Baorong bb97d982e4 fix: npm run start not work #1152 2024-04-13 12:49:16 +08:00
Baorong Li 700f58015d Merge pull request #1156 from XyanZhang/master
fix: clicking on the sidebar collapse button results in a blank screen.
2023-06-14 15:17:56 +08:00
XyanZhang 872ce3a825 fix: can't scroll when the media is mobile 2023-06-14 14:47:06 +08:00
XyanZhang 385b31e7bb fix: clicking on the sidebar collapse button results in a blank screen. 2023-06-14 14:16:33 +08:00
Baorong Li 3d66a616ff chore readme: change documentation url 2023-02-21 10:36:16 +08:00
superlbr b7e18a1214 fix locale: change locale filepath due to antd5 #1146 2023-01-22 20:44:28 +08:00
Baorong Li 3b0bae9c70 chore umirc: set antd to false since v5 2022-12-22 10:14:27 +08:00
Baorong Li 8b9343a320 Merge pull request #1142 from vagusX/feat/upgrade-antd-v5
feat: upgrade antd v5
2022-11-27 17:32:40 +08:00
vagusx 8618c75459 feat: replace moment to dayjs 2022-11-27 16:29:42 +08:00
vagusx 711d2077e2 feat: import BackTop from FloatButton 2022-11-27 16:17:07 +08:00
vagusx b25646b033 feat: rename modal open 2022-11-27 15:54:25 +08:00
vagusx 02bf7784b1 feat: upgrade antd v5 2022-11-26 22:15:15 +08:00
Baorong Li c164d60643 Merge pull request #1132 from SuperRay3/master
fix: 修复 antd 组件无法国际化的问题
2022-05-23 15:59:15 +08:00
leijialiang 1bd4cfe73c fix: 修复 antd 组件无法国际化的问题 2022-05-23 14:43:09 +08:00
superlbr f4e1eeb21c fix login: form mis password 2021-12-24 15:19:15 +08:00
superlbr 4b5fe62014 fix i18n: t not work use Trans instead 2021-12-23 20:09:35 +08:00
superlbr 36c2be61ae chore readme: add account/password desc. 2021-11-16 16:08:26 +08:00
superlbr e9206a9448 fix dev: loading chunk mf-dep_async failed 2021-08-03 18:31:03 +08:00
superlbr 0bf28879c4 chore umi: enable mfsu 2021-08-03 17:47:27 +08:00
superlbr 7c936712b9 fix dev: #1108 #1109 2021-07-15 08:15:51 +08:00
Baorong Li e32a19fe3b chore dep: normal 2021-03-26 22:22:33 +08:00
Baorong Li c6382c6221 chore umi: use default targets no ie support 2021-03-26 22:20:53 +08:00
Baorong Li 02280fbed9 Merge pull request #1091 from jaywhen/patch-1
fix garbled characters in README-zh_CN.md
2021-03-17 17:46:29 +08:00
zainchen d897eb205d fix: adding babel preset to fixing extract command 2021-03-17 17:44:38 +08:00
Baorong Li b1f290ea32 upgrade dep: echarts@5.x 2021-03-17 17:28:05 +08:00
Baorong Li 0e36b6b7a0 fix utils: remove params to close #1067 in request 2021-03-17 17:03:14 +08:00
Baorong Li d92f121cf4 upgrade lingui: 3.x 2021-03-17 16:50:49 +08:00
Xiang Jaywhen 0a73ba0f12 fix garbled characters in README-zh_CN.md 2021-02-19 23:19:51 +08:00
Baorong Li 9e4e0efbab Merge pull request #1082 from AmagiDDmxh/master
Fix en layout doc typo, same as before #1081
2020-12-21 23:27:57 -06:00
amagiddmxh 524dff07d2 Fix en layout doc typo 2020-12-21 16:05:47 +08:00
Baorong Li 8b3e5c08f7 Merge pull request #1081 from AmagiDDmxh/master
There were some type in the API-config doc, https://doc.antd-admin.zuiidea.com/#/API-configuration
2020-12-21 00:23:22 -06:00
amagiddmxh c17444f5d6 Fix API config uppercase typo 2020-12-21 12:55:26 +08:00
Baorong Li df85ecdfff Merge pull request #1080 from karlhorky/patch-1
Fix typo
2020-12-15 08:24:21 -06:00
Karl Horky b8824141d6 Fix typo 2020-12-15 15:21:29 +01:00
Karl Horky aedd2ec3db Fix typo 2020-12-15 15:20:49 +01:00
zuiidea 5abad21082 ✨ Optimization Split Chunks, Antd Icon, CI. 2020-08-20 14:16:32 +08:00
Baorong Li 1ae41c7ae5 Merge pull request #1060 from mlboy/patch-1
Update README.md
2020-06-03 14:46:44 +08:00
mlboy e41671dbf9 Update README.md
调整antd版本
2020-05-13 07:42:01 +08:00
Baorong Li eaeb9092e4 bump antd-admin@5.3.0 2020-05-10 21:05:23 +08:00
Baorong Li 2f3ca0a006 Merge branch 'develop' 2020-05-10 21:04:40 +08:00
Baorong Li 1b65351be4 upgrade umi@3 fin. 2020-03-24 15:17:57 +08:00
Baorong Li 0422f6d51e upgrade umi@3 partly 2020-03-24 12:12:20 +08:00
Baorong Li 8d050d4040 fix pathToRegexp not work 2020-03-20 11:59:04 +08:00
zuiidea 82b3a4b384 ⚡️ Exclude node_modules by babel-loader. 2020-03-19 22:26:55 +08:00
Baorong Li d2f391a881 upgrade umi@3 partly 2020-03-19 17:06:31 +08:00
Baorong Li 9e778487d9 upgrade umi@3 partly 2020-03-19 12:50:12 +08:00
Baorong Li 7938d71f2f upgrade umi@3 partly 2020-03-19 00:05:44 +08:00
Baorong Li 422a6fed30 upgrade umi@3 partly 2020-03-18 16:17:25 +08:00
Baorong Li c6ea0454b3 upgrade umi@3 partly 2020-03-15 08:24:05 +08:00
Baorong Li 467767a6c5 upgrade umi@3 partly 2020-03-05 23:22:02 +08:00
Baorong Li cf795c512a bump antd-admin@5.2.0 2020-03-05 20:21:22 +08:00
Baorong Li 18b499030d upgrade antd@4.0.0 2020-02-29 22:47:45 +08:00
zuiidea d800d6f3ce Merge branch 'master' into develop 2020-01-08 09:31:45 +08:00
zuiidea 185f706107 💚 Fixing now config. 2020-01-07 17:17:57 +08:00
zuiidea 5a4661cdad Merge branch 'develop' of https://github.com/zuiidea/antd-admin into develop 2020-01-07 16:02:51 +08:00
zuiidea 7abdd94a0e 🚀 Optimize deployment configuration. 2020-01-07 16:02:33 +08:00
Baorong Li 22f0684ab0 chore: docs normal 2020-01-07 15:58:48 +08:00
Baorong Li 80b2c63693 Merge pull request #1040 from vagusX/feat/migrate-antd-v4
feat: migrate to antd v4
2019-12-02 22:07:08 -06:00
vagusX 4b2a553bda feat: add antd v4 dependencies 2019-12-03 10:19:54 +08:00
vagusX ad30e73b10 feat: migrate to antd v4 2019-12-03 09:58:49 +08:00
Baorong Li 15b62e302d bump antd-admin@5.1.3 2019-10-22 10:38:52 +08:00
Baorong Li 9d5ec20f20 Merge branch 'develop' 2019-10-22 10:37:43 +08:00
Baorong Li c878425fd0 upgrade antd@3.24.x 2019-10-22 10:30:04 +08:00
Baorong Li 90e80dda64 Merge pull request #1022 from rioarray/feature/inline-conditional-rendering
inline conditional rendering in some components
2019-10-17 16:32:30 +08:00
rioarray 1db5bfeab5 inline conditional rendering in some components 2019-10-17 14:45:30 +07:00
zuiidea b2b3fcd4a3 ✨ Support disable i18n. 2019-10-09 20:20:11 +08:00
Baorong Li 9cf97375c7 fix 404 when path = '/' 2019-08-01 23:12:42 +08:00
Baorong Li 7839b6affb Merge pull request #1007 from Louis-7/master
[fix] add notification popup to #primaryLayout instead of #layoutHeader
2019-07-25 08:26:35 -05:00
Louis 597064a07d [fix] add notification popup to #primaryLayout instead of #layoutHeader 2019-07-25 11:47:32 +08:00
Baorong Li 0ea57d14c9 fix #980 cannot read property 'map' of null 2019-07-07 23:01:20 +08:00
Baorong Li afd736d615 bump antd-admin@5.1.2 2019-07-07 22:48:34 +08:00
Baorong Li ce9b8c376f Merge pull request #996 from tuantvk/master
🐛 fix bug sub menu Charts when collapsed
2019-07-07 08:03:31 -05:00
tuantv 79776e6752 🐛 fix bug sub menu Charts when collapsed 2019-07-07 11:56:52 +07:00
zuiidea a2432022a1 ✨ Updating manifest logo and adding portugal flag image. 2019-07-02 17:47:16 +08:00
zuiidea ea5ee96bdc 🎨 Improving format of the code. 2019-07-06 11:02:56 +08:00
zuiidea fe954092a4 🐛 Fixing dva history.listen multiple triggers. 2019-07-06 10:50:50 +08:00
zuiidea 5e148fec92 Merge branch 'develop' of https://github.com/zuiidea/antd-admin into develop 2019-07-06 09:43:30 +08:00
Baorong Li 5daa2a9548 add moment locale setting 2019-07-05 22:26:29 +08:00
Baorong Li f1913f7b18 add pt_BR locale-provider in layout 2019-07-05 22:16:54 +08:00
zuiidea 65a453553e ✨ Updating mock avatar image. 2019-07-05 19:52:37 +08:00
Baorong Li 29da8f24da Merge pull request #989 from tuantvk/master
🐛 fix remove and update user error handleRefresh
2019-06-29 09:57:46 -05:00
tuantv 3443ad9968 🐛 fix edit and update user error handleRefresh 2019-06-29 21:19:57 +07:00
Baorong Li 838dc93eff bump antd-admin@5.1.1 2019-06-29 10:56:03 +08:00
Baorong Li 07ef68ec1f Merge remote-tracking branch 'origin/develop' 2019-06-29 10:54:10 +08:00
Baorong Li 3cb7f86086 fix login jump to /enundefined 2019-06-29 10:49:11 +08:00
Baorong Li fc9565c157 add a flag to detect refresh saving query times in model/app.js 2019-06-24 20:50:09 +08:00
Baorong Li 557f6022f3 bump antd-admin@5.1.0 2019-06-24 16:05:28 +08:00
Baorong Li d0a56169e3 revert dispatch query in model/app.js 2019-06-24 15:40:44 +08:00
Baorong Li f7d6e2b4ed Merge pull request #984 from lovemegowin/master
refactor user,menu,permission storage strategy
2019-06-24 15:26:06 +08:00
lovemegowin df82ee42fd fix #985 , close #985 2019-06-24 15:20:25 +08:00
lovemegowin a29d761483 refactor user, menu, permission storage strategy then make full loader unnecessary 2019-06-21 15:26:06 +08:00
lovemegowin 713a86b9d6 add null point assert 2019-06-21 15:23:19 +08:00
lovemegowin d6250f16b0 fullScreen loader is not always necessary refactor default spinning to false 2019-06-21 14:12:15 +08:00
Baorong Li 1c5d5c3788 upgrade umi@2.8.x 2019-06-20 16:31:00 +08:00
Baorong Li 699e9d9ff1 fix warning inlineCollapsed not control Menu under Sider 2019-06-04 15:47:35 +08:00
Baorong Li fad0cb931a use options instead of option in react-perfect-scrollbar 2019-06-04 15:34:45 +08:00
Baorong Li 5d7ff55869 upgrade dependency 2019-06-04 15:09:51 +08:00
Baorong Li 642ccb5b82 Merge pull request #975 from deepred5/master
fix: 关闭弹窗时有动画效果
2019-05-27 23:59:48 -05:00
深红 51a7ce0827 update: 关闭弹窗时销毁子元素,保证initialValue更新并且关闭弹窗时有动画效果 2019-05-28 10:27:27 +08:00
Baorong Li 157a54b11d Merge pull request #969 from ccyanxyz/patch-1
fix markdown code block in docs/layout.md
2019-05-04 18:32:34 -05:00
cyan be083d81fc fix markdown code block 2019-05-05 00:38:43 +08:00
zuiidea f6c869ad47 Merge pull request #965 from zuiidea/develop
✏️  Fixing typos, exlude => exclude.
2019-04-18 10:10:14 +08:00
zuiidea bef5b26509 ✏️ Fixing typos, exlude => exclude. 2019-04-18 09:58:16 +08:00
Baorong Li 59cfe24178 Merge branch 'develop'
# Conflicts:
#	package.json
2019-04-10 07:27:25 +08:00
Baorong Li 87d0d58ae8 Merge commit '0ac4839e948f7b1d34c90d33f4ece483bd9d2f48' 2019-04-10 07:24:20 +08:00
Baorong Li de8b6c972d bump antd-admin@5.0.6 2019-04-10 07:24:06 +08:00
Baorong Li 0ac4839e94 upgrade umi@2.6.x 2019-04-10 07:22:53 +08:00
Baorong Li 565bce6d8b refix #958 by reset form in componentDidUpdate 2019-04-05 09:02:25 +08:00
Baorong Li b278b028cf fix #958 redux form initialValues not updating with state 2019-04-04 16:45:28 +08:00
Baorong Li 33abd9369e fix #957 an flaw ignored 2019-03-19 10:59:37 +08:00
Baorong Li e6b2f7f271 fix #950 modify z-index in header 2019-03-13 16:59:12 +08:00
Baorong Li 0bd1e9cf59 fix #947 misspelled word cancel 2019-03-06 12:11:42 +08:00
Baorong Li 693625bf2c bump antd-admin@5.0.5 2019-03-05 16:23:16 +08:00
Baorong Li 3a6c587569 upgrade antd@3.14 2019-03-05 16:17:12 +08:00
Baorong Li bd85eaf0a0 fix less is not a command in prettier 2019-03-05 16:15:33 +08:00
Baorong Li 29aa0f416e fix Cannot read property 'addEventListener' of null #946 2019-03-05 16:15:02 +08:00
Baorong Li 5b32607911 bump antd-admin@5.0.4 2019-02-19 22:00:36 +08:00
Baorong Li 76e5359752 upgrade umi@2.5.0 2019-02-19 21:46:01 +08:00
Baorong Li 5ec4efbb6e reset default lang to en 2019-02-07 09:19:39 +08:00
Baorong Li f8bb7b421a remove description about support IE9/IE10 that not working well 2019-02-07 08:52:40 +08:00
Baorong Li 887c169aa8 upgrade and lock antd@3.13.0 2019-02-07 08:36:53 +08:00
Baorong Li 8c10dcc6c2 Merge pull request #939 from rrfaria/feature/pt-br
added pt-br language and some improvements to language control to route mocks
2019-02-07 08:08:01 +08:00
Rafael Faria 671da6b5fa added pt-br and some improvements to language control 2019-02-06 14:39:02 -02:00
Baorong Li 7fdb2f4ea8 bump antd-admin@5.0.3 2019-01-11 18:43:48 +08:00
Baorong Li 67388b31f5 enable umi treeShaking 2019-01-11 18:40:39 +08:00
Baorong Li 2379cca765 upgrade umi@2.4.0 & antd@3.12.x 2019-01-11 18:39:19 +08:00
Baorong Li 34be6651e5 fix #935 root path / will be not found after login 2019-01-10 17:36:23 +08:00
Baorong Li 10f8e3e8ef fix mix require / module.exports and import / export #934 2019-01-08 23:30:18 +08:00
Baorong Li 0c6ea8eb5c fix cannot read property 'hash' of undefined by turn off hardSource 2019-01-08 22:56:23 +08:00
Baorong Li 6e69b06e61 Publish antd-admin@5.0.2 2018-12-28 14:01:51 +08:00
Baorong Li 98ba383a68 upgrade umi@2.3 antd@3.11 2018-12-28 11:42:44 +08:00
Baorong Li 4e6d81a9bf Merge pull request #928 from luckcoding/fix-bread-link
fix bread route has no matching lang
2018-12-13 22:35:56 +08:00
Baorong Li 0e2c206023 Merge branch 'develop' into fix-bread-link 2018-12-13 22:35:47 +08:00
luckcoding 285f06d1e7 fix bread route has no matching lang 2018-12-13 16:57:49 +08:00
Baorong Li d03787a871 Publish antd-admin@5.0.1 2018-12-12 13:48:34 +08:00
zuiidea 6aead03f3e 🐛 Fix style error when menu is collapsed. 2018-12-12 11:35:44 +08:00
Baorong Li 0949a298c9 fix #924 404 Not Found after router change 2018-12-12 06:53:26 +08:00
Baorong Li 94c8e2168f fix #926 createUser api in services 2018-12-12 06:17:30 +08:00
Baorong Li 358134f417 fallback umi to 2.2.8 to fix #923 2018-12-09 23:07:16 +08:00
Baorong Li 7eb9e06452 upgrade antd@3.10.9, umi@2.3.0-beta.3 2018-12-01 11:29:09 +08:00
zuiidea fab06d5005 fix: 🐛 CI fails when pull request 2018-11-29 10:00:11 +08:00
Baorong Li 7483572b1d Merge pull request #921 from francolaiuppa/patch-1
Mini typo fixes for English docs, Configuration section.
2018-11-29 09:45:38 +08:00
Franco Laiuppa d1ce8e0739 Mini typo fixes
Hi! Great project!

I was reading the docs and noticed some typos regarding the layout names and the `exclude` parameter, so I've decided to fix it.
I'm new to this repo so I hope this is a valid edit if not please let me know and I'll change it.

Thanks!
2018-11-28 16:53:25 +01:00
zuiidea 16860f93ad Merge pull request #920 from alexxyzeng/alex
fix: show vertical centered modal in ie
2018-11-28 10:18:22 +08:00
xiayao 6ba45d5bc3 fix: show vertical centered modal in ie 2018-11-28 00:44:21 +08:00
zuiidea f230429ddd Merge branch 'develop' of https://github.com/zuiidea/antd-admin into develop 2018-11-21 10:36:51 +08:00
zuiidea 78c3b6d597 📝 Add some comments image to document. 2018-11-20 21:30:22 +08:00
Baorong Li cdbc2782ff chore: remove beta tag in version from package.json 2018-11-20 17:50:20 +08:00
zuiidea 108aca99b9 📝 Add documentation for Add Layout. 2018-11-18 23:08:12 +08:00
Baorong Li 7dd5dfb1bf fix: icons' format not right in manifest.json 2018-11-15 14:11:47 +08:00
zuiidea ee7a0fffc2 ⚡ Update manifest.json. 2018-11-15 00:11:17 +08:00
zuiidea 6999871860 📝 Add documentation for Add Luanguage. 2018-11-14 23:46:59 +08:00
zuiidea 6742207c77 🐛 Fix cannot translate fields with variables. 2018-11-14 22:44:46 +08:00
Baorong Li d71c29ee2e feat: enable pwa 2018-11-14 13:37:34 +08:00
zuiidea 6917d6b89a Merge branch 'develop' of https://github.com/zuiidea/antd-admin into develop 2018-11-12 23:31:11 +08:00
zuiidea c3f369f0b3 📝 Add documentation for API configuration. 2018-11-12 23:30:56 +08:00
Baorong Li 01abc5ca38 upgrade antd@3.10.5 2018-11-10 09:50:20 +08:00
Baorong Li 08a348090c upgrade umi@2.3.0-beta.1 2018-11-09 23:16:42 +08:00
Baorong Li 63b1ecf79f Upgrade umi@2.2.5 2018-11-09 11:26:19 +08:00
Baorong Li efe7e74375 fix #913 handleMenuClick is not defined 2018-11-07 16:23:36 +08:00
Baorong Li 6d552d22d9 add doc about custom http header #829 2018-11-05 11:23:43 +08:00
425 changed files with 42632 additions and 27876 deletions
-16
View File
@@ -1,16 +0,0 @@
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
-3
View File
@@ -1,3 +0,0 @@
BROWSER=none
HOST=0.0.0.0
PORT=7000
-7
View File
@@ -1,7 +0,0 @@
src/**/*-test.js
src/public
src/routes/chart/ECharts/theme
src/routes/chart/highCharts/mapdata
src/locales/_build/
src/locales/**/*.js
docs/**/*.js
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "react-app",
"rules": {
"jsx-a11y/href-no-hash": "off",
"no-console": "warn",
"valid-jsdoc": "warn"
}
}
-8
View File
@@ -1,8 +0,0 @@
> 请酌情提供细节,并保持issue精简便于查阅
> 1. 功能需求 => 详细说明
> 2. Bug反馈
> 2.1 简单描述下报错
> 2.2 你期望什么结果
> 2.3 如何操作导致的
> 2.4 可提供运行环境信息
> 3. 代码求助 => 可以提,未必及时答复
@@ -0,0 +1,69 @@
# Publish npm package `init-antd-admin` from packages/create
#
# Setup (one-time):
# 1. npmjs.com → Access Tokens → Granular or Automation token with publish rights
# 2. Repo → Settings → Secrets and variables → Actions → New repository secret
# Name: NPM_TOKEN Value: <token>
#
# Release:
# A) Manual: Actions → "Publish init-antd-admin" → Run workflow
# B) Tag: git tag init-antd-admin-v1.0.3 && git push origin init-antd-admin-v1.0.3
# (tag suffix must match packages/create/package.json "version")
name: Publish init-antd-admin
on:
workflow_dispatch:
push:
tags:
- "init-antd-admin-v*"
permissions:
contents: read
id-token: write
concurrency:
group: publish-init-antd-admin
cancel-in-progress: true
jobs:
publish:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.6
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Test init-antd-admin
run: pnpm test
working-directory: packages/create
- name: Build init-antd-admin
run: pnpm run build
working-directory: packages/create
- name: Publish to npm
# Provenance only in CI (OIDC). publishConfig must not force provenance or local npm publish fails.
run: npm publish --access public --provenance
working-directory: packages/create
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+40 -17
View File
@@ -1,23 +1,46 @@
coverage
dist
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Dependencies
node_modules
npm-debug.log
yarn-error.log
yarn.lock
package-lock.json
.pnp
.pnp.js
# ide
.idea
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Mac General
# Testing
coverage
# Turbo
.turbo
# Vercel
.vercel
# Build Outputs
.next/
out/
build
dist
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
.DS_Store
.AppleDouble
.LSOverride
*.pem
# umi
.umi
.umi-production
.pnpm-store
# jslingui
src/locales/_build
src/locales/**/*.js
# Git worktrees
.worktrees/
.cursor
./docs/superpowers
-6
View File
@@ -1,6 +0,0 @@
*.svg
*.ejs
.DS_Store
.umi
.umi-production
src/locales/**/*.json
-5
View File
@@ -1,5 +0,0 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5"
}
-9
View File
@@ -1,9 +0,0 @@
{
"extends": ["stylelint-config-standard", "stylelint-config-prettier"],
"rules": {
"declaration-empty-line-before": null,
"no-descending-specificity": null,
"selector-pseudo-class-no-unknown": null,
"selector-pseudo-element-colon-notation": null
}
}
-23
View File
@@ -1,23 +0,0 @@
language: node_js
node_js:
- node
script:
- npm run build
before_install:
- |
if [ "$TRAVIS_BRANCH" = "develop" ]; then
for prefixed_envvar in ${!DEV_*}; do
eval export ${prefixed_envvar#DEV_}="${!prefixed_envvar}"
done
elif [ "$TRAVIS_BRANCH" = "master" ]; then
for prefixed_envvar in ${!PROD_*}; do
eval export ${prefixed_envvar#PROD_}="${!prefixed_envvar}"
done
fi
- openssl aes-256-cbc -K $encrypted_18b2305b78c9_key -iv $encrypted_18b2305b78c9_iv -in config/id_rsa.enc -out ~/.ssh/id_rsa -d
- chmod 600 ~/.ssh/id_rsa
- echo -e "Host $HOST\n\tStrictHostKeyChecking no\n" >> ~/.ssh/config
- yarn global add now
after_script:
- scp -o stricthostkeychecking=no -r ./dist root@$HOST:$BUILD_DIR
- cd ./docs && now -A $DOC_NOW_CONFIG -t $NOW_TOKEN && now alias -A $DOC_NOW_CONFIG -t $NOW_TOKEN
-97
View File
@@ -1,97 +0,0 @@
// https://umijs.org/config/
import { resolve } from 'path'
import { i18n } from './src/utils/config'
export default {
ignoreMomentLocale: true,
targets: { ie: 9 },
plugins: [
[
// https://umijs.org/plugin/umi-plugin-react.html
'umi-plugin-react',
{
dva: { immer: true },
antd: true,
dynamicImport: {
webpackChunkName: true,
loadingComponent: './components/Loader/Loader',
},
routes: {
exclude: [
/model\.(j|t)sx?$/,
/service\.(j|t)sx?$/,
/models\//,
/components\//,
/services\//,
/chart\/Container\.js$/,
/chart\/ECharts\/.+Component\.js$/,
/chart\/ECharts\/.+ComPonent\.js$/,
/chart\/ECharts\/theme\/.+\.js$/,
/chart\/highCharts\/.+Component\.js$/,
/chart\/highCharts\/mapdata\/.+\.js$/,
/chart\/Recharts\/.+Component\.js$/,
/chart\/Recharts\/Container\.js$/,
],
update: routes => {
if (!i18n) return routes
const newRoutes = []
for (const item of routes[0].routes) {
newRoutes.push(item)
if (item.path) {
newRoutes.push(
Object.assign({}, item, {
path:
`/:lang(${i18n.languages
.map(item => item.key)
.join('|')})` + item.path,
})
)
}
}
routes[0].routes = newRoutes
return routes
},
},
dll: {
include: ['dva', 'dva/router', 'dva/saga', 'dva/fetch', 'antd/es'],
},
hardSource: /* isMac */ process.platform === 'darwin',
},
],
],
// Theme for antd
// https://ant.design/docs/react/customize-theme
theme: './config/theme.config.js',
// Webpack Configuration
proxy: {
'/api/v1/weather': {
target: 'https://api.seniverse.com/',
changeOrigin: true,
pathRewrite: { '^/api/v1/weather': '/v3/weather' },
},
},
alias: {
api: resolve(__dirname, './src/services/'),
components: resolve(__dirname, './src/components'),
config: resolve(__dirname, './src/utils/config'),
models: resolve(__dirname, './src/models'),
routes: resolve(__dirname, './src/routes'),
services: resolve(__dirname, './src/services'),
themes: resolve(__dirname, './src/themes'),
utils: resolve(__dirname, './src/utils'),
},
extraBabelPresets: ['@lingui/babel-preset-react'],
extraBabelPlugins: [
[
'import',
{
libraryName: 'lodash',
libraryDirectory: '',
camel2DashComponentName: false,
},
'lodash',
],
],
}
-87
View File
@@ -1,87 +0,0 @@
<p align="center">
<a href="http://github.com/zuiidea/antd-admin">
<img alt="antd-admin" height="64" src="./docs/_media/logo.svg">
</a>
</p>
<h1 align="center">AntD Admin</h1>
<div align="center">
一套优秀的中后台前端解决方案
[![antd](https://img.shields.io/badge/antd-^3.10.0-blue.svg?style=flat-square)](https://github.com/ant-design/ant-design)
[![umi](https://img.shields.io/badge/umi-^2.2.1-orange.svg?style=flat-square)](https://github.com/umijs/umi)
[![GitHub issues](https://img.shields.io/github/issues/zuiidea/antd-admin.svg?style=flat-square)](https://github.com/zuiidea/antd-admin/issues)
[![MIT](https://img.shields.io/dub/l/vibe-d.svg?style=flat-square)](http://opensource.org/licenses/MIT)
![Travis (.org)](https://img.shields.io/travis/zuiidea/antd-admin.svg)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/zuiidea/antd-admin/pulls)
[![Gitter](https://img.shields.io/gitter/room/antd-admin/antd-admin.svg)](https://gitter.im/antd-admin/antd-admin)
</div>
- 在线演示 - [https://antd-admin.zuiidea.com](https://antd-admin.zuiidea.com)
- 使用文档 - [https://doc.antd-admin.zuiidea.com/#/zh-cn/](https://doc.antd-admin.zuiidea.com/#/zh-cn/)
- 常见问题 - [https://doc.antd-admin.zuiidea.com/#/zh-cn/faq](https://doc.antd-admin.zuiidea.com/#/zh-cn/faq)
- 更新日志 - [https://doc.antd-admin.zuiidea.com/#/zh-cn/change-log](https://doc.antd-admin.zuiidea.com/#/zh-cn/change-log)
[English](./README.md) | 简体中文
## 特性
- 国际化,源码中抽离翻译字段,按需加载语言包
- 动态权限,不同权限对应不同菜单
- 优雅美观,Ant Design 设计体系
- Mock 数据,本地数据调试
## 使用
1. 下载项目代码。
```bash
git clone https://github.com/zuiidea/antd-admin.git my-project
cd my-project
```
2. 进入目录安装依赖,国内用户推荐使用 [cnpm](https://cnpmjs.org) 进行加速。
```bash
yarn install
```
或者
```bash
npm install
```
3. 启动本地服务器。
```bash
npm run start
```
4. 启动完成后打开浏览器访问 [http://localhost:7000](http://localhost:7000),如果需要更改启动端口,可在 `.env` 文件中配置。
> 更多信息请参考 [使用文档](https://doc.antd-admin.zuiidea.com/#/zh-cn/)。
## 支持环境
现代浏览器及 IE9。
| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/edge/edge_48x48.png" alt="IE / Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>IE / Edge | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Firefox | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Chrome | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png" alt="Safari" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Safari | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/opera/opera_48x48.png" alt="Opera" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Opera |
| --------- | --------- | --------- | --------- | --------- |
| IE9, IE10, IE11, Edge| last 2 versions| last 2 versions| last 2 versions| last 2 versions
## 参与贡献
我们非常欢迎你的贡献,你可以通过以下方式和我们一起共建 :smiley:
- 在你的公司或个人项目中使用 AntD Admin。
- 通过 [Issue](http://github.com/zuiidea/antd-admin/issues) 报告 bug 或进行咨询。
- 提交 [Pull Request](http://github.com/zuiidea/antd-admin/pulls) 改进代码。
> 强烈推荐阅读 [《提问的智慧》](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way)、[《如何向开源社区提问题》](https://github.com/seajs/seajs/issues/545) 和 [《如何有效地报告 Bug》](http://www.chiark.greenend.org.uk/%7Esgtatham/bugs-cn.html)、[《如何向开源项目提交无法解答的问题》](https://zhuanlan.zhihu.com/p/25795393),更好的问题更容易获得帮助。
+224 -53
View File
@@ -1,86 +1,257 @@
# Antd Admin
<p align="center">
<a href="http://github.com/zuiidea/antd-admin">
<img alt="antd-admin" height="64" src="./docs/_media/logo.svg">
</a>
</p>
Balanced admin monorepo: MSW mocks, minimal RBAC, full CRUD, and Playwright E2E — built with React 19, Ant Design 6, and Vite+. Ship either an **English-only** app (`apps/basic`) or a **Lingui i18n** variant (`apps/with-lingui`); see [Templates](#templates) below.
<h1 align="center">AntD Admin</h1>
[antd](https://github.com/ant-design/ant-design)
[react](https://react.dev)
[vite](https://viteplus.dev)
[TypeScript](https://www.typescriptlang.org/)
[GitHub issues](https://github.com/zuiidea/antd-admin/issues)
[GitHub stars](https://github.com/zuiidea/antd-admin/stargazers)
[License](http://opensource.org/licenses/MIT)
[PRs Welcome](https://github.com/zuiidea/antd-admin/pulls)
<div align="center">
## Tech Stack
A excellent front-end solution for enterprise applications.
[![antd](https://img.shields.io/badge/antd-^3.10.0-blue.svg?style=flat-square)](https://github.com/ant-design/ant-design)
[![umi](https://img.shields.io/badge/umi-^2.2.1-orange.svg?style=flat-square)](https://github.com/umijs/umi)
[![GitHub issues](https://img.shields.io/github/issues/zuiidea/antd-admin.svg?style=flat-square)](https://github.com/zuiidea/antd-admin/issues)
[![MIT](https://img.shields.io/dub/l/vibe-d.svg?style=flat-square)](http://opensource.org/licenses/MIT)
![Travis (.org)](https://img.shields.io/travis/zuiidea/antd-admin.svg)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/zuiidea/antd-admin/pulls)
[![Gitter](https://img.shields.io/gitter/room/antd-admin/antd-admin.svg)](https://gitter.im/antd-admin/antd-admin)
| Category | Technology |
| ------------ | ------------------------------------------------------------------------------------------------------- |
| Build Tool | [Vite+](https://viteplus.dev) (VoidZero unified toolchain) |
| UI Framework | [Ant Design 6.x](https://ant.design) |
| Routing | [TanStack Router](https://tanstack.com/router) (file-based, type-safe) |
| Async State | [TanStack Query v5](https://tanstack.com/query) |
| Local State | [Zustand](https://zustand.docs.pmnd.rs) (persisted auth & settings) |
| Validation | [Zod v4](https://zod.dev) (schemas, API contracts, form validation) |
| i18n | [LinguiJS](https://lingui.dev) in `**apps/with-lingui` only** (`apps/basic` is English-only, no Lingui) |
| Icons | [lucide-react](https://lucide.dev/guide/packages/lucide-react) |
| API Mocking | [MSW 2.x](https://mswjs.io) (Service Worker based) |
| E2E Testing | [Playwright](https://playwright.dev) |
| Language | TypeScript 5.9 (strict mode) |
</div>
- Preview - [https://antd-admin.zuiidea.com](https://antd-admin.zuiidea.com)
- Documentation - [https://doc.antd-admin.zuiidea.com](https://doc.antd-admin.zuiidea.com)
- FAQ - [https://doc.antd-admin.zuiidea.com/#/faq](https://doc.antd-admin.zuiidea.com/#/faq)
- ChangeLog - [https://doc.antd-admin.zuiidea.com/#/change-log](https://doc.antd-admin.zuiidea.com/#/change-log)
English | [简体中文](./README-zh_CN.md)
## Features
- Internationalization, extracting translation fields from source code, loading language packs on demand
- Dynamic permissions, different permissions for different menus
- Elegant and beautiful, Ant Design system
- Mock data, local data debugging
- **JWT Authentication** — Login with access/refresh token flow, persisted via Zustand
- **Dynamic Menu & RBAC** — Backend-driven sidebar menu with permission guards and 403 page
- **URL-First State** — Table search params (page, pageSize, keyword, sort) synced to URL
- **i18n (with-lingui app)** — LinguiJS with English (`en`) and Chinese (`zh`); Ant Design locales follow the active locale in `apps/with-lingui`
- **Dark Mode** — One-click toggle with Ant Design theme algorithm
- **Full Type Safety** — Zod schemas validate API boundaries at runtime where used
- **Zero-Config Mocking** — MSW intercepts API calls in development, no backend needed
## Templates
`npx init-antd-admin@latest` · `pnpm dlx init-antd-admin@latest`
## Usage
| Directory | Use when |
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `[apps/with-lingui](./apps/with-lingui/)` | You want **LinguiJS** (en + zh), `.po` extract/compile, and a language switcher — this is the fuller reference stack. |
| `[apps/basic](./apps/basic/)` | You want the **same product surface** with **English-only** UI (no Lingui, fixed `en_US` for Ant Design). |
1. Clone project code.
Pick one app directory, install, and run Vite+ from there:
```bash
git clone https://github.com/zuiidea/antd-admin.git my-project
cd my-project
cd apps/basic && vp install && vp dev
# or
cd apps/with-lingui && vp install && vp dev
```
2. Installation dependence.
### 文档站(Nextra)
中文使用文档位于 `**apps/docs**`(Next.js + Nextra)。在仓库根或该目录安装依赖后:
```bash
yarn install
cd apps/docs && pnpm install && pnpm dev
```
Or
浏览器打开 [http://localhost:3000](http://localhost:3000)。模板 Vite+ 应用仍为 [http://localhost:5173](http://localhost:5173)。开发与构建命令说明见 `[apps/docs/README.md](./apps/docs/README.md)`。
Core Playwright flows (from the app you are working in):
```bash
npm install
cd apps/basic && pnpm run test:e2e:core
```
3. Start local server.
## Getting Started
Follow these steps to set up and run the project:
### Prerequisites
- **Node.js 20+**: Ensure you have Node.js installed.
- **pnpm**: Install pnpm globally using `npm install -g pnpm`.
### Installation
1. Clone the repository:
```bash
git clone https://github.com/zuiidea/antd-admin.git
cd antd-admin
```
2. Install dependencies:
```bash
pnpm install
```
### Running the Project
- **Basic App**:
```bash
pnpm --filter apps/basic dev
```
- **Lingui i18n App**:
```bash
pnpm --filter apps/with-lingui dev
```
### Testing
Run Playwright end-to-end tests:
```bash
pnpm test:e2e
```
## Getting Started
### Prerequisites
- [Node.js](https://nodejs.org) >= 20
- [Vite+](https://viteplus.dev/guide/) CLI (`vp`)
### Install & Run
From the repository root, choose an app under `apps/` (see [Templates](#templates)), then:
```bash
npm run start
cd apps/with-lingui # or: cd apps/basic
vp install
vp dev
```
4. After the startup is complete, open a browser and visit [http://localhost:7000](http://localhost:7000), If you need to change the startup port, you can configure it in the `.env` file.
Open [http://localhost:5173](http://localhost:5173) — you'll be redirected to the login page.
**Default credentials:** `admin` / `admin`
### Build
```bash
vp build
vp preview
```
### E2E Tests
Run from the app package (example: `apps/basic`):
```bash
cd apps/basic
pnpm run test:e2e
pnpm run test:e2e:core
pnpm run test:e2e:ui # interactive UI mode
```
`test:e2e:core` runs the scaffold's highest-value flows only: login and users CRUD.
## Project Structure
```
src/
├── api/ # Zod schemas, endpoint constants, type exports
│ ├── schemas.ts # Domain models (User, AuthTokens, MenuItem, etc.)
│ ├── auth.ts # Auth endpoint constants
│ └── user.ts # User CRUD endpoint constants
├── components/
│ ├── Aurora/ # Login background effect
│ ├── Auth/ # Auth (permission gate): index.tsx
│ ├── DataTable/ # Shared table shell, skeleton, empty state
│ ├── FilterToolbar/ # Shared filter/action toolbar
│ ├── FormModal/ # Reusable modal + form shell
│ ├── Icon/ # Shared icons and theme toggle icon
│ └── Layout/ # Admin shell (sidebar, header, main)
│ ├── MainLayout/ # Main layout shell: index.tsx
│ ├── AppFooter/ # Login footer: Powered by + GitHub → zuiidea/antd-admin
│ ├── Sidebar/ # Dynamic menu sidebar: index.tsx
│ ├── UserMenu/ # User dropdown in sidebar: index.tsx + index.css
│ └── Header/ # Top bar: index.tsx
├── hooks/
│ ├── tokenBuilders.ts # Shared Ant Design token/config builders
│ ├── useAppTheme.ts # Theme selection hook (ConfigProvider)
│ ├── usePermission.ts # Permission check hook
│ └── useResourceCRUD.ts # Shared CRUD query/mutation wiring
├── utils/
│ ├── constants.ts # API base URL, static assets
│ └── http.ts # HTTP client with JWT injection & error handling
├── locales/ # LinguiJS catalogs (.po + compiled .js)
│ ├── en/messages.po
│ └── zh/messages.po
├── mocks/
│ ├── browser.ts # MSW worker setup
│ ├── createHandler.ts # Shared MSW success/error/delay helpers
│ ├── data.ts # Mock seed data (users, menus)
│ ├── utils.ts # Mock-only helpers (filters, pagination, demo avatar URLs)
│ └── handlers/ # Request handlers (auth, user CRUD)
├── routes/ # TanStack Router file-based routes
│ ├── __root.tsx # Root layout (QueryClient, ConfigProvider, I18n)
│ ├── _auth.tsx # Auth guard layout (redirects to /login)
│ ├── _auth/dashboard/index.tsx
│ ├── _auth/users/index.tsx # Full CRUD with URL-synced search params
│ ├── _auth/403/index.tsx
│ ├── login/index.tsx
│ ├── 404/index.tsx
│ └── index.tsx # Redirects / → /login
├── stores/
│ ├── auth.ts # Auth store (tokens, user, menus, permissions)
│ ├── createPersistentStore.ts # Shared persisted-store factory
│ └── settings.ts # Settings store (darkMode, locale, sidebar)
└── main.tsx # Entry point (MSW init → React render)
tests/
└── e2e/ # Playwright E2E tests
├── helpers.ts
├── login.spec.ts
└── users.spec.ts
```
## Internationalization (i18n)
The project uses [LinguiJS](https://lingui.dev) with compile-time macros. Locales: `**en**` (source) and `**zh**`.
### Workflow
```bash
vp run i18n:extract
vp run i18n:compile
```
Ant Design strings follow the active locale via `ConfigProvider` in `__root.tsx`.
## Extending the template
- **Real backend:** Point `VITE_API_BASE_URL` in env and disable or remove MSW in `main.tsx` when you no longer need mocks.
- **Drop i18n:** Remove Lingui packages, vite/swc plugins, and replace `t` macros with plain strings (larger refactor).
## Developer Notes
- Prefer Vite+ commands for installs, checks, and scripts. This repo is configured around `vp`.
- Run `vp check --no-fmt` for type/lint validation.
- Core regression coverage lives in the login and users E2E flows.
## Pages
> More instructions at [documentation](https://doc.antd-admin.zuiidea.com)。
| Route | Description |
| ------------ | ----------------------------------------------------------- |
| `/login` | Login form with validation |
| `/dashboard` | Statistics overview cards |
| `/users` | User CRUD table with search, pagination, create/edit/delete |
| `/403` | Forbidden error page |
| `/404` | Not found error page |
## Browsers support
## License
Modern browsers and IE9.
| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/edge/edge_48x48.png" alt="IE / Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>IE / Edge | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Firefox | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Chrome | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png" alt="Safari" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Safari | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/opera/opera_48x48.png" alt="Opera" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Opera |
| --------- | --------- | --------- | --------- | --------- |
| IE9, IE10, IE11, Edge| last 2 versions| last 2 versions| last 2 versions| last 2 versions
## Contributing
We very much welcome your contribution, you can build together with us in the following ways :smiley:
- Use Ant Design Pro in your daily work.
- Submit [GitHub issues](http://github.com/zuiidea/antd-admin/issues)s to report bugs or ask questions.
- Propose [Pull Request](http://github.com/zuiidea/antd-admin/pulls) to improve our code.
MIT
+17
View File
@@ -0,0 +1,17 @@
# AI Instruction Files
This directory contains scoped instruction files used by AI coding agents.
## Files
- `frontend.instructions.md`: UI/routes/components/style changes
- `testing.instructions.md`: Playwright/MSW/test changes
- `api.instructions.md`: API client/schema/handler changes
- `refactor.instructions.md`: behavior-preserving cleanup and deduplication
## Authoring Rules
- Keep each file focused on one domain.
- Put trigger phrases in `description` so agents can discover the file.
- Use narrow `applyTo` patterns to avoid unnecessary context load.
- Keep instructions executable: short Do/Do Not/Validation sections.
+29
View File
@@ -0,0 +1,29 @@
---
applyTo: "src/api/**/*.ts,src/utils/http.ts,src/mocks/handlers/**/*.ts,src/mocks/createHandler.ts"
description: "Use when editing API clients, schemas, HTTP behavior, response types, or MSW endpoint handlers. Keywords: api, http, schema, zod, handler, endpoint, auth, users."
---
# API Instructions
## Purpose
Maintain strict API contracts and consistent server/client mock behavior.
## Do
- Keep request/response shapes schema-driven and type-safe.
- Reuse endpoint constants and shared response helpers.
- Keep handler response envelope consistent (`code`, `data`, `message`).
- Ensure mock handlers reflect real API behavior and error semantics.
- Keep naming stable for endpoint keys and mutation/query keys.
## Do Not
- Do not return ad-hoc response shapes for one-off convenience.
- Do not bypass schema parsing in new client mutations/queries.
- Do not mix unrelated domain endpoints in the same module.
## Validation
- Verify TypeScript and schema parsing paths compile cleanly.
- Run: `vp check --no-fmt`
@@ -0,0 +1,31 @@
---
applyTo: "src/**/*.{ts,tsx,css}"
description: "Use when editing React UI, route pages, components, styles, layout, i18n text, or Ant Design behavior. Keywords: frontend, component, page, ui, layout, style, antd, i18n."
---
# Frontend Instructions
## Purpose
Keep frontend changes predictable, minimal, and reusable for AI-assisted edits.
## Do
- Preserve existing route contracts and UI behavior unless explicitly asked.
- Prefer reusable components and hooks over copy-paste logic.
- Keep styles token-driven using existing theme/store patterns.
- For Ant Design styling, follow this priority: component native API (`variant`/`type`/`size`/`styles`/`classNames`) -> `theme.useToken()` with inline `style` -> `className` + external CSS.
- Keep translations compatible with Lingui macros and catalogs.
- Follow current folder conventions under src/components and src/routes.
## Do Not
- Do not introduce new UI libraries when Ant Design already covers the need.
- Do not bypass existing theme/store abstractions with hardcoded global state.
- Do not write CSS overrides first when props or semantic `styles` can express the same UI.
- Do not refactor unrelated files in the same change.
## Validation
- Run: `vp check --no-fmt`
- For UI flow changes, run focused e2e where possible.
@@ -0,0 +1,37 @@
---
## applyTo: "src/\*_/_.{ts,tsx}"
description: "Use when refactoring existing code for deduplication, modularization, naming cleanup, or maintainability improvements without behavior changes. Keywords: refactor, cleanup, simplify, deduplicate, maintainability."
# Refactor Instructions
## Purpose
Reduce duplication while preserving behavior and public contracts.
## Do
- Keep refactors incremental and scoped to one concern per change.
- Prefer extracting shared utilities/hooks/components over large rewrites.
- Preserve existing external behavior and route/store contracts.
- Keep diffs reviewable with minimal unrelated formatting churn.
- Add or update lightweight comments only when logic is non-obvious.
## Do Not
- Do not change runtime behavior unless explicitly requested.
- Do not rename broadly across the codebase without clear benefit.
- Do not delete tests that still cover unique critical scenarios.
- Do not introduce large dependencies for minor convenience; prefer composition or small utilities.
## Considerations
- Bundle: Main app chunk is ~135KB (gzip ~41KB). Ant Design (vendor-antd) and TanStack (vendor-tanstack) are in separate chunks for parallel loading. Avoid adding large UI libraries when Ant Design covers the need.
- Type safety: Keep validations at API boundaries (Zod schemas) rather than adding more runtime checks throughout the app.
## Validation
- Confirm no new diagnostics in touched files.
- Run: `vp check --no-fmt`
- For significant changes, verify bundle size impact: `vp build 2>&1 | grep "kB"`
+30
View File
@@ -0,0 +1,30 @@
---
## applyTo: "tests/**/\*.ts,tests/**/_.tsx,src/mocks/\*\*/_.ts,playwright.config.ts"
description: "Use when adding or editing tests, Playwright cases, MSW handlers, mock data, or test configuration. Keywords: test, e2e, playwright, mock, msw, coverage."
# Testing Instructions
## Purpose
Keep tests fast, deterministic, and aligned with core business flows.
## Do
- Prioritize core flows first (auth, users CRUD, permission gates).
- Keep assertions user-visible and behavior-focused.
- Reuse existing helper patterns in tests before adding new helpers.
- Keep mock handlers and mock data synchronized with API schemas.
- Prefer focused test runs over full-suite runs during iteration.
## Do Not
- Do not add brittle selectors tied to unstable DOM internals.
- Do not expand fixture data without a clear scenario need.
- Do not duplicate scenarios already covered by existing tests.
## Validation
- Run focused tests first, then broader checks if needed.
- Run: `vp check --no-fmt`
+36
View File
@@ -0,0 +1,36 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# LinguiJS compiled catalogs
src/locales/*/messages.js
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.agent
.pnpm-store
.cursor
.tanstack
# Playwright
playwright-report
test-results
+25
View File
@@ -0,0 +1,25 @@
# oxfmt configuration for antd-admin
# Reference: https://oxc.rs/docs/guide/formatter
# Line width for formatting
line_width = 100
# Indentation
indent_width = 2
use_tabs = false
# Semicolons
semi = true
# Quotes
single_quote = false
# Trailing commas
trailing_comma = "es5"
# Arrows
arrow_parens = "always"
# Spaces
bracket_spacing = true
space_before_function_parentheses = false
+84
View File
@@ -0,0 +1,84 @@
# Using Vite+, the Unified Toolchain for the Web
This project is using Vite+, a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. Vite+ is distinct from Vite, but it invokes Vite through `vp dev` and `vp build`.
## Vite+ Workflow
`vp` is a global binary that handles the full development lifecycle. Run `vp help` to print a list of commands and `vp <command> --help` for information about a specific command.
### Start
- create - Create a new project from a template
- migrate - Migrate an existing project to Vite+
- config - Configure hooks and agent integration
- staged - Run linters on staged files
- install (`i`) - Install dependencies
- env - Manage Node.js versions
### Develop
- dev - Run the development server
- check - Run format, lint, and TypeScript type checks
- lint - Lint code
- fmt - Format code
- test - Run tests
### Execute
- run - Run monorepo tasks
- exec - Execute a command from local `node_modules/.bin`
- dlx - Execute a package binary without installing it as a dependency
- cache - Manage the task cache
### Build
- build - Build for production
- pack - Build libraries
- preview - Preview production build
### Manage Dependencies
Vite+ automatically detects and wraps the underlying package manager such as pnpm, npm, or Yarn through the `packageManager` field in `package.json` or package manager-specific lockfiles.
- add - Add packages to dependencies
- remove (`rm`, `un`, `uninstall`) - Remove packages from dependencies
- update (`up`) - Update packages to latest versions
- dedupe - Deduplicate dependencies
- outdated - Check for outdated packages
- list (`ls`) - List installed packages
- why (`explain`) - Show why a package is installed
- info (`view`, `show`) - View package information from the registry
- link (`ln`) / unlink - Manage local package links
- pm - Forward a command to the package manager
### Maintain
- upgrade - Update `vp` itself to the latest version
These commands map to their corresponding tools. For example, `vp dev --port 3000` runs Vite's dev server and works the same as Vite. `vp test` runs JavaScript tests through the bundled Vitest. The version of all tools can be checked using `vp --version`. This is useful when researching documentation, features, and bugs.
## Common Pitfalls
- **Using the package manager directly:** Do not use pnpm, npm, or Yarn directly. Vite+ can handle all package manager operations.
- **Always use Vite commands to run tools:** Don't attempt to run `vp vitest` or `vp oxlint`. They do not exist. Use `vp test` and `vp lint` instead.
- **Running scripts:** Vite+ commands take precedence over `package.json` scripts. If there is a `test` script defined in `scripts` that conflicts with the built-in `vp test` command, run it using `vp run test`.
- **Do not install Vitest, Oxlint, Oxfmt, or tsdown directly:** Vite+ wraps these tools. They must not be installed directly. You cannot upgrade these tools by installing their latest versions. Always use Vite+ commands.
- **Use Vite+ wrappers for one-off binaries:** Use `vp dlx` instead of package-manager-specific `dlx`/`npx` commands.
- **Import JavaScript modules from `vite-plus`:** Instead of importing from `vite` or `vitest`, all modules should be imported from the project's `vite-plus` dependency. For example, `import { defineConfig } from 'vite-plus';` or `import { expect, test, vi } from 'vite-plus/test';`. You must not install `vitest` to import test utilities.
- **Type-Aware Linting:** There is no need to install `oxlint-tsgolint`, `vp lint --type-aware` works out of the box.
## Review Checklist for Agents
- Run `vp install` after pulling remote changes and before getting started.
- Run `vp check` and `vp test` to validate changes.
## AI Instruction Files
This repository also uses scoped AI instruction files in `.github/instructions/`.
- `frontend.instructions.md` for UI/routes/components/style work
- `testing.instructions.md` for Playwright/MSW/test updates
- `api.instructions.md` for API/schema/handler changes
- `refactor.instructions.md` for behavior-preserving cleanup
See `.github/instructions/README.md` for usage and authoring rules.
+174
View File
@@ -0,0 +1,174 @@
<p align="center">
<a href="http://github.com/zuiidea/antd-admin" target="_blank">
<img alt="antd-admin-logo" height="80" src="./public/favicon.svg">
</a>
</p>
<h1 align="center">Antd Admin</h1>
<div align="center">
English-only admin scaffold (no Lingui): MSW mocks, minimal RBAC, full CRUD, and Playwright E2E — built with React 19, Ant Design 6, and Vite+. For Lingui-based i18n (en + zh), use the sibling app [`apps/with-lingui`](../with-lingui/).
[![antd](https://img.shields.io/badge/AntD-^6.0.0-1890ff?style=flat-square&logo=ant-design)](https://github.com/ant-design/ant-design)
[![react](https://img.shields.io/badge/React-19.2-61dafb?style=flat-square&logo=react)](https://react.dev)
[![vite](https://img.shields.io/badge/Vite+-Latest-646cff?style=flat-square&logo=vite)](https://viteplus.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178c6?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![GitHub issues](https://img.shields.io/github/issues/zuiidea/antd-admin?style=flat-square)](https://github.com/zuiidea/antd-admin/issues)
[![GitHub stars](https://img.shields.io/github/stars/zuiidea/antd-admin?style=flat-square)](https://github.com/zuiidea/antd-admin/stargazers)
[![License](https://img.shields.io/badge/License-MIT-blue?style=flat-square)](http://opensource.org/licenses/MIT)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square)](https://github.com/zuiidea/antd-admin/pulls)
</div>
## Tech Stack
| Category | Technology |
| ------------ | ---------------------------------------------------------------------- |
| Build Tool | [Vite+](https://viteplus.dev) (VoidZero unified toolchain) |
| UI Framework | [Ant Design 6.x](https://ant.design) |
| Routing | [TanStack Router](https://tanstack.com/router) (file-based, type-safe) |
| Async State | [TanStack Query v5](https://tanstack.com/query) |
| Local State | [Zustand](https://zustand.docs.pmnd.rs) (persisted auth & settings) |
| Validation | [Zod v4](https://zod.dev) (schemas, API contracts, form validation) |
| UI language | English strings in source (no i18n framework) |
| Icons | [lucide-react](https://lucide.dev/guide/packages/lucide-react) |
| API Mocking | [MSW 2.x](https://mswjs.io) (Service Worker based) |
| E2E Testing | [Playwright](https://playwright.dev) |
| Language | TypeScript 5.9 (strict mode) |
## Features
- **JWT Authentication** — Login with access/refresh token flow, persisted via Zustand
- **Dynamic Menu & RBAC** — Backend-driven sidebar menu with permission guards and 403 page
- **URL-First State** — Table search params (page, pageSize, keyword, sort) synced to URL
- **English-only** — No Lingui or locale catalogs; Ant Design uses a fixed `en_US` locale. See [`apps/with-lingui`](../with-lingui/) for bilingual LinguiJS.
- **Dark Mode** — One-click toggle with Ant Design theme algorithm
- **Full Type Safety** — Zod schemas validate API boundaries at runtime where used
- **Zero-Config Mocking** — MSW intercepts API calls in development, no backend needed
## Getting Started
### Prerequisites
- [Node.js](https://nodejs.org) >= 20
- [Vite+](https://viteplus.dev/guide/) CLI (`vp`)
### Install & Run
From the monorepo root, or after `cd apps/basic`:
```bash
cd apps/basic
vp install
vp dev
```
Open [http://localhost:5173](http://localhost:5173) — you'll be redirected to the login page.
**Default credentials:** `admin` / `admin`
### Build
```bash
vp build
vp preview
```
### E2E Tests
```bash
cd apps/basic
pnpm run test:e2e
pnpm run test:e2e:core
pnpm run test:e2e:ui # interactive UI mode
```
`test:e2e:core` runs the scaffold's highest-value flows only: login and users CRUD.
## Project Structure
```
src/
├── api/ # Zod schemas, endpoint constants, type exports
│ ├── schemas.ts # Domain models (User, AuthTokens, MenuItem, etc.)
│ ├── auth.ts # Auth endpoint constants
│ └── user.ts # User CRUD endpoint constants
├── components/
│ ├── Aurora/ # Login background effect
│ ├── Auth/ # Auth (permission gate): index.tsx
│ ├── DataTable/ # Shared table shell, skeleton, empty state
│ ├── FilterToolbar/ # Shared filter/action toolbar
│ ├── FormModal/ # Reusable modal + form shell
│ ├── Icon/ # Shared icons and theme toggle icon
│ └── Layout/ # Admin shell (sidebar, header, main)
│ ├── MainLayout/ # Main layout shell: index.tsx
│ ├── AppFooter/ # Login footer: Powered by + GitHub → zuiidea/antd-admin
│ ├── Sidebar/ # Dynamic menu sidebar: index.tsx
│ ├── UserMenu/ # User dropdown in sidebar: index.tsx + index.css
│ └── Header/ # Top bar: index.tsx
├── hooks/
│ ├── tokenBuilders.ts # Shared Ant Design token/config builders
│ ├── useAppTheme.ts # Theme selection hook (ConfigProvider)
│ ├── usePermission.ts # Permission check hook
│ └── useResourceCRUD.ts # Shared CRUD query/mutation wiring
├── utils/
│ ├── constants.ts # API base URL, static assets
│ └── http.ts # HTTP client with JWT injection & error handling
├── mocks/
│ ├── browser.ts # MSW worker setup
│ ├── createHandler.ts # Shared MSW success/error/delay helpers
│ ├── data.ts # Mock seed data (users, menus)
│ ├── utils.ts # Mock-only helpers (filters, pagination, demo avatar URLs)
│ └── handlers/ # Request handlers (auth, user CRUD)
├── routes/ # TanStack Router file-based routes
│ ├── __root.tsx # Root layout (QueryClient, ConfigProvider, en_US)
│ ├── _auth.tsx # Auth guard layout (redirects to /login)
│ ├── _auth/dashboard/index.tsx
│ ├── _auth/users/index.tsx # Full CRUD with URL-synced search params
│ ├── _auth/403/index.tsx
│ ├── login/index.tsx
│ ├── 404/index.tsx
│ └── index.tsx # Redirects / → /login
├── stores/
│ ├── auth.ts # Auth store (tokens, user, menus, permissions)
│ ├── createPersistentStore.ts # Shared persisted-store factory
│ └── settings.ts # Settings store (darkMode, sidebar)
└── main.tsx # Entry point (MSW init → React render)
e2e/ # Playwright E2E tests
├── helpers.ts
├── login.spec.ts
└── users.spec.ts
```
## Language & i18n
This template is **English-only**: user-facing copy lives in components and routes as plain strings; there is no Lingui catalog or language switcher.
For **LinguiJS** (English + Chinese, `.po` extract/compile, and Ant Design locale switching), use [`apps/with-lingui`](../with-lingui/).
## Extending the template
- **Real backend:** Point `VITE_API_BASE_URL` in env and disable or remove MSW in `main.tsx` when you no longer need mocks.
- **Add i18n:** Start from [`apps/with-lingui`](../with-lingui/) or port Lingui setup from that app; this `basic` tree intentionally omits it.
## Developer Notes
- Prefer Vite+ commands for installs, checks, and scripts. This repo is configured around `vp`.
- Run `vp check --no-fmt` for type/lint validation.
- Core regression coverage lives in the login and users E2E flows.
## Pages
| Route | Description |
| ------------ | ----------------------------------------------------------- |
| `/login` | Login form with validation |
| `/dashboard` | Statistics overview cards |
| `/users` | User CRUD table with search, pagination, create/edit/delete |
| `/403` | Forbidden error page |
| `/404` | Not found error page |
## License
MIT
+42
View File
@@ -0,0 +1,42 @@
import { expect } from "@playwright/test";
import type { Page } from "@playwright/test";
export async function loginAsAdmin(page: Page) {
await page.goto("/login");
await page.getByLabel(/Username|用户名/).fill("admin");
await page.getByLabel(/Password|密码/).fill("admin");
await page.getByRole("button", { name: /Sign In|登录/ }).click();
await expect(page).toHaveURL(/dashboard/);
}
/** Navigate to the users page */
export async function gotoUsers(page: Page): Promise<void> {
await page.goto("/users");
await page.waitForSelector(".ant-table-thead");
}
/** Verify table headers are visible */
export async function verifyTableHeaders(page: Page): Promise<void> {
const thead = page.locator(".ant-table-thead");
await Promise.all([
thead.getByText(/Username|用户名/).isVisible(),
thead.getByText(/Email|邮箱/).isVisible(),
thead.getByText(/Roles|角色/).isVisible(),
]);
}
/** Search for a user by keyword */
export async function searchUser(page: Page, keyword: string): Promise<void> {
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
await searchInput.fill(keyword);
await searchInput.press("Enter");
await page.waitForURL((url) => url.searchParams.get("keyword") === keyword);
}
/** Verify role-based filtering */
export async function verifyRoleFilter(page: Page, role: string): Promise<void> {
await page.goto(`/users?role=${role}`);
await page.waitForSelector(".ant-table-thead");
const roleCell = page.getByRole("cell", { name: new RegExp(role, "i") });
await roleCell.first().isVisible();
}
+27
View File
@@ -0,0 +1,27 @@
import { test, expect } from "@playwright/test";
import { loginAsAdmin } from "./helpers";
test.describe("Login Flow", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/login");
});
test("should display login form", async ({ page }) => {
await expect(page.getByLabel(/Username|用户名/)).toBeVisible();
await expect(page.getByLabel(/Password|密码/)).toBeVisible();
await expect(page.getByRole("button", { name: /Sign In|登录/ })).toBeVisible();
});
test("should login successfully with admin/admin", async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByText("Total Revenue")).toBeVisible();
});
test("should show error for wrong credentials", async ({ page }) => {
await page.getByLabel(/Username|用户名/).fill("wrong");
await page.getByLabel(/Password|密码/).fill("wrong");
await page.getByRole("button", { name: /Sign In|登录/ }).click();
await expect(page.getByText("Invalid username or password")).toBeVisible();
});
});
+33
View File
@@ -0,0 +1,33 @@
import { test, expect } from "@playwright/test";
import {
gotoUsers,
verifyTableHeaders,
searchUser,
verifyRoleFilter,
} from "./helpers";
test.describe("User Management", () => {
test.beforeEach(async ({ page }) => {
await gotoUsers(page);
});
test("should display user table", async ({ page }) => {
await verifyTableHeaders(page);
});
test("should search users", async ({ page }) => {
await searchUser(page, "zhao.ming");
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
});
test("should open create user modal", async ({ page }) => {
await page.getByRole("button", { name: /Create User|创建用户/ }).click();
await expect(page.getByRole("dialog").getByText(/New User|新建用户/)).toBeVisible();
});
});
test.describe("User Management — role in URL", () => {
test("should filter users by role", async ({ page }) => {
await verifyRoleFilter(page, "admin");
});
});
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<title>Antd Admin</title>
<style>
body {
margin: 0;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
{
"name": "antd-admin-basic",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vp dev",
"build": "tsc && vp build",
"preview": "vp preview",
"prepare": "vp config",
"fmt": "vp fmt",
"lint": "vp lint",
"check": "vp check --no-fmt",
"test:e2e": "playwright test",
"test:e2e:core": "playwright test e2e/login.spec.ts e2e/users.spec.ts",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@tanstack/react-query": "^5.100.3",
"@tanstack/react-router": "^1.168.24",
"antd": "^6.3.6",
"lucide-react": "^1.11.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"zod": "^4.3.6",
"zustand": "^5.0.12"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tanstack/router-plugin": "^1.167.26",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.3.0",
"msw": "^2.13.6",
"typescript": "~6.0.3",
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vite-plus": "latest"
},
"msw": {
"workerDirectory": [
"public"
]
}
}
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig, devices } from "@playwright/test";
/** Node global when `tsconfig` does not include `@types/node` (pre-commit may lint before lockfile updates). */
declare const process: { env: { CI?: string } };
const ci = Boolean(process.env.CI);
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: ci,
retries: ci ? 2 : 0,
workers: ci ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:5173",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "vp dev",
url: "http://localhost:5173",
reuseExistingServer: !ci,
},
});
+4876
View File
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

+336
View File
@@ -0,0 +1,336 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = "2.12.12";
const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82";
const IS_MOCKED_RESPONSE = Symbol("isMockedResponse");
const activeClientIds = new Set();
addEventListener("install", function () {
self.skipWaiting();
});
addEventListener("activate", function (event) {
event.waitUntil(self.clients.claim());
});
addEventListener("message", async function (event) {
const clientId = Reflect.get(event.source || {}, "id");
if (!clientId || !self.clients) {
return;
}
const client = await self.clients.get(clientId);
if (!client) {
return;
}
const allClients = await self.clients.matchAll({
type: "window",
});
switch (event.data) {
case "KEEPALIVE_REQUEST": {
sendToClient(client, {
type: "KEEPALIVE_RESPONSE",
});
break;
}
case "INTEGRITY_CHECK_REQUEST": {
sendToClient(client, {
type: "INTEGRITY_CHECK_RESPONSE",
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
});
break;
}
case "MOCK_ACTIVATE": {
activeClientIds.add(clientId);
sendToClient(client, {
type: "MOCKING_ENABLED",
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
});
break;
}
case "CLIENT_CLOSED": {
activeClientIds.delete(clientId);
const remainingClients = allClients.filter((client) => {
return client.id !== clientId;
});
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister();
}
break;
}
}
});
addEventListener("fetch", function (event) {
const requestInterceptedAt = Date.now();
// Bypass navigation requests.
if (event.request.mode === "navigate") {
return;
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (event.request.cache === "only-if-cached" && event.request.mode !== "same-origin") {
return;
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return;
}
const requestId = crypto.randomUUID();
event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
});
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event);
const requestCloneForEvents = event.request.clone();
const response = await getResponse(event, client, requestId, requestInterceptedAt);
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents);
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone();
sendToClient(
client,
{
type: "RESPONSE",
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
);
}
return response;
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId);
if (activeClientIds.has(event.clientId)) {
return client;
}
if (client?.frameType === "top-level") {
return client;
}
const allClients = await self.clients.matchAll({
type: "window",
});
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === "visible";
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id);
});
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone();
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers);
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get("accept");
if (acceptHeader) {
const values = acceptHeader.split(",").map((value) => value.trim());
const filteredValues = values.filter((value) => value !== "msw/passthrough");
if (filteredValues.length > 0) {
headers.set("accept", filteredValues.join(", "));
} else {
headers.delete("accept");
}
}
return fetch(requestClone, { headers });
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough();
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough();
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request);
const clientMessage = await sendToClient(
client,
{
type: "REQUEST",
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
);
switch (clientMessage.type) {
case "MOCK_RESPONSE": {
return respondWithMock(clientMessage.data);
}
case "PASSTHROUGH": {
return passthrough();
}
}
return passthrough();
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error);
}
resolve(event.data);
};
client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]);
});
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error();
}
const mockedResponse = new Response(response.body, response);
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
});
return mockedResponse;
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
};
}
+13
View File
@@ -0,0 +1,13 @@
import type { CreateAdminRequest, UpdateAdminRequest, User } from "./schemas";
export const ADMIN_ENDPOINTS = {
list: "/api/admins",
create: "/api/admins",
detail: (id: string) => `/api/admins/${id}`,
update: (id: string) => `/api/admins/${id}`,
delete: (id: string) => `/api/admins/${id}`,
} as const;
export type Admin = User;
export type { CreateAdminRequest, UpdateAdminRequest };
+19
View File
@@ -0,0 +1,19 @@
import type {
AuthTokens,
LoginRequest,
RegisterRequest,
User,
MenuItem,
PermissionsList,
} from "./schemas";
export const AUTH_ENDPOINTS = {
login: "/api/auth/login",
register: "/api/auth/register",
refresh: "/api/auth/refresh",
logout: "/api/auth/logout",
user: "/api/auth/user",
permissions: "/api/auth/permissions",
} as const;
export type { AuthTokens, LoginRequest, RegisterRequest, User, MenuItem, PermissionsList };
+3
View File
@@ -0,0 +1,3 @@
export const LOG_ENDPOINTS = {
list: "/api/logs",
} as const;
+8
View File
@@ -0,0 +1,8 @@
import type { MenuItem } from "./schemas";
export const MENU_ENDPOINTS = {
list: "/api/menus",
update: "/api/menus",
} as const;
export type { MenuItem };
+6
View File
@@ -0,0 +1,6 @@
export const NOTIFICATION_ENDPOINTS = {
list: "/api/notifications",
markRead: (id: string) => `/api/notifications/${id}/read`,
markAllRead: "/api/notifications/read-all",
delete: (id: string) => `/api/notifications/${id}`,
} as const;
+6
View File
@@ -0,0 +1,6 @@
export const PROFILE_ENDPOINTS = {
get: "/api/auth/user",
update: "/api/user/profile",
changePassword: "/api/user/password",
uploadAvatar: "/api/user/avatar",
} as const;
+56
View File
@@ -0,0 +1,56 @@
import { z } from "zod/v4";
// ── Notification ───────────────────────────────────────
export const NotificationSchema = z.object({
id: z.string(),
title: z.string(),
content: z.string(),
type: z.enum(["system", "mention", "task", "alert"]),
read: z.boolean(),
createdAt: z.string(),
createdBy: z.string().nullable().optional(),
});
export type Notification = z.infer<typeof NotificationSchema>;
// ── Audit Log ──────────────────────────────────────────
export const AuditLogSchema = z.object({
id: z.string(),
user: z.string(),
action: z.string(),
resource: z.string(),
resourceId: z.string().nullable(),
detail: z.string().nullable(),
ip: z.string().nullable(),
status: z.enum(["success", "failure"]),
createdAt: z.string(),
});
export type AuditLog = z.infer<typeof AuditLogSchema>;
// ── Profile ────────────────────────────────────────────
export const UpdateProfileSchema = z.object({
realName: z.string().optional(),
nickName: z.string().optional(),
email: z.string().email().optional(),
mobile: z.string().optional(),
department: z.string().optional(),
remark: z.string().optional(),
avatar: z.string().optional(),
});
export type UpdateProfile = z.infer<typeof UpdateProfileSchema>;
export const ChangePasswordSchema = z.object({
oldPassword: z.string().min(1),
newPassword: z.string().min(6),
confirmPassword: z.string().min(6),
}).refine(
(data) => data.newPassword === data.confirmPassword,
{ message: "Passwords do not match", path: ["confirmPassword"] },
);
export type ChangePassword = z.infer<typeof ChangePasswordSchema>;
+183
View File
@@ -0,0 +1,183 @@
import { z } from "zod/v4";
export const UserSchema = z.object({
id: z.string(),
username: z.string(),
avatar: z.string().nullable(),
email: z.string().nullable(),
roles: z.array(z.string()),
permissions: z.array(z.string()),
realName: z.string().nullable().optional(),
nickName: z.string().nullable().optional(),
mobile: z.string().nullable().optional(),
department: z.string().nullable().optional(),
role: z.string().nullable().optional(),
status: z.number().int().optional(),
remark: z.string().nullable().optional(),
lastLogin: z.string().nullable().optional(),
createdBy: z.string().nullable().optional(),
});
export type User = z.infer<typeof UserSchema>;
/** GET `/api/auth/user` body (no `permissions`; load via `/api/auth/permissions`). */
export const AuthUserResponseSchema = UserSchema.omit({ permissions: true });
export type AuthUserResponse = z.infer<typeof AuthUserResponseSchema>;
export const AuthTokensSchema = z.object({
accessToken: z.string().min(1),
refreshToken: z.string().min(1),
});
export type AuthTokens = z.infer<typeof AuthTokensSchema>;
export const LoginRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
export const RegisterRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(6),
email: z.string().email().optional(),
});
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
const optionalNullableString = z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => {
if (v == null) return null;
const t = String(v).trim();
return t === "" ? null : t;
});
export const CreateAdminRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(6),
email: z.string().email().nullable().optional(),
realName: optionalNullableString.optional(),
nickName: optionalNullableString.optional(),
mobile: optionalNullableString.optional(),
department: optionalNullableString.optional(),
role: optionalNullableString.optional(),
remark: optionalNullableString.optional(),
status: z.number().int().catch(1),
permissions: z.array(z.string()).default([]),
roles: z.array(z.string()).default(["editor"]),
});
export type CreateAdminRequest = z.infer<typeof CreateAdminRequestSchema>;
export const UpdateAdminRequestSchema = CreateAdminRequestSchema.partial();
export type UpdateAdminRequest = z.infer<typeof UpdateAdminRequestSchema>;
export const PermissionsListSchema = z.array(z.string());
export type PermissionsList = z.infer<typeof PermissionsListSchema>;
const BaseMenuNodeSchema = z.object({
id: z.string(),
name: z.string(),
icon: z.string().nullable(),
permissions: z.array(z.string()).nullable(),
sort: z.int(),
hidden: z.boolean(),
});
export type MenuItem =
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "item";
path: string;
children: MenuItem[] | null;
})
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "group";
path: null;
children: MenuItem[];
});
export const MenuItemSchema: z.ZodType<MenuItem> = z.lazy(() =>
z.discriminatedUnion("kind", [
BaseMenuNodeSchema.extend({
kind: z.literal("item"),
path: z.string(),
children: z.array(MenuItemSchema).nullable(),
}),
BaseMenuNodeSchema.extend({
kind: z.literal("group"),
path: z.null(),
children: z.array(MenuItemSchema),
}),
]),
);
export function ApiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
return z.object({
code: z.int(),
data: dataSchema,
message: z.string(),
});
}
export type ApiResponse<T> = {
code: number;
data: T;
message: string;
};
export function PaginatedResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
code: z.int(),
data: z.object({
list: z.array(itemSchema),
total: z.int(),
}),
message: z.string(),
});
}
export type PaginatedData<T> = {
list: T[];
total: number;
};
export const SearchParamsSchema = z.object({
page: z.number().int().positive().catch(1),
pageSize: z.number().int().positive().catch(10),
sortField: z.string().nullable().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
});
export type SearchParams = z.infer<typeof SearchParamsSchema>;
/** Ant Design Input submits `""` when empty; treat as null for optional email */
const createUserEmailSchema = z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => {
if (v == null) return null;
const t = String(v).trim();
return t === "" ? null : t;
})
.pipe(z.union([z.string().email(), z.null()]));
export const CreateUserRequestSchema = z.object({
username: z.string().min(1),
email: createUserEmailSchema,
mobile: z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => {
if (v == null) return null;
const t = String(v).trim();
return t === "" ? null : t;
}),
});
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
export const UpdateUserRequestSchema = CreateUserRequestSchema.partial();
export type UpdateUserRequest = z.infer<typeof UpdateUserRequestSchema>;
+10
View File
@@ -0,0 +1,10 @@
import type { CreateUserRequest, UpdateUserRequest, User } from "./schemas";
export const USER_ENDPOINTS = {
list: "/api/users",
create: "/api/users",
update: (id: string) => `/api/users/${id}`,
delete: (id: string) => `/api/users/${id}`,
} as const;
export type { CreateUserRequest, UpdateUserRequest, User };
@@ -0,0 +1,156 @@
import { useState } from "react";
import { Button, Drawer, Empty, Flex, Input, Tag, Typography, theme } from "antd";
import { Bot, Send, Sparkles } from "lucide-react";
import { useAIStore } from "@/stores/ai";
const { Text } = Typography;
type ChatRole = "user" | "assistant";
type ChatMessage = {
id: string;
role: ChatRole;
content: string;
};
function buildAssistantReply(prompt: string): string {
const lower = prompt.toLowerCase();
if (lower.includes("menu")) {
return "You can edit menu names, icons, visibility and permissions in Menus page, then save to apply globally.";
}
if (lower.includes("permission") || lower.includes("auth")) {
return "Try checking user permissions first, then route mapping in app menu rules, and finally API response payload.";
}
if (lower.includes("dashboard")) {
return "Dashboard now supports timeline blocks. You can add monthly milestones or deployment events to make it useful.";
}
return "I can help with admin, users, menus, and dashboard changes. Tell me what you want to update.";
}
export function GlobalAIChat() {
const { token } = theme.useToken();
const enabled = useAIStore((s) => s.enabled);
const model = useAIStore((s) => s.model);
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>([]);
if (!enabled) return null;
const send = () => {
const text = input.trim();
if (!text) return;
const userMessage: ChatMessage = {
id: `u-${Date.now()}`,
role: "user",
content: text,
};
const assistantMessage: ChatMessage = {
id: `a-${Date.now()}`,
role: "assistant",
content: buildAssistantReply(text),
};
setMessages((prev) => [...prev, userMessage, assistantMessage]);
setInput("");
};
return (
<>
{!open ? (
<Button
type="primary"
shape="round"
size="large"
icon={<Sparkles size={token.fontSize} />}
onClick={() => setOpen(true)}
style={{
position: "fixed",
right: token.marginLG,
bottom: token.marginLG,
zIndex: 1001,
boxShadow: token.boxShadowSecondary,
}}
>
Ask AI
</Button>
) : null}
<Drawer
open={open}
title={
<Flex align="center" gap={token.marginXS}>
<Bot size={token.fontSizeLG} />
<span>Global AI Assistant</span>
</Flex>
}
size="large"
onClose={() => setOpen(false)}
destroyOnClose={false}
>
<Flex vertical gap={token.margin} style={{ height: "100%" }}>
<Tag color="blue" variant="filled" style={{ width: "fit-content" }}>
Context: current admin workspace
</Tag>
<Tag color="purple" variant="filled" style={{ width: "fit-content" }}>
Model: {model}
</Tag>
<Flex
vertical
gap={token.marginSM}
style={{
flex: 1,
overflow: "auto",
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: token.borderRadius,
padding: token.paddingSM,
}}
>
{messages.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="Start a conversation about users, admins, menus, or dashboard."
/>
) : (
messages.map((msg) => (
<Flex
key={msg.id}
justify={msg.role === "user" ? "flex-end" : "flex-start"}
>
<div
style={{
maxWidth: "85%",
padding: `${token.paddingXS}px ${token.paddingSM}px`,
borderRadius: token.borderRadius,
background:
msg.role === "user" ? token.colorPrimaryBgHover : token.colorFillQuaternary,
}}
>
<Text>{msg.content}</Text>
</div>
</Flex>
))
)}
</Flex>
<Input.TextArea
value={input}
rows={3}
placeholder="Ask anything about this admin app..."
onChange={(e) => setInput(e.target.value)}
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
send();
}
}}
/>
<Button type="primary" icon={<Send size={token.fontSize} />} onClick={send}>
Send
</Button>
</Flex>
</Drawer>
</>
);
}
@@ -0,0 +1,88 @@
.aurora__root {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
--aurora: repeating-linear-gradient(
100deg,
#3b82f6 10%,
#a5b4fc 15%,
#93c5fd 20%,
#ddd6fe 25%,
#60a5fa 30%
);
--dark-gradient: repeating-linear-gradient(
100deg,
#000 0%,
#000 7%,
transparent 10%,
transparent 12%,
#000 16%
);
--white-gradient: repeating-linear-gradient(
100deg,
#fff 0%,
#fff 7%,
transparent 10%,
transparent 12%,
#fff 16%
);
}
.aurora__glow {
position: absolute;
inset: -10px;
background-image: var(--white-gradient), var(--aurora);
background-size: 300% 200%;
background-position:
50% 50%,
50% 50%;
opacity: 0.5;
filter: blur(10px) invert(1);
-webkit-mask-image: radial-gradient(ellipse at 100% 0%, #000 10%, transparent 70%);
mask-image: radial-gradient(ellipse at 100% 0%, #000 10%, transparent 70%);
}
html[data-theme="dark"] .aurora__glow {
background-image: var(--dark-gradient), var(--aurora);
filter: blur(10px);
}
.aurora__glow::after {
content: "";
position: absolute;
inset: 0;
background-image: var(--white-gradient), var(--aurora);
background-size: 200% 100%;
background-position:
50% 50%,
50% 50%;
background-attachment: fixed;
mix-blend-mode: difference;
}
html[data-theme="dark"] .aurora__glow::after {
background-image: var(--dark-gradient), var(--aurora);
}
@keyframes aurora-keyframes {
0% {
background-position: 50%, 50%;
}
to {
background-position: 350%, 350%;
}
}
@media (prefers-reduced-motion: no-preference) {
.aurora__glow::after {
animation: aurora-keyframes 60s linear infinite;
}
}
@media (prefers-reduced-motion: reduce) {
.aurora__glow::after {
animation: none;
}
}
@@ -0,0 +1,14 @@
import "./index.css";
export type AuroraProps = {
className?: string;
};
export function Aurora({ className }: AuroraProps) {
const rootClass = className ? `aurora__root ${className}` : "aurora__root";
return (
<div className={rootClass} aria-hidden>
<div className="aurora__glow" />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { ReactNode } from "react";
import { usePermission } from "@/hooks/usePermission";
interface AuthProps {
permission: string;
children: ReactNode;
fallback?: ReactNode;
}
export function Auth({ permission, children, fallback = null }: AuthProps) {
const allowed = usePermission(permission);
return allowed ? <>{children}</> : <>{fallback}</>;
}
@@ -0,0 +1,62 @@
import { Flex, theme } from "antd";
import { BarChart3 } from "lucide-react";
import type { ReactElement } from "react";
/**
* Table empty state: icon in soft tile + bold title + secondary description (dashboard-style).
*/
export function DataTableEmpty(): ReactElement {
const { token } = theme.useToken();
return (
<Flex
vertical
align="center"
justify="center"
gap={token.marginMD}
className="data-table-empty"
style={{
paddingBlock: token.paddingXL,
paddingInline: token.paddingLG,
maxWidth: 360,
marginInline: "auto",
}}
>
<Flex
align="center"
justify="center"
style={{
width: 44,
height: 44,
borderRadius: token.borderRadiusLG,
background: token.colorFillTertiary,
color: token.colorTextQuaternary,
}}
>
<BarChart3 size={22} strokeWidth={1.75} aria-hidden />
</Flex>
<Flex vertical align="center" gap={token.marginXXS} style={{ textAlign: "center" }}>
<span
style={{
fontSize: token.fontSizeLG,
fontWeight: token.fontWeightStrong,
color: token.colorText,
lineHeight: token.lineHeightLG,
}}
>
No data
</span>
<span
style={{
fontSize: token.fontSize,
fontWeight: 400,
color: token.colorTextSecondary,
lineHeight: token.lineHeight,
}}
>
Nothing to show in this list yet
</span>
</Flex>
</Flex>
);
}
@@ -0,0 +1,298 @@
import { Skeleton, Table, theme } from "antd";
import type { TableProps } from "antd";
import type { ColumnGroupType, ColumnsType, ColumnType } from "antd/es/table";
import type { GlobalToken } from "antd/es/theme/interface";
import type { CSSProperties } from "react";
import { useLayoutEffect, useMemo, useRef, useState } from "react";
function rowHeight(size: TableProps["size"]): number {
if (size === "large") return 56;
if (size === "middle") return 48;
return 40;
}
function numericScrollY(scroll: TableProps<object>["scroll"]): number | undefined {
const y = scroll?.y;
return typeof y === "number" && Number.isFinite(y) && y > 0 ? y : undefined;
}
function clampRows(bodyHeight: number, rowH: number): number {
return Math.max(3, Math.floor(bodyHeight / rowH));
}
function initialRowCount(scroll: TableProps<object>["scroll"], size: TableProps["size"]): number {
const y = numericScrollY(scroll);
return y != null ? clampRows(y, rowHeight(size)) : 10;
}
export type DataTableSkeletonProps<RecordType extends object> = Pick<
TableProps<RecordType>,
"columns" | "rowSelection" | "pagination" | "size" | "scroll" | "rootClassName" | "style"
> & {
/**
* Fixed skeleton row count. When omitted, rows are derived from `scroll.y` (if numeric)
* or from the table frame height so the body roughly fills the visible area.
*/
rowCount?: number;
};
function isActionColumn<RecordType extends object>(col: ColumnType<RecordType>): boolean {
return (
col.key === "actions" ||
col.dataIndex === "actions" ||
(typeof col.title === "string" && col.title.toLowerCase() === "actions")
);
}
function isIdColumn<RecordType extends object>(col: ColumnType<RecordType>): boolean {
return col.key === "id" || col.dataIndex === "id";
}
function isColumnGroup<RecordType extends object>(
col: ColumnType<RecordType> | ColumnGroupType<RecordType>,
): col is ColumnGroupType<RecordType> {
return "children" in col && Array.isArray(col.children) && col.children.length > 0;
}
/** ~ text icon button, capped small */
function actionSkeletonDiameter<RecordType extends object>(col: ColumnType<RecordType>): number {
if (typeof col.width === "number") {
return Math.min(24, Math.max(20, col.width - 36));
}
return 22;
}
const DATA_CELL_MIN = 48;
function dataCellMinWidth<RecordType extends object>(col: ColumnType<RecordType>): number {
const cap = typeof col.minWidth === "number" ? col.minWidth : col.width;
if (typeof cap !== "number") return DATA_CELL_MIN;
return Math.max(DATA_CELL_MIN, Math.min(cap, 320));
}
function cellJustify(align: ColumnType<object>["align"]): CSSProperties["justifyContent"] {
if (align === "right") return "flex-end";
if (align === "center") return "center";
return "flex-start";
}
function skeletonInputBarStyle(
token: GlobalToken,
opts: { floor: number; maxWidth: number | string; flex: string },
): CSSProperties {
return {
width: "100%",
minWidth: opts.floor,
maxWidth: opts.maxWidth,
height: token.controlHeightSM,
borderRadius: token.borderRadiusSM,
flex: opts.flex,
};
}
function mapSkeletonColumn<RecordType extends object>(
col: ColumnType<RecordType> | ColumnGroupType<RecordType>,
token: GlobalToken,
): ColumnType<RecordType> | ColumnGroupType<RecordType> {
if (isColumnGroup(col)) {
return {
...col,
children: (col.children as ColumnsType<RecordType>).map((child) =>
mapSkeletonColumn(child, token),
),
};
}
const c = col as ColumnType<RecordType>;
return {
...c,
sorter: false,
sortOrder: undefined,
render: (_: unknown, __: RecordType) => {
if (isActionColumn(c)) {
const w = actionSkeletonDiameter(c);
return (
<span
style={{
display: "flex",
width: "100%",
justifyContent: cellJustify(c.align),
lineHeight: 1,
}}
>
<Skeleton.Button
active
size="small"
shape="circle"
style={{ width: w, height: w, minWidth: w, flexShrink: 0 }}
/>
</span>
);
}
const justify = cellJustify(c.align);
const id = isIdColumn(c);
if (id) {
const floor = 28;
const maxBar = typeof c.width === "number" ? Math.max(28, Math.min(c.width - 10, 52)) : 40;
return (
<Skeleton.Input
active
size="small"
style={skeletonInputBarStyle(token, {
floor,
maxWidth: maxBar,
flex: "0 1 auto",
})}
/>
);
}
const floor = dataCellMinWidth(c);
const bar = skeletonInputBarStyle(token, {
floor,
maxWidth: "100%",
flex: "1 1 auto",
});
return (
<div
style={{
width: "100%",
minWidth: floor,
maxWidth: "100%",
display: "flex",
alignItems: "center",
justifyContent: justify,
boxSizing: "border-box",
...(c.ellipsis ? { overflow: "hidden" as const } : {}),
}}
>
<Skeleton.Input active size="small" style={bar} />
</div>
);
},
};
}
export function DataTableSkeleton<RecordType extends object = object>({
columns,
rowSelection,
pagination,
size = "small",
scroll,
rootClassName,
style,
rowCount,
}: DataTableSkeletonProps<RecordType>) {
const { token } = theme.useToken();
const frameRef = useRef<HTMLDivElement>(null);
const [measuredRows, setMeasuredRows] = useState(() => initialRowCount(scroll, size));
useLayoutEffect(() => {
if (rowCount != null) return;
const y = numericScrollY(scroll);
if (y != null) {
setMeasuredRows(clampRows(y, rowHeight(size)));
return;
}
const el = frameRef.current;
if (!el) return;
const rowH = rowHeight(size);
const measure = () => {
const thead = el.querySelector<HTMLElement>(".ant-table-thead");
const pag = pagination === false ? null : el.querySelector<HTMLElement>(".ant-pagination");
const theadH = thead?.offsetHeight ?? 39;
const pagH = pag ? pag.offsetHeight + 8 : 0;
const bodyH = Math.max(0, el.clientHeight - theadH - pagH - 2);
setMeasuredRows((prev) => {
const next = clampRows(bodyH, rowH);
return prev === next ? prev : next;
});
};
const ro = new ResizeObserver(() => requestAnimationFrame(measure));
ro.observe(el);
let raf = requestAnimationFrame(() => {
raf = requestAnimationFrame(measure);
});
return () => {
ro.disconnect();
cancelAnimationFrame(raf);
};
}, [rowCount, pagination, scroll?.y, size]);
const effectiveRowCount = rowCount ?? measuredRows;
const skeletonColumns = useMemo(() => {
if (!columns?.length) return [];
return (columns as ColumnsType<RecordType>).map((col) => mapSkeletonColumn(col, token));
}, [columns, token]);
const skeletonData = useMemo(
() =>
Array.from({ length: effectiveRowCount }, (_, i) => ({
__sk: String(i),
})) as unknown as RecordType[],
[effectiveRowCount],
);
const skeletonPagination = useMemo((): TableProps<RecordType>["pagination"] => {
if (pagination === false) return false;
if (typeof pagination === "object" && pagination !== null) {
return {
...pagination,
disabled: true,
onChange: undefined,
onShowSizeChange: undefined,
};
}
return {
current: 1,
pageSize: 10,
total: 100,
disabled: true,
showSizeChanger: true,
showTotal: () => (
<Skeleton.Input active size="small" style={{ width: 88, height: token.fontSize }} />
),
};
}, [pagination, token.fontSize]);
const selection =
rowSelection != null
? { ...rowSelection, getCheckboxProps: () => ({ disabled: true }) }
: undefined;
return (
<div
ref={frameRef}
style={{
display: "flex",
flexDirection: "column",
flex: 1,
minHeight: 0,
overflow: "hidden",
...style,
}}
>
<Table<RecordType>
rootClassName={rootClassName}
style={{ flex: 1, minHeight: 0, width: "100%" }}
size={size}
columns={skeletonColumns}
dataSource={skeletonData}
rowKey={(record) => String((record as unknown as { __sk: string }).__sk)}
pagination={skeletonPagination}
rowSelection={selection}
scroll={scroll}
showSorterTooltip={false}
/>
</div>
);
}
@@ -0,0 +1,35 @@
.data-table__table.ant-table-wrapper {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.data-table__table .ant-spin-nested-loading,
.data-table__table .ant-spin-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.data-table__table .ant-table {
flex: 1;
min-height: 0;
}
.data-table__table .ant-table-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Empty state: let placeholder row breathe in flex-height tables */
.data-table__table .ant-table-tbody > tr.ant-table-placeholder > td {
border-bottom: none;
}
.data-table__table .ant-table-tbody > tr.ant-table-placeholder .ant-table-cell {
padding-block: 0;
}
@@ -0,0 +1,125 @@
import { Flex, Table, theme } from "antd";
import type { TableProps } from "antd";
import type { CSSProperties, ReactElement, ReactNode, Ref } from "react";
import { useMemo } from "react";
import { DataTableEmpty } from "./DataTableEmpty";
import { DataTableSkeleton } from "./DataTableSkeleton";
import "./index.css";
/** Merged onto `Table` `rootClassName` with the flex layout class. */
export const DATA_TABLE_ROOT_CLASS = "data-table__table";
export type DataTableProps<RecordType extends object = object> = {
/**
* When the table uses `scroll.y`, set true so the frame does not shrink in the flex column
* and optional `frameHeight` can pin the outer height.
*/
lockScrollHeight?: boolean;
/** Outer `max-height` (px) */
maxHeight?: number;
/** When `lockScrollHeight`, optional fixed outer `height` (usually same as max height) */
frameHeight?: number;
/** Flex column wrapping frame + optional `bottomExtra` */
layoutRef?: Ref<HTMLDivElement | null>;
/** Bordered box around the table (e.g. for `scrollHeight` vs `clientHeight` checks) */
frameRef?: Ref<HTMLDivElement | null>;
/** Optional content below the bordered table frame */
bottomExtra?: ReactNode;
} & TableProps<RecordType>;
export function DataTable<RecordType extends object = object>(
props: DataTableProps<RecordType>,
): ReactElement {
const {
lockScrollHeight,
maxHeight,
frameHeight,
layoutRef,
frameRef,
bottomExtra,
rootClassName,
style: tableStyle,
...tableProps
} = props;
const { token } = theme.useToken();
const { loading, ...restTableProps } = tableProps;
const spinLoading =
loading === true ||
(typeof loading === "object" &&
loading !== null &&
(loading as { spinning?: boolean }).spinning !== false);
const outerStyle: CSSProperties = {
flexGrow: 0,
flexShrink: lockScrollHeight ? 0 : 1,
flexBasis: "auto",
minHeight: 0,
maxHeight,
...(lockScrollHeight && frameHeight != null ? { height: frameHeight } : {}),
alignSelf: "stretch",
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: token.borderRadiusLG,
overflow: "hidden",
display: "flex",
flexDirection: "column",
};
const mergedRootClassName = [DATA_TABLE_ROOT_CLASS, rootClassName].filter(Boolean).join(" ");
const mergedLocale = useMemo(
() => ({
...restTableProps.locale,
emptyText: restTableProps.locale?.emptyText ?? <DataTableEmpty />,
}),
[restTableProps.locale],
);
return (
<Flex
ref={layoutRef}
vertical
gap={token.marginSM}
style={{
flex: "1 1 0%",
minHeight: 0,
overflow: "hidden",
}}
>
<div ref={frameRef} style={outerStyle}>
<div
className="data-table__inner"
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{spinLoading ? (
<DataTableSkeleton<RecordType>
columns={restTableProps.columns}
rowSelection={restTableProps.rowSelection}
pagination={restTableProps.pagination}
size={restTableProps.size}
scroll={restTableProps.scroll}
rootClassName={mergedRootClassName}
style={{ flex: 1, minHeight: 0, ...tableStyle }}
/>
) : (
<Table<RecordType>
{...restTableProps}
locale={mergedLocale}
loading={loading}
rootClassName={mergedRootClassName}
style={{ flex: 1, minHeight: 0, ...tableStyle }}
/>
)}
</div>
</div>
{bottomExtra}
</Flex>
);
}
@@ -0,0 +1,119 @@
import { Component } from "react";
import type { ErrorInfo, ReactNode } from "react";
import { Button, Result, Typography, theme, Flex, Collapse } from "antd";
import { reportError, ErrorSeverity } from "@/utils/errorReporter";
export interface ErrorBoundaryProps {
children: ReactNode;
fallback?: (error: Error, reset: () => void) => ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
const componentStack = errorInfo.componentStack ?? undefined;
reportError(error, ErrorSeverity.ERROR, { componentStack });
this.props.onError?.(error, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError && this.state.error) {
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.handleReset);
}
return (
<DefaultErrorFallback
error={this.state.error}
onReset={this.handleReset}
/>
);
}
return this.props.children;
}
}
function DefaultErrorFallback({
error,
onReset,
}: {
error: Error;
onReset: () => void;
}) {
const { token } = theme.useToken();
return (
<Flex
justify="center"
align="center"
style={{
minHeight: "60vh",
padding: token.paddingLG,
}}
>
<Result
status="error"
title="Something went wrong"
subTitle={
<Flex vertical gap={token.marginSM} style={{ maxWidth: 560 }}>
<Typography.Text type="secondary">{error.message}</Typography.Text>
<Collapse
size="small"
items={[
{
key: "stack",
label: "Error details",
children: (
<Typography.Paragraph
code
style={{
fontSize: token.fontSizeSM,
maxHeight: 200,
overflow: "auto",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
margin: 0,
}}
>
{error.stack ?? error.message}
</Typography.Paragraph>
),
},
]}
/>
</Flex>
}
extra={[
<Button type="primary" key="retry" onClick={onReset}>
Try again
</Button>,
<Button
key="reload"
onClick={() => {
window.location.reload();
}}
>
Reload page
</Button>,
]}
/>
</Flex>
);
}
@@ -0,0 +1,105 @@
import { Button, App, Tooltip } from "antd";
import { Download } from "lucide-react";
import { useCallback, useState } from "react";
export interface ExportButtonProps<TData extends Record<string, unknown>> {
/** Data to export */
data: TData[];
/** Column definitions: header label and data key or custom accessor */
columns: {
title: string;
dataIndex?: keyof TData & string;
render?: (record: TData) => string;
}[];
/** Filename without extension */
filename?: string;
/** Button text */
children?: React.ReactNode;
/** Disable button */
disabled?: boolean;
/** Click handler before export (e.g. confirm) */
onBeforeExport?: () => boolean | Promise<boolean>;
}
/**
* Reusable CSV export button.
* Works with any tabular data + column definitions.
*/
export function ExportButton<TData extends Record<string, unknown>>({
data,
columns,
filename = "export",
children = "Export CSV",
disabled,
onBeforeExport,
}: ExportButtonProps<TData>) {
const { message } = App.useApp();
const [exporting, setExporting] = useState(false);
const handleExport = useCallback(async () => {
if (onBeforeExport) {
const ok = await onBeforeExport();
if (!ok) return;
}
setExporting(true);
try {
// Build CSV content
const header = columns.map((c) => `"${c.title}"`).join(",");
const rows = data.map((record) =>
columns
.map((c) => {
let value: unknown;
if (c.render) {
value = c.render(record);
} else if (c.dataIndex) {
value = record[c.dataIndex];
} else {
value = "";
}
// Escape quotes and wrap
const str = String(value ?? "");
return `"${str.replace(/"/g, '""')}"`;
})
.join(","),
);
const csv = [header, ...rows].join("\n");
// Add BOM for Excel UTF-8 compatibility
const bom = "\uFEFF";
const blob = new Blob([bom + csv], { type: "text/csv;charset=utf-8;" });
// Trigger download
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${filename}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
message.success(`Exported ${data.length} rows`);
} catch {
message.error("Export failed");
} finally {
setExporting(false);
}
}, [data, columns, filename, onBeforeExport, message]);
return (
<Tooltip title={`Export ${data.length} rows as CSV`}>
<Button
icon={<Download size={14} />}
onClick={handleExport}
loading={exporting}
disabled={disabled || !data.length}
>
{children}
</Button>
</Tooltip>
);
}
@@ -0,0 +1,129 @@
import { Upload, App, theme } from "antd";
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
import { Upload as UploadIcon, Inbox } from "lucide-react";
import { useState } from "react";
export interface FileUploadProps {
/** Upload endpoint URL */
action?: string;
/** Accepted MIME types, e.g. "image/*,.pdf" */
accept?: string;
/** Maximum file size in bytes */
maxSize?: number;
/** Allow multiple files */
multiple?: boolean;
/** Max file count */
maxCount?: number;
/** Custom on-success callback */
onSuccess?: (file: UploadFile, response: unknown) => void;
/** Custom on-error callback */
onError?: (file: UploadFile, error: unknown) => void;
/** Button text */
children?: React.ReactNode;
/** Custom request override (e.g. MSW) */
customRequest?: UploadProps["customRequest"];
/** Control visibility of file list */
showUploadList?: boolean;
}
/**
* Drag-and-drop file upload component built on Ant Design Upload.
* Supports size/type validation and visual feedback.
*/
export function FileUpload({
action = "/api/files/upload",
accept,
maxSize = 10 * 1024 * 1024, // 10 MB
multiple = false,
maxCount = 5,
onSuccess,
onError,
customRequest,
children,
showUploadList = true,
}: FileUploadProps) {
const { message } = App.useApp();
const { token } = theme.useToken();
const [uploading, setUploading] = useState(false);
const beforeUpload = (file: File): boolean | Promise<File> => {
const allowed = accept?.split(",").map((s) => s.trim());
if (allowed?.length && !allowed.some((a) => matchMime(file, a))) {
message.error(`File type not supported: ${file.type || file.name}`);
return false;
}
if (file.size > maxSize) {
const maxMB = (maxSize / (1024 * 1024)).toFixed(1);
message.error(`File exceeds ${maxMB} MB limit: ${file.name}`);
return false;
}
return true;
};
const handleChange: UploadProps["onChange"] = (info) => {
const { file } = info;
if (file.status === "uploading") {
setUploading(true);
}
if (file.status === "done") {
setUploading(false);
onSuccess?.(file, file.response);
}
if (file.status === "error") {
setUploading(false);
onError?.(file, file.error);
}
};
return (
<Upload.Dragger
name="file"
action={action}
accept={accept}
multiple={multiple}
maxCount={maxCount}
beforeUpload={beforeUpload}
onChange={handleChange}
customRequest={customRequest}
showUploadList={showUploadList}
style={{ padding: token.padding }}
>
<p className="ant-upload-drag-icon" style={{ marginBottom: token.marginSM }}>
{uploading ? (
<UploadIcon size={32} style={{ color: token.colorPrimary, opacity: 0.7 }} />
) : (
<Inbox size={32} style={{ color: token.colorTextQuaternary }} />
)}
</p>
<p className="ant-upload-text" style={{ fontWeight: 500 }}>
{children || "Click or drag file to this area to upload"}
</p>
<p className="ant-upload-hint" style={{ color: token.colorTextSecondary }}>
{accept
? `Supported: ${accept}. Max ${(maxSize / (1024 * 1024)).toFixed(1)} MB per file`
: `Max ${(maxSize / (1024 * 1024)).toFixed(1)} MB per file`}
{multiple ? `. Up to ${maxCount} files` : ""}
</p>
</Upload.Dragger>
);
}
function matchMime(file: File, pattern: string): boolean {
if (!pattern) return true;
// Simple glob: "image/*"
if (pattern.endsWith("/*")) {
const base = pattern.slice(0, -2);
return file.type.startsWith(base + "/");
}
// Extension match: ".pdf"
if (pattern.startsWith(".")) {
return file.name.toLowerCase().endsWith(pattern.toLowerCase());
}
// Exact MIME
return file.type === pattern;
}
@@ -0,0 +1,168 @@
import { Button, Flex, Popover, theme, Tooltip } from "antd";
import { ListFilter } from "lucide-react";
import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
export type FilterToolbarSlot = {
key: string;
/** Used to decide how many filters fit in one row before the rest go into the popover */
minWidth: number;
children: React.ReactNode;
};
export type FilterToolbarProps = {
slots: FilterToolbarSlot[];
/** Right side: primary actions (not collapsed) */
actions: React.ReactNode;
/** Reserve width for the “more filters” control when some slots are in the popover */
collapseTriggerReserve?: number;
/** Accessible label + optional popover title */
moreFiltersLabel: React.ReactNode;
moreFiltersTitle?: React.ReactNode;
};
function maxVisibleSlots(
slotWidths: number[],
availableForLeft: number,
innerGap: number,
triggerReserve: number,
): number {
const n = slotWidths.length;
if (n === 0) return 0;
const sum = (count: number) => {
let s = 0;
for (let i = 0; i < count; i++) s += slotWidths[i] ?? 0;
return s;
};
const inlineCost = (m: number) => {
if (m <= 0) return 0;
return sum(m) + (m - 1) * innerGap;
};
const totalLeft = (m: number) => {
if (m <= 0) return n > 0 ? triggerReserve : 0;
const base = inlineCost(m);
if (m >= n) return base;
return base + innerGap + triggerReserve;
};
for (let m = n; m >= 0; m--) {
if (totalLeft(m) <= availableForLeft) return m;
}
return 0;
}
export const FilterToolbar = forwardRef<HTMLDivElement, FilterToolbarProps>(function FilterToolbar(
{ slots, actions, collapseTriggerReserve = 40, moreFiltersLabel, moreFiltersTitle },
ref,
) {
const { token } = theme.useToken();
const gap = token.marginSM;
const containerRef = useRef<HTMLDivElement>(null);
const actionsRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(slots.length);
const [popoverOpen, setPopoverOpen] = useState(false);
const widths = useMemo(() => slots.map((s) => s.minWidth), [slots]);
const assignRef = useCallback(
(node: HTMLDivElement | null) => {
containerRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
},
[ref],
);
const remeasure = useCallback(() => {
const root = containerRef.current;
const act = actionsRef.current;
if (!root) return;
const actionsW = act?.offsetWidth ?? 0;
const hasActions = Boolean(act);
const between = hasActions ? gap : 0;
const available = Math.max(0, root.clientWidth - actionsW - between);
const next = maxVisibleSlots(widths, available, gap, collapseTriggerReserve);
setVisibleCount((prev) => (prev === next ? prev : next));
}, [widths, gap, collapseTriggerReserve]);
useLayoutEffect(() => {
remeasure();
const root = containerRef.current;
const act = actionsRef.current;
if (!root) return;
const ro = new ResizeObserver(() => remeasure());
ro.observe(root);
if (act) ro.observe(act);
return () => ro.disconnect();
}, [remeasure, slots.length, widths]);
useLayoutEffect(() => {
setPopoverOpen(false);
}, [visibleCount]);
const overflowSlots = slots.slice(visibleCount);
const inlineSlots = slots.slice(0, visibleCount);
const popoverContent =
overflowSlots.length > 0 ? (
<Flex vertical gap={token.marginMD} style={{ width: "100%" }}>
{overflowSlots.map((slot) => (
<div key={slot.key} style={{ minWidth: slot.minWidth }}>
{slot.children}
</div>
))}
</Flex>
) : null;
return (
<Flex
ref={assignRef}
wrap={false}
gap={gap}
align="center"
justify="space-between"
style={{ flexShrink: 0, minWidth: 0 }}
>
<Flex
wrap={false}
gap={gap}
align="center"
style={{ flex: "1 1 auto", minWidth: 0, overflow: "hidden" }}
>
{inlineSlots.map((slot) => (
<div
key={slot.key}
style={{
flex: "0 0 auto",
minWidth: slot.minWidth,
maxWidth: "100%",
}}
>
{slot.children}
</div>
))}
{overflowSlots.length > 0 ? (
<Popover
title={moreFiltersTitle ?? moreFiltersLabel}
content={popoverContent}
trigger="click"
placement="bottomLeft"
open={popoverOpen}
onOpenChange={setPopoverOpen}
overlayInnerStyle={{ maxWidth: "min(100vw - 32px, 360px)" }}
>
<Tooltip title={moreFiltersLabel}>
<Button
type="default"
icon={<ListFilter size={token.fontSize} />}
aria-label={typeof moreFiltersLabel === "string" ? moreFiltersLabel : undefined}
style={{ flex: "0 0 auto" }}
/>
</Tooltip>
</Popover>
) : null}
</Flex>
<Flex ref={actionsRef} wrap={false} gap={gap} align="center" style={{ flexShrink: 0 }}>
{actions}
</Flex>
</Flex>
);
});
@@ -0,0 +1,51 @@
import { Form, Modal, theme } from "antd";
import type { FormInstance } from "antd/es/form";
import type { ReactNode } from "react";
export type BaseFormModalProps<TValues> = {
open: boolean;
title: ReactNode;
okText: ReactNode;
cancelText: ReactNode;
form: FormInstance<TValues>;
confirmLoading?: boolean;
onCancel: () => void;
onFinish: (values: TValues) => void;
children: ReactNode;
};
export function BaseFormModal<TValues>({
open,
title,
okText,
cancelText,
form,
confirmLoading,
onCancel,
onFinish,
children,
}: BaseFormModalProps<TValues>) {
const { token } = theme.useToken();
return (
<Modal
centered
title={title}
open={open}
okText={okText}
cancelText={cancelText}
onCancel={onCancel}
onOk={() => form.submit()}
confirmLoading={confirmLoading}
styles={{
body: {
paddingTop: token.padding,
},
}}
>
<Form form={form} layout="vertical" scrollToFirstError onFinish={onFinish}>
{children}
</Form>
</Modal>
);
}
+11
View File
@@ -0,0 +1,11 @@
export function GitHub({ size }: { size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.833.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
/>
</svg>
);
}
+21
View File
@@ -0,0 +1,21 @@
export function Theme({ size }: { size: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M12 3l0 18" />
<path d="M12 9l4.65 -4.65" />
<path d="M12 14.3l7.37 -7.37" />
<path d="M12 19.6l8.85 -8.85" />
</svg>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { Theme } from "./Theme";
export { GitHub } from "./GitHub";
@@ -0,0 +1,39 @@
import { Flex, Typography, theme } from "antd";
import { GitHub } from "@/components/Icon";
const ANTD_ADMIN_REPO = "https://github.com/zuiidea/antd-admin";
export function AppFooter() {
const { token } = theme.useToken();
const iconSize = Math.max(12, Math.round(Number(token.fontSizeSM)));
return (
<Flex
align="center"
justify="center"
wrap
gap={4}
style={{
lineHeight: token.lineHeight,
}}
>
<Typography.Text type="secondary" style={{ fontSize: token.fontSizeSM, marginBottom: 0 }}>
Powered by
</Typography.Text>
<a
href={ANTD_ADMIN_REPO}
target="_blank"
rel="noopener noreferrer"
style={{
color: token.colorLink,
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
<GitHub size={iconSize} />
antd-admin
</a>
</Flex>
);
}
@@ -0,0 +1,317 @@
/**
* Header — breadcrumb + global actions (notification bell, fullscreen, theme).
*
* ── Architecture ──
* Left: mobile sidebar toggle (lg-) + breadcrumb
* Right: NotificationBell (popover) → Fullscreen → Theme toggle
*
* ── Data flow ──
* breadcrumb ← authStore.menus (flat-tree match) + current route path
* notification badge ← useQuery poll (30s interval)
*/
import {
Badge,
Button,
Divider,
Flex,
Grid,
Layout,
Popover,
Space,
Typography,
theme,
} from "antd";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { Link, useLocation, useMatches } from "@tanstack/react-router";
import {
Bell,
Book,
BrainCircuit,
Briefcase,
CheckCheck,
CircleDashed,
Fullscreen,
History,
Home,
Minimize2,
PanelLeft,
ShieldAlert,
ShieldCheck,
SlidersHorizontal,
Star,
User,
Users,
Zap,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { Theme } from "@/components/Icon";
import type { MenuItem } from "@/api/schemas";
import type { Notification } from "@/api/schemas-ext";
import { httpClient } from "@/utils/http";
import { NOTIFICATION_ENDPOINTS } from "@/api/notification";
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
const { Header: AntHeader } = Layout;
// ── Route → label mapping (fallback when not found in menu tree) ──
const PATH_LABEL: Record<string, string> = {
"/dashboard": "Dashboard",
"/notifications": "Notifications",
"/logs": "Audit Logs",
"/profile": "Profile",
"/403": "403",
};
// ── Menu icon string → Lucide component ──
const MENU_ICON_MAP: Record<string, LucideIcon> = {
IconLucideHistory: History,
IconLucideLayoutDashboard: Home,
IconLucideUsers: User,
IconLucideUserList: Users,
IconLucideStar: Star,
IconLucideSettings: SlidersHorizontal,
IconLucideBriefcase: Briefcase,
IconLucideBookOpen: Book,
IconLucideSparkles: Zap,
IconLucideShieldCheck: ShieldCheck,
IconLucideBrainCircuit: BrainCircuit,
};
// ── Utilities ──
/** Normalize pathname: strip trailing slash */
function normalizePath(pathname: string): string {
if (pathname === "/") return pathname;
return pathname.replace(/\/+$/, "") || "/";
}
/** Flatten a recursive menu tree into a flat list */
function flattenMenuItems(nodes: MenuItem[]): MenuItem[] {
const result: MenuItem[] = [];
for (const node of nodes) {
result.push(node);
if (node.kind === "group") { result.push(...flattenMenuItems(node.children)); continue; }
if (node.children?.length) result.push(...flattenMenuItems(node.children));
}
return result;
}
/** Find the deepest menu item matching the current path */
function findBestMenuMatch(nodes: MenuItem[], pathname: string): MenuItem | null {
const p = normalizePath(pathname);
const candidates = flattenMenuItems(nodes)
.filter((n): n is Extract<MenuItem, { kind: "item" }> => {
if (n.kind !== "item") return false;
const target = normalizePath(n.path);
return p === target || p.startsWith(`${target}/`);
});
if (!candidates.length) return null;
candidates.sort((a, b) => b.path.length - a.path.length);
return candidates[0];
}
function resolveMenuIcon(icon: string | null): LucideIcon {
return (icon && MENU_ICON_MAP[icon]) || CircleDashed;
}
// ── Props ──
export type HeaderProps = {
/** Hide breadcrumb; routes may override via `staticData.hideBreadcrumb` */
showBreadcrumb?: boolean;
};
// ── Main component ──
export function Header({ showBreadcrumb: showProp = true }: HeaderProps) {
const toggleSidebar = useSettingsStore((s) => s.toggleSidebar);
const toggleDarkMode = useSettingsStore((s) => s.toggleDarkMode);
const menus = useAuthStore((s) => s.menus);
const location = useLocation();
const matches = useMatches();
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = !screens.lg;
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const handler = () => setIsFullscreen(Boolean(document.fullscreenElement));
handler();
document.addEventListener("fullscreenchange", handler);
return () => document.removeEventListener("fullscreenchange", handler);
}, []);
// ── Breadcrumb ──
const path = normalizePath(location.pathname);
const segments = path.split("/").filter(Boolean);
const match = findBestMenuMatch(menus, path);
const leafLabel = match?.name ?? PATH_LABEL[path] ?? segments[0] ?? "Dashboard";
const leafIcon: LucideIcon = path === "/403" ? ShieldAlert : match ? resolveMenuIcon(match.icon) : Home;
// Render a breadcrumb link label
const crumb = (IconCmp: LucideIcon, label: ReactNode, link?: "/dashboard") => {
const row = <><IconCmp size={token.fontSize} aria-hidden style={{ flexShrink: 0, opacity: 0.88 }} /><span>{label}</span></>;
const rowStyle = { display: "inline-flex" as const, alignItems: "center" as const, gap: token.marginXS, color: "inherit" as const };
return link ? <Link to={link} style={rowStyle}>{row}</Link> : <span style={rowStyle}>{row}</span>;
};
const onDashboard = path === "/dashboard" || path === "/";
const items = onDashboard
? [{ title: crumb(Home, "Dashboard") }]
: [{ title: crumb(Home, "Dashboard", "/dashboard") }, { title: crumb(leafIcon, leafLabel) },
...(segments.length > 1 ? [{ title: segments.slice(1).join(" / ") }] : [])];
const hideByRoute = (matches.at(-1)?.staticData as { hideBreadcrumb?: boolean } | undefined)?.hideBreadcrumb === true;
const showBc = showProp && !hideByRoute && items.length > 0;
return (
<AntHeader style={{ background: "transparent", borderBottom: `1px solid ${token.colorBorderSecondary}`, padding: `0 ${token.padding}px`, gap: token.sizeLG, display: "flex" }}>
{/* Left */}
<Flex align="center" flex={1} style={{ minWidth: 0, overflow: "hidden" }}>
{isMobile && <Button type="text" size="small" onClick={toggleSidebar} icon={<PanelLeft size={token.size} />} aria-label="Toggle sidebar" />}
{showBc && (
<>
{isMobile && <Divider vertical />}
{isMobile ? (
<Typography.Text ellipsis style={{ fontSize: token.fontSizeLG, fontWeight: 600 }}>
{leafLabel}
</Typography.Text>
) : (
// Desktop: full Breadcrumb component
<Flex align="center" style={{ minWidth: 0 }}>
{/* antd Breadcrumb expects items, but we render them inline for simplicity */}
{items.map((it, i) => (
<span key={i}>
{i > 0 && <span style={{ margin: `0 ${token.marginXXS}px`, color: token.colorTextQuaternary }}>/</span>}
{it.title}
</span>
))}
</Flex>
)}
</>
)}
</Flex>
{/* Right */}
<Space>
<NotificationTrigger />
<Button type="text" onClick={() => document.fullscreenElement ? void document.exitFullscreen() : void document.documentElement.requestFullscreen()}
icon={isFullscreen ? <Minimize2 size={token.size} /> : <Fullscreen size={token.size} />} aria-label="Toggle fullscreen" />
<Button type="text" onClick={toggleDarkMode} icon={<Theme size={token.size} />} aria-label="Toggle theme" />
</Space>
</AntHeader>
);
}
// ═══════════════════════════════════════════════════════
// Notification Bell (Popover)
// ═══════════════════════════════════════════════════════
/** Inline notification item for the popover dropdown */
function NotifRow({ n, onMark }: { n: Notification; onMark: () => void }) {
const { token } = theme.useToken();
return (
<div
onClick={onMark}
style={{
padding: `${token.paddingXS}px ${token.paddingSM}px`,
cursor: n.read ? "default" : "pointer",
background: n.read ? "transparent" : token.colorFillQuaternary,
borderRadius: token.borderRadius,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
>
<Flex justify="space-between" align="start" gap={token.marginXS}>
<Flex vertical gap={2} style={{ minWidth: 0 }}>
<Typography.Text strong={!n.read} ellipsis style={{ fontSize: token.fontSizeSM }}>
{n.title}
</Typography.Text>
<Typography.Text type="secondary" ellipsis style={{ fontSize: 11 }}>
{n.content}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 10 }}>
{new Date(n.createdAt).toLocaleString()}
</Typography.Text>
</Flex>
{!n.read && <Badge status="processing" style={{ flexShrink: 0 }} />}
</Flex>
</div>
);
}
function NotificationTrigger() {
const { token } = theme.useToken();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
// Poll unread-only + latest 5 for popover (lightweight)
const { data: unreadList = [] } = useQuery({
queryKey: ["notifications", "bell-unread"],
queryFn: () =>
httpClient.get<Notification[]>(NOTIFICATION_ENDPOINTS.list, {
params: { unreadOnly: "true" },
}),
refetchInterval: 30_000,
});
const unreadCount = unreadList.length;
// Load full list when popover opens (for any read ones)
const { data: fullList = [] } = useQuery({
queryKey: ["notifications", "bell-all"],
queryFn: () => httpClient.get<Notification[]>(NOTIFICATION_ENDPOINTS.list),
enabled: open,
});
const merged = open
? [...fullList].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 5)
: [];
const markAsRead = useMutation({
mutationFn: (id: string) => httpClient.put(NOTIFICATION_ENDPOINTS.markRead(id)),
onSuccess: () => { void qc.invalidateQueries({ queryKey: ["notifications"] }); },
});
const markAll = useMutation({
mutationFn: () => httpClient.put(NOTIFICATION_ENDPOINTS.markAllRead),
onSuccess: () => { void qc.invalidateQueries({ queryKey: ["notifications"] }); },
});
const content = (
<div style={{ width: 360, maxHeight: 440, overflow: "hidden", display: "flex", flexDirection: "column" }}>
<Flex justify="space-between" align="center" style={{ padding: `${token.paddingSM}px ${token.padding}px`, borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
<Typography.Text strong>Notifications</Typography.Text>
{unreadCount > 0 && (
<Button type="link" size="small" icon={<CheckCheck size={token.fontSize} />}
loading={markAll.isPending} onClick={() => markAll.mutate()}>
Mark all read
</Button>
)}
</Flex>
<div style={{ flex: 1, overflow: "auto" }}>
{merged.length === 0 ? (
<Flex justify="center" style={{ padding: token.paddingMD * 2 }}>
<Typography.Text type="secondary">No notifications</Typography.Text>
</Flex>
) : (
merged.map((n) => <NotifRow key={n.id} n={n} onMark={() => !n.read && markAsRead.mutate(n.id)} />)
)}
</div>
<Flex justify="center" style={{ padding: token.paddingSM, borderTop: `1px solid ${token.colorBorderSecondary}` }}>
<Link to="/notifications" onClick={() => setOpen(false)}>
<Typography.Link>View All Notifications</Typography.Link>
</Link>
</Flex>
</div>
);
return (
<Popover content={content} trigger="click" open={open} onOpenChange={setOpen} placement="bottomRight">
<Badge count={unreadCount} size="small" offset={[-2, 4]}>
<Button type="text" icon={<Bell size={token.size} />} aria-label="Notifications" />
</Badge>
</Popover>
);
}
@@ -0,0 +1,41 @@
import { Layout, theme, Flex } from "antd";
import { Outlet } from "@tanstack/react-router";
import { Sidebar } from "../Sidebar";
import { Header } from "../Header";
import { GlobalAIChat } from "@/components/AIChat/GlobalAIChat";
const { Content } = Layout;
export function MainLayout() {
const { token } = theme.useToken();
return (
<Layout style={{ minHeight: "100vh" }}>
<Sidebar />
<Flex
vertical
style={{
flex: 1,
minWidth: 0,
minHeight: "100vh",
}}
>
<Header />
<Content
className="main-layout-main"
style={{
flex: 1,
padding: token.paddingLG,
minHeight: 0,
display: "flex",
flexDirection: "column",
overflow: "auto",
}}
>
<Outlet />
</Content>
</Flex>
<GlobalAIChat />
</Layout>
);
}
@@ -0,0 +1,62 @@
/* Allow collapsed Menu popup submenus to overflow the Sider rail */
.ant-layout-sider-collapsed > .ant-layout-sider-children {
overflow: visible !important;
}
.sidebar-collapsed-brand {
position: relative;
width: min(40px, 100%);
max-width: 100%;
aspect-ratio: 1;
height: auto;
flex-shrink: 1;
min-width: 0;
border-radius: var(--ant-border-radius-lg, 12px);
transition: background-color 0.2s ease;
box-sizing: border-box;
}
.sidebar-collapsed-brand__logoLayer,
.sidebar-collapsed-brand__toggleLayer {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.sidebar-collapsed-brand__logoLayer {
opacity: 1;
transition: opacity 0.2s ease;
pointer-events: none;
}
.sidebar-collapsed-brand__toggleLayer {
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.sidebar-collapsed-brand:hover .sidebar-collapsed-brand__logoLayer {
opacity: 0;
}
.sidebar-collapsed-brand:hover .sidebar-collapsed-brand__toggleLayer {
opacity: 1;
pointer-events: auto;
}
.sidebar-collapsed-brand:focus-within .sidebar-collapsed-brand__logoLayer {
opacity: 0;
}
.sidebar-collapsed-brand:focus-within .sidebar-collapsed-brand__toggleLayer {
opacity: 1;
pointer-events: auto;
}
.sidebar-collapsed-brand__toggle.ant-btn {
width: 100%;
height: 100%;
margin: 0;
}
@@ -0,0 +1,412 @@
import { Menu, Layout, theme, Flex, Grid, Drawer, Button } from "antd";
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useLocation } from "@tanstack/react-router";
import {
Book,
Briefcase,
CircleDashed,
Folder,
Home,
PanelLeft,
ShieldCheck,
SlidersHorizontal,
Star,
User,
Users,
Zap,
BrainCircuit,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants";
import { useAuthStore } from "@/stores/auth";
import { useSettingsStore } from "@/stores/settings";
import type { MenuItem as MenuItemType } from "@/api/schemas";
import type { MenuProps } from "antd";
import { UserMenu } from "../UserMenu";
import "./index.css";
const { Sider } = Layout;
/** API menu `name` → English labels for known keys; unknown keys pass through as `menu.name`. */
const MENU_LABELS: Record<string, string> = {
Platform: "Platform",
Projects: "Projects",
Dashboard: "Dashboard",
Users: "Users",
Admins: "Admins",
Menus: "Menus",
Settings: "Settings",
Notifications: "Notifications",
Logs: "Audit Logs",
Profile: "Profile",
"AI Settings": "AI Settings",
"Design Engineering": "Design Engineering",
"Sales & Marketing": "Sales & Marketing",
};
type AntMenuItem = Required<MenuProps>["items"][number];
type BuildMenuResult = {
items: AntMenuItem[];
keyToPath: Record<string, string>;
pathToKeyChain: Record<string, string[]>;
};
const MENU_ICON_MAP: Record<string, LucideIcon> = {
IconLucideLayoutDashboard: Home,
IconLucideUsers: User,
IconLucideUserList: Users,
/** Back-compat for older menu payloads still using IconLucideHistory */
IconLucideHistory: Users,
IconLucideStar: Star,
IconLucideSettings: SlidersHorizontal,
IconLucideBriefcase: Briefcase,
IconLucideBookOpen: Book,
IconLucideFolderKanban: Folder,
IconLucideSparkles: Zap,
IconLucideShieldCheck: ShieldCheck,
IconLucideBrainCircuit: BrainCircuit,
};
function renderMenuIcon(icon: string | null, size = 16) {
const Icon = (icon && MENU_ICON_MAP[icon]) || CircleDashed;
return <Icon size={size} />;
}
function buildMenuItems(
menus: MenuItemType[],
token: ReturnType<typeof theme.useToken>["token"],
collapsed = false,
iconSize = 16,
parentKeys: string[] = [],
): BuildMenuResult {
const sorted = menus.filter((m) => !m.hidden).sort((a, b) => a.sort - b.sort);
const keyToPath: Record<string, string> = {};
const pathToKeyChain: Record<string, string[]> = {};
const items: AntMenuItem[] = [];
for (const menu of sorted) {
const label = MENU_LABELS[menu.name] ?? menu.name;
const key = menu.id;
if (menu.kind === "group") {
const built = buildMenuItems(menu.children, token, collapsed, iconSize, parentKeys);
Object.assign(keyToPath, built.keyToPath);
Object.assign(pathToKeyChain, built.pathToKeyChain);
if (built.items.length > 0) {
items.push({
type: "group",
key,
label: collapsed ? (
<span
style={{
width: "100%",
display: "inline-flex",
justifyContent: "center",
}}
>
<span
aria-label={String(label)}
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: token.colorTextQuaternary,
display: "inline-block",
}}
/>
</span>
) : (
<span
style={{
fontSize: token.fontSizeSM,
color: token.colorTextQuaternary,
}}
>
{label}
</span>
),
children: built.items,
});
}
continue;
}
const nextParents = [...parentKeys, key];
keyToPath[key] = menu.path;
const existing = pathToKeyChain[menu.path];
if (!existing || nextParents.length > existing.length) {
pathToKeyChain[menu.path] = nextParents;
}
let children: AntMenuItem[] | undefined;
if (menu.children?.length) {
const built = buildMenuItems(menu.children, token, collapsed, iconSize, nextParents);
Object.assign(keyToPath, built.keyToPath);
Object.assign(pathToKeyChain, built.pathToKeyChain);
children = built.items.length ? built.items : undefined;
}
items.push({
key,
label,
icon: renderMenuIcon(menu.icon, iconSize),
children,
});
}
return { items, keyToPath, pathToKeyChain };
}
export function Sidebar() {
const menus = useAuthStore((s) => s.menus);
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const collapsed = useSettingsStore((s) => s.sidebarCollapsed);
const setSidebarCollapsed = useSettingsStore((s) => s.setSidebarCollapsed);
const toggleSidebar = useSettingsStore((s) => s.toggleSidebar);
const navigate = useNavigate();
const location = useLocation();
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = !screens.lg;
const mobileOpen = collapsed;
const builtMenu = useMemo(
() => buildMenuItems(menus, token, !isMobile && collapsed, token.size),
[menus, token, isMobile, collapsed, token.size],
);
const { selectedKey, routeOpenKeys } = useMemo(() => {
const chain = builtMenu.pathToKeyChain[location.pathname] ?? [];
return { selectedKey: chain.at(-1), routeOpenKeys: chain.slice(0, -1) };
}, [builtMenu, location.pathname]);
const routeOpenKeysSig = routeOpenKeys.join("\0");
const [openKeys, setOpenKeys] = useState<string[]>([]);
useEffect(() => {
if (isMobile) {
setSidebarCollapsed(false);
}
}, [isMobile, setSidebarCollapsed]);
/** Merge open keys required by the route with any submenu the user already expanded. */
useEffect(() => {
setOpenKeys((prev) => {
const next = new Set(prev);
for (const k of routeOpenKeys) next.add(k);
return [...next];
});
}, [location.pathname, routeOpenKeysSig]);
const userMenuItems: MenuProps["items"] = [
{
key: "logout",
label: "Sign Out",
onClick: () => {
if (isMobile) {
setSidebarCollapsed(false);
}
logout();
void navigate({ to: "/login" });
},
},
];
const sidebarContent = (isCollapsed: boolean, omitBrandToggle = false) => (
<Flex
vertical
style={{
height: "100%",
width: "100%",
minWidth: 0,
maxWidth: "100%",
boxSizing: "border-box",
}}
>
<Flex
vertical
justify="center"
align={isCollapsed ? "center" : "stretch"}
style={{
paddingBlock: token.paddingSM,
/* Collapsed rail is 64px: keep horizontal padding minimal so 40px brand / icons are not clipped by Sider overflow. */
paddingInline: isCollapsed ? token.paddingXXS : token.paddingSM,
minHeight: 64,
flexShrink: 0,
width: "100%",
minWidth: 0,
maxWidth: "100%",
boxSizing: "border-box",
}}
>
<Flex
align="center"
gap={token.marginSM}
style={{
width: "100%",
minWidth: 0,
maxWidth: "100%",
boxSizing: "border-box",
/* Collapsed: outer row already has paddingSM; extra inline padding would clip the 40px brand box. */
paddingInline: isCollapsed ? 0 : token.paddingXS,
minHeight: 40,
justifyContent: isCollapsed ? "center" : "flex-start",
}}
>
{isCollapsed ? (
<div className="sidebar-collapsed-brand">
<div className="sidebar-collapsed-brand__logoLayer">
<img
src={APP_FAVICON_SRC}
alt="logo"
width={24}
height={24}
style={{
borderRadius: token.borderRadius,
display: "block",
objectFit: "contain",
}}
/>
</div>
<div className="sidebar-collapsed-brand__toggleLayer">
<Button
type="text"
size="small"
className="sidebar-collapsed-brand__toggle"
onClick={toggleSidebar}
icon={<PanelLeft size={token.size} />}
aria-label="Toggle sidebar"
/>
</div>
</div>
) : (
<>
<Flex
align="center"
gap={token.marginSM}
style={{
minWidth: 0,
flex: 1,
overflow: "hidden",
}}
>
<img
src={APP_FAVICON_SRC}
alt="logo"
width={24}
height={24}
style={{
borderRadius: token.borderRadius,
display: "block",
flexShrink: 0,
objectFit: "contain",
}}
/>
<div
style={{
minWidth: 0,
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontWeight: 600,
fontSize: token.fontSizeLG,
textTransform: "uppercase",
}}
>
{APP_BRAND_NAME}
</div>
</Flex>
{!omitBrandToggle ? (
<Button
type="text"
size="small"
onClick={toggleSidebar}
icon={<PanelLeft size={token.size} />}
aria-label="Toggle sidebar"
style={{ flexShrink: 0 }}
/>
) : null}
</>
)}
</Flex>
</Flex>
<Menu
mode="inline"
selectedKeys={selectedKey ? [selectedKey] : []}
openKeys={openKeys}
onOpenChange={(keys) => setOpenKeys(keys as string[])}
items={builtMenu.items}
getPopupContainer={() => document.body}
onClick={({ key }) => {
const path = builtMenu.keyToPath[String(key)];
if (!path) return;
if (isMobile) {
setSidebarCollapsed(false);
}
void navigate({ to: path });
}}
style={{
borderRight: "none",
flex: 1,
minWidth: 0,
maxWidth: "100%",
width: "100%",
boxSizing: "border-box",
overflowX: "hidden",
overflowY: "auto",
background: "transparent",
}}
/>
<UserMenu collapsed={isCollapsed} user={user} userMenuItems={userMenuItems} />
</Flex>
);
if (isMobile) {
return (
<Drawer
open={mobileOpen}
placement="left"
onClose={() => setSidebarCollapsed(false)}
size={320}
styles={{
body: {
padding: 0,
background: token.colorBgLayout,
overflow: "hidden",
maxWidth: "100%",
boxSizing: "border-box",
},
header: { display: "none" },
mask: { opacity: 0.5 },
}}
>
{sidebarContent(false, true)}
</Drawer>
);
}
return (
<Sider
key={collapsed ? "collapsed" : "expanded"}
theme="light"
collapsible
collapsed={collapsed}
trigger={null}
width={240}
collapsedWidth={64}
breakpoint="lg"
onBreakpoint={(broken) => {
if (broken) {
setSidebarCollapsed(false);
}
}}
style={{
borderRight: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgLayout,
alignSelf: "stretch",
minHeight: "100vh",
overflow: "visible",
}}
>
{sidebarContent(collapsed)}
</Sider>
);
}
@@ -0,0 +1,7 @@
.user-menu-trigger {
transition: background-color 0.2s ease;
}
.user-menu-trigger:hover {
background-color: var(--user-menu-hover-bg);
}
@@ -0,0 +1,103 @@
import { Avatar, Button, Dropdown, Flex, Typography, theme } from "antd";
import type { MenuProps } from "antd";
import type { User } from "@/api/schemas";
import { MoreVertical } from "lucide-react";
import "./index.css";
const { Text } = Typography;
interface UserMenuProps {
collapsed: boolean;
user: User | null;
userMenuItems: MenuProps["items"];
}
export function UserMenu({ collapsed, user, userMenuItems }: UserMenuProps) {
const { token } = theme.useToken();
const avatarSrc = (user?.avatar ?? "").trim() || undefined;
const avatarSize = collapsed ? 32 : token.controlHeight;
const avatar = (
<Avatar
size={avatarSize}
src={avatarSrc || undefined}
shape="circle"
style={{
flexShrink: 0,
width: avatarSize,
}}
>
{user?.username?.[0]?.toUpperCase()}
</Avatar>
);
const moreButton = (
<Button type="text" size="small" icon={<MoreVertical size={token.fontSize} />} />
);
return (
<Dropdown menu={{ items: userMenuItems }} trigger={["click"]}>
<div
style={{
margin: token.marginXS,
}}
>
<Flex
className="user-menu-trigger"
align="center"
justify="flex-start"
gap={token.marginSM}
style={{
width: "100%",
cursor: "pointer",
borderRadius: token.borderRadius,
padding: token.paddingXS,
["--user-menu-hover-bg" as string]: token.colorFillTertiary,
}}
>
{avatar}
{!collapsed ? (
<>
<Flex
vertical
justify="center"
gap={2}
style={{
flex: "1 1 0%",
minWidth: 0,
maxWidth: "100%",
overflow: "hidden",
}}
>
<Text
ellipsis
style={{
lineHeight: 1,
display: "block",
maxWidth: "100%",
}}
>
{user?.username ?? "—"}
</Text>
<Text
ellipsis
style={{
lineHeight: 1,
display: "block",
maxWidth: "100%",
fontSize: token.fontSizeSM,
color: token.colorTextQuaternary,
}}
>
{user?.email ?? "—"}
</Text>
</Flex>
{moreButton}
</>
) : null}
</Flex>
</div>
</Dropdown>
);
}
@@ -0,0 +1,68 @@
import { useNavigate } from "@tanstack/react-router";
import { ArrowLeft, Home, SearchX } from "lucide-react";
import { Button, Flex, Result, Space, theme } from "antd";
/** Shared 404 UI for `/404` route and root `notFoundComponent`. */
export function NotFound() {
const navigate = useNavigate();
const { token } = theme.useToken();
const goDashboard = () => {
void navigate({ to: "/dashboard" });
};
const goBack = () => {
if (typeof window !== "undefined" && window.history.length > 1) {
window.history.back();
} else {
goDashboard();
}
};
return (
<Flex
vertical
style={{
/* Lock to viewport so tall Result + padding cannot grow past 100vh and scroll the page */
height: "100dvh",
maxHeight: "100dvh",
overflow: "hidden",
boxSizing: "border-box",
background: token.colorBgLayout,
color: token.colorText,
}}
>
<Flex
flex={1}
align="center"
justify="center"
style={{
minHeight: 0,
width: "100%",
padding: token.paddingXL,
overflow: "hidden",
boxSizing: "border-box",
}}
>
<Result
// Avoid status="404"|"403"|"500": antd ignores `icon` and shows built-in art; keep Lucide as the main icon.
icon={
<SearchX size={64} strokeWidth={1.25} color={token.colorTextQuaternary} aria-hidden />
}
title="404"
subTitle="Sorry, the page you visited does not exist."
extra={
<Space wrap size="middle">
<Button type="primary" icon={<Home size={16} aria-hidden />} onClick={goDashboard}>
Back to Home
</Button>
<Button icon={<ArrowLeft size={16} aria-hidden />} onClick={goBack}>
Go back
</Button>
</Space>
}
/>
</Flex>
</Flex>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { theme } from "antd";
import type { ConfigProviderProps, ThemeConfig } from "antd";
/**
* Common theme token builders
* Reduces duplication between light and dark theme definitions
*/
export const SHARED_DESIGN_TOKENS = {
fontFamily:
"ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
borderRadius: 8,
borderRadiusSM: 6,
borderRadiusLG: 12,
} as const;
export const CONTROL_COMPONENTS = {
Button: {
primaryShadow: "none",
defaultShadow: "none",
dangerShadow: "none",
},
Input: {
activeShadow: "none",
},
} as const;
/**
* Calculate table row selected color based on theme algorithm
*/
export function buildTableTokens(cfg: ThemeConfig) {
const rowSelectedBg = theme.getDesignToken(cfg).colorFillAlter;
return {
rowSelectedBg,
rowSelectedHoverBg: rowSelectedBg,
};
}
/**
* Build menu tokens for light theme
*/
export const MENU_LIGHT = {
itemSelectedBg: "rgba(0, 0, 0, 0.06)",
itemSelectedColor: "rgba(0, 0, 0, 0.88)",
subMenuItemSelectedColor: "rgba(0, 0, 0, 0.88)",
itemHoverBg: "rgba(0, 0, 0, 0.04)",
itemHoverColor: "rgba(0, 0, 0, 0.88)",
itemActiveBg: "rgba(0, 0, 0, 0.08)",
itemBorderRadius: SHARED_DESIGN_TOKENS.borderRadiusSM,
itemMarginInline: 8,
horizontalItemSelectedColor: "rgba(0, 0, 0, 0.88)",
horizontalItemHoverColor: "rgba(0, 0, 0, 0.88)",
} as const;
/**
* Build menu tokens for dark theme
*/
export const MENU_DARK = {
itemSelectedBg: "rgba(255, 255, 255, 0.08)",
itemSelectedColor: "rgba(255, 255, 255, 0.85)",
subMenuItemSelectedColor: "rgba(255, 255, 255, 0.85)",
itemHoverBg: "rgba(255, 255, 255, 0.06)",
itemHoverColor: "rgba(255, 255, 255, 0.85)",
itemActiveBg: "rgba(255, 255, 255, 0.1)",
itemBorderRadius: SHARED_DESIGN_TOKENS.borderRadiusSM,
itemMarginInline: 8,
horizontalItemSelectedColor: "rgba(255, 255, 255, 0.85)",
horizontalItemHoverColor: "rgba(255, 255, 255, 0.85)",
} as const;
/**
* Build light theme config
*/
export function buildLightThemeConfig(): ConfigProviderProps {
const lightSeed: ThemeConfig["token"] = {
colorBgLayout: "#ffffff",
...SHARED_DESIGN_TOKENS,
};
return {
theme: {
algorithm: theme.defaultAlgorithm,
token: lightSeed,
components: {
...CONTROL_COMPONENTS,
Table: buildTableTokens({
algorithm: theme.defaultAlgorithm,
token: lightSeed,
}),
Menu: MENU_LIGHT,
},
},
};
}
/**
* Build dark theme config
*/
export function buildDarkThemeConfig(): ConfigProviderProps {
const darkSeed: ThemeConfig["token"] = {
...SHARED_DESIGN_TOKENS,
};
return {
theme: {
algorithm: theme.darkAlgorithm,
token: darkSeed,
components: {
...CONTROL_COMPONENTS,
Table: buildTableTokens({
algorithm: theme.darkAlgorithm,
token: darkSeed,
}),
Menu: MENU_DARK,
},
},
};
}
+14
View File
@@ -0,0 +1,14 @@
import { useMemo } from "react";
import type { ConfigProviderProps } from "antd";
import { buildLightThemeConfig, buildDarkThemeConfig } from "./tokenBuilders";
import { useSettingsStore } from "@/stores/settings";
/**
* Theme selection for ConfigProvider: light/dark share radius, font, control tweaks.
* @see https://ant.design/docs/react/customize-theme
*/
export function useAppTheme(): ConfigProviderProps {
const darkMode = useSettingsStore((s) => s.darkMode);
return useMemo(() => (darkMode ? buildDarkThemeConfig() : buildLightThemeConfig()), [darkMode]);
}
+10
View File
@@ -0,0 +1,10 @@
import { useAuthStore } from "@/stores/auth";
export function hasPermission(point: string): boolean {
return useAuthStore.getState().hasPermission(point);
}
export function usePermission(point: string): boolean {
const user = useAuthStore((s) => s.user);
return user?.permissions.includes(point) ?? false;
}
+82
View File
@@ -0,0 +1,82 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
type MutationLifecycle<TValues> = {
onMutate?: (values: TValues) => void;
onSuccess?: (values: TValues) => void;
onError?: (values: TValues, error: unknown) => void;
};
type UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues> = {
queryKey: readonly unknown[];
invalidateKey: readonly unknown[];
queryFn: () => Promise<unknown>;
select: (raw: unknown) => TListData;
createFn: (values: TCreateValues) => Promise<unknown>;
updateFn: (values: TUpdateValues) => Promise<unknown>;
deleteFn: (id: string) => Promise<unknown>;
createLifecycle?: MutationLifecycle<TCreateValues>;
updateLifecycle?: MutationLifecycle<TUpdateValues>;
deleteLifecycle?: MutationLifecycle<string>;
};
export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
options: UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues>,
) {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: options.queryKey,
queryFn: options.queryFn,
select: options.select,
});
const createMutation = useMutation({
mutationFn: options.createFn,
onMutate: (values) => {
options.createLifecycle?.onMutate?.(values);
},
onSuccess: (_data, values) => {
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
options.createLifecycle?.onSuccess?.(values);
},
onError: (error, values) => {
options.createLifecycle?.onError?.(values, error);
},
});
const updateMutation = useMutation({
mutationFn: options.updateFn,
onMutate: (values) => {
options.updateLifecycle?.onMutate?.(values);
},
onSuccess: (_data, values) => {
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
options.updateLifecycle?.onSuccess?.(values);
},
onError: (error, values) => {
options.updateLifecycle?.onError?.(values, error);
},
});
const deleteMutation = useMutation({
mutationFn: options.deleteFn,
onMutate: (id) => {
options.deleteLifecycle?.onMutate?.(id);
},
onSuccess: (_data, id) => {
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
options.deleteLifecycle?.onSuccess?.(id);
},
onError: (error, id) => {
options.deleteLifecycle?.onError?.(id, error);
},
});
return {
data: query.data,
isLoading: query.isLoading,
createMutation,
updateMutation,
deleteMutation,
};
}
+44
View File
@@ -0,0 +1,44 @@
:root {
--app-scrollbar-size: 8px;
--app-scrollbar-track: rgba(0, 0, 0, 0);
--app-scrollbar-thumb: rgba(0, 0, 0, 0.22);
--app-scrollbar-thumb-hover: rgba(0, 0, 0, 0.38);
}
:root[data-theme="dark"] {
--app-scrollbar-track: rgba(255, 255, 255, 0);
--app-scrollbar-thumb: rgba(255, 255, 255, 0.22);
--app-scrollbar-thumb-hover: rgba(255, 255, 255, 0.38);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--app-scrollbar-thumb) var(--app-scrollbar-track);
}
/* Chromium / Safari / Edge */
*::-webkit-scrollbar {
width: var(--app-scrollbar-size);
height: var(--app-scrollbar-size);
}
*::-webkit-scrollbar-track {
background: var(--app-scrollbar-track);
border-radius: 9999px;
}
*::-webkit-scrollbar-thumb {
background: var(--app-scrollbar-thumb);
border-radius: 9999px;
border: 2px solid transparent;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background-color: var(--app-scrollbar-thumb-hover);
}
*::-webkit-scrollbar-corner {
background: transparent;
}
+58
View File
@@ -0,0 +1,58 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
import { useAuthStore } from "./stores/auth";
import { fetchSessionAndApplyToStore } from "./utils/session";
import { installGlobalErrorHandlers } from "./utils/errorReporter";
// 全局兜底异常捕获(window.onerror / unhandledrejection)
installGlobalErrorHandlers();
const router = createRouter({ routeTree });
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
async function enableMocking() {
const enableMockInBuild = import.meta.env.VITE_ENABLE_MOCK === "true";
if (!import.meta.env.DEV && !enableMockInBuild) return;
const { worker } = await import("./mocks/browser");
worker.start({
onUnhandledRequest(_, print) {
if (import.meta.env.DEV) {
// 开发环境下 warn
print.warning();
} else {
// 生产/构建 mock 环境下 error
print.error();
}
},
});
}
enableMocking()
.then(async () => {
await useAuthStore.persist.rehydrate();
const { isAuthenticated, tokens } = useAuthStore.getState();
if (isAuthenticated && tokens) {
try {
await fetchSessionAndApplyToStore();
} catch {
useAuthStore.getState().logout();
}
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>,
);
})
.catch((err) => {
console.error("Failed to initialize app:", err);
});
+4
View File
@@ -0,0 +1,4 @@
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);
+57
View File
@@ -0,0 +1,57 @@
import { HttpResponse, delay as mswDelay } from "msw";
/**
* Standard API response format
*/
export interface ApiResponse<T> {
code: number;
data: T;
message: string;
}
/**
* MSW handler factory functions
* Provides consistent response wrapping and error handling
*/
/**
* Wrap a handler function with standard response format
* Automatically handles delay, error responses, and data wrapping
*/
export async function withDelay(ms: number = 200): Promise<void> {
await mswDelay(ms);
}
/**
* Create a success response
*/
export function successResponse<T>(data: T, message: string = "ok") {
return HttpResponse.json({
code: 0,
data,
message,
});
}
/**
* Create an error response
*/
export function errorResponse(code: number, message: string, data: unknown = null) {
return HttpResponse.json({
code,
data,
message,
});
}
/**
* Predefined error codes
*/
export const ERROR_CODES = {
INVALID_CREDENTIALS: 1001,
NOT_FOUND: 1002,
UNAUTHORIZED: 1003,
FORBIDDEN: 1004,
BAD_REQUEST: 1005,
INTERNAL_ERROR: 1006,
} as const;
+466
View File
@@ -0,0 +1,466 @@
import type { MenuItem, User } from "@/api/schemas";
import { APP_MENU_TREE } from "@/utils/appMenu";
import { vercelAvatarUrl } from "./utils";
const ADMIN_PERMISSIONS = [
"user:view",
"user:create",
"user:edit",
"user:delete",
"admin:view",
"admin:create",
"admin:edit",
"admin:delete",
];
const EDITOR_PERMISSIONS = ["user:view"];
export interface MockAuthUser extends User {
password: string;
}
function permissionsFromRoles(roles: string[]): string[] {
return roles.includes("admin") ? [...ADMIN_PERMISSIONS] : [...EDITOR_PERMISSIONS];
}
function uniquePermissions(permissions: string[]): string[] {
return [...new Set(permissions)];
}
function createMockAuthUser(seed: {
id: string;
username: string;
password: string;
email: string | null;
roles: string[];
realName?: string | null;
nickName?: string | null;
mobile?: string | null;
department?: string | null;
role?: string | null;
status?: number;
remark?: string | null;
createdBy?: string | null;
}): MockAuthUser {
return {
id: seed.id,
username: seed.username,
password: seed.password,
avatar: vercelAvatarUrl(seed.username),
email: seed.email,
roles: seed.roles,
permissions: permissionsFromRoles(seed.roles),
realName: seed.realName ?? null,
nickName: seed.nickName ?? null,
mobile: seed.mobile ?? null,
department: seed.department ?? null,
role: seed.role ?? null,
status: seed.status ?? 1,
remark: seed.remark ?? null,
lastLogin: null,
createdBy: seed.createdBy ?? "system",
};
}
const authUsers: MockAuthUser[] = [
createMockAuthUser({
id: "1",
username: "admin",
password: "admin",
email: "ops.admin@northstar.io",
roles: ["admin"],
realName: "Li Si",
nickName: "Super Admin",
mobile: "13800000000",
department: "Operations",
role: "Administrator",
remark: "Full access account",
}),
createMockAuthUser({
id: "2",
username: "guest",
password: "guest",
email: "guest@northstar.io",
roles: ["editor"],
realName: "Guest User",
nickName: "Guest",
mobile: "15100000000",
department: "External",
role: "Viewer",
remark: "Read-only account",
}),
createMockAuthUser({
id: "3",
username: "zhao.ming",
password: "zhao1234",
email: "zhao.ming@northstar.io",
roles: ["editor"],
realName: "Zhao Ming",
nickName: "Ming",
department: "Product",
role: "Editor",
}),
];
let nextId = authUsers.length + 1;
const usersData: User[] = [
{
id: "u1",
username: "olivia",
avatar: vercelAvatarUrl("olivia"),
email: "olivia@northstar.io",
roles: ["editor"],
permissions: ["user:view"],
realName: "Olivia Martin",
nickName: "Olivia",
mobile: "13600000001",
department: "Product",
role: "Product Manager",
status: 1,
remark: null,
lastLogin: null,
createdBy: "seed",
},
{
id: "u2",
username: "jackson",
avatar: vercelAvatarUrl("jackson"),
email: "jackson@northstar.io",
roles: ["editor"],
permissions: ["user:view"],
realName: "Jackson Lee",
nickName: "JL",
mobile: "13600000002",
department: "Sales",
role: "Sales Lead",
status: 1,
remark: null,
lastLogin: null,
createdBy: "seed",
},
{
id: "u3",
username: "isabella",
avatar: vercelAvatarUrl("isabella"),
email: "isabella@northstar.io",
roles: ["editor"],
permissions: ["user:view"],
realName: "Isabella Nguyen",
nickName: "Isa",
mobile: "13600000003",
department: "Marketing",
role: "Campaign Specialist",
status: 1,
remark: null,
lastLogin: null,
createdBy: "seed",
},
];
const adminsData: User[] = [
{
id: "a1",
username: "admin",
avatar: vercelAvatarUrl("admin"),
email: "ops.admin@northstar.io",
roles: ["admin"],
permissions: ["*"],
realName: "Li Si",
nickName: "Super Admin",
mobile: "13800000000",
department: "Operations",
role: "Administrator",
status: 1,
remark: "Full access account",
lastLogin: null,
createdBy: "system",
},
{
id: "a2",
username: "guest-admin",
avatar: vercelAvatarUrl("guest-admin"),
email: "guest.admin@northstar.io",
roles: ["editor"],
permissions: ["admin:view"],
realName: "Guest Admin",
nickName: "GA",
mobile: "13800000001",
department: "Support",
role: "Support Admin",
status: 1,
remark: null,
lastLogin: null,
createdBy: "system",
},
];
let nextUserId = usersData.length + 1;
let nextAdminId = adminsData.length + 1;
let menusData: MenuItem[] = JSON.parse(JSON.stringify(APP_MENU_TREE)) as MenuItem[];
function stripPassword(user: MockAuthUser): User {
const { password: _password, ...rest } = user;
return rest;
}
export function issueMockTokens(userId: string) {
return {
accessToken: `mock-access-token:${userId}`,
refreshToken: `mock-refresh-token:${userId}`,
};
}
export function parseUserIdFromAccessToken(authHeader: string | null): string | null {
if (!authHeader?.startsWith("Bearer ")) return null;
const token = authHeader.slice(7);
if (!token.startsWith("mock-access-token:")) return null;
return token.slice("mock-access-token:".length) || null;
}
export function parseUserIdFromRefreshToken(token: string | null | undefined): string | null {
if (!token || !token.startsWith("mock-refresh-token:")) return null;
return token.slice("mock-refresh-token:".length) || null;
}
export function listUsers(): User[] {
return usersData.map((u) => ({ ...u }));
}
export function listAdmins(): User[] {
return adminsData.map((u) => ({ ...u }));
}
export function findAdminById(adminId: string): User | undefined {
return adminsData.find((u) => u.id === adminId);
}
export function findAdminByUsername(username: string): User | undefined {
return adminsData.find((u) => u.username.toLowerCase() === username.trim().toLowerCase());
}
export function findUserById(userId: string): MockAuthUser | undefined {
return authUsers.find((u) => u.id === userId);
}
export function findUserByUsername(username: string): MockAuthUser | undefined {
return authUsers.find((u) => u.username.toLowerCase() === username.trim().toLowerCase());
}
export function authenticateUser(username: string, password: string): MockAuthUser | undefined {
const user = findUserByUsername(username);
if (!user || user.password !== password || user.status !== 1) return undefined;
user.lastLogin = new Date().toISOString();
return user;
}
export function registerUser(payload: {
username: string;
password: string;
email?: string;
}): MockAuthUser {
const user = createMockAuthUser({
id: String(nextId++),
username: payload.username,
password: payload.password,
email: payload.email ?? null,
roles: ["editor"],
realName: payload.username,
nickName: payload.username,
department: "Community",
role: "Editor",
remark: "Registered from mock auth endpoint",
createdBy: "self-register",
});
user.lastLogin = new Date().toISOString();
authUsers.push(user);
return user;
}
export function createUser(payload: {
username: string;
email: string | null;
mobile: string | null;
}): User {
const user: User = {
id: `u${nextUserId++}`,
username: payload.username,
avatar: vercelAvatarUrl(payload.username),
email: payload.email,
roles: ["editor"],
permissions: permissionsFromRoles(["editor"]),
realName: payload.username,
nickName: payload.username,
mobile: payload.mobile,
department: "Operations",
role: "User",
status: 1,
remark: null,
lastLogin: null,
createdBy: "users-page",
};
usersData.push(user);
return { ...user };
}
export function createAdmin(payload: {
username: string;
password: string;
email: string | null;
roles: string[];
permissions: string[];
realName?: string | null;
nickName?: string | null;
mobile?: string | null;
department?: string | null;
role?: string | null;
remark?: string | null;
status?: number;
}): User {
const roles = payload.roles.length ? payload.roles : ["editor"];
const derivedPermissions = payload.permissions.length
? uniquePermissions(payload.permissions)
: permissionsFromRoles(roles);
const user: User = {
id: `a${nextAdminId++}`,
username: payload.username,
avatar: vercelAvatarUrl(payload.username),
email: payload.email,
roles,
permissions: derivedPermissions,
realName: payload.realName ?? payload.username,
nickName: payload.nickName ?? payload.username,
mobile: payload.mobile ?? null,
department: payload.department ?? "Administration",
role: payload.role ?? (roles.includes("admin") ? "Administrator" : "Editor"),
remark: payload.remark ?? null,
status: payload.status ?? 1,
lastLogin: null,
createdBy: "admins-page",
};
adminsData.push(user);
const authUser = createMockAuthUser({
id: user.id,
username: user.username,
password: payload.password,
email: user.email,
roles: user.roles,
realName: user.realName,
nickName: user.nickName,
mobile: user.mobile,
department: user.department,
role: user.role,
status: user.status,
remark: user.remark,
createdBy: user.createdBy,
});
authUser.permissions = user.permissions;
authUsers.push(authUser);
return { ...user };
}
export function updateUser(userId: string, payload: Partial<User>): User | undefined {
const user = usersData.find((u) => u.id === userId);
if (!user) return undefined;
if (payload.username != null) {
user.username = payload.username;
user.avatar = vercelAvatarUrl(payload.username);
}
if (payload.email !== undefined) user.email = payload.email;
if (payload.roles) {
user.roles = payload.roles;
user.permissions = permissionsFromRoles(payload.roles);
}
if (payload.permissions) user.permissions = payload.permissions;
if (payload.realName !== undefined) user.realName = payload.realName ?? null;
if (payload.nickName !== undefined) user.nickName = payload.nickName ?? null;
if (payload.mobile !== undefined) user.mobile = payload.mobile ?? null;
if (payload.department !== undefined) user.department = payload.department ?? null;
if (payload.role !== undefined) user.role = payload.role ?? null;
if (payload.status !== undefined) user.status = payload.status;
if (payload.remark !== undefined) user.remark = payload.remark ?? null;
return { ...user };
}
export function updateAdmin(
userId: string,
payload: Partial<User> & { password?: string | null },
): User | undefined {
const user = adminsData.find((u) => u.id === userId);
if (!user) return undefined;
if (payload.username != null) {
user.username = payload.username;
user.avatar = vercelAvatarUrl(payload.username);
}
if (payload.email !== undefined) user.email = payload.email;
if (payload.roles) {
user.roles = payload.roles;
user.permissions = permissionsFromRoles(payload.roles);
}
if (payload.realName !== undefined) user.realName = payload.realName ?? null;
if (payload.nickName !== undefined) user.nickName = payload.nickName ?? null;
if (payload.mobile !== undefined) user.mobile = payload.mobile ?? null;
if (payload.department !== undefined) user.department = payload.department ?? null;
if (payload.role !== undefined) user.role = payload.role ?? null;
if (payload.status !== undefined) user.status = payload.status;
if (payload.remark !== undefined) user.remark = payload.remark ?? null;
if (payload.permissions) {
user.permissions = uniquePermissions(payload.permissions);
}
if (payload.roles && !payload.permissions) {
user.permissions = permissionsFromRoles(payload.roles);
}
const authUser = authUsers.find((u) => u.id === userId);
if (authUser) {
authUser.username = user.username;
authUser.avatar = user.avatar;
authUser.email = user.email;
authUser.roles = user.roles;
authUser.permissions = user.permissions;
authUser.realName = user.realName;
authUser.nickName = user.nickName;
authUser.mobile = user.mobile;
authUser.department = user.department;
authUser.role = user.role;
authUser.status = user.status;
authUser.remark = user.remark;
if (payload.password && payload.password.trim().length > 0) {
authUser.password = payload.password;
}
}
return { ...user };
}
export function deleteAdmin(userId: string): boolean {
const idx = adminsData.findIndex((u) => u.id === userId);
if (idx === -1) return false;
adminsData.splice(idx, 1);
const authIdx = authUsers.findIndex((u) => u.id === userId);
if (authIdx !== -1) {
authUsers.splice(authIdx, 1);
}
return true;
}
export function deleteUser(userId: string): boolean {
const idx = usersData.findIndex((u) => u.id === userId);
if (idx === -1) return false;
usersData.splice(idx, 1);
return true;
}
export function toPublicUser(user: MockAuthUser): User {
return stripPassword(user);
}
export function listMenus(): MenuItem[] {
return JSON.parse(JSON.stringify(menusData)) as MenuItem[];
}
export function saveMenus(nextMenus: MenuItem[]): MenuItem[] {
menusData = JSON.parse(JSON.stringify(nextMenus)) as MenuItem[];
return listMenus();
}
+154
View File
@@ -0,0 +1,154 @@
import { http } from "msw";
import {
createAdmin,
deleteAdmin,
findAdminById,
findAdminByUsername,
findUserById,
findUserByUsername,
listAdmins,
listMenus,
parseUserIdFromAccessToken,
saveMenus,
updateAdmin,
} from "../data";
import { filterUsers, paginateList, parsePaginationParams } from "../utils";
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
function hasPoint(authHeader: string | null, point: string): boolean {
const userId = parseUserIdFromAccessToken(authHeader);
const user = userId ? findUserById(userId) : undefined;
if (!user) return false;
return user.permissions.includes("*") || user.permissions.includes(point);
}
export const adminHandlers = [
http.get("/api/admins", async ({ request }) => {
await withDelay(180);
if (!hasPoint(request.headers.get("authorization"), "admin:view")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const url = new URL(request.url);
const { limit, offset } = parsePaginationParams(url.searchParams);
const keyword = url.searchParams.get("keyword") ?? "";
const role = url.searchParams.get("role") ?? "";
const filtered = filterUsers(listAdmins(), { keyword, role });
const list = paginateList(filtered, limit, offset);
return successResponse({ list, total: filtered.length });
}),
http.get("/api/admins/:id", async ({ params, request }) => {
await withDelay(120);
if (!hasPoint(request.headers.get("authorization"), "admin:view")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const admin = findAdminById(String(params.id));
if (!admin) {
return errorResponse(ERROR_CODES.NOT_FOUND, "Admin not found");
}
return successResponse(admin);
}),
http.post("/api/admins", async ({ request }) => {
await withDelay(240);
if (!hasPoint(request.headers.get("authorization"), "admin:create")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const body = (await request.json()) as Record<string, unknown>;
const username = String(body.username ?? "").trim();
const password = String(body.password ?? "");
if (!username || password.length < 6) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Username and password are required");
}
if (findAdminByUsername(username) || findUserByUsername(username)) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Username already exists");
}
const created = createAdmin({
username,
password,
email: typeof body.email === "string" ? body.email : null,
roles: Array.isArray(body.roles) ? (body.roles as string[]) : ["editor"],
permissions: Array.isArray(body.permissions) ? (body.permissions as string[]) : [],
realName: typeof body.realName === "string" ? body.realName : null,
nickName: typeof body.nickName === "string" ? body.nickName : null,
mobile: typeof body.mobile === "string" ? body.mobile : null,
department: typeof body.department === "string" ? body.department : null,
role: typeof body.role === "string" ? body.role : null,
remark: typeof body.remark === "string" ? body.remark : null,
status: typeof body.status === "number" ? body.status : 1,
});
return successResponse(created);
}),
http.put("/api/admins/:id", async ({ params, request }) => {
await withDelay(220);
if (!hasPoint(request.headers.get("authorization"), "admin:edit")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const body = (await request.json()) as Record<string, unknown>;
const nextUsername = typeof body.username === "string" ? body.username.trim() : undefined;
const existingAdmin = nextUsername ? findAdminByUsername(nextUsername) : undefined;
const existingAuth = nextUsername ? findUserByUsername(nextUsername) : undefined;
if (
(existingAdmin && existingAdmin.id !== String(params.id)) ||
(existingAuth && existingAuth.id !== String(params.id))
) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Username already exists");
}
const updated = updateAdmin(String(params.id), {
username: nextUsername,
email: typeof body.email === "string" || body.email === null ? (body.email as string | null) : undefined,
roles: Array.isArray(body.roles) ? (body.roles as string[]) : undefined,
permissions: Array.isArray(body.permissions) ? (body.permissions as string[]) : undefined,
realName: typeof body.realName === "string" || body.realName === null ? (body.realName as string | null) : undefined,
nickName: typeof body.nickName === "string" || body.nickName === null ? (body.nickName as string | null) : undefined,
mobile: typeof body.mobile === "string" || body.mobile === null ? (body.mobile as string | null) : undefined,
department:
typeof body.department === "string" || body.department === null
? (body.department as string | null)
: undefined,
role: typeof body.role === "string" || body.role === null ? (body.role as string | null) : undefined,
remark: typeof body.remark === "string" || body.remark === null ? (body.remark as string | null) : undefined,
status: typeof body.status === "number" ? body.status : undefined,
password: typeof body.password === "string" && body.password.length > 0 ? body.password : undefined,
});
if (!updated) {
return errorResponse(ERROR_CODES.NOT_FOUND, "Admin not found");
}
return successResponse(updated);
}),
http.delete("/api/admins/:id", async ({ params, request }) => {
await withDelay(180);
if (!hasPoint(request.headers.get("authorization"), "admin:delete")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const ok = deleteAdmin(String(params.id));
if (!ok) {
return errorResponse(ERROR_CODES.NOT_FOUND, "Admin not found");
}
return successResponse(null);
}),
http.get("/api/menus", async ({ request }) => {
await withDelay(120);
if (!hasPoint(request.headers.get("authorization"), "admin:view")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
return successResponse(listMenus());
}),
http.put("/api/menus", async ({ request }) => {
await withDelay(220);
if (!hasPoint(request.headers.get("authorization"), "admin:edit")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const body = (await request.json()) as { menus?: unknown };
if (!Array.isArray(body?.menus)) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid menus payload");
}
return successResponse(saveMenus(body.menus as any));
}),
];
+81
View File
@@ -0,0 +1,81 @@
import { http } from "msw";
import {
authenticateUser,
findUserById,
findUserByUsername,
issueMockTokens,
parseUserIdFromAccessToken,
parseUserIdFromRefreshToken,
registerUser,
toPublicUser,
} from "../data";
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
export const authHandlers = [
http.post("/api/auth/login", async ({ request }) => {
await withDelay(300);
const body = (await request.json()) as {
username: string;
password: string;
};
const user = authenticateUser(body.username, body.password);
if (user) {
return successResponse(issueMockTokens(user.id));
}
return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password");
}),
http.post("/api/auth/register", async ({ request }) => {
await withDelay(350);
const body = (await request.json()) as {
username?: string;
password?: string;
email?: string;
};
const username = String(body.username ?? "").trim();
const password = String(body.password ?? "");
const email = typeof body.email === "string" ? body.email.trim() : undefined;
if (!username || password.length < 6) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid register payload");
}
if (findUserByUsername(username)) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Username already exists");
}
const user = registerUser({ username, password, email });
return successResponse(issueMockTokens(user.id));
}),
http.post("/api/auth/refresh", async ({ request }) => {
await withDelay(100);
const body = (await request.json().catch(() => ({}))) as { refreshToken?: string };
const userId = parseUserIdFromRefreshToken(body.refreshToken);
if (!userId || !findUserById(userId)) {
return errorResponse(ERROR_CODES.UNAUTHORIZED, "Invalid refresh token");
}
return successResponse(issueMockTokens(userId));
}),
http.post("/api/auth/logout", () => successResponse(null)),
http.get("/api/auth/user", ({ request }) => {
const userId = parseUserIdFromAccessToken(request.headers.get("authorization"));
const user = userId ? findUserById(userId) : undefined;
if (!user) {
return errorResponse(ERROR_CODES.UNAUTHORIZED, "Unauthorized");
}
const { permissions: _permissions, ...userWithoutPermissions } = toPublicUser(user);
return successResponse(userWithoutPermissions);
}),
http.get("/api/auth/permissions", ({ request }) => {
const userId = parseUserIdFromAccessToken(request.headers.get("authorization"));
const user = userId ? findUserById(userId) : undefined;
if (!user) {
return errorResponse(ERROR_CODES.UNAUTHORIZED, "Unauthorized");
}
return successResponse(user.permissions);
}),
];
+13
View File
@@ -0,0 +1,13 @@
import { authHandlers } from "./auth";
import { adminHandlers } from "./admin";
import { userHandlers } from "./user";
import { notificationHandlers } from "./notification";
import { logHandlers } from "./log";
export const handlers = [
...authHandlers,
...adminHandlers,
...userHandlers,
...notificationHandlers,
...logHandlers,
];
+57
View File
@@ -0,0 +1,57 @@
import { http, HttpResponse, delay } from "msw";
import type { AuditLog } from "@/api/schemas-ext";
const ACTIONS = ["create", "update", "delete", "login", "logout", "export", "import", "approve"];
const RESOURCES = ["user", "role", "menu", "file", "system", "notification", "report"];
const USERS = ["admin", "manager", "alice", "bob", "john_doe", "system"];
function genLogs(): AuditLog[] {
const logs: AuditLog[] = [];
for (let i = 0; i < 42; i++) {
const action = ACTIONS[i % ACTIONS.length];
const resource = RESOURCES[i % RESOURCES.length];
logs.push({
id: `log-${i}`,
user: USERS[i % USERS.length],
action,
resource,
resourceId: `res-${(i % 20) + 1}`,
detail: `${action} ${resource} record #${(i % 20) + 1}`,
ip: `192.168.1.${(i % 254) + 1}`,
status: i % 7 === 0 ? "failure" : "success",
createdAt: new Date(Date.now() - (i * 7200000)).toISOString(),
});
}
return logs;
}
const logs = genLogs();
export const logHandlers = [
http.get("/api/logs", async ({ request }) => {
await delay(300);
const url = new URL(request.url);
const user = url.searchParams.get("user");
const action = url.searchParams.get("action");
const status = url.searchParams.get("status");
const keyword = url.searchParams.get("keyword");
let list = [...logs];
if (user) list = list.filter((l) => l.user === user);
if (action) list = list.filter((l) => l.action === action);
if (status) list = list.filter((l) => l.status === status);
if (keyword) {
const kw = keyword.toLowerCase();
list = list.filter(
(l) =>
l.detail?.toLowerCase().includes(kw) ||
l.resource.toLowerCase().includes(kw) ||
l.user.toLowerCase().includes(kw),
);
}
// Return list directly (standard pattern for list APIs)
return HttpResponse.json(list);
}),
];
@@ -0,0 +1,122 @@
import { http, HttpResponse, delay } from "msw";
import type { Notification } from "@/api/schemas-ext";
let notifications: Notification[] = [
{
id: "n1",
title: "System update available",
content: "Version 6.1.0 is now available with performance improvements and bug fixes.",
type: "system",
read: false,
createdAt: new Date(Date.now() - 3600000).toISOString(),
createdBy: "System",
},
{
id: "n2",
title: "New user registered",
content: "User 'john_doe' has created an account and is pending approval.",
type: "mention",
read: false,
createdAt: new Date(Date.now() - 7200000).toISOString(),
createdBy: "admin",
},
{
id: "n3",
title: "Task assigned: Review PR #42",
content: "You have been assigned to review 'feat: add export functionality'.",
type: "task",
read: false,
createdAt: new Date(Date.now() - 10800000).toISOString(),
createdBy: "manager",
},
{
id: "n4",
title: "Backup completed",
content: "Daily database backup completed successfully. Size: 2.3 GB.",
type: "system",
read: true,
createdAt: new Date(Date.now() - 86400000).toISOString(),
createdBy: "System",
},
{
id: "n5",
title: "Security alert: Multiple failed logins",
content: "Detected 15 failed login attempts from IP 192.168.1.100 in the last 5 minutes.",
type: "alert",
read: true,
createdAt: new Date(Date.now() - 86400000 * 2).toISOString(),
createdBy: "System",
},
{
id: "n6",
title: "Comment on 'Dashboard Redesign'",
content: "Alice commented: 'The new layout looks great! Let's discuss the chart placement.'",
type: "mention",
read: false,
createdAt: new Date(Date.now() - 86400000 * 3).toISOString(),
createdBy: "alice",
},
{
id: "n7",
title: "Monthly report ready",
content: "The April 2026 analytics report has been generated and is ready for review.",
type: "system",
read: true,
createdAt: new Date(Date.now() - 86400000 * 5).toISOString(),
createdBy: "System",
},
{
id: "n8",
title: "Deployment to production",
content: "Release v6.0.0 has been deployed to production environment at 14:30 UTC.",
type: "system",
read: true,
createdAt: new Date(Date.now() - 86400000 * 7).toISOString(),
createdBy: "admin",
},
];
export const notificationHandlers = [
http.get("/api/notifications", async ({ request }) => {
await delay(600);
const url = new URL(request.url);
const type = url.searchParams.get("type");
const unreadOnly = url.searchParams.get("unreadOnly") === "true";
let list = notifications.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
if (type) {
list = list.filter((n) => n.type === type);
}
if (unreadOnly) {
list = list.filter((n) => !n.read);
}
return HttpResponse.json({
code: 0,
data: list,
message: "OK",
});
}),
http.put("/api/notifications/:id/read", async ({ params }) => {
await delay(200);
const n = notifications.find((n) => n.id === params.id);
if (n) n.read = true;
return HttpResponse.json({ code: 0, data: null, message: "OK" });
}),
http.put("/api/notifications/read-all", async () => {
await delay(300);
notifications.forEach((n) => (n.read = true));
return HttpResponse.json({ code: 0, data: null, message: "OK" });
}),
http.delete("/api/notifications/:id", async ({ params }) => {
await delay(200);
notifications = notifications.filter((n) => n.id !== params.id);
return HttpResponse.json({ code: 0, data: null, message: "OK" });
}),
];
+66
View File
@@ -0,0 +1,66 @@
import { http } from "msw";
import { createUser, deleteUser, listUsers, updateUser } from "../data";
import { filterUsers, paginateList, parsePaginationParams } from "../utils";
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
import { findUserById, parseUserIdFromAccessToken } from "../data";
function hasPoint(authHeader: string | null, point: string): boolean {
const userId = parseUserIdFromAccessToken(authHeader);
const user = userId ? findUserById(userId) : undefined;
if (!user) return false;
return user.permissions.includes("*") || user.permissions.includes(point);
}
export const userHandlers = [
http.get("/api/users", async ({ request }) => {
await withDelay(200);
if (!hasPoint(request.headers.get("authorization"), "user:view")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const url = new URL(request.url);
const { limit, offset } = parsePaginationParams(url.searchParams);
const keyword = url.searchParams.get("keyword") ?? "";
const role = url.searchParams.get("role") ?? "";
const filtered = filterUsers(listUsers(), { keyword, role });
const list = paginateList(filtered, limit, offset);
return successResponse({ list, total: filtered.length });
}),
http.post("/api/users", async ({ request }) => {
await withDelay(200);
if (!hasPoint(request.headers.get("authorization"), "user:create")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const body = (await request.json()) as Record<string, unknown>;
const newUser = createUser({
username: String(body.username ?? ""),
email: typeof body.email === "string" ? body.email : null,
mobile: typeof body.mobile === "string" ? body.mobile : null,
});
return successResponse(newUser);
}),
http.put("/api/users/:id", async ({ params, request }) => {
await withDelay(200);
if (!hasPoint(request.headers.get("authorization"), "user:edit")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const body = (await request.json()) as Record<string, unknown>;
const updated = updateUser(String(params.id), body);
if (!updated) {
return errorResponse(ERROR_CODES.NOT_FOUND, "User not found");
}
return successResponse(updated);
}),
http.delete("/api/users/:id", async ({ params, request }) => {
await withDelay(200);
if (!hasPoint(request.headers.get("authorization"), "user:delete")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
deleteUser(String(params.id));
return successResponse(null);
}),
];
+52
View File
@@ -0,0 +1,52 @@
/**
* Mock-only helpers: demo avatars, list filtering, pagination.
*/
/** Deterministic demo avatar URL from username (mock seed data only). */
export function vercelAvatarUrl(username: string): string {
return `https://vercel.com/api/www/avatar?u=${encodeURIComponent(username)}`;
}
export interface User {
id: string;
username: string;
email?: string | null;
roles: string[];
[key: string]: any;
}
export interface FilterOptions {
keyword?: string;
role?: string;
}
export function filterUsers(users: User[], options: FilterOptions): User[] {
let filtered = [...users];
if (options.keyword) {
filtered = filtered.filter(
(u) =>
u.username.includes(options.keyword!) || (u.email && u.email.includes(options.keyword!)),
);
}
if (options.role) {
filtered = filtered.filter((u) => u.roles.includes(options.role!));
}
return filtered;
}
export function paginateList<T>(items: T[], limit: number, offset: number): T[] {
return items.slice(offset, offset + limit);
}
export function parsePaginationParams(searchParams: URLSearchParams) {
const limit = Number(searchParams.get("limit") ?? searchParams.get("pageSize") ?? 50);
const offset = Number(searchParams.get("offset") ?? 0);
const page = Number(searchParams.get("page") ?? 1);
const actualOffset = searchParams.get("offset") !== null ? offset : (page - 1) * limit;
return { limit, offset: actualOffset };
}
+360
View File
@@ -0,0 +1,360 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as IndexRouteImport } from './routes/index'
import { Route as RegisterIndexRouteImport } from './routes/register/index'
import { Route as LoginIndexRouteImport } from './routes/login/index'
import { Route as R404IndexRouteImport } from './routes/404/index'
import { Route as AuthUsersIndexRouteImport } from './routes/_auth/users/index'
import { Route as AuthProfileIndexRouteImport } from './routes/_auth/profile/index'
import { Route as AuthNotificationsIndexRouteImport } from './routes/_auth/notifications/index'
import { Route as AuthMenusIndexRouteImport } from './routes/_auth/menus/index'
import { Route as AuthLogsIndexRouteImport } from './routes/_auth/logs/index'
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
import { Route as AuthAiSettingsIndexRouteImport } from './routes/_auth/ai-settings/index'
import { Route as AuthAdminsIndexRouteImport } from './routes/_auth/admins/index'
import { Route as Auth403IndexRouteImport } from './routes/_auth/403/index'
import { Route as AuthAdminsIdRouteImport } from './routes/_auth/admins/$id'
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const RegisterIndexRoute = RegisterIndexRouteImport.update({
id: '/register/',
path: '/register/',
getParentRoute: () => rootRouteImport,
} as any)
const LoginIndexRoute = LoginIndexRouteImport.update({
id: '/login/',
path: '/login/',
getParentRoute: () => rootRouteImport,
} as any)
const R404IndexRoute = R404IndexRouteImport.update({
id: '/404/',
path: '/404/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthUsersIndexRoute = AuthUsersIndexRouteImport.update({
id: '/users/',
path: '/users/',
getParentRoute: () => AuthRoute,
} as any)
const AuthProfileIndexRoute = AuthProfileIndexRouteImport.update({
id: '/profile/',
path: '/profile/',
getParentRoute: () => AuthRoute,
} as any)
const AuthNotificationsIndexRoute = AuthNotificationsIndexRouteImport.update({
id: '/notifications/',
path: '/notifications/',
getParentRoute: () => AuthRoute,
} as any)
const AuthMenusIndexRoute = AuthMenusIndexRouteImport.update({
id: '/menus/',
path: '/menus/',
getParentRoute: () => AuthRoute,
} as any)
const AuthLogsIndexRoute = AuthLogsIndexRouteImport.update({
id: '/logs/',
path: '/logs/',
getParentRoute: () => AuthRoute,
} as any)
const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
id: '/dashboard/',
path: '/dashboard/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAiSettingsIndexRoute = AuthAiSettingsIndexRouteImport.update({
id: '/ai-settings/',
path: '/ai-settings/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAdminsIndexRoute = AuthAdminsIndexRouteImport.update({
id: '/admins/',
path: '/admins/',
getParentRoute: () => AuthRoute,
} as any)
const Auth403IndexRoute = Auth403IndexRouteImport.update({
id: '/403/',
path: '/403/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAdminsIdRoute = AuthAdminsIdRouteImport.update({
id: '/admins/$id',
path: '/admins/$id',
getParentRoute: () => AuthRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/404/': typeof R404IndexRoute
'/login/': typeof LoginIndexRoute
'/register/': typeof RegisterIndexRoute
'/admins/$id': typeof AuthAdminsIdRoute
'/403/': typeof Auth403IndexRoute
'/admins/': typeof AuthAdminsIndexRoute
'/ai-settings/': typeof AuthAiSettingsIndexRoute
'/dashboard/': typeof AuthDashboardIndexRoute
'/logs/': typeof AuthLogsIndexRoute
'/menus/': typeof AuthMenusIndexRoute
'/notifications/': typeof AuthNotificationsIndexRoute
'/profile/': typeof AuthProfileIndexRoute
'/users/': typeof AuthUsersIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/404': typeof R404IndexRoute
'/login': typeof LoginIndexRoute
'/register': typeof RegisterIndexRoute
'/admins/$id': typeof AuthAdminsIdRoute
'/403': typeof Auth403IndexRoute
'/admins': typeof AuthAdminsIndexRoute
'/ai-settings': typeof AuthAiSettingsIndexRoute
'/dashboard': typeof AuthDashboardIndexRoute
'/logs': typeof AuthLogsIndexRoute
'/menus': typeof AuthMenusIndexRoute
'/notifications': typeof AuthNotificationsIndexRoute
'/profile': typeof AuthProfileIndexRoute
'/users': typeof AuthUsersIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/_auth': typeof AuthRouteWithChildren
'/404/': typeof R404IndexRoute
'/login/': typeof LoginIndexRoute
'/register/': typeof RegisterIndexRoute
'/_auth/admins/$id': typeof AuthAdminsIdRoute
'/_auth/403/': typeof Auth403IndexRoute
'/_auth/admins/': typeof AuthAdminsIndexRoute
'/_auth/ai-settings/': typeof AuthAiSettingsIndexRoute
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
'/_auth/logs/': typeof AuthLogsIndexRoute
'/_auth/menus/': typeof AuthMenusIndexRoute
'/_auth/notifications/': typeof AuthNotificationsIndexRoute
'/_auth/profile/': typeof AuthProfileIndexRoute
'/_auth/users/': typeof AuthUsersIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/404/'
| '/login/'
| '/register/'
| '/admins/$id'
| '/403/'
| '/admins/'
| '/ai-settings/'
| '/dashboard/'
| '/logs/'
| '/menus/'
| '/notifications/'
| '/profile/'
| '/users/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/404'
| '/login'
| '/register'
| '/admins/$id'
| '/403'
| '/admins'
| '/ai-settings'
| '/dashboard'
| '/logs'
| '/menus'
| '/notifications'
| '/profile'
| '/users'
id:
| '__root__'
| '/'
| '/_auth'
| '/404/'
| '/login/'
| '/register/'
| '/_auth/admins/$id'
| '/_auth/403/'
| '/_auth/admins/'
| '/_auth/ai-settings/'
| '/_auth/dashboard/'
| '/_auth/logs/'
| '/_auth/menus/'
| '/_auth/notifications/'
| '/_auth/profile/'
| '/_auth/users/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuthRoute: typeof AuthRouteWithChildren
R404IndexRoute: typeof R404IndexRoute
LoginIndexRoute: typeof LoginIndexRoute
RegisterIndexRoute: typeof RegisterIndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/_auth': {
id: '/_auth'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/register/': {
id: '/register/'
path: '/register'
fullPath: '/register/'
preLoaderRoute: typeof RegisterIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/login/': {
id: '/login/'
path: '/login'
fullPath: '/login/'
preLoaderRoute: typeof LoginIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/404/': {
id: '/404/'
path: '/404'
fullPath: '/404/'
preLoaderRoute: typeof R404IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/users/': {
id: '/_auth/users/'
path: '/users'
fullPath: '/users/'
preLoaderRoute: typeof AuthUsersIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/profile/': {
id: '/_auth/profile/'
path: '/profile'
fullPath: '/profile/'
preLoaderRoute: typeof AuthProfileIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/notifications/': {
id: '/_auth/notifications/'
path: '/notifications'
fullPath: '/notifications/'
preLoaderRoute: typeof AuthNotificationsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/menus/': {
id: '/_auth/menus/'
path: '/menus'
fullPath: '/menus/'
preLoaderRoute: typeof AuthMenusIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/logs/': {
id: '/_auth/logs/'
path: '/logs'
fullPath: '/logs/'
preLoaderRoute: typeof AuthLogsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/dashboard/': {
id: '/_auth/dashboard/'
path: '/dashboard'
fullPath: '/dashboard/'
preLoaderRoute: typeof AuthDashboardIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/ai-settings/': {
id: '/_auth/ai-settings/'
path: '/ai-settings'
fullPath: '/ai-settings/'
preLoaderRoute: typeof AuthAiSettingsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/admins/': {
id: '/_auth/admins/'
path: '/admins'
fullPath: '/admins/'
preLoaderRoute: typeof AuthAdminsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/403/': {
id: '/_auth/403/'
path: '/403'
fullPath: '/403/'
preLoaderRoute: typeof Auth403IndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/admins/$id': {
id: '/_auth/admins/$id'
path: '/admins/$id'
fullPath: '/admins/$id'
preLoaderRoute: typeof AuthAdminsIdRouteImport
parentRoute: typeof AuthRoute
}
}
}
interface AuthRouteChildren {
AuthAdminsIdRoute: typeof AuthAdminsIdRoute
Auth403IndexRoute: typeof Auth403IndexRoute
AuthAdminsIndexRoute: typeof AuthAdminsIndexRoute
AuthAiSettingsIndexRoute: typeof AuthAiSettingsIndexRoute
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
AuthLogsIndexRoute: typeof AuthLogsIndexRoute
AuthMenusIndexRoute: typeof AuthMenusIndexRoute
AuthNotificationsIndexRoute: typeof AuthNotificationsIndexRoute
AuthProfileIndexRoute: typeof AuthProfileIndexRoute
AuthUsersIndexRoute: typeof AuthUsersIndexRoute
}
const AuthRouteChildren: AuthRouteChildren = {
AuthAdminsIdRoute: AuthAdminsIdRoute,
Auth403IndexRoute: Auth403IndexRoute,
AuthAdminsIndexRoute: AuthAdminsIndexRoute,
AuthAiSettingsIndexRoute: AuthAiSettingsIndexRoute,
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
AuthLogsIndexRoute: AuthLogsIndexRoute,
AuthMenusIndexRoute: AuthMenusIndexRoute,
AuthNotificationsIndexRoute: AuthNotificationsIndexRoute,
AuthProfileIndexRoute: AuthProfileIndexRoute,
AuthUsersIndexRoute: AuthUsersIndexRoute,
}
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthRoute: AuthRouteWithChildren,
R404IndexRoute: R404IndexRoute,
LoginIndexRoute: LoginIndexRoute,
RegisterIndexRoute: RegisterIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
+6
View File
@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { NotFound } from "@/components/NotFound";
export const Route = createFileRoute("/404/")({
component: NotFound,
});
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useLayoutEffect } from "react";
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { ConfigProvider, App } from "antd";
import enUS from "antd/locale/en_US";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useSettingsStore } from "@/stores/settings";
import { useAppTheme } from "@/hooks/useAppTheme";
import { NotFound } from "@/components/NotFound";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import "@/index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false },
},
});
function RootComponent() {
const darkMode = useSettingsStore((s) => s.darkMode);
const configProviderProps = useAppTheme();
useLayoutEffect(() => {
document.documentElement.lang = "en";
}, []);
useEffect(() => {
document.documentElement.setAttribute("data-theme", darkMode ? "dark" : "light");
}, [darkMode]);
return (
<QueryClientProvider client={queryClient}>
<ConfigProvider {...configProviderProps} locale={enUS}>
<App>
<ErrorBoundary>
<Outlet />
</ErrorBoundary>
</App>
</ConfigProvider>
</QueryClientProvider>
);
}
export const Route = createRootRoute({
component: RootComponent,
notFoundComponent: NotFound,
});
+21
View File
@@ -0,0 +1,21 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useAuthStore } from "@/stores/auth";
import { MainLayout } from "@/components/Layout/MainLayout";
import { canAccessPath, normalizeAppPath } from "@/utils/appMenu";
export const Route = createFileRoute("/_auth")({
beforeLoad: ({ location }) => {
const { isAuthenticated, user } = useAuthStore.getState();
if (!isAuthenticated) {
throw redirect({ to: "/login" });
}
const path = normalizeAppPath(location.pathname);
if (path === "/403") return;
if (!canAccessPath(location.pathname, user?.permissions)) {
throw redirect({ to: "/403" });
}
},
component: MainLayout,
});
+39
View File
@@ -0,0 +1,39 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Home, ShieldAlert } from "lucide-react";
import { Button, Flex, Result, theme } from "antd";
export const Route = createFileRoute("/_auth/403/")({
component: ForbiddenPage,
});
function ForbiddenPage() {
const navigate = useNavigate();
const { token } = theme.useToken();
const goDashboard = () => {
void navigate({ to: "/dashboard" });
};
return (
<Flex
flex={1}
align="center"
justify="center"
style={{ minHeight: 0, width: "100%", padding: token.paddingXL }}
>
<Result
// Avoid status="403": antd ignores `icon` for built-in illustrations; keep Lucide as the main icon.
icon={
<ShieldAlert size={64} strokeWidth={1.25} color={token.colorTextQuaternary} aria-hidden />
}
title="403"
subTitle="Sorry, you don't have permission to access this page."
extra={
<Button type="primary" icon={<Home size={16} aria-hidden />} onClick={goDashboard}>
Back to Home
</Button>
}
/>
</Flex>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { App, Avatar, Button, Card, Col, Descriptions, Flex, Row, Space, Spin, Tag, theme } from "antd";
import { httpClient } from "@/utils/http";
import { ADMIN_ENDPOINTS } from "@/api/admin";
import { UserSchema } from "@/api/schemas";
export const Route = createFileRoute("/_auth/admins/$id")({
component: AdminDetailPage,
});
function AdminDetailPage() {
const { id } = Route.useParams();
const { message } = App.useApp();
const { token } = theme.useToken();
const adminQuery = useQuery({
queryKey: ["admin-detail", id],
queryFn: async () => {
const raw = await httpClient.get(ADMIN_ENDPOINTS.detail(id));
return UserSchema.parse(raw);
},
});
if (adminQuery.isLoading) {
return (
<Flex align="center" justify="center" style={{ minHeight: 240 }}>
<Spin size="large" />
</Flex>
);
}
if (adminQuery.isError || !adminQuery.data) {
return <Card>Admin not found.</Card>;
}
const admin = adminQuery.data;
return (
<Card>
<Row gutter={[24, 24]} align="middle" style={{ marginBottom: token.marginLG }}>
<Col>
<Avatar src={admin.avatar ?? undefined} size={96}>
{admin.username?.[0]?.toUpperCase()}
</Avatar>
</Col>
<Col flex="auto">
<Flex vertical gap={token.marginXS}>
<div>
<strong>Username:</strong> {admin.username}
</div>
<div>
<strong>Real Name:</strong> {admin.realName || "-"}
</div>
<div>
<strong>Nick Name:</strong> {admin.nickName || "-"}
</div>
<Space wrap>
<Tag color={admin.status === 1 ? "success" : "default"}>
{admin.status === 1 ? "Enabled" : "Disabled"}
</Tag>
<Tag>{admin.role || "No title"}</Tag>
{admin.department ? <Tag color="processing">{admin.department}</Tag> : null}
</Space>
<Space style={{ marginTop: token.marginSM }}>
<Button type="primary" onClick={() => message.info("Password reset simulated")}>Reset Password</Button>
<Button
onClick={() => message.info(`${admin.status === 1 ? "Disable" : "Enable"} action simulated`)}
>
{admin.status === 1 ? "Disable Account" : "Enable Account"}
</Button>
</Space>
</Flex>
</Col>
<Col style={{ width: 260 }}>
<div>
<strong>Last Login:</strong>
</div>
<div style={{ color: token.colorTextSecondary }}>{admin.lastLogin || "-"}</div>
<div style={{ marginTop: token.marginSM }}>
<strong>Created By:</strong> {admin.createdBy || "-"}
</div>
</Col>
</Row>
<Descriptions column={1} bordered>
<Descriptions.Item label="ID">{admin.id}</Descriptions.Item>
<Descriptions.Item label="Email">{admin.email || "-"}</Descriptions.Item>
<Descriptions.Item label="Mobile">{admin.mobile || "-"}</Descriptions.Item>
<Descriptions.Item label="Remark">{admin.remark || "-"}</Descriptions.Item>
</Descriptions>
<Card type="inner" title="Permission List" style={{ marginTop: token.marginLG }}>
{admin.permissions.length ? (
<Space size={[8, 8]} wrap>
{admin.permissions.map((permission) => (
<Tag key={permission} color="blue">
{permission}
</Tag>
))}
</Space>
) : (
<div style={{ color: token.colorTextSecondary }}>No permissions</div>
)}
</Card>
</Card>
);
}
@@ -0,0 +1,104 @@
import { Form, Input, Select, Switch } from "antd";
import type { FormInstance } from "antd/es/form";
import type { CreateAdminRequest, Admin } from "@/api/admin";
import { BaseFormModal } from "@/components/FormModal";
import { APP_PERMISSION_OPTIONS } from "@/utils/appMenu";
export type AdminFormValues = Omit<CreateAdminRequest, "status"> & {
password?: string;
status?: boolean;
};
export type FormModalProps = {
open: boolean;
editingAdmin: Admin | null;
form: FormInstance<AdminFormValues>;
confirmLoading: boolean;
onCancel: () => void;
onFinish: (values: AdminFormValues) => void;
};
export function FormModal({
open,
editingAdmin,
form,
confirmLoading,
onCancel,
onFinish,
}: FormModalProps) {
return (
<BaseFormModal<AdminFormValues>
open={open}
title={editingAdmin ? "Edit Admin" : "New Admin"}
okText="Save"
cancelText="Cancel"
form={form}
confirmLoading={confirmLoading}
onCancel={onCancel}
onFinish={onFinish}
>
<Form.Item
name="username"
label="Username"
rules={[{ required: true, message: "Please enter username" }]}
>
<Input />
</Form.Item>
<Form.Item
name="password"
label="Password"
rules={
editingAdmin
? [{ min: 6, message: "Password must be at least 6 characters" }]
: [
{ required: true, message: "Please enter password" },
{ min: 6, message: "Password must be at least 6 characters" },
]
}
extra={editingAdmin ? "Leave blank to keep the current password" : undefined}
>
<Input.Password />
</Form.Item>
<Form.Item name="realName" label="Real Name">
<Input />
</Form.Item>
<Form.Item name="nickName" label="Nick Name">
<Input />
</Form.Item>
<Form.Item name="email" label="Email" rules={[{ type: "email", message: "Invalid email" }]}>
<Input />
</Form.Item>
<Form.Item name="mobile" label="Mobile">
<Input />
</Form.Item>
<Form.Item name="department" label="Department">
<Input />
</Form.Item>
<Form.Item name="role" label="Title">
<Input />
</Form.Item>
<Form.Item
name="roles"
label="Roles"
rules={[{ required: true, message: "Please select roles" }]}
>
<Select
mode="multiple"
options={[
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
]}
/>
</Form.Item>
<Form.Item name="permissions" label="Permissions">
<Select mode="multiple" options={[...APP_PERMISSION_OPTIONS]} />
</Form.Item>
<Form.Item name="remark" label="Remark">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item name="status" label="Enabled" valuePropName="checked" initialValue={true}>
<Switch checkedChildren="Enabled" unCheckedChildren="Disabled" />
</Form.Item>
</BaseFormModal>
);
}
@@ -0,0 +1,91 @@
import { Button, Input, Select, theme } from "antd";
import { Plus, ShieldCheck } from "lucide-react";
import { forwardRef, useMemo } from "react";
import { FilterToolbar } from "@/components/FilterToolbar";
const FILTER_CONTROL_WIDTH = 220;
export type ToolbarProps = {
keywordInput: string;
onKeywordChange: (value: string) => void;
onSearch: (keyword: string) => void;
onClearSearch: () => void;
roleValue: string | undefined;
onRoleChange: (role: string) => void;
canCreate: boolean;
onCreateClick: () => void;
};
export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar(
{
keywordInput,
onKeywordChange,
onSearch,
onClearSearch,
roleValue,
onRoleChange,
canCreate,
onCreateClick,
},
ref,
) {
const { token } = theme.useToken();
const slots = useMemo(
() => [
{
key: "keyword",
minWidth: FILTER_CONTROL_WIDTH,
children: (
<Input.Search
allowClear
placeholder="Search admin"
style={{ width: FILTER_CONTROL_WIDTH }}
value={keywordInput}
onChange={(e) => onKeywordChange(e.target.value)}
onSearch={(v) => onSearch(v)}
onClear={onClearSearch}
/>
),
},
{
key: "role",
minWidth: FILTER_CONTROL_WIDTH,
children: (
<Select
allowClear
placeholder="Role"
style={{ width: FILTER_CONTROL_WIDTH }}
prefix={<ShieldCheck size={token.fontSize} />}
value={roleValue}
onChange={(v) => onRoleChange(v ?? "")}
options={[
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
]}
/>
),
},
],
[keywordInput, onClearSearch, onKeywordChange, onRoleChange, onSearch, roleValue, token.fontSize],
);
return (
<FilterToolbar
ref={ref}
slots={slots}
actions={
<Button
type="primary"
icon={<Plus size={token.fontSize} />}
onClick={onCreateClick}
disabled={!canCreate}
>
Create Admin
</Button>
}
moreFiltersLabel="More filters"
moreFiltersTitle="More filters"
/>
);
});
@@ -0,0 +1,364 @@
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { Avatar, Button, Card, Dropdown, Flex, Form, Space, Tag, App, theme } from "antd";
import type { TablePaginationConfig } from "antd/es/table/interface";
import { useEffect, useMemo, useState } from "react";
import { z } from "zod/v4";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import { DataTable } from "@/components/DataTable";
import { useResourceCRUD } from "@/hooks/useResourceCRUD";
import { ADMIN_ENDPOINTS } from "@/api/admin";
import type { Admin, CreateAdminRequest, UpdateAdminRequest } from "@/api/admin";
import {
CreateAdminRequestSchema,
PaginatedResponseSchema,
UpdateAdminRequestSchema,
UserSchema,
} from "@/api/schemas";
import { httpClient } from "@/utils/http";
import { useAuthStore } from "@/stores/auth";
import { FormModal, type AdminFormValues } from "./-FormModal";
import { Toolbar } from "./-Toolbar";
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message.trim()) return error.message;
return fallback;
}
const AdminSearchParamsSchema = z.object({
limit: z.number().int().positive().catch(20),
offset: z.number().int().nonnegative().catch(0),
sortField: z.string().nullable().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
keyword: z.string().catch(""),
role: z.string().catch("")
});
export const Route = createFileRoute("/_auth/admins/")({
validateSearch: (search) => AdminSearchParamsSchema.parse(search),
component: AdminsPage,
});
const paginatedAdminSchema = PaginatedResponseSchema(UserSchema);
function AdminsPage() {
const search = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const { message, modal } = App.useApp();
const { token } = theme.useToken();
const [modalOpen, setModalOpen] = useState(false);
const [editingAdmin, setEditingAdmin] = useState<Admin | null>(null);
const [form] = Form.useForm<AdminFormValues>();
const hasPermission = useAuthStore((s) => s.hasPermission);
const canCreate = hasPermission("admin:create");
const canEdit = hasPermission("admin:edit");
const canDelete = hasPermission("admin:delete");
const [keywordInput, setKeywordInput] = useState(search.keyword);
const currentPage = Math.floor(search.offset / search.limit) + 1;
useEffect(() => {
setKeywordInput(search.keyword);
}, [search.keyword]);
const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD<
{ list: Admin[]; total: number },
CreateAdminRequest,
UpdateAdminRequest & { id: string }
>({
queryKey: [
"admins",
search.limit,
search.offset,
search.keyword,
search.role,
search.sortField,
search.sortOrder,
],
invalidateKey: ["admins"],
queryFn: () =>
httpClient.get(ADMIN_ENDPOINTS.list, {
params: {
limit: search.limit,
offset: search.offset,
keyword: search.keyword || undefined,
role: search.role || undefined,
},
}),
select: (raw) => paginatedAdminSchema.shape.data.parse(raw),
createFn: (values) => httpClient.post(ADMIN_ENDPOINTS.create, CreateAdminRequestSchema.parse(values)),
updateFn: ({ id, ...values }) =>
httpClient.put(ADMIN_ENDPOINTS.update(id), UpdateAdminRequestSchema.parse(values)),
deleteFn: (id) => httpClient.delete(ADMIN_ENDPOINTS.delete(id)),
createLifecycle: {
onSuccess: () => {
message.success("Admin created");
setModalOpen(false);
form.resetFields();
},
onError: (_values, error) => message.error(getErrorMessage(error, "Create admin failed")),
},
updateLifecycle: {
onSuccess: () => {
message.success("Admin updated");
setModalOpen(false);
setEditingAdmin(null);
form.resetFields();
},
onError: (_values, error) => message.error(getErrorMessage(error, "Update admin failed")),
},
deleteLifecycle: {
onSuccess: () => message.success("Admin deleted"),
onError: (_id, error) => message.error(getErrorMessage(error, "Delete admin failed")),
},
});
const confirmDelete = (record: Admin) => {
modal.confirm({
title: "Delete admin?",
content: `This will remove ${record.username}.`,
okText: "Delete",
okType: "danger",
cancelText: "Cancel",
onOk: () => deleteMutation.mutate(record.id),
});
};
const columns = useMemo(
() => [
{
title: "Admin",
dataIndex: "username",
key: "username",
width: 320,
render: (_: unknown, record: Admin) => (
<Flex align="center" gap={token.marginSM} style={{ minWidth: 0 }}>
<Avatar size={28} src={record.avatar ?? undefined}>
{record.username?.[0]?.toUpperCase()}
</Avatar>
<Flex vertical style={{ minWidth: 0 }}>
<Link
to="/admins/$id"
params={{ id: record.id }}
style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}
>
{record.username}
</Link>
<span style={{ color: token.colorTextSecondary, fontSize: token.fontSizeSM }}>
{record.realName || record.email || "No profile info"}
</span>
</Flex>
</Flex>
),
},
{
title: "Role",
dataIndex: "role",
key: "role",
width: 240,
render: (_: unknown, record: Admin) => (
<Space wrap>
{(record.roles ?? []).map((role) => (
<Tag key={role}>{role}</Tag>
))}
</Space>
),
},
{
title: "Permissions",
dataIndex: "permissions",
key: "permissions",
width: 720,
render: (_: unknown, record: Admin) => {
const permissions = record.permissions ?? [];
const isAdmin = (record.roles ?? []).includes("admin");
if (isAdmin || permissions.includes("*")) {
return <Tag color="gold">*</Tag>;
}
return (
<Space size={[4, 4]} wrap>
{permissions.length ? permissions.map((point) => <Tag key={point}>{point}</Tag>) : <Tag>none</Tag>}
</Space>
);
},
},
{
title: "Status",
dataIndex: "status",
key: "status",
width: 150,
render: (status: number | undefined) => (
<Tag color={status === 1 ? "success" : "default"}>{status === 1 ? "Enabled" : "Disabled"}</Tag>
),
},
{
title: "Actions",
key: "actions",
width: 120,
align: "right" as const,
render: (_: unknown, record: Admin) => {
const items = [
canEdit
? {
key: "edit",
icon: <Pencil size={token.fontSize} />,
label: "Edit",
onClick: () => {
setEditingAdmin(record);
form.setFieldsValue({
...record,
password: "",
status: record.status === 1,
} as AdminFormValues);
setModalOpen(true);
},
}
: null,
canDelete
? {
key: "delete",
icon: <Trash2 size={token.fontSize} />,
label: "Delete",
danger: true,
onClick: () => confirmDelete(record),
}
: null,
].filter((item): item is NonNullable<typeof item> => item != null);
if (!items.length) {
return <span style={{ color: token.colorTextSecondary }}>—</span>;
}
return (
<Dropdown menu={{ items }} placement="bottomRight">
<Button type="text" icon={<MoreVertical size={token.fontSize} />} />
</Dropdown>
);
},
},
],
[
canDelete,
canEdit,
form,
modal,
token.colorTextSecondary,
token.fontSize,
token.fontSizeSM,
token.marginSM,
],
);
const applySearch = (keyword: string) => {
void navigate({
search: {
...search,
keyword: keyword.trim(),
offset: 0,
},
});
};
const handleSubmit = (values: AdminFormValues) => {
if (editingAdmin && !canEdit) {
message.error("No permission to edit admins");
return;
}
if (!editingAdmin && !canCreate) {
message.error("No permission to create admins");
return;
}
const payload = {
...values,
email: values.email || null,
realName: values.realName || null,
nickName: values.nickName || null,
mobile: values.mobile || null,
department: values.department || null,
role: values.role || null,
remark: values.remark || null,
status: values.status ? 1 : 0,
roles: values.roles?.length ? values.roles : ["editor"],
permissions: values.permissions ?? [],
};
if (editingAdmin) {
const updatePayload = {
...payload,
password: payload.password?.trim() ? payload.password : undefined,
};
updateMutation.mutate({ id: editingAdmin.id, ...updatePayload });
return;
}
createMutation.mutate(payload as CreateAdminRequest);
};
const pagination: TablePaginationConfig = {
current: currentPage,
pageSize: search.limit,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (page, pageSize) => {
void navigate({
search: {
...search,
limit: pageSize,
offset: (page - 1) * pageSize,
},
});
},
};
return (
<Card style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<Flex vertical gap={token.margin} style={{ flex: 1, minHeight: 0 }}>
<Toolbar
keywordInput={keywordInput}
onKeywordChange={setKeywordInput}
onSearch={applySearch}
onClearSearch={() => applySearch("")}
roleValue={search.role || undefined}
onRoleChange={(role) => {
void navigate({
search: {
...search,
role,
offset: 0,
},
});
}}
canCreate={canCreate}
onCreateClick={() => {
if (!canCreate) return;
setEditingAdmin(null);
form.resetFields();
form.setFieldsValue({ roles: ["admin"], permissions: ["admin:view"], status: true });
setModalOpen(true);
}}
/>
<DataTable<Admin>
dataSource={data?.list ?? []}
columns={columns}
rowKey="id"
pagination={pagination}
loading={isLoading}
size="small"
scroll={{ x: "max-content" }}
/>
</Flex>
<FormModal
open={modalOpen}
editingAdmin={editingAdmin}
form={form}
confirmLoading={createMutation.isPending || updateMutation.isPending}
onCancel={() => {
setModalOpen(false);
setEditingAdmin(null);
form.resetFields();
}}
onFinish={handleSubmit}
/>
</Card>
);
}
@@ -0,0 +1,84 @@
import { createFileRoute } from "@tanstack/react-router";
import { App, Button, Card, Flex, Form, Input, InputNumber, Select, Switch, Typography, theme } from "antd";
import { useMemo } from "react";
import { useAIStore } from "@/stores/ai";
const { Text } = Typography;
type AIFormValues = {
enabled: boolean;
provider: "openai" | "azure-openai" | "anthropic";
model: string;
systemPrompt: string;
temperature: number;
maxTokens: number;
};
export const Route = createFileRoute("/_auth/ai-settings/")({
component: AISettingsPage,
});
function AISettingsPage() {
const { message } = App.useApp();
const { token } = theme.useToken();
const [form] = Form.useForm<AIFormValues>();
const { enabled, provider, model, systemPrompt, temperature, maxTokens, setConfig } = useAIStore();
const initialValues = useMemo(
() => ({ enabled, provider, model, systemPrompt, temperature, maxTokens }),
[enabled, provider, model, systemPrompt, temperature, maxTokens],
);
return (
<Flex vertical gap={token.marginLG} style={{ flex: 1, minHeight: 0 }}>
<Card title="AI Configuration">
<Form
form={form}
layout="vertical"
initialValues={initialValues}
onFinish={(values) => {
setConfig(values);
message.success("AI settings saved");
}}
>
<Form.Item label="Enable AI" name="enabled" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label="Provider" name="provider" rules={[{ required: true }]}>
<Select
options={[
{ label: "OpenAI", value: "openai" },
{ label: "Azure OpenAI", value: "azure-openai" },
{ label: "Anthropic", value: "anthropic" },
]}
/>
</Form.Item>
<Form.Item label="Model" name="model" rules={[{ required: true, message: "Model is required" }]}>
<Input placeholder="gpt-5.3-codex" />
</Form.Item>
<Form.Item
label="System Prompt"
name="systemPrompt"
rules={[{ required: true, message: "System prompt is required" }]}
>
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item label="Temperature" name="temperature">
<InputNumber min={0} max={1} step={0.1} style={{ width: 200 }} />
</Form.Item>
<Form.Item label="Max Tokens" name="maxTokens">
<InputNumber min={256} max={8192} step={128} style={{ width: 200 }} />
</Form.Item>
<Text type="secondary" style={{ display: "block", marginBottom: token.margin }}>
This page configures global AI behavior for the app shell and AI assistants.
</Text>
<Button type="primary" htmlType="submit">
Save AI Settings
</Button>
</Form>
</Card>
</Flex>
);
}
@@ -0,0 +1,42 @@
.dash-card-interactive.ant-card {
transition:
border-color 0.2s ease,
background-color 0.2s ease;
box-shadow: none;
}
.dash-card-interactive.ant-card:hover {
background-color: var(--dash-card-hover-bg) !important;
border-color: var(--dash-card-hover-border) !important;
box-shadow: var(--dash-card-hover-shadow);
}
.dash-recent-row {
cursor: pointer;
transition: background-color 0.2s ease;
}
.dash-recent-row:hover {
background-color: var(--dash-recent-hover-bg);
}
.dash-timeline {
padding-top: 4px;
}
.dash-timeline .ant-timeline-item-content {
min-height: 52px;
}
.dash-skel-stat .ant-skeleton-input {
min-height: unset !important;
}
.dash-skel-recent .ant-skeleton-input {
min-height: unset !important;
margin-block: 0 !important;
}
.dash-skel-recent .ant-skeleton-avatar {
margin-inline-end: 0 !important;
}
@@ -0,0 +1,342 @@
import type { CSSProperties } from "react";
import { useMemo } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { Card, Col, Row, Typography, Avatar, theme, Flex, Skeleton, Timeline, Tag } from "antd";
import { useQuery } from "@tanstack/react-query";
import { DollarSign, Users, CreditCard, Activity } from "lucide-react";
import "./index.css";
const { Title, Text } = Typography;
export const Route = createFileRoute("/_auth/dashboard/")({
component: DashboardPage,
});
async function fetchDashboardShell() {
await new Promise((r) => setTimeout(r, 1000));
return true;
}
type AntToken = ReturnType<typeof theme.useToken>["token"];
/** Matches loaded stat card body height: title row, value, description. */
function StatCardSkeleton({ token }: { token: AntToken }) {
const titleLine = Math.round(token.fontSizeSM * token.lineHeight);
/** Lucide default icon box 24px to align with the first row of real cards. */
const iconBox = 24;
const titleRowHeight = Math.max(titleLine, iconBox);
const valueLine = Math.round(24 * 1);
const descLine = Math.round(token.fontSizeSM * token.lineHeight);
return (
<Card styles={{ body: { padding: token.paddingLG } }}>
<div className="dash-skel-stat">
<Flex
justify="space-between"
align="center"
style={{ marginBottom: token.marginXS, minHeight: titleRowHeight }}
>
<Skeleton.Input active size="small" style={{ width: "55%", height: titleLine }} />
<Skeleton.Avatar active size="small" style={{ width: iconBox, height: iconBox }} />
</Flex>
<Flex vertical gap={token.marginXS}>
<Skeleton.Input active style={{ width: "25%", height: valueLine }} />
<Skeleton.Input
active
size="small"
style={{ width: "75%", maxWidth: 200, height: descLine }}
/>
</Flex>
</div>
</Card>
);
}
function DashboardSkeleton() {
const { token } = theme.useToken();
const body = { padding: token.paddingLG } as const;
const cardTitleSkel = (w: number) => (
<Skeleton.Input
active
style={{
width: w,
height: Math.round(token.fontSizeHeading5 * token.lineHeightHeading5),
}}
/>
);
return (
<Flex vertical gap={token.marginLG}>
<Row gutter={[16, 16]}>
{[0, 1, 2, 3].map((i) => (
<Col xs={24} sm={12} lg={6} key={i}>
<StatCardSkeleton token={token} />
</Col>
))}
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} lg={14}>
<Card title={cardTitleSkel(88)} styles={{ body: body }} style={{ height: "100%" }}>
<Skeleton.Node active style={{ width: "100%", height: 300 }}>
<div style={{ width: "100%", height: "100%" }} />
</Skeleton.Node>
</Card>
</Col>
<Col xs={24} lg={10}>
<Card title={cardTitleSkel(112)} styles={{ body: body }} style={{ height: "100%" }}>
<Flex vertical className="dash-skel-recent">
{[0, 1, 2, 3, 4].map((i) => (
<Flex
key={i}
align="center"
justify="space-between"
gap={token.marginSM}
style={{
padding: `${token.paddingXS}px ${token.paddingSM}px`,
borderRadius: token.borderRadius,
}}
>
<Flex align="center" gap={token.marginSM} style={{ minWidth: 0, flex: 1 }}>
<Skeleton.Avatar active size={40} shape="circle" />
<Flex vertical style={{ minWidth: 0, flex: 1 }} gap={2}>
<Skeleton.Input active size="small" style={{ width: "50%", height: 12 }} />
<Skeleton.Input active size="small" style={{ width: "85%", height: 12 }} />
</Flex>
</Flex>
</Flex>
))}
</Flex>
</Card>
</Col>
</Row>
</Flex>
);
}
function DashboardPage() {
const { token } = theme.useToken();
const { isPending } = useQuery({
queryKey: ["dashboard"],
queryFn: fetchDashboardShell,
staleTime: 60_000,
});
/* Hover: emphasize border; keep background matching the card so light theme doesn't gray the whole block */
const cardHoverStyle = {
["--dash-card-hover-bg" as string]: token.colorBgContainer,
["--dash-card-hover-border" as string]: token.colorPrimaryBorderHover,
["--dash-card-hover-shadow" as string]: "none",
} as CSSProperties;
const stats = useMemo(
() => [
{
title: "Total Revenue",
value: "$45,231.89",
description: "+20.1% from last month",
icon: <DollarSign style={{ color: token.colorTextSecondary }} />,
},
{
title: "Subscriptions",
value: "+2350",
description: "+180.1% from last month",
icon: <Users style={{ color: token.colorTextSecondary }} />,
},
{
title: "Sales",
value: "+12,234",
description: "+19% from last month",
icon: <CreditCard style={{ color: token.colorTextSecondary }} />,
},
{
title: "Active Now",
value: "+573",
description: "+201 since last hour",
icon: <Activity style={{ color: token.colorTextSecondary }} />,
},
],
[token.colorTextSecondary],
);
const recentSales = useMemo(
() => [
{
name: "Olivia Martin",
email: "olivia.martin@email.com",
amount: "+$1,999.00",
initials: "OM",
},
{
name: "Jackson Lee",
email: "jackson.lee@email.com",
amount: "+$39.00",
initials: "JL",
},
{
name: "Isabella Nguyen",
email: "isabella.nguyen@email.com",
amount: "+$299.00",
initials: "IN",
},
{
name: "William Kim",
email: "will@email.com",
amount: "+$99.00",
initials: "WK",
},
{
name: "Sofia Davis",
email: "sofia.davis@email.com",
amount: "+$39.00",
initials: "SD",
},
],
[],
);
const timelineItems = useMemo(
() => [
{
color: "green",
content: (
<Flex vertical gap={4}>
<Text strong>08:30 Deploy V3.2.0</Text>
<Text type="secondary">Release branch merged and production rollout completed.</Text>
</Flex>
),
},
{
color: "blue",
content: (
<Flex vertical gap={4}>
<Text strong>10:10 Menu policy updated</Text>
<Text type="secondary">Admin changed sidebar visibility and permission mapping.</Text>
</Flex>
),
},
{
color: "gold",
content: (
<Flex vertical gap={4}>
<Text strong>13:20 Security review</Text>
<Text type="secondary">Token refresh behavior and 403 routes validated.</Text>
</Flex>
),
},
{
color: "red",
content: (
<Flex vertical gap={4}>
<Text strong>15:50 Incident recovery</Text>
<Text type="secondary">User creation spike handled and queue restored.</Text>
</Flex>
),
},
],
[],
);
if (isPending) {
return <DashboardSkeleton />;
}
return (
<Flex vertical gap={token.marginLG}>
<Row gutter={[16, 16]}>
{stats.map((stat) => (
<Col xs={24} sm={12} lg={6} key={stat.title}>
<Card
className="dash-card-interactive"
style={cardHoverStyle}
styles={{ body: { padding: token.paddingLG } }}
>
<Flex justify="space-between" align="center" style={{ marginBottom: token.marginXS }}>
<Text strong style={{ fontSize: token.fontSizeSM }}>
{stat.title}
</Text>
{stat.icon}
</Flex>
<div style={{ fontSize: 24, fontWeight: "bold", marginBottom: 4 }}>{stat.value}</div>
<Text ellipsis type="secondary" style={{ fontSize: token.fontSizeSM }}>
{stat.description}
</Text>
</Card>
</Col>
))}
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} lg={14}>
<Card
className="dash-card-interactive"
style={{ ...cardHoverStyle, height: "100%" }}
title={
<Flex align="center" gap={token.marginSM}>
<Title level={5} style={{ margin: 0 }}>
Timeline
</Title>
<Tag variant="filled" color="processing">
Today
</Tag>
</Flex>
}
>
<Timeline className="dash-timeline" items={timelineItems} />
</Card>
</Col>
<Col xs={24} lg={10}>
<Card
className="dash-card-interactive"
style={{ ...cardHoverStyle, height: "100%" }}
title={<Title level={5} style={{ margin: 0 }}>Recent Sales</Title>}
>
<Flex
vertical
style={{
["--dash-recent-hover-bg" as string]: token.colorBgTextHover,
}}
>
{recentSales.map((item) => (
<Flex
key={item.email}
className="dash-recent-row"
align="center"
justify="space-between"
style={{
padding: `${token.paddingXS}px ${token.paddingSM}px`,
borderRadius: token.borderRadius,
}}
>
<Flex align="center" gap={token.marginSM} style={{ minWidth: 0 }}>
<Avatar
size={40}
shape="circle"
style={{
flexShrink: 0,
backgroundColor: token.colorFillSecondary,
color: token.colorTextSecondary,
fontWeight: 600,
fontSize: token.fontSizeSM,
}}
>
{item.initials}
</Avatar>
<Flex vertical style={{ minWidth: 0 }}>
<Text strong style={{ fontSize: token.fontSizeSM, lineHeight: 1 }}>
{item.name}
</Text>
<Text type="secondary" style={{ fontSize: token.fontSizeSM }} ellipsis>
{item.email}
</Text>
</Flex>
</Flex>
<div style={{ fontWeight: 500 }}>{item.amount}</div>
</Flex>
))}
</Flex>
</Card>
</Col>
</Row>
</Flex>
);
}
+440
View File
@@ -0,0 +1,440 @@
import { createFileRoute } from "@tanstack/react-router";
import {
App,
Button,
Card,
Divider,
Flex,
Form,
Input,
InputNumber,
Switch,
Tree,
Typography,
theme,
} from "antd";
import type { DataNode } from "antd/es/tree";
import { useMemo, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { httpClient } from "@/utils/http";
import { MENU_ENDPOINTS } from "@/api/menu";
import { MenuItemSchema } from "@/api/schemas";
import type { MenuItem } from "@/api/schemas";
import { fetchSessionAndApplyToStore } from "@/utils/session";
import { FolderPlus, Plus, Trash2 } from "lucide-react";
const { Text } = Typography;
export const Route = createFileRoute("/_auth/menus/")({
component: MenusPage,
});
type MenuFormValues = {
name: string;
path: string;
icon: string;
sort: number;
hidden: boolean;
permissionsCsv: string;
};
type NodeContext = {
node: MenuItem;
parentId: string | null;
index: number;
};
function cloneMenus(menus: MenuItem[]): MenuItem[] {
return JSON.parse(JSON.stringify(menus)) as MenuItem[];
}
function nextNodeId(): string {
return `menu-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
function withChildren(item: MenuItem, children: MenuItem[]): MenuItem {
if (item.kind === "group") {
return { ...item, children };
}
return { ...item, children: children.length ? children : null };
}
function normalizeSort(items: MenuItem[]): MenuItem[] {
return items.map((item, index) => {
if (item.kind === "group") {
return { ...item, sort: index, children: normalizeSort(item.children) };
}
if (!item.children?.length) {
return { ...item, sort: index };
}
return { ...item, sort: index, children: normalizeSort(item.children) };
});
}
function buildTreeData(items: MenuItem[]): DataNode[] {
return items
.slice()
.sort((a, b) => a.sort - b.sort)
.map((item) => {
const suffix = item.hidden ? " (hidden)" : "";
const title = `${item.name}${suffix}`;
return {
key: item.id,
title: item.kind === "group" ? `${title}` : `${title} ${item.path}`,
children: item.children?.length ? buildTreeData(item.children) : undefined,
};
});
}
function findNodeContext(items: MenuItem[], id: string, parentId: string | null = null): NodeContext | null {
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
if (item.id === id) return { node: item, parentId, index };
if (item.children?.length) {
const found = findNodeContext(item.children, id, item.id);
if (found) return found;
}
}
return null;
}
function findNode(items: MenuItem[], id: string): MenuItem | null {
return findNodeContext(items, id)?.node ?? null;
}
function updateNode(items: MenuItem[], id: string, patch: Partial<MenuItem>): MenuItem[] {
return items.map((item) => {
if (item.id === id) {
if (item.kind === "group") {
return {
...item,
name: patch.name ?? item.name,
icon: patch.icon ?? item.icon,
permissions: patch.permissions ?? item.permissions,
sort: patch.sort ?? item.sort,
hidden: patch.hidden ?? item.hidden,
};
}
return {
...item,
name: patch.name ?? item.name,
path: patch.path && typeof patch.path === "string" ? patch.path : item.path,
icon: patch.icon ?? item.icon,
permissions: patch.permissions ?? item.permissions,
sort: patch.sort ?? item.sort,
hidden: patch.hidden ?? item.hidden,
};
}
if (!item.children?.length) return item;
return withChildren(item, updateNode(item.children, id, patch));
});
}
function removeNode(items: MenuItem[], id: string): { next: MenuItem[]; removed: MenuItem | null } {
let removed: MenuItem | null = null;
const next: MenuItem[] = [];
for (const item of items) {
if (item.id === id) {
removed = item;
continue;
}
if (item.children?.length) {
const childResult = removeNode(item.children, id);
if (childResult.removed) {
removed = childResult.removed;
next.push(withChildren(item, childResult.next));
continue;
}
}
next.push(item);
}
return { next, removed };
}
function insertChild(items: MenuItem[], parentId: string, child: MenuItem): MenuItem[] {
return items.map((item) => {
if (item.id === parentId) {
const currentChildren = item.children ? [...item.children] : [];
return withChildren(item, normalizeSort([...currentChildren, child]));
}
if (!item.children?.length) return item;
return withChildren(item, insertChild(item.children, parentId, child));
});
}
function replaceSiblingList(
items: MenuItem[],
parentId: string | null,
transform: (siblings: MenuItem[]) => MenuItem[],
): MenuItem[] {
if (parentId == null) {
return normalizeSort(transform(items));
}
return items.map((item) => {
if (item.id === parentId) {
const siblings = item.children ? [...item.children] : [];
return withChildren(item, normalizeSort(transform(siblings)));
}
if (!item.children?.length) return item;
return withChildren(item, replaceSiblingList(item.children, parentId, transform));
});
}
function permissionsToCsv(permissions: string[] | null): string {
return permissions?.join(",") ?? "";
}
function csvToPermissions(csv: string): string[] | null {
const list = csv
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return list.length ? list : null;
}
function MenusPage() {
const { message } = App.useApp();
const { token } = theme.useToken();
const [form] = Form.useForm<MenuFormValues>();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [draftMenus, setDraftMenus] = useState<MenuItem[] | null>(null);
const { data, isLoading } = useQuery({
queryKey: ["menus"],
queryFn: () => httpClient.get(MENU_ENDPOINTS.list),
select: (raw) => MenuItemSchema.array().parse(raw),
});
const menus = draftMenus ?? data ?? [];
const selectedNode = selectedId ? findNode(menus, selectedId) : null;
const selectedContext = selectedId ? findNodeContext(menus, selectedId) : null;
const treeData = useMemo(() => buildTreeData(menus), [menus]);
const saveMutation = useMutation({
mutationFn: (nextMenus: MenuItem[]) => httpClient.put(MENU_ENDPOINTS.update, { menus: nextMenus }),
onSuccess: async () => {
setDraftMenus(null);
await fetchSessionAndApplyToStore();
message.success("Menus saved");
},
onError: (error) => {
const content = error instanceof Error ? error.message : "Save menus failed";
message.error(content);
},
});
const bindForm = (node: MenuItem | null) => {
if (!node) {
form.resetFields();
return;
}
form.setFieldsValue({
name: node.name,
path: node.path ?? "",
icon: node.icon ?? "",
sort: node.sort,
hidden: node.hidden,
permissionsCsv: permissionsToCsv(node.permissions),
});
};
const createDefaultItem = (name: string): MenuItem => ({
id: nextNodeId(),
kind: "item",
name,
path: `/path-${Date.now()}`,
icon: "IconLucideCircleDashed",
permissions: null,
sort: 0,
hidden: false,
children: null,
});
const createDefaultGroup = (): MenuItem => ({
id: nextNodeId(),
kind: "group",
name: "New Group",
path: null,
icon: "IconLucideSettings",
permissions: null,
sort: 0,
hidden: false,
children: [],
});
const canDrop = () => true;
const handleDrop = (info: {
dragNode: DataNode;
node: DataNode;
dropToGap: boolean;
dropPosition: number;
}) => {
const dragId = String(info.dragNode.key);
const targetId = String(info.node.key);
if (dragId === targetId) return;
const removed = removeNode(menus, dragId);
if (!removed.removed) return;
const targetPosStr = String((info.node as { pos?: string }).pos ?? "0");
const rawPosition = Number(targetPosStr.split("-").at(-1) ?? 0);
const relativeDrop = info.dropPosition - rawPosition;
if (!info.dropToGap) {
const withChild = insertChild(removed.next, targetId, removed.removed);
setDraftMenus(normalizeSort(withChild));
return;
}
const targetCtx = findNodeContext(removed.next, targetId);
if (!targetCtx) return;
const insertIndex = relativeDrop < 0 ? targetCtx.index : targetCtx.index + 1;
const withSibling = replaceSiblingList(removed.next, targetCtx.parentId, (siblings) => {
const next = [...siblings];
next.splice(insertIndex, 0, removed.removed as MenuItem);
return next;
});
setDraftMenus(normalizeSort(withSibling));
};
return (
<Flex vertical gap={token.marginLG} style={{ flex: 1, minHeight: 0 }}>
<Card title="Menus Editor" loading={isLoading}>
<Flex gap={token.marginSM} wrap style={{ marginBottom: token.margin }}>
<Button
icon={<FolderPlus size={token.fontSize} />}
onClick={() => {
const next = normalizeSort([...menus, createDefaultGroup()]);
setDraftMenus(next);
}}
>
Add Group
</Button>
<Button
icon={<Plus size={token.fontSize} />}
disabled={!selectedContext}
onClick={() => {
if (!selectedContext) return;
const next = replaceSiblingList(menus, selectedContext.parentId, (siblings) => {
const copy = [...siblings];
copy.splice(selectedContext.index + 1, 0, createDefaultItem("New Item"));
return copy;
});
setDraftMenus(normalizeSort(next));
}}
>
Add Sibling Item
</Button>
<Button
icon={<Plus size={token.fontSize} />}
disabled={!selectedNode}
onClick={() => {
if (!selectedNode) return;
const next = insertChild(menus, selectedNode.id, createDefaultItem("New Child Item"));
setDraftMenus(normalizeSort(next));
}}
>
Add Child Item
</Button>
<Button
danger
icon={<Trash2 size={token.fontSize} />}
disabled={!selectedNode}
onClick={() => {
if (!selectedNode) return;
const removed = removeNode(menus, selectedNode.id);
setDraftMenus(normalizeSort(removed.next));
setSelectedId(null);
form.resetFields();
}}
>
Delete
</Button>
</Flex>
<Divider style={{ marginBlock: token.marginSM }} />
<Flex gap={token.marginLG} align="stretch" style={{ minHeight: 420 }}>
<Card
style={{ flex: "0 0 360px", overflow: "auto" }}
styles={{ body: { padding: token.paddingSM } }}
>
<Tree
draggable
allowDrop={canDrop}
selectedKeys={selectedId ? [selectedId] : []}
treeData={treeData}
onDrop={handleDrop}
onSelect={(keys) => {
const nextId = String(keys[0] ?? "");
setSelectedId(nextId || null);
bindForm(nextId ? findNode(menus, nextId) : null);
}}
/>
</Card>
<Card style={{ flex: 1 }}>
{selectedNode ? (
<Form
form={form}
layout="vertical"
onValuesChange={(_, values) => {
if (!selectedId) return;
const next = updateNode(menus, selectedId, {
name: values.name,
path: selectedNode.kind === "item" ? (values.path || "/") : null,
icon: values.icon || null,
sort: values.sort ?? 0,
hidden: !!values.hidden,
permissions: csvToPermissions(values.permissionsCsv || ""),
});
setDraftMenus(normalizeSort(next));
}}
>
<Form.Item label="Name" name="name" rules={[{ required: true, message: "Name is required" }]}>
<Input />
</Form.Item>
<Form.Item label="Path" name="path" extra="Group nodes should keep path empty">
<Input disabled={selectedNode.kind === "group"} />
</Form.Item>
<Form.Item label="Icon" name="icon">
<Input placeholder="IconLucideSettings" />
</Form.Item>
<Form.Item label="Sort" name="sort">
<InputNumber style={{ width: 180 }} />
</Form.Item>
<Form.Item label="Permissions (comma separated)" name="permissionsCsv">
<Input placeholder="admin:view,user:view" />
</Form.Item>
<Form.Item label="Hidden" name="hidden" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
) : (
<Text type="secondary">Select one menu node to edit, or use the actions above to create nodes.</Text>
)}
</Card>
</Flex>
<Flex justify="flex-end" style={{ marginTop: token.margin }}>
<Button
type="primary"
loading={saveMutation.isPending}
onClick={() => {
void saveMutation.mutate(cloneMenus(menus));
}}
>
Save Menus
</Button>
</Flex>
</Card>
</Flex>
);
}
@@ -0,0 +1,137 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
import { App, Badge, Button, Flex, List, Segmented, Space, Tag, Tooltip, Typography, theme } from "antd";
import { AtSign, CheckCheck, ClipboardList, Info, AlertTriangle, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { httpClient } from "@/utils/http";
import { NOTIFICATION_ENDPOINTS } from "@/api/notification";
import { NotificationSchema } from "@/api/schemas-ext";
import type { Notification } from "@/api/schemas-ext";
export const Route = createFileRoute("/_auth/notifications/")({ component: NotificationsPage });
const TYPE_META = {
system: { Icon: Info, color: "blue", label: "System" },
mention: { Icon: AtSign, color: "purple", label: "Mention" },
task: { Icon: ClipboardList, color: "green", label: "Task" },
alert: { Icon: AlertTriangle, color: "red", label: "Alert" },
} as const;
function NotificationsPage() {
const { token } = theme.useToken();
const { message } = App.useApp();
const qc = useQueryClient();
const [type, setType] = useState("all");
const { data: raw = [], isLoading } = useQuery({
queryKey: ["notifications", type],
queryFn: () =>
httpClient.get<Notification[]>(NOTIFICATION_ENDPOINTS.list, {
params: type !== "all" ? { type } : undefined,
}),
});
const items = useMemo(
() => raw
.filter((n) => NotificationSchema.safeParse(n).success)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
[raw],
);
const inv = () => { void qc.invalidateQueries({ queryKey: ["notifications"] }); };
const mark = useMutation({
mutationFn: (id: string) => httpClient.put(NOTIFICATION_ENDPOINTS.markRead(id)),
onSuccess: inv,
});
const markAll = useMutation({
mutationFn: () => httpClient.put(NOTIFICATION_ENDPOINTS.markAllRead),
onSuccess: () => { message.success("All marked as read"); inv(); },
});
const del = useMutation({
mutationFn: (id: string) => httpClient.delete(NOTIFICATION_ENDPOINTS.delete(id)),
onSuccess: () => { message.success("Deleted"); inv(); },
});
const unread = items.filter((n) => !n.read).length;
return (
<Flex vertical gap={token.marginMD} style={{ flex: 1, minHeight: 0 }}>
<Flex justify="space-between" align="center" wrap="wrap" gap={token.marginSM}>
<Typography.Title level={4} style={{ margin: 0 }}>
Notifications
{unread > 0 && <Badge count={unread} style={{ marginInlineStart: token.marginSM }} overflowCount={99} />}
</Typography.Title>
<Space>
<Segmented
size="small"
value={type}
onChange={(v) => setType(v as string)}
options={[
{ label: "All", value: "all" },
{ label: "Unread", value: "unreadOnly" },
...Object.entries(TYPE_META).map(([k, m]) => ({ label: m.label, value: k })),
]}
/>
{unread > 0 && (
<Button size="small" icon={<CheckCheck size={token.fontSize} />}
onClick={() => markAll.mutate()} loading={markAll.isPending}>
Mark all read
</Button>
)}
</Space>
</Flex>
<List
loading={isLoading}
dataSource={items}
style={{ flex: 1, overflow: "auto" }}
locale={{ emptyText: "No notifications" }}
renderItem={(item) => {
const { Icon, color } = TYPE_META[item.type];
return (
<List.Item
key={item.id}
onClick={() => !item.read && mark.mutate(item.id)}
style={{
padding: `${token.paddingSM}px ${token.padding}px`,
borderRadius: token.borderRadiusLG,
background: item.read ? "transparent" : token.colorFillQuaternary,
cursor: item.read ? "default" : "pointer",
marginBottom: token.marginXXS,
}}
actions={[
<Tooltip title="Delete" key="x">
<Button type="text" size="small" danger icon={<Trash2 size={token.fontSize} />}
onClick={(e) => { e.stopPropagation(); del.mutate(item.id); }} />
</Tooltip>,
]}
>
<List.Item.Meta
avatar={<Tag color={color} style={{ display: "inline-flex", alignItems: "center", gap: 4, marginInlineEnd: 0 }}><Icon size={12} /></Tag>}
title={
<Flex align="center" gap={token.marginXS}>
{!item.read && <Badge status="processing" style={{ flexShrink: 0 }} />}
<Typography.Text strong={!item.read}>{item.title}</Typography.Text>
</Flex>
}
description={
<Flex vertical gap={4}>
<Typography.Paragraph type="secondary" ellipsis={{ rows: 2 }} style={{ margin: 0, fontSize: token.fontSizeSM }}>
{item.content}
</Typography.Paragraph>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{new Date(item.createdAt).toLocaleString()}
</Typography.Text>
</Flex>
}
/>
</List.Item>
);
}}
/>
</Flex>
);
}
@@ -0,0 +1,300 @@
import { createFileRoute } from "@tanstack/react-router";
import { useMutation } from "@tanstack/react-query";
import {
App, Button, Card, Col, Divider, Flex, Form, Input, Row, Typography,
theme, Descriptions, Tag, Avatar, Tabs, Space, Upload
} from "antd";
import {
Mail, Phone, Building, Shield, Clock, Upload as UploadIcon,
Save, Key
} from "lucide-react";
import { useState } from "react";
import { httpClient } from "@/utils/http";
import { PROFILE_ENDPOINTS } from "@/api/profile";
import { useAuthStore } from "@/stores/auth";
import { UpdateProfileSchema, ChangePasswordSchema } from "@/api/schemas-ext";
import type { UpdateProfile, ChangePassword } from "@/api/schemas-ext";
import { UserSchema } from "@/api/schemas";
export const Route = createFileRoute("/_auth/profile/")({
component: ProfilePage,
});
function ProfilePage() {
const { token } = theme.useToken();
const { message } = App.useApp();
const user = useAuthStore((s) => s.user);
const setUser = useAuthStore((s) => s.setUser);
const [profileForm] = Form.useForm();
const [passwordForm] = Form.useForm();
const [editing, setEditing] = useState(false);
const profileMutation = useMutation({
mutationFn: (values: UpdateProfile) =>
httpClient.put(PROFILE_ENDPOINTS.update, UpdateProfileSchema.parse(values)),
onSuccess: (data) => {
const validated = UserSchema.parse(data);
setUser(validated);
setEditing(false);
message.success("Profile updated");
},
onError: (err) => {
message.error(err instanceof Error ? err.message : "Update failed");
},
});
const passwordMutation = useMutation({
mutationFn: (values: ChangePassword) =>
httpClient.put(
PROFILE_ENDPOINTS.changePassword,
ChangePasswordSchema.parse(values),
),
onSuccess: () => {
message.success("Password changed");
passwordForm.resetFields();
},
onError: (err) => {
message.error(err instanceof Error ? err.message : "Change password failed");
},
});
const avatarMutation = useMutation({
mutationFn: async (file: File) => {
// Simulated upload
await new Promise((r) => setTimeout(r, 1000));
return { url: URL.createObjectURL(file) };
},
onSuccess: (data) => {
// Optimistically update avatar
if (user) {
setUser({ ...user, avatar: data.url });
}
message.success("Avatar updated");
},
});
if (!user) {
return (
<Flex justify="center" align="center" style={{ minHeight: 200 }}>
<Typography.Text type="secondary">Loading profile...</Typography.Text>
</Flex>
);
}
const tabItems = [
{
key: "info",
label: "Profile",
children: (
<Flex vertical gap={token.marginMD}>
<Flex align="center" gap={token.marginLG} wrap="wrap">
<Upload
showUploadList={false}
accept="image/*"
customRequest={({ file }) => {
avatarMutation.mutate(file as File);
}}
>
<div style={{ position: "relative", cursor: "pointer" }}>
<Avatar
size={80}
src={user.avatar}
shape="circle"
style={{
border: `2px solid ${token.colorBorderSecondary}`,
}}
>
{user.username?.[0]?.toUpperCase()}
</Avatar>
<div
style={{
position: "absolute",
bottom: 0,
right: 0,
background: token.colorPrimary,
borderRadius: "50%",
padding: 4,
display: "flex",
}}
>
<UploadIcon size={12} style={{ color: "#fff" }} />
</div>
</div>
</Upload>
<Flex vertical gap={4}>
<Typography.Title level={4} style={{ margin: 0 }}>
{user.realName || user.username}
</Typography.Title>
<Typography.Text type="secondary">
@{user.username}
</Typography.Text>
<Space size={4}>
{user.roles.map((r) => (
<Tag key={r} color="blue" style={{ textTransform: "capitalize" }}>
{r}
</Tag>
))}
</Space>
</Flex>
</Flex>
<Divider style={{ margin: 0 }} />
{!editing ? (
<>
<Descriptions size="small" column={{ xs: 1, sm: 2 }} bordered>
<Descriptions.Item label={<><Mail size={12} /> Email</>}>
{user.email || "—"}
</Descriptions.Item>
<Descriptions.Item label={<><Phone size={12} /> Mobile</>}>
{user.mobile || "—"}
</Descriptions.Item>
<Descriptions.Item label={<><Building size={12} /> Department</>}>
{user.department || "—"}
</Descriptions.Item>
<Descriptions.Item label={<><Shield size={12} /> Role</>}>
{user.role || "—"}
</Descriptions.Item>
<Descriptions.Item label="Nickname">
{user.nickName || "—"}
</Descriptions.Item>
<Descriptions.Item label="Remark">
{user.remark || "—"}
</Descriptions.Item>
<Descriptions.Item label={<><Clock size={12} /> Last Login</>}>
{user.lastLogin
? new Date(user.lastLogin).toLocaleString()
: "—"}
</Descriptions.Item>
<Descriptions.Item label="Created By">
{user.createdBy || "—"}
</Descriptions.Item>
</Descriptions>
<Button
type="primary"
onClick={() => {
profileForm.setFieldsValue({
email: user.email,
mobile: user.mobile,
department: user.department,
nickName: user.nickName,
realName: user.realName,
remark: user.remark,
});
setEditing(true);
}}
style={{ width: "fit-content" }}
>
Edit Profile
</Button>
</>
) : (
<Form
form={profileForm}
layout="vertical"
onFinish={(values) => profileMutation.mutate(values)}
style={{ maxWidth: 480 }}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="realName" label="Real Name">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="nickName" label="Nickname">
<Input />
</Form.Item>
</Col>
</Row>
<Form.Item name="email" label="Email" rules={[{ type: "email" }]}>
<Input />
</Form.Item>
<Form.Item name="mobile" label="Mobile">
<Input />
</Form.Item>
<Form.Item name="department" label="Department">
<Input />
</Form.Item>
<Form.Item name="remark" label="Remark">
<Input.TextArea rows={2} />
</Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
loading={profileMutation.isPending}
icon={<Save size={token.fontSize} />}
>
Save
</Button>
<Button onClick={() => {
setEditing(false);
profileForm.resetFields();
}}>
Cancel
</Button>
</Space>
</Form>
)}
</Flex>
),
},
{
key: "password",
label: "Change Password",
children: (
<Form
form={passwordForm}
layout="vertical"
onFinish={(values) => passwordMutation.mutate(values)}
style={{ maxWidth: 400 }}
>
<Form.Item
name="oldPassword"
label="Current Password"
rules={[{ required: true, message: "Please enter current password" }]}
>
<Input.Password placeholder="••••••" />
</Form.Item>
<Form.Item
name="newPassword"
label="New Password"
rules={[
{ required: true, message: "Please enter new password" },
{ min: 6, message: "Minimum 6 characters" },
]}
>
<Input.Password placeholder="At least 6 characters" />
</Form.Item>
<Form.Item
name="confirmPassword"
label="Confirm Password"
rules={[{ required: true, message: "Please confirm new password" }]}
>
<Input.Password placeholder="••••••" />
</Form.Item>
<Button
type="primary"
htmlType="submit"
loading={passwordMutation.isPending}
icon={<Key size={token.fontSize} />}
>
Change Password
</Button>
</Form>
),
},
];
return (
<Flex vertical gap={token.marginMD} style={{ flex: 1, minHeight: 0 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
Profile
</Typography.Title>
<Card>
<Tabs items={tabItems} />
</Card>
</Flex>
);
}
@@ -0,0 +1,52 @@
import { Form, Input } from "antd";
import type { FormInstance } from "antd/es/form";
import type { CreateUserRequest, User } from "@/api/schemas";
import { BaseFormModal } from "@/components/FormModal";
export type FormModalProps = {
open: boolean;
editingUser: User | null;
form: FormInstance<CreateUserRequest>;
confirmLoading: boolean;
onCancel: () => void;
onFinish: (values: CreateUserRequest) => void;
};
export function FormModal({
open,
editingUser,
form,
confirmLoading,
onCancel,
onFinish,
}: FormModalProps) {
return (
<BaseFormModal<CreateUserRequest>
open={open}
title={editingUser ? "Edit User" : "New User"}
okText="OK"
cancelText="Cancel"
form={form}
confirmLoading={confirmLoading}
onCancel={onCancel}
onFinish={onFinish}
>
<Form.Item
name="username"
label="Username"
rules={[{ required: true, message: "Please enter username" }]}
>
<Input />
</Form.Item>
<Form.Item
name="mobile"
label="Mobile"
>
<Input />
</Form.Item>
<Form.Item name="email" label="Email">
<Input />
</Form.Item>
</BaseFormModal>
);
}
@@ -0,0 +1,62 @@
import { Button, Input, theme } from "antd";
import { Plus } from "lucide-react";
import { forwardRef, useMemo } from "react";
import { FilterToolbar } from "@/components/FilterToolbar";
const FILTER_CONTROL_WIDTH = 220;
export type ToolbarProps = {
keywordInput: string;
onKeywordChange: (value: string) => void;
onSearch: (keyword: string) => void;
onClearSearch: () => void;
canCreate: boolean;
onCreateClick: () => void;
};
export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar(
{ keywordInput, onKeywordChange, onSearch, onClearSearch, canCreate, onCreateClick },
ref,
) {
const { token } = theme.useToken();
const slots = useMemo(
() => [
{
key: "keyword",
minWidth: FILTER_CONTROL_WIDTH,
children: (
<Input.Search
allowClear
placeholder="Search User"
style={{ width: FILTER_CONTROL_WIDTH }}
value={keywordInput}
onChange={(e) => onKeywordChange(e.target.value)}
onSearch={(v) => onSearch(v)}
onClear={onClearSearch}
/>
),
},
],
[keywordInput, onClearSearch, onKeywordChange, onSearch],
);
return (
<FilterToolbar
ref={ref}
slots={slots}
actions={
<Button
type="primary"
icon={<Plus size={token.fontSize} />}
onClick={onCreateClick}
disabled={!canCreate}
>
Create User
</Button>
}
moreFiltersLabel="More filters"
moreFiltersTitle="More filters"
/>
);
});
+496
View File
@@ -0,0 +1,496 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Avatar, Button, Form, App, Dropdown, theme, Flex } from "antd";
import type { TablePaginationConfig } from "antd/es/table/interface";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { httpClient } from "@/utils/http";
import { USER_ENDPOINTS } from "@/api/user";
import { PaginatedResponseSchema, UserSchema, CreateUserRequestSchema } from "@/api/schemas";
import type { User, CreateUserRequest } from "@/api/schemas";
import { z } from "zod/v4";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import { DataTable } from "@/components/DataTable";
import { useResourceCRUD } from "@/hooks/useResourceCRUD";
import { useAuthStore } from "@/stores/auth";
import { Toolbar } from "./-Toolbar";
import { FormModal } from "./-FormModal";
const UserSearchParamsSchema = z.object({
limit: z.number().int().positive().catch(100),
offset: z.number().int().nonnegative().catch(0),
sortField: z.string().nullable().optional().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().optional().catch(null),
keyword: z.string().catch(""),
role: z.string().catch("")
});
export const Route = createFileRoute("/_auth/users/")({
validateSearch: (search) => UserSearchParamsSchema.parse(search),
component: UsersPage,
});
const paginatedUserSchema = PaginatedResponseSchema(UserSchema);
const MESSAGE_KEY_USER_CREATE = "user-mutation-create";
const MESSAGE_KEY_USER_UPDATE = "user-mutation-update";
const MESSAGE_KEY_USER_DELETE = "user-mutation-delete";
function UsersPage() {
const search = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const { message, modal } = App.useApp();
const { token } = theme.useToken();
const [modalOpen, setModalOpen] = useState(false);
const pageShellRef = useRef<HTMLDivElement>(null);
const toolbarRowRef = useRef<HTMLDivElement>(null);
const middleSectionRef = useRef<HTMLDivElement>(null);
const tableFrameRef = useRef<HTMLDivElement>(null);
const tableAvailableRef = useRef(0);
/**
* If both start as undefined on the first frame, the table grows to full row height (flex min-height:auto)
* and the main content area scrolls. Seed from the viewport; useLayoutEffect then refines (e.g. available≈901, bodyMax≈861).
*/
const [tableAreaMaxHeight, setTableAreaMaxHeight] = useState<number | undefined>(() =>
typeof window !== "undefined" ? Math.max(240, Math.floor(window.innerHeight - 280)) : undefined,
);
const [tableScrollY, setTableScrollY] = useState<number | undefined>(() => {
if (typeof window === "undefined") return undefined;
const maxH = Math.max(240, Math.floor(window.innerHeight - 280));
return Math.max(120, maxH - 40);
});
const [editingUser, setEditingUser] = useState<User | null>(null);
const [form] = Form.useForm();
const hasPermission = useAuthStore((s) => s.hasPermission);
const canCreate = hasPermission("user:create");
const canEdit = hasPermission("user:edit");
const canDelete = hasPermission("user:delete");
const [keywordInput, setKeywordInput] = useState(search.keyword);
const currentPage = Math.floor(search.offset / search.limit) + 1;
useEffect(() => {
setKeywordInput(search.keyword);
}, [search.keyword]);
const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD<
{ list: User[]; total: number },
CreateUserRequest,
CreateUserRequest & { id: string }
>({
queryKey: [
"users",
search.limit,
search.offset,
search.keyword,
search.role,
search.sortField,
search.sortOrder,
],
invalidateKey: ["users"],
queryFn: () =>
httpClient.get(USER_ENDPOINTS.list, {
params: {
limit: search.limit,
offset: search.offset,
keyword: search.keyword || undefined,
role: search.role || undefined,
sortField: search.sortField ?? undefined,
sortOrder: search.sortOrder ?? undefined,
},
}),
select: (raw) => paginatedUserSchema.shape.data.parse(raw),
createFn: (values) =>
httpClient.post(USER_ENDPOINTS.create, CreateUserRequestSchema.parse(values)),
updateFn: ({ id, ...values }) => httpClient.put(USER_ENDPOINTS.update(id), values),
deleteFn: (id) => httpClient.delete(USER_ENDPOINTS.delete(id)),
createLifecycle: {
onSuccess: () => {
message.success({
content: "Created successfully",
key: MESSAGE_KEY_USER_CREATE,
});
setModalOpen(false);
form.resetFields();
},
onError: () => {
message.error({
content: "Create failed",
key: MESSAGE_KEY_USER_CREATE,
});
},
},
updateLifecycle: {
onMutate: () => {
message.loading({
content: "Updating…",
key: MESSAGE_KEY_USER_UPDATE,
duration: 0,
});
},
onSuccess: () => {
message.success({
content: "Updated successfully",
key: MESSAGE_KEY_USER_UPDATE,
});
setModalOpen(false);
setEditingUser(null);
form.resetFields();
},
onError: () => {
message.error({
content: "Update failed",
key: MESSAGE_KEY_USER_UPDATE,
});
},
},
deleteLifecycle: {
onMutate: () => {
message.loading({
content: "Deleting…",
key: MESSAGE_KEY_USER_DELETE,
duration: 0,
});
},
onSuccess: () => {
message.success({
content: "Deleted successfully",
key: MESSAGE_KEY_USER_DELETE,
});
},
onError: () => {
message.error({
content: "Delete failed",
key: MESSAGE_KEY_USER_DELETE,
});
},
},
});
const confirmDelete = (record: User) => {
modal.confirm({
title: "Are you absolutely sure?",
content: "This action cannot be undone. This will permanently delete the user.",
okText: "Delete",
okType: "danger",
cancelText: "Cancel",
onOk: () => deleteMutation.mutate(record.id),
});
};
const columns = [
{
title: "ID",
dataIndex: "id",
key: "id",
sorter: true,
sortOrder: search.sortField === "id" ? search.sortOrder : null,
},
{
title: "Username",
dataIndex: "username",
key: "username",
sorter: true,
sortOrder: search.sortField === "username" ? search.sortOrder : null,
render: (_: unknown, record: User) => {
const src = (record.avatar ?? "").trim() || undefined;
return (
<Flex align="center" gap={token.marginSM} style={{ minWidth: 0 }}>
<Avatar size={24} src={src} shape="circle" style={{ flexShrink: 0 }}>
{record.username?.[0]?.toUpperCase()}
</Avatar>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{record.username}
</span>
</Flex>
);
},
},
{
title: "Email",
dataIndex: "email",
key: "email",
sorter: true,
sortOrder: search.sortField === "email" ? search.sortOrder : null,
},
{
title: "Roles",
dataIndex: "roles",
key: "roles",
render: (_: unknown, record: User) => {
if (!record.roles || record.roles.length === 0) return "—";
return (
<span style={{ whiteSpace: "nowrap" }}>
{record.roles.join(", ")}
</span>
);
},
},
{
title: "Mobile",
dataIndex: "mobile",
key: "mobile",
render: (mobile: string | null | undefined) => mobile || "—",
},
{
title: "Actions",
key: "actions",
width: 60,
align: "right" as const,
sorter: false,
render: (_: unknown, record: User) => {
const items = [
canEdit
? {
key: "edit",
icon: <Pencil size={token.fontSize} />,
label: "Edit",
onClick: () => {
setEditingUser(record);
form.setFieldsValue(record);
setModalOpen(true);
},
}
: null,
canDelete
? {
key: "delete",
icon: <Trash2 size={token.fontSize} />,
label: "Delete",
danger: true,
onClick: () => confirmDelete(record),
}
: null,
].filter((item): item is NonNullable<typeof item> => item != null);
if (!items.length) {
return <span style={{ color: token.colorTextSecondary }}>—</span>;
}
return (
<Dropdown menu={{ items }} placement="bottomRight">
<Button type="text" icon={<MoreVertical size={token.fontSize} />} />
</Dropdown>
);
},
},
];
const applySearch = (keyword: string) => {
void navigate({
search: {
...search,
keyword: keyword.trim(),
offset: 0,
},
});
};
const showPagination = (data?.total ?? 0) > search.limit;
useLayoutEffect(() => {
const shell = pageShellRef.current;
const toolbarEl = toolbarRowRef.current;
const mid = middleSectionRef.current;
if (!shell || !toolbarEl || !mid) return;
/** Fixed small table header row height */
const headReserve = 40;
/** Ant Design Table size="small" row ~39px; slightly higher for Tags / selection column */
const rowEstimate = 44;
const paginationBlock = 56;
let rafRetry = 0;
const maxRafRetries = 8;
const measure = () => {
const toolbarRect = toolbarEl.getBoundingClientRect();
/**
* Do not use the page shell bottom: when the table stretches the document, shell.bottom moves down and
* `available` becomes too large, so we skip scroll.y. Use the main content box bottom (viewport-bounded) instead.
*/
const mainEl = shell.closest<HTMLElement>(".main-layout-main");
const mainRect = mainEl?.getBoundingClientRect();
const viewportBottom =
typeof window !== "undefined" ? window.innerHeight : Number.POSITIVE_INFINITY;
const clipBottom = mainRect
? mainRect.bottom
: Math.min(shell.getBoundingClientRect().bottom, viewportBottom);
const maxMiddleFromShell = Math.max(
0,
Math.floor(clipBottom - toolbarRect.bottom - token.marginLG),
);
const midHRaw = mid.clientHeight;
const midH = midHRaw > 0 ? Math.min(midHRaw, maxMiddleFromShell) : maxMiddleFromShell;
if (maxMiddleFromShell === 0 && rafRetry < maxRafRetries) {
rafRetry += 1;
requestAnimationFrame(() => requestAnimationFrame(measure));
return;
}
/** Bordered frame now includes Table built-in pagination */
const frameInner = Math.max(0, Math.floor(midH));
tableAvailableRef.current = frameInner;
setTableAreaMaxHeight(frameInner > 0 ? frameInner : undefined);
const rowCount = data?.list?.length ?? 0;
const pagReserve = showPagination ? paginationBlock : 0;
const bodyMax = Math.max(120, frameInner - headReserve - pagReserve);
if (isLoading && rowCount === 0) {
setTableScrollY(bodyMax);
return;
}
if (rowCount === 0 && !isLoading) {
setTableScrollY(undefined);
return;
}
const naturalBodyH = headReserve + rowCount * rowEstimate;
const naturalTotalH = naturalBodyH + pagReserve;
const slack = 12;
/**
* Without scroll.y, Ant Design does not scroll .ant-table-body; the full table height moves scrolling to the page.
* Row-height estimates can wrongly say “fits”. After clearing scroll.y, re-check frame scrollHeight vs clientHeight.
*/
if (naturalTotalH + slack > frameInner) {
setTableScrollY(bodyMax);
return;
}
setTableScrollY(undefined);
requestAnimationFrame(() => {
const frame = tableFrameRef.current;
const avail = tableAvailableRef.current;
const minFrame = headReserve + pagReserve;
if (!frame || avail <= minFrame) return;
if (frame.scrollHeight > frame.clientHeight + 1) {
setTableScrollY(Math.max(120, avail - headReserve - pagReserve));
}
});
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(shell);
ro.observe(mid);
return () => ro.disconnect();
}, [showPagination, data?.list?.length, isLoading, token.marginLG]);
const tablePagination: false | TablePaginationConfig = useMemo(
() =>
showPagination
? {
total: data?.total ?? 0,
current: currentPage,
pageSize: search.limit,
showSizeChanger: true,
showTotal: (total) => `${total} rows`,
onChange: (page, pageSize) => {
void navigate({
search: {
...search,
limit: pageSize ?? search.limit,
offset: (page - 1) * (pageSize ?? search.limit),
},
});
},
}
: false,
[showPagination, data?.total, currentPage, search, navigate],
);
return (
<Flex
ref={pageShellRef}
vertical
gap={token.marginMD}
style={{ flex: "1 1 0%", minHeight: 0, overflow: "hidden" }}
>
<Toolbar
ref={toolbarRowRef}
keywordInput={keywordInput}
onKeywordChange={setKeywordInput}
onSearch={applySearch}
onClearSearch={() => {
setKeywordInput("");
applySearch("");
}}
canCreate={canCreate}
onCreateClick={() => {
if (!canCreate) return;
setEditingUser(null);
form.resetFields();
setModalOpen(true);
}}
/>
<DataTable<User>
layoutRef={middleSectionRef}
frameRef={tableFrameRef}
lockScrollHeight={tableScrollY != null}
maxHeight={tableAreaMaxHeight}
frameHeight={
tableScrollY != null && tableAreaMaxHeight != null ? tableAreaMaxHeight : undefined
}
rowKey="id"
size="small"
columns={columns}
dataSource={data?.list ?? []}
loading={isLoading}
pagination={tablePagination}
style={{ flex: 1, minHeight: 0 }}
scroll={tableScrollY != null ? { x: "max-content", y: tableScrollY } : { x: "max-content" }}
onChange={(_pagination, _filters, sorter) => {
if (Array.isArray(sorter)) return;
// 修正排序回退逻辑:无排序时清空 sortField/sortOrder,否则用当前字段和顺序
const nextSortField = sorter.order ? String(sorter.field) : undefined;
const nextSortOrder = sorter.order ? sorter.order : undefined;
void navigate({
search: {
...search,
sortField: nextSortField,
sortOrder: nextSortOrder,
},
});
}}
/>
<FormModal
open={modalOpen}
editingUser={editingUser}
form={form}
confirmLoading={createMutation.isPending || updateMutation.isPending}
onCancel={() => {
setModalOpen(false);
setEditingUser(null);
form.resetFields();
}}
onFinish={(values) => {
if (editingUser) {
if (!canEdit) {
message.error("No permission to edit users");
return;
}
updateMutation.mutate({ ...values, id: editingUser.id });
} else {
if (!canCreate) {
message.error("No permission to create users");
return;
}
createMutation.mutate(values);
}
}}
/>
</Flex>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
beforeLoad: () => {
throw redirect({ to: "/login" });
},
});

Some files were not shown because too many files have changed in this diff Show More