Compare commits

..
927 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
zuiidea 017af0ddfc 📝 Update docs. 2018-11-04 22:55:50 +08:00
zuiidea c2161ceca9 📝 Update README.md and package.json. 2018-11-04 21:08:43 +08:00
Baorong Li 140af31bec adjust user mock 2018-11-03 09:39:37 +08:00
zuiidea 1199e7e618 👷 Update travis.yml. 2018-11-02 19:27:55 +08:00
zuiidea 3fa24d00fa 📝 👷 Add some English docs, Update travis.yml. 2018-11-02 19:21:22 +08:00
zuiidea 5d23c77100 📝 Add some Chinese documentations. 2018-11-02 18:13:43 +08:00
zuiidea 286178fc5e 📝 Update README.md and Add README-zh_CN.md. 2018-11-02 18:08:29 +08:00
zuiidea f6621572df ⚡ Improving performance. 2018-11-02 18:06:28 +08:00
zuiidea d32b09d802 🔧 Change configuration file 2018-11-01 19:37:09 +08:00
zuiidea dbe86ec0a8 🐛 💄 Fix can't scroll on the table, Update the UI in the dashbord page. 2018-11-01 19:34:36 +08:00
zuiidea 5f8141148a :lipstick:Update logo and favicon. 2018-11-01 09:41:34 +08:00
zuiidea c7c24983cc :memo:Write the documentation of getting started. 2018-10-31 23:33:49 +08:00
zuiidea f80b6d366f :memo:Rewrite the documentation of app structure. 2018-10-30 22:32:59 +08:00
zuiidea 0b360eeebb fix some style, remove svg config. 2018-10-28 22:34:24 +08:00
zuiidea c92a1e1342 remove pwa config. 2018-10-28 20:44:19 +08:00
zuiidea c0963cbcd9 change umi version. 2018-10-27 14:09:35 +08:00
Baorong Li 4979d5cbc4 Upgrade umi@2.2.x 2018-10-26 17:17:55 +08:00
zuiidea ff85c1447b Upgrade umi@2.2.0-beta.8 2018-10-25 13:34:34 +08:00
Baorong Li df608b2dc1 remove yarn.lock 2018-10-25 09:29:49 +08:00
Baorong Li f38e0ce7e2 upgrade antd@3.10.x 2018-10-25 08:47:35 +08:00
zuiidea 0499b70218 Translation user and post page. 2018-10-24 18:52:56 +08:00
zuiidea f654b277fa Remove table animation. 2018-10-23 22:08:47 +08:00
zuiidea 95eeebb09e Add configuration for sticky primary layout header. 2018-10-23 16:29:35 +08:00
zuiidea bb61d7f7dc Optimize the Request page. 2018-10-20 17:03:26 +08:00
zuiidea d821a7cf59 Add automatic translation commend. 2018-10-18 23:13:55 +08:00
zuiidea 9db8827d4a Add automatic translation scripts. 2018-10-17 23:51:39 +08:00
zuiidea 0b17931c62 Menu supports multiple languages. 2018-10-16 16:20:48 +08:00
zuiidea c1df88c25f Added notification component on the Header. 2018-10-16 14:44:35 +08:00
zuiidea f6454e71f0 Added language switching drop-down menu. 2018-10-15 15:08:48 +08:00
zuiidea 66a6e55191 Added Scrollbar. 2018-10-12 22:05:16 +08:00
zuiidea 0a3fac2e72 Added component Scrollbar, Added avatar 2018-10-11 22:50:42 +08:00
zuiidea 2f928329d1 fix style. 2018-10-10 22:06:13 +08:00
zuiidea 9a5ff4aa98 mobile device supports drawer menu. 2018-10-09 11:46:19 +08:00
Baorong Li dd3c191139 upgrade umi@2.1 2018-09-28 14:17:23 +08:00
Baorong Li 9a702fb774 try to optimize city.js 2018-09-26 18:15:47 +08:00
Baorong Li d0c2713f20 fix login with wrong path 2018-09-22 18:31:20 +08:00
Baorong Li a6bf4286dc add favicon 2018-09-22 18:29:31 +08:00
zuiidea 08f40efd2b Optimization: cancel the request of the current page when the page jumps. 2018-09-20 19:55:32 +08:00
zuiidea 4e7795ec7c Optimization and rewriting PrimaryLayout. 2018-09-18 22:04:30 +08:00
zuiidea 5aa187334a Optimize mock related files and variable names. 2018-09-17 20:54:06 +08:00
zuiidea abfcf2460e Added new logo. 2018-09-17 17:22:29 +08:00
zuiidea bcd6248833 Merge branch 'develop' of https://github.com/zuiidea/antd-admin into develop 2018-09-17 10:52:33 +08:00
zuiidea baa5869fe5 doc:Initialize 2018-09-16 23:38:17 +08:00
Baorong Li 58f62d8145 add a simple e2e test and enable pwa 2018-09-16 12:32:54 +08:00
zuiidea 2044beb7f1 fix:Menu and breadcrumbs cannot be highlighted with the path. 2018-09-15 21:34:18 +08:00
zuiidea 5f8a298ece fix:change language after switching language login. 2018-09-15 18:06:24 +08:00
zuiidea 487b39f97c Merge branch 'develop' of https://github.com/zuiidea/antd-admin into develop 2018-09-15 17:50:48 +08:00
zuiidea c5724f208a Adjust the router to automatically add the current language prefix before the pathname in push and replace.
Added `pathMatchRegexp` to match routes and regexp if the route prefix is ignored
2018-09-15 17:50:15 +08:00
Baorong Li 03a23bab9a use dva-immer 2018-09-15 17:32:26 +08:00
zuiidea d58b6ce948 Merge branch 'develop' of https://github.com/zuiidea/antd-admin into develop 2018-09-15 12:28:34 +08:00
zuiidea 7c83c80d5d Support for dynamically loading language packs.
Load the `lodash` function as needed.
Added i18n related configuration.
2018-09-15 12:26:22 +08:00
Baorong Li 0801d83856 add github icon in GlobalFooter 2018-09-15 11:02:27 +08:00
zuiidea 8aa55330b4 Support antd-design-pro,Added language switching on login page. 2018-09-14 22:35:49 +08:00
Baorong Li ee10965f43 fix first page jump to login fail 2018-09-14 11:32:28 +08:00
zuiidea 755e19ed12 Use jslingui as the i18n solution. 2018-09-13 23:52:08 +08:00
zuiidea 40d3914b3c Optimize layout configuration to make it simpler and more flexible. 2018-09-13 20:20:25 +08:00
zuiidea 47fd515944 Using the decorator syntax, Fixed user page filter data by address. 2018-09-12 19:44:34 +08:00
zuiidea ba426defdd Optimize the utils function and add JSdoc
Change the variable color to Color.
Add package loadsh.isempty.
2018-09-12 18:10:09 +08:00
zuiidea a8bd1a587a Added eslint rule 'no-conole', Fixed request page and post page,Replace utils/enums.js to utils/contant.js 2018-09-12 16:00:36 +08:00
zuiidea 237efb4e72 Optimize the way to import api, Removed some configuration in utils/config.js. 2018-09-12 14:05:20 +08:00
zuiidea 0de24b8251 Merge branch 'develop' of https://github.com/zuiidea/antd-admin into develop 2018-09-11 22:00:29 +08:00
zuiidea 7077ef15f3 Added CI for document. 2018-09-11 22:00:18 +08:00
Baorong Li b3d5d11539 try add a simple test 2018-09-11 18:09:06 +08:00
Baorong Li 2522b971f1 clean README.md 2018-09-11 17:52:44 +08:00
Baorong Li c6be37ca09 fix login page miss 2018-09-11 17:52:16 +08:00
zuiidea ae8ac62938 Added docsify and update umi config. 2018-09-11 17:28:51 +08:00
zuiidea 349eb3216d Optimize .travis.yml. 2018-09-10 22:14:35 +08:00
zuiidea d1d559b1d6 Added travis. 2018-09-10 18:01:26 +08:00
zuiidea 7d5ad95cff Optimize file 'request.js',and remove code about cross domin. 2018-09-09 23:25:12 +08:00
zuiidea e09c22fc18 Merge branch 'develop' of https://github.com/zuiidea/antd-admin into develop
# Conflicts:
#	package.json
#	src/pages/dashboard/components/cpu.js
#	src/pages/dashboard/components/user.js
#	yarn.lock
2018-09-09 14:23:00 +08:00
zuiidea a7e84edad3 Added lint-staged and husky to unify code style. 2018-09-09 14:18:22 +08:00
zuiidea 0f466b2abe Added lint-statged and husky to unify code style. 2018-09-09 14:02:46 +08:00
Baorong Li 0ccdcdbc92 upgrade dep 2018-09-09 09:06:56 +08:00
Baorong Li 3d3f8e70dd remove MoonComponent in ECharts 2018-09-09 08:57:25 +08:00
Baorong Li 417215cfcc remove useless mock 2018-09-09 08:53:06 +08:00
Baorong Li ae8aba68f2 remove iconfont component related svg 2018-09-09 08:22:06 +08:00
zuiidea cd53b36e76 Deprecated component layer、Search、DataTable、Iconfont,because there are exist similar component in antd. 2018-09-08 23:39:49 +08:00
Baorong Li a44f5795fd remove iconfont component @part 2018-09-08 18:26:42 +08:00
zuiidea 556169871a Support Stylelint.
Added Prettierrc.
Optimize the configuration file .umirc.js, and Added annotations.
Replace eslint-plugin-umi to eslint-plugin-react-app as eslint bese plugin.
And so on.
2018-09-08 17:10:57 +08:00
zuiidea 261fbb8428 Support Stylelint.
Added Prettierrc.
Optimize the configuration file .umirc.js, and Added annotations.
Replace eslint-plugin-umi to eslint-plugin-react-app as eslint bese plugin.
And so on.
2018-09-08 17:10:20 +08:00
Baorong Li f02d39ace0 Merge pull request #891 from Louis-7/bugfix/motion-switch
Bugfix/motion switch
2018-09-04 15:58:00 +08:00
Louis-7 4d32829445 fix motion switch style on Users page 2018-09-04 15:06:48 +08:00
Baorong Li 5438e04fc5 Merge pull request #888 from Louis-7/bugfix/chart-shake
Bugfix/chart shake
2018-09-03 15:38:35 +08:00
Baorong Li 54bbe4d70a 🎉🎉 upgrade umi@2.0.0 2018-09-03 15:38:03 +08:00
Louis 78ece0c9fb Merge pull request #2 from Louis-7/bugfix/chart-shake
fix move mouse into chart will cause chart shake issue.
2018-09-03 15:26:48 +08:00
Louis-7 3407f9a0c1 fix move mouse into chart will cause chart shake issue. 2018-09-03 15:24:15 +08:00
Louis f1b78e8d1f Merge pull request #1 from Louis-7/bugfix/sidebar-shake
Bugfix/sidebar shake
2018-09-03 15:15:40 +08:00
Baorong Li df2fc9c32e Merge pull request #886 from Louis-7/bugfix/sidebar-shake
Bugfix/sidebar shake
2018-09-03 15:11:15 +08:00
Louis-7 cc5c319332 remove useless rule
Signed-off-by: Louis-7 <louisgh.cn@gmail.com>
2018-09-03 14:58:56 +08:00
Louis-7 43ef09dfed Revert "restrict rule"
This reverts commit 843bd1a088.
2018-09-03 14:57:04 +08:00
Louis-7 0c95751b8d restrict rule
Signed-off-by: Louis-7 <louisgh.cn@gmail.com>
2018-09-03 14:56:06 +08:00
Louis-7 843bd1a088 restrict rule
Signed-off-by: Louis-7 <louisgh.cn@gmail.com>
2018-09-03 14:54:20 +08:00
Louis-7 a0db71e571 Revert "fix sidebar shake once mouse over it"
This reverts commit 7fc14eddd9.
2018-09-03 13:28:13 +08:00
Louis-7 7fc14eddd9 fix sidebar shake once mouse over it
Signed-off-by: Louis-7 <louisgh.cn@gmail.com>
2018-09-03 13:18:46 +08:00
Baorong Li 7f2fdc383b upgrade query-string@6 2018-08-22 15:58:12 +08:00
Baorong Li ff2caecb3a Merge pull request #877 from sorrycc/refact/migrate-to-umi-2
[WIP] refact: migrate to umi@2
2018-08-22 15:32:08 +08:00
sorrycc d01df1ef13 refact: migrate to umi@2 2018-08-22 15:12:01 +08:00
Baorong Li cd55df3a15 upgrade antd@3.8.0 2018-08-05 22:46:55 +08:00
Baorong Li ce59f3e7b2 Update README.md 2018-08-02 17:25:18 +08:00
Baorong Li 4d722f103a Update README.md 2018-08-02 17:20:02 +08:00
Baorong Li c1bc6f27b1 upgrade antd@3.7.3 2018-08-02 16:50:34 +08:00
Baorong Li ed88c087bf forgot to remove css part in dashboard/user 2018-07-21 13:15:16 +08:00
Baorong Li 4e337caa07 remove unuse png 2018-07-21 12:49:56 +08:00
Baorong Li ea3929b4c2 cut project size 2018-07-21 12:43:10 +08:00
Baorong Li 9eff4ca129 modify mock method in users delete, fix #859 2018-07-20 11:48:33 +08:00
Baorong Li d6d94e2121 upgrade antd@3.7.0 2018-07-20 11:18:42 +08:00
Baorong Li 737c0236d6 Merge pull request #844 from JChord/master
refactor:修改父ID取值逻辑
2018-06-25 12:42:14 +08:00
DosLin 69571f2916 refactor:修改父ID取值逻辑 2018-06-25 10:53:10 +08:00
Baorong Li 6dffa7378c Merge pull request #842 from JChord/master
fix:进入动态路由下的二级页面菜单栏无法选中问题
2018-06-22 17:02:08 +08:00
DosLin c2a7bc7106 fix:进入动态路由下的二级页面菜单栏无法选中问题 2018-06-22 16:50:58 +08:00
Baorong Li e752750c9e update doc, fix #839 2018-06-20 09:07:52 +08:00
Baorong Li 32bde8f69e Merge pull request #834 from codeif/master
Rename variable name  domin  to domain
2018-06-08 10:26:44 +08:00
codeif b5f9ef51f1 Rename variable name domin
to domain
2018-06-07 21:53:54 +08:00
Baorong Li 8c10be6ee6 upgrade umi@1.3.5 => fix #723 2018-06-01 15:56:37 +08:00
zuiidea c1e3872792 Fix README conflic 2018-05-25 17:37:23 +08:00
zuiidea c3eef53e4d Merge develop and update to 5.0.0-beta 2018-05-25 17:20:20 +08:00
zuiidea a1b9c0c33d Fix the files under the 'public' dicrectoy can't visit after build 2018-05-25 17:13:30 +08:00
Baorong Li c49b0edbe9 update umi@1.3.4 & antd@3.5.3 [use MOCK=none for production] 2018-05-25 13:49:13 +08:00
Baorong Li 0fd8824ad7 fix logout not work #826, #669 2018-05-25 11:52:39 +08:00
Baorong Li 973199135f update umi@1.3.3 antd@3.5.2 [no production for this commit] 2018-05-17 18:32:33 +08:00
Baorong Li 43b7b21ad3 update umi@1.3.1 [no production for this commit] 2018-05-15 22:04:42 +08:00
Baorong Li ba96ebb874 update dep 2018-05-12 14:34:00 +08:00
Baorong Li c66f086f42 fix #372 update navOpenKeys when copy link open 2018-05-05 12:11:03 +08:00
Baorong Li 07b2175e92 upgrade dep antd@3.5.0, umi@1.2.6 2018-05-05 12:05:07 +08:00
Baorong Li d245a1eabc upgrade umi@1.2.1 2018-04-24 23:00:31 +08:00
Baorong Li f1e2fd3b18 fix: alias 2018-04-24 22:51:12 +08:00
Baorong Li a6b3dba92e cut moment locale/lodash size & revert d06ee20 2018-04-13 18:19:22 +08:00
Baorong Li 9fd0abe6ea update dep 2018-04-12 15:21:03 +08:00
Baorong Li d06ee20c47 remove dep highcharts-more/highcharts-exporting 2018-04-12 12:02:59 +08:00
Baorong Li 2ef1fa0367 upgrade dep umi 2018-04-12 12:00:35 +08:00
Baorong Li 9d459106c1 Update README.md 2018-04-10 17:27:19 +08:00
Baorong Li cd5a52a8fc Merge pull request #793 from iversonwool/add_leehow
comfirmLoding
2018-04-10 09:09:40 +08:00
李浩 d89320f578 comfirmLoding 2018-04-10 08:59:16 +08:00
Baorong Li df5ce2991e improve by github.com/sorrycc/blog/issues/66 2018-03-31 15:36:26 +08:00
Baorong Li 668c9c7dfe upgrade umi@1.1.4 (support dll gzip & cache) 2018-03-30 15:25:39 +08:00
Baorong Li 9bd13055c0 5.0.0-beta coming 2018-03-29 11:26:30 +08:00
Baorong Li 9f3adda59d upgrade dependencies 2018-03-29 11:16:11 +08:00
Baorong Li bfa7aaecb8 Merge pull request #770 from umijs/refact/migrate-to-umi
Refact/migrate to umi
2018-03-29 10:17:07 +08:00
Baorong Li 9d4310d8e0 Merge branch 'develop' into refact/migrate-to-umi 2018-03-29 10:16:31 +08:00
Baorong Li b3555b01f3 Merge branch 'master' into develop 2018-03-29 10:08:50 +08:00
chencheng 74895fbb0d fix: uglifyjs problem, use query-string@5 2018-03-26 17:59:22 +08:00
chencheng 5f04d19d2d add lost dep query-string 2018-03-26 17:18:43 +08:00
chencheng 0fef06e0c6 use umi-plugin-dll to enable dll 2018-03-26 17:15:27 +08:00
chencheng 89d22451c5 fix: svg don't work 2018-03-24 18:08:16 +08:00
chencheng fb49056c73 make login work 2018-03-24 14:39:54 +08:00
chencheng a3071819c4 eslint 2018-03-24 09:07:36 +08:00
chencheng d4e10ad5c2 refact the directory structure 2018-03-23 23:31:09 +08:00
chencheng 59e9a1053e refact config files and package.json 2018-03-23 22:51:41 +08:00
Baorong Li 308fc09b33 Merge pull request #767 from iversonwool/add_leehow
refresh bug in router/user
2018-03-16 14:30:42 +08:00
李浩 99ec9facd3 refresh bug 2018-03-16 10:29:32 +08:00
Baorong Li 2a96d7a5f6 Merge pull request #749 from nazarlitvin/remove-async
remove unnecessary async functions
2018-03-10 11:43:00 +08:00
Baorong Li 195e1d17f8 Merge pull request #755 from LizPeng/patch-1
Update Echarts version
2018-03-09 14:00:03 +08:00
Baorong Li 580ff59b3d Create issue_template.md 2018-03-09 11:18:39 +08:00
Baorong Li cfa5aae5da fix #754 add set port guide 2018-03-09 10:58:54 +08:00
Baorong Li 761125735a fix #572, move cross-env to devDependencies 2018-03-08 17:53:12 +08:00
zuiidea d1b12ff0e1 Fix issue resulting User page showing wrong content when delete alll items in last page. 2018-03-01 17:45:34 +08:00
zuiidea a47176a4e5 fix user table 2018-02-27 19:17:59 +08:00
Dustin bc3d7fb39a Update Echarts version
require Echarts  ^4.0.0
2018-02-27 19:02:53 +08:00
zuiidea 458548150c fix popup position 2018-02-26 20:02:46 +08:00
zuiidea f6e11bb464 eslint fix 2018-02-26 19:35:58 +08:00
zuiidea 479fc647c3 Merge branch 'Maple-Ro-master' into develop 2018-02-26 19:19:56 +08:00
zuiidea 9a4c63390d locale example 2018-02-26 19:19:42 +08:00
zuiidea 42cd1221fa logo text fadeIn 2018-02-26 19:13:43 +08:00
zuiidea 98b773fdc3 logo text fadeIn 2018-02-26 19:13:12 +08:00
zuiidea cd54fcbf52 Merge branch 'hiooyUI-master' into develop 2018-02-26 10:58:30 +08:00
nazarlitvin bd68f8c631 remove unnecessary async functions 2018-02-19 22:16:33 +02:00
liutsing 3fde5aaa3c 1、本地化
2、解决侧边栏收起时菜单图标悬浮下无菜单文字显示的问题
2018-02-16 01:22:44 +08:00
Nelson Kuang 2f34de5602 修复左侧菜单合拢时mouseover提示空白视觉bug
当一级菜单没子菜单时,mouseover会弹出tooltips,但提示空白无内容
2018-02-07 17:25:16 +08:00
zuiidea 2de1e81661 Merge pull request #711 from zuiidea/develop
Develop
2017-12-27 15:41:00 +08:00
zuiidea cea60c5683 update Readme 2017-12-27 15:39:45 +08:00
zuiidea f16e104f73 fix all echarts example 2017-12-26 14:14:52 +08:00
zuiidea c397f552c3 fix some style 2017-12-26 11:31:05 +08:00
zuiidea 11c4ac803e fix users pagination 2017-12-25 20:39:48 +08:00
zuiidea 09b9a100de fix header style 2017-12-25 19:54:48 +08:00
zuiidea 0e59df39ee Compatibility upgrade 2017-12-25 18:08:25 +08:00
zuiidea 30254a2ec7 upgrade package 2017-12-25 10:51:36 +08:00
zuiidea cd796a6db9 fix Search style 2017-11-24 11:27:00 +08:00
zuiidea 6ba75de576 add backTop 2017-11-24 11:02:52 +08:00
zuiidea a3637b3a99 user search 2017-11-24 10:32:12 +08:00
zuiidea 85ec09ec9a Merge branch 'weishijun14-patch-1' into develop 2017-11-24 10:28:25 +08:00
zuiidea eb683b98e7 Merge branch 'patch-1' of https://github.com/weishijun14/antd-admin into weishijun14-patch-1 2017-11-24 10:28:17 +08:00
zuiidea 9221173309 Merge branch 'silentred-master' into develop 2017-11-24 10:26:15 +08:00
zuiidea 55a96f41c6 Merge branch 'master' of https://github.com/silentred/antd-admin into silentred-master 2017-11-24 10:25:42 +08:00
zuiidea ce510e79c7 Merge branch 'nazarlitvin-pagination' into develop 2017-11-24 10:24:56 +08:00
zuiidea 3d4f2a8814 Merge branch 'pagination' of https://github.com/nazarlitvin/antd-admin into nazarlitvin-pagination 2017-11-24 10:24:22 +08:00
nazarlitvin 38e24edd7b fix incorrect pagination property 2017-11-22 16:01:49 +02:00
Jason W 841c987a1a Update package.json
update package.json
2017-11-03 04:21:16 -05:00
shijun Wei dc26f7d25e update query to search
history 的 location 属性上不再包含 query。
2017-11-02 13:19:09 +08:00
zuiidea bc754e683e assets 2017-10-30 14:07:09 +08:00
zuiidea c7acc8db4b Merge pull request #649 from zuiidea/develop
Develop
2017-10-30 11:56:42 +08:00
zuiidea 057acc514f Merge branch 'smackgg-master' into develop 2017-10-30 11:52:52 +08:00
zuiidea 53ce7d08fb Merge branch 'xx1595140265-patch-1' into develop 2017-10-30 11:51:27 +08:00
zuiidea 8ffd68a4e2 Merge branch 'dkvirus-patch-1' into develop 2017-10-30 11:50:18 +08:00
zuiidea 766653ee7a merge #640 2017-10-30 11:49:27 +08:00
zuiidea 9dedcc68c3 Merge branch 'the-wang-master' into develop 2017-10-30 11:28:23 +08:00
zuiidea 5ea2b3c9b8 extend chart 2017-10-30 11:25:53 +08:00
汪亚辉 d2ac91839a 面包屑导航支持多级参数
例:
当前router为/:appkey/:packageId
父级router为/:appkey
当前pathname为/com.mock.ly/58

可以看出appkey=com.mock.ly, packageId=58
目前这种场景下,面包屑中父级连接直接展示为/:appkey,没有解析

本次commit支持根据当前多param去填充面包屑中父级页面连接
2017-10-24 18:55:06 +08:00
the-wang bfca2746d8 Merge pull request #1 from zuiidea/master
up
2017-10-24 18:54:10 +08:00
dkvirus b417065ac3 Update Menu.js
在 Slider.js 中调用 Menu 组件,并没有传入 handleClickNavMenu 属性的值,代码优化。
2017-10-12 16:27:52 +08:00
xuxue abdc97f087 Update index.js
取错值,修正
2017-10-10 04:23:34 -05:00
smackxu 07016c6c26 Fixed dark theme menu bug 2017-09-12 16:27:22 +08:00
zuiidea b8162060cd Merge pull request #581 from zuiidea/develop
Develop
2017-09-11 10:27:01 +08:00
zuiidea 36fb5d293c delete extra code 2017-09-11 10:19:43 +08:00
zuiidea 429280a97a Merge branch 'DiroKate-master' into develop 2017-09-11 10:18:37 +08:00
zuiidea 7ac36c779e merge #332 2017-09-11 10:18:20 +08:00
zuiidea d2e7b464e3 megre #563 2017-09-11 10:00:25 +08:00
zuiidea 25839ea99f Merge branch 'akliyiping-develop' into develop 2017-09-11 09:55:27 +08:00
zuiidea 9c99581aa6 Merge branch 'develop' of https://github.com/akliyiping/antd-admin into akliyiping-develop 2017-09-11 09:55:05 +08:00
zuiidea e42c21f0eb fix #574 2017-09-11 09:53:22 +08:00
zuiidea 782bf214e5 Merge branch 'dong894-master' into develop 2017-09-11 09:47:45 +08:00
zuiidea a8a4496c7a Merge branch 'master' of https://github.com/dong894/antd-admin into dong894-master 2017-09-11 09:47:29 +08:00
zuiidea f5f013ff23 Merge branch 'develop' 2017-09-11 09:35:39 +08:00
zuiidea 8cff3af897 Merge branch 'master' of https://github.com/zuiidea/antd-admin 2017-09-11 09:35:32 +08:00
zuiidea 322d5a6d0e merge #576 2017-09-11 09:35:09 +08:00
zuiidea 2ee26f51a7 Merge branch 'yangbin1994-bug575' into develop 2017-09-11 09:27:00 +08:00
zuiidea e670ed2f96 Merge branch 'bug575' of https://github.com/yangbin1994/antd-admin into yangbin1994-bug575 2017-09-11 09:26:36 +08:00
杨斌 601a03b5b4 fix bug#575 2017-09-10 02:26:27 +08:00
dongyulei 00582e70ff 修复 4.3.5 中用户画面翻页无效的问题 2017-09-10 00:02:10 +08:00
zuiidea fa4385055b Update README.md 2017-09-09 14:54:18 +08:00
zuiidea 01f4de55df Merge pull request #571 from zuiidea/develop
Develop
2017-09-09 14:52:50 +08:00
zuiidea dedd34e8e8 4.3.5 2017-09-09 14:50:47 +08:00
zuiidea d1b373201e fix some grammar and details 2017-09-09 14:20:51 +08:00
zuiidea dbcc90e7ad 出去吃烧烤,回来接着看 2017-09-08 16:41:05 +08:00
zuiidea 87fce74d5b Update app.js
fix mobile Header menu
2017-09-08 15:45:38 +08:00
zuiidea 5c51679ad7 Merge branch 'yangbin1994-dva2.0' into develop 2017-09-08 14:56:35 +08:00
zuiidea 6d17b5b876 Merge branch 'yangbin1994-dva2.0' 2017-09-08 14:51:51 +08:00
杨斌 1288853112 解决冲突 2017-09-08 14:19:40 +08:00
杨斌 d3e7e17789 query-string 2017-09-08 13:27:07 +08:00
eric 6077d74f10 .gitignore 增加排除项 2017-09-07 11:00:00 +08:00
杨斌 7bf031699a add LoadingComponent 2017-09-06 20:36:14 +08:00
杨斌 a6eb693636 dva@2.0 2017-09-06 11:13:26 +08:00
zuiidea 39c377e0e2 Merge pull request #552 from zuiidea/develop
add entry.ejs and add lte IE10 polyfill
2017-09-06 09:56:25 +08:00
zuiidea 67b90c4df0 add entry.ejs and add lte IE10 polyfill 2017-09-06 09:55:41 +08:00
zuiidea 0e2727f9c8 Merge pull request #542 from zuiidea/develop
4.3.4
2017-09-04 11:48:32 +08:00
zuiidea 0cf02e7324 4.3.4 2017-09-04 11:47:24 +08:00
zuiidea 68c9f2b65f Merge pull request #540 from zuiidea/develop
fix login success redirect;fix css load delay
2017-09-04 11:03:13 +08:00
zuiidea 587b1f6b9d fix login success redirect;fix css load delay 2017-09-04 11:01:31 +08:00
zuiidea d94580a253 Merge pull request #302 from jackple/patch-1
Update app.js
2017-09-04 09:58:42 +08:00
zuiidea 531e3c0fcc Merge pull request #532 from p2227/patch-1
fixes #529
2017-09-04 09:57:39 +08:00
zuiidea d68de7a30c Merge pull request #378 from chenkang084/remote
fixed:when click breadcrumb back to other page,the left menu displaye…
2017-09-04 09:57:24 +08:00
zuiidea 45cb70f006 Merge pull request #518 from quanpower/patch-1
Update roadhogrc.js
2017-09-04 09:56:18 +08:00
kuang af85384f11 fixes #529 2017-08-28 23:20:38 -05:00
William Zhang 208539134f Update roadhogrc.js
proxy加引号,我这报错了,去掉了正常运行
2017-08-19 18:06:16 +08:00
zuiidea a3f41e3d64 Merge pull request #496 from zuiidea/develop
fix weather
2017-08-13 17:20:42 +08:00
zuiidea 3855c402c9 fix weather 2017-08-13 17:18:46 +08:00
zuiidea 6a58d8434b Merge pull request #493 from zuiidea/develop
fix lint
2017-08-10 19:11:39 +08:00
zuiidea d26a92ce79 fix lint 2017-08-10 19:11:10 +08:00
zuiidea 0d20717c3c Merge pull request #488 from zuiidea/develop
update
2017-08-09 09:46:45 +08:00
zuiidea a0d81dcda9 update 2017-08-08 19:46:38 +08:00
zuiidea 4490af8553 Merge pull request #486 from zuiidea/develop
Develop
2017-08-07 14:43:08 +08:00
zuiidea 75cc86152d 4.3.2 2017-08-07 14:28:32 +08:00
zuiidea 3d9f263330 add Alias 2017-08-07 11:46:42 +08:00
zuiidea 70b5d05f1a format 2017-08-03 14:01:49 +08:00
zuiidea 4da23b9133 version.js 2017-08-03 13:50:43 +08:00
zuiidea da1c52e82b npm run build:new 2017-07-31 17:28:03 +08:00
zuiidea 6ae39bfd55 Merge pull request #453 from phproot/master
fix README.md
2017-07-31 11:51:42 +08:00
Code d86f0ebd78 fix README.md 2017-07-31 11:38:39 +08:00
zuiidea ecbd8bf51e fix little bug 2017-07-31 10:49:15 +08:00
zuiidea 0c9fabe2b4 Merge pull request #452 from zuiidea/develop
fix build:dll
2017-07-30 23:51:07 +08:00
zuiidea 0f27528d2f fix build:dll 2017-07-30 23:50:12 +08:00
zuiidea a36d44d402 Merge pull request #450 from zuiidea/develop
Develop
2017-07-30 14:54:15 +08:00
zuiidea 37deddc911 update 4.3.0 2017-07-30 14:51:02 +08:00
zuiidea 52ee780e40 fix build error 2017-07-30 14:25:04 +08:00
zuiidea 057853cfde fix ejs-loader 2017-07-29 22:30:39 +08:00
zuiidea c0222c3957 add version build 2017-07-28 19:26:05 +08:00
zuiidea 00d8fc5ea5 add build:dll 2017-07-27 20:02:20 +08:00
zuiidea 1e08d7adaa optimized code 2017-07-27 19:13:31 +08:00
zuiidea b2b0aa872f Merge pull request #431 from zuiidea/develop
pagenation style
2017-07-21 19:03:48 +08:00
zuiidea a2b4ee4047 Merge branch 'master' into develop 2017-07-21 19:03:42 +08:00
zuiidea c6b477c993 pagenation style 2017-07-21 19:02:11 +08:00
zuiidea 548d7a8ddb Merge pull request #427 from zuiidea/develop
fix webpack.resoleve.alias module import/no-unresolved
2017-07-19 10:33:14 +08:00
zuiidea 755c1250e5 fix webpack.resoleve.alias module import/no-unresolved 2017-07-19 10:32:39 +08:00
zuiidea c5ebb91980 Merge pull request #413 from afc163/patch-1
No need customzied card style
2017-07-17 10:50:26 +08:00
zuiidea ae12492887 Merge pull request #416 from zuiidea/develop
update package.json
2017-07-17 10:48:01 +08:00
zuiidea 28a5863701 update package.json 2017-07-17 10:47:31 +08:00
偏右 bf65aa5478 Don't need customzied card style
https://github.com/ant-design/ant-design/issues/6833
2017-07-14 13:49:28 +08:00
zuiidea acd3fe8337 Merge pull request #407 from zuiidea/develop
fix permission.visit is null
2017-07-12 19:41:58 +08:00
zuiidea 3ad5a6cfb6 fix permission.visit is null 2017-07-12 19:41:32 +08:00
zuiidea c278c8f6f8 Merge pull request #393 from zuiidea/develop
666
2017-07-10 10:08:26 +08:00
zuiidea 71c75e15ff 666 2017-07-10 10:07:22 +08:00
zuiidea ec8db0e0fb Merge pull request #389 from zuiidea/develop
Develop
2017-07-06 19:16:04 +08:00
zuiidea 2a3587bae5 fix user role and update package.json 2017-07-06 19:15:32 +08:00
zuiidea a33772cbbc Merge branch 'master' into develop 2017-07-06 19:08:06 +08:00
zuiidea 89c58b2f98 Merge pull request #385 from zuiidea/develop
Develop
2017-07-05 19:46:48 +08:00
zuiidea b31bd32ca2 roleType 2017-07-05 19:45:57 +08:00
zuiidea f113fbb238 revise menu property : router => route 2017-07-05 19:29:14 +08:00
rengised a320c77ecb Merge branch 'master' into remote 2017-07-05 15:08:43 +08:00
zuiidea ce1d957212 Merge pull request #382 from zuiidea/develop
Develop
2017-07-05 13:56:00 +08:00
zuiidea 7f40ac6a60 Page Permission 2017-07-05 13:54:57 +08:00
zuiidea 3a01626df9 Permissions Simple Version 2017-07-04 23:41:13 +08:00
zuiidea 6df97916fa webpack alias 2017-07-04 22:44:48 +08:00
chenkang1 7f091d7642 fixed:the error request should be capured in catch method 2017-07-03 14:06:28 +08:00
chenkang1 d8f222f312 fixed:when click breadcrumb back to other page,the left menu displayed the last select item 2017-07-01 16:08:03 +08:00
zuiidea 3378510166 Merge pull request #363 from kuanger/develop
fix logout menu block by show more button
2017-06-19 16:14:49 +08:00
hausir edf1158351 fix logout menu block by show more button 2017-06-19 13:51:03 +08:00
zuiidea e4bd4e9aa0 Merge pull request #355 from blackboyhc/master
Update app.js
2017-06-19 10:18:58 +08:00
zuiidea 5904eae0c9 Merge pull request #349 from zuiidea/develop
Develop
2017-06-19 10:18:42 +08:00
zuiidea 7f26153472 Merge pull request #358 from wuyao1994/dev
showModal return bug
2017-06-19 10:16:59 +08:00
elviswu 4b9e142aa8 showModal return bug 2017-06-16 16:09:22 +08:00
blackboyhc 2679361559 Update app.js
如果在config.js中添加了openPages,比如openPages: ['/login', '/home'],但是进入/home页面仍然要先登录,所以在app model中如果query不成功需要判断当前页面是否被设置成openPages,再决定是否跳转到/login
2017-06-15 17:31:24 +08:00
zuiidea 2d246655e0 add example 2017-06-13 20:36:47 +08:00
zuiidea 04b147d33f proxy example 2017-06-13 20:30:24 +08:00
zuiidea 81801983a6 fix request 2017-06-13 20:26:38 +08:00
zuiidea e484b89518 Merge pull request #340 from zuiidea/develop
add eslint-loader
2017-06-09 10:01:45 +08:00
zuiidea bfff28411d add eslint-loader 2017-06-09 10:00:59 +08:00
zuiidea ae97c9d18a Merge pull request #335 from zuiidea/develop
Develop
2017-06-08 11:22:54 +08:00
zuiidea a063d8be39 fix production className to hash 2017-06-08 11:22:10 +08:00
zuiidea 79eab09941 fix production className to hash 2017-06-08 10:52:05 +08:00
zuiidea 2e3cb5970d pre eslint 2017-06-07 20:16:15 +08:00
zuiidea 460a8c906b Merge pull request #334 from zuiidea/develop
Develop
2017-06-07 19:47:37 +08:00
zuiidea 05a86e2f56 Filename and Classname To Hash 2017-06-07 19:46:56 +08:00
zuiidea 0295da6326 add Posts Page 2017-06-07 14:24:26 +08:00
diro 38637ef167 Example for the ”custom className iconfont” 2017-06-06 21:10:45 +08:00
diro fe8527e72e enable the custom ClassName 2017-06-06 21:08:42 +08:00
diro da64cac414 fix the wrong iconName 2017-06-06 20:45:56 +08:00
zuiidea 85e12caf12 update 2017-06-06 19:31:12 +08:00
zuiidea b83c5e7819 user Multiple Option 2017-06-01 20:16:18 +08:00
zuiidea 9cff4e8dfe 666 2017-05-25 13:55:36 +08:00
jackple f6021cbaa4 Update app.js
先判断menu长度,解决当远程加载menu时刷新页面,当前位置不会选中
2017-05-21 20:32:50 +08:00
zuiidea 6ac7e5e815 Merge pull request #296 from darcygail/master
fix YQL bugs:the chat '&' doesn't encoded
2017-05-17 16:59:21 +08:00
darcy e63dfc9291 fix YQL bugs:the chat '&' doesn't encoded 2017-05-17 15:55:40 +08:00
zuiidea 81e7d2bdca Merge pull request #292 from i06/master
Search.js has a mistake var name
2017-05-15 18:59:57 +08:00
i06 1c46693246 Search.js has a mistake var name
the variable named 'handeleSelectChange' seems not right.
turn `handeleSelectChange` to `handleSelectChange`
Hope  accept my advice, thanks
2017-05-15 17:22:55 +08:00
zuiidea 4832a720e6 Merge pull request #288 from zuiidea/develop
Develop
2017-05-13 14:37:37 +08:00
zuiidea 54964fabfb Update README.md 2017-05-13 14:37:18 +08:00
zuiidea b60e593f09 Update README.md 2017-05-13 14:35:53 +08:00
zuiidea 5359efce36 Merge pull request #287 from zuiidea/develop
update 4.2.1
2017-05-12 18:07:53 +08:00
zuiidea 3decccc87d update 4.2.1 2017-05-12 18:06:43 +08:00
zuiidea d92eecb620 Merge pull request #285 from zuiidea/develop
local icon
2017-05-12 15:56:18 +08:00
zuiidea c3c8cf6f9b local icon 2017-05-12 15:55:42 +08:00
zuiidea b4699e75f1 Merge pull request #284 from zuiidea/develop
fix NProgress
2017-05-12 12:26:35 +08:00
zuiidea 47ae7517a8 fix NProgress 2017-05-12 12:25:57 +08:00
zuiidea 19e07aeb7a Merge pull request #277 from zuiidea/develop
favicon
2017-05-11 11:11:49 +08:00
zuiidea c7789f103e favicon 2017-05-11 11:11:21 +08:00
zuiidea 2c74295843 Merge pull request #266 from zuiidea/develop
default.less
2017-05-09 16:36:12 +08:00
zuiidea 44605c6311 default.less 2017-05-09 16:35:37 +08:00
zuiidea 55ef9dfc0e Merge pull request #263 from zuiidea/develop
Develop
2017-05-09 15:25:45 +08:00
zuiidea 664aeba133 fix qureyuser 2017-05-09 15:25:08 +08:00
zuiidea d2595271f8 iconfont 2017-05-09 15:21:50 +08:00
zuiidea 0f03e1aa1a ICONFONT 2017-05-09 11:28:49 +08:00
zuiidea 255b0a95a7 Merge pull request #258 from zuiidea/develop
add:nprogress,dva-loading
2017-05-08 15:34:15 +08:00
zuiidea 839a7c5e0d add:nprogress,dva-loading 2017-05-08 15:32:25 +08:00
zuiidea bf260db940 Update README.md 2017-05-03 17:30:44 +08:00
zuiidea 3e1e782617 Update package.json 2017-05-03 17:29:44 +08:00
zuiidea a1e8f2ed9b Update package.json 2017-05-03 17:15:35 +08:00
zuiidea 88d835cee4 Merge pull request #237 from zuiidea/4.2-dev
Update README.md
2017-05-02 10:45:05 +08:00
zuiidea 2434368b1f Update README.md 2017-05-02 10:44:46 +08:00
zuiidea 058407e442 Update 2017-05-02 10:41:58 +08:00
zuiidea d486fce33e Merge pull request #232 from zhangpenglin/master
fix typo
2017-04-30 19:06:37 +08:00
zuiidea abbcfb8c3c Merge pull request #233 from afc163/master
Fix devDeps
2017-04-30 19:04:01 +08:00
Zhang Penglin 78166e6a83 fix bug 2017-04-30 12:29:48 +08:00
afc163 ca59db9f04 Fix devDeps 2017-04-29 17:35:54 +08:00
Zhang Penglin 5c8286b5cb fix typo 2017-04-28 23:31:50 +08:00
zuiidea ed8555f15e Update README.md 2017-04-28 17:01:27 +08:00
zuiidea 33d4f1679e Merge branch '4.2-dev' 2017-04-28 16:42:59 +08:00
zuiidea 37dd7d011e merge 2017-04-28 16:42:31 +08:00
zuiidea 5641cc61c4 user mocj 2017-04-28 16:34:08 +08:00
zuiidea 30f14769f0 responsive 2017-04-27 20:40:47 +08:00
zuiidea 1b7c076057 filters 2017-04-27 20:26:30 +08:00
zuiidea fbd0fcb5c4 user detail 2017-04-27 17:59:39 +08:00
zuiidea 19ee8eacce user API restful 2017-04-27 17:29:50 +08:00
zuiidea 0b0c7c3f91 update some texts and configs 2017-04-27 14:40:36 +08:00
zuiidea 5d6abb67b6 Merge pull request #219 from HeChengjun/master
修正 arrayToTree 函数中 children 拼写错误
2017-04-26 16:55:17 +08:00
HeChengjun 1a58dcac95 修正 arrayToTree 函数中 children 拼写错误 2017-04-26 16:16:35 +08:00
zuiidea 3d2d524898 Update README.md 2017-04-25 16:17:25 +08:00
zuiidea 2869ead7bd Merge pull request #215 from zuiidea/4.2-dev
fix
2017-04-25 12:34:10 +08:00
zuiidea 25f7a65897 fix 2017-04-25 12:32:42 +08:00
zuiidea 75e6284873 Merge pull request #214 from zuiidea/4.2-dev
Menu defaultSelected
2017-04-25 12:19:03 +08:00
zuiidea 5fbb1b338a Menu defaultSelected 2017-04-25 12:09:20 +08:00
zuiidea ed2b0b25bd Merge pull request #212 from HeChengjun/patch-1
menu.js line 96 : lineChart -> barChart
2017-04-24 17:57:56 +08:00
HeChengjun a4922c7541 menu.js line 96 : lineChart -> barChart 2017-04-24 17:17:10 +08:00
zuiidea 4566171265 Merge pull request #209 from zuiidea/4.2-dev
hot update
2017-04-24 10:10:55 +08:00
zuiidea 6c48480031 hot update 2017-04-24 10:09:10 +08:00
zuiidea 3401215d41 Merge pull request #202 from zuiidea/4.2-dev
fix userInfo
2017-04-21 18:37:36 +08:00
zuiidea 7284cad8aa fix userInfo 2017-04-21 18:36:35 +08:00
zuiidea 53b8ee6e63 Update README.md 2017-04-21 11:06:23 +08:00
zuiidea b5423129f7 Merge pull request #198 from zuiidea/4.2-dev
4.2 dev
2017-04-21 10:49:31 +08:00
zuiidea 4c6deb49d3 fix menu open 2017-04-21 10:47:55 +08:00
zuiidea b6c3cb8a53 rebuild bread,menu,router 2017-04-20 19:26:17 +08:00
zuiidea a42e749c22 Update README.md 2017-04-19 19:27:23 +08:00
zuiidea cfd0172204 error message 2017-04-19 19:10:20 +08:00
zuiidea 4a694b6df8 fix prop-types 2017-04-18 09:56:15 +08:00
zuiidea 3d03fa0695 Merge pull request #184 from zlotus/remote_dev
fix mock error caused by invalid username
2017-04-15 18:45:25 +08:00
ZealotuS 69fbb4742e fix mock error caused by invalid username 2017-04-15 18:13:56 +08:00
zuiidea db8b283def Merge branch '4.1-dev' 2017-04-14 19:47:55 +08:00
zuiidea 4f673b6e3a merge 2017-04-14 19:47:33 +08:00
zuiidea 3fa7ee9e01 update README 2017-04-14 19:38:28 +08:00
zuiidea b0f790ffaf update theme 2017-04-14 18:55:19 +08:00
zuiidea 4cf25852da userlogin 2017-04-14 11:45:00 +08:00
zuiidea 688d411dc1 request demo 2017-04-14 10:16:37 +08:00
zuiidea d5e763febc underline 2017-04-13 19:06:00 +08:00
zuiidea b03f2ac89c users 2017-04-13 19:00:32 +08:00
zuiidea 28efa1a67b mock 2017-04-13 18:31:48 +08:00
zuiidea a9ff576958 login 2017-04-10 23:33:10 +08:00
zuiidea 63e4fea0ca update roadhog 2017-04-10 21:06:31 +08:00
zuiidea f7616d524b update request 2017-04-01 17:53:06 +08:00
zuiidea 45d7abfbf4 fix bug 2017-04-01 16:46:32 +08:00
zuiidea c2c0ce0267 adjust 2017-04-01 15:25:45 +08:00
zuiidea d25b1cb5dc mock login 2017-04-01 14:47:42 +08:00
zuiidea 83f71c7d3e roadhog first commit 2017-04-01 12:04:03 +08:00
zuiidea 08fdd0d04f Merge pull request #159 from zuiidea/4.0-dev
fix getUserInfo
2017-03-31 21:05:51 +08:00
zuiidea 847f003cd8 fix getUserInfo 2017-03-31 21:04:33 +08:00
zuiidea 0be85a93b9 Merge pull request #158 from zuiidea/4.0-dev
user permissions
2017-03-31 20:31:50 +08:00
zuiidea 6c4733ff82 user permissions 2017-03-31 20:28:34 +08:00
zuiidea 6c4e347299 Merge pull request #152 from pmg1989/master
fix modal form
2017-03-30 11:47:44 +08:00
zuiidea f121b8884c Update skin.less 2017-03-30 11:47:26 +08:00
zuiidea 42c536b958 Update package.json 2017-03-30 11:47:05 +08:00
pmg1989 73bcc3901a fix modal form bug 2017-03-29 15:52:14 +08:00
pmg1989 870efa0771 Merge branch 'master' of github.com:pmg1989/antd-admin 2017-03-29 15:47:00 +08:00
pmg1989 d5e2af2e46 fix package.json 2017-03-29 15:46:35 +08:00
pmg1989 a3429133c3 fix package.json 2017-03-29 15:46:07 +08:00
zuiidea 71065c3922 Merge pull request #151 from zuiidea/4.0-dev
fix some bugs
2017-03-29 10:11:58 +08:00
zuiidea a5cb3c6fce fix some bugs 2017-03-28 20:50:02 +08:00
zuiidea cb1a955723 Merge pull request #147 from zuiidea/4.0-dev
4.0 dev
2017-03-27 20:28:41 +08:00
zuiidea e1a6f41192 dashboard 2017-03-27 20:27:58 +08:00
zuiidea e23cd6726f adjust dasheboard 2017-03-27 15:25:41 +08:00
zuiidea a85277ea82 adjust components DataTable 2017-03-27 15:09:36 +08:00
zuiidea efdf65a1f9 Merge pull request #144 from zuiidea/4.0-dev
adjust request
2017-03-27 14:42:48 +08:00
zuiidea 47c1fdcaff adjust request 2017-03-27 14:41:01 +08:00
zuiidea f649247a0e Merge pull request #142 from zuiidea/4.0-dev
4.0 dev
2017-03-24 20:34:56 +08:00
zuiidea f595f04a53 modify request 2017-03-24 20:32:27 +08:00
zuiidea d3c4912df3 requestPage 2017-03-24 19:25:17 +08:00
zuiidea 22c6365129 update request 2017-03-24 14:49:23 +08:00
zuiidea e5a184a568 Merge pull request #141 from zuiidea/4.0-dev
4.0 dev
2017-03-24 12:09:33 +08:00
zuiidea 3474b35966 Merge pull request #140 from zuiidea/master
Merge pull request #139 from zuiidea/4.0-dev
2017-03-24 12:07:00 +08:00
zuiidea 8227e61b64 delete extra layout filefold 2017-03-24 12:04:40 +08:00
zuiidea 1a11abb2f3 handle fileName 2017-03-24 11:57:57 +08:00
zuiidea 930c8427e0 handle FileName 2017-03-24 11:56:26 +08:00
zuiidea 8d4762fd0c Merge pull request #139 from zuiidea/4.0-dev
4.0 dev
2017-03-24 11:25:42 +08:00
zuiidea f0f9deb89a Merge branch 'master' into 4.0-dev 2017-03-24 11:25:36 +08:00
zuiidea 76d0af964f fileName fist word toLocaleUpperCase 2017-03-24 11:20:27 +08:00
zuiidea 4425a289e7 switch requst form robe-ajax to axios 2017-03-24 11:14:49 +08:00
zuiidea de68881f32 adjust layout 2017-03-23 19:30:00 +08:00
zuiidea 5c790b4215 Merge pull request #138 from xland/patch-1
delete useless code
2017-03-23 15:45:25 +08:00
liulun 2b24bcecf1 这几行代码没什么用 2017-03-23 15:21:02 +08:00
zuiidea 82351e60a6 Merge pull request #137 from zuiidea/4.0-dev
4.0 dev
2017-03-23 15:17:17 +08:00
zuiidea d71d66c80a add recharts 2017-03-23 15:14:13 +08:00
zuiidea a88f230071 add pre-commit 2017-03-23 11:11:36 +08:00
zuiidea 10bb0098d4 add UI Components: charts 2017-03-23 10:44:26 +08:00
zuiidea 9d011b9c75 add components:Editor 2017-03-23 09:57:31 +08:00
zuiidea c6227ba583 add Helmet 2017-03-22 10:49:16 +08:00
zuiidea 56ca4407ed Merge pull request #134 from zuiidea/4.0-dev
sorry,rebuild folder structure,😂
2017-03-21 16:44:55 +08:00
zuiidea 3585852fff sorry,rebuild folder structure,cry.. 2017-03-21 16:43:14 +08:00
zuiidea 30141661c2 update components DataTable 2017-03-20 17:45:08 +08:00
zuiidea d8ca570348 fix dataTable other fetch params 2017-03-20 10:58:26 +08:00
zuiidea 19d5c3c4bd fix request.js 2017-03-20 10:09:17 +08:00
zuiidea ec8f808b4d add components DataTable 2017-03-17 19:30:39 +08:00
zuiidea 741ba9e67a add eslint style for mock-folder 2017-03-17 10:27:52 +08:00
zuiidea ac6fdc5fba fix react-countup version 2017-03-16 16:59:18 +08:00
zuiidea 4dbbce5de7 Merge pull request #126 from pmg1989/master
fix menu style
2017-03-16 15:00:53 +08:00
pmg1989 6ab878061d 修复左侧栏菜单在key相同时,会同时选中bug 2017-03-16 14:58:20 +08:00
pmg1989 df1b53130f fix menu style 2017-03-16 14:36:58 +08:00
zuiidea f8a7eadf33 Merge pull request #125 from zuiidea/4.0-dev
fix sider-menu style
2017-03-16 13:36:32 +08:00
zuiidea 03539b3cc5 fix sider-menu style 2017-03-16 13:09:42 +08:00
zuiidea 8b9690fc69 Merge pull request #122 from zuiidea/4.0-dev
4.0 dev
2017-03-15 17:46:33 +08:00
zuiidea 4847dcb335 Merge branch 'master' into 4.0-dev 2017-03-15 17:46:28 +08:00
zuiidea 73abfd7009 Merge pull request #120 from catest/patch-1
Update src/components/users/modal.js
2017-03-15 17:41:09 +08:00
zuiidea e1b543969d Merge pull request #121 from catest/patch-2
修改git clone
2017-03-15 17:40:59 +08:00
zuiidea 8c9e9179e0 add: standard.md 2017-03-15 17:40:45 +08:00
catest 7869326836 修改git clone
https://github.com/zuiidea/antd-admin.git
2017-03-15 17:24:36 +08:00
catest a3cf837e5c Update src/components/users/modal.js
<Form horizontal>已弃用,改成<Form layout="horizontal">
2017-03-15 17:20:24 +08:00
zuiidea 9a89b7f4cd For static build 2017-03-15 16:42:35 +08:00
zuiidea 8bb1e1f964 updateReademe 2017-03-15 16:36:45 +08:00
zuiidea a6f2a2a67a add: ui-component layer 2017-03-15 16:31:43 +08:00
zuiidea b715b72041 Ajust router.js 2017-03-14 20:14:58 +08:00
zuiidea c43910f235 add gitignore 2017-03-14 20:04:48 +08:00
zuiidea 47e7d580bb eslint apply all 2017-03-14 20:03:21 +08:00
zuiidea 23540e4394 add eslintrc and aplly to src/ui 2017-03-14 16:44:54 +08:00
zuiidea db5105e482 add:standard-js 2017-03-14 14:25:17 +08:00
zuiidea 66ee718374 Adjust theme font-family 2017-03-14 11:44:59 +08:00
zuiidea 9492aa6d86 add: dva-loading 2017-03-13 18:11:57 +08:00
zuiidea 0e8434e32b add: dva-loading 2017-03-13 18:03:28 +08:00
zuiidea cdc607ba07 add:ui-components 2017-03-13 16:15:28 +08:00
zuiidea 41184c549a 'add:babel-polyfill' 2017-03-12 22:32:13 +08:00
zuiidea ed5125a27b fix request data 2017-03-09 15:22:21 +08:00
zuiidea c0600658e5 add readme 2017-03-07 15:29:46 +08:00
zuiidea 1f796a9a23 add demo : local-image as background 2017-03-07 15:13:51 +08:00
zuiidea 48e343450f add ajax param 2017-03-07 15:01:59 +08:00
zuiidea 0d9127fda2 propTypes 2017-03-01 20:09:59 +08:00
zuiidea 60c046b200 fix build 2017-02-28 17:29:03 +08:00
zuiidea 85cf4af52c Merge pull request #91 from pmg1989/3.0
publicPath from webpack.config.js to package.json
2017-02-28 12:45:33 +08:00
pmg1989 cc77167efb publicPath from webpack.config.js to package.json 2017-02-28 12:09:15 +08:00
zuiidea 6466276d0b chunkName 2017-02-27 22:29:45 +08:00
zuiidea c5dc892300 add user-IsMotion 2017-02-27 22:15:13 +08:00
zuiidea f303d0ae3c fix 移动端模式下菜单不可见 2017-02-26 22:04:12 +08:00
zuiidea 86de7f1658 fix 侧边收展时跳白块 2017-02-26 21:11:23 +08:00
zuiidea ff6e601777 code style 2017-02-25 22:08:01 +08:00
zuiidea 9bc01e4170 Merge pull request #83 from pmg1989/3.0
列表动画提取TableBodyWrapper.js组件
2017-02-25 21:50:36 +08:00
pmg1989 547811ced9 列表动画提取TableBodyWrapper.js组件 2017-02-25 21:40:02 +08:00
zuiidea 3106b2488e Merge pull request #82 from pmg1989/3.0
hashHistory to browserHistory
2017-02-25 20:48:56 +08:00
pmg1989 e03316423f hashHistory to browserHistory 2017-02-25 15:19:27 +08:00
zuiidea a07b2a6b0e update antd 2017-02-23 14:02:12 +08:00
zuiidea 86f08fb914 option style 2017-02-20 22:15:34 +08:00
zuiidea 702b3cc196 js format 2017-02-14 21:45:55 +08:00
zuiidea b9f8912c86 i dont know 2017-02-14 21:06:33 +08:00
zuiidea b8d98947e6 fix:单行动画 2017-02-13 23:03:57 +08:00
zuiidea 7716b24933 Merge pull request #73 from pmg1989/3.0
users-motion rc-tween-one 优化
2017-02-13 11:21:58 +08:00
felixpmg 26bff8fbb7 users-motion rc-tween-one 优化 2017-02-12 14:49:47 +08:00
zuiidea 136b61e9e3 Merge branch '3.0' of github.com:zuiidea/antd-admin into 3.0 2017-02-12 09:45:05 +08:00
zuiidea 11977625d2 Merge pull request #71 from pmg1989/3.0
优化侧边栏导航刷新后依旧能展开选中当前地址的菜单项
2017-02-12 09:41:54 +08:00
felixpmg ceabac1a14 优化 users-motion 2017-02-12 00:26:38 +08:00
felixpmg d1ffb7e3e9 优化侧边栏导航刷新后依旧能展开选中当前地址的菜单项 2017-02-10 22:25:11 +08:00
zuiidea 64c152fb2d 修改操作样式 2017-02-10 16:20:39 +08:00
zuiidea 46ff98b8af add:users-motion 2017-02-10 15:27:31 +08:00
zuiidea c97cf3e4f5 add:user-motion 2017-02-09 23:37:23 +08:00
zuiidea 4def8c5d70 add:userMotion 2017-02-09 22:33:32 +08:00
zuiidea 803d8ac72c add:badge 2017-02-06 11:20:10 +08:00
zuiidea 2df2b36d33 fix:sider zIndex;fix:search repeat request data 2017-02-05 22:05:53 +08:00
zuiidea 0700bcd700 fix:Table's rowKey;antd upgrade 2017-02-05 22:00:06 +08:00
zuiidea 184613d003 menu overflow-x hidden 2017-02-03 19:47:28 +08:00
zuiidea a1642fa671 menu overflow-x hidden 2017-02-03 19:01:03 +08:00
zuiidea a2abe411c4 fix:sidebar scroll and fold overflow visible 2017-01-30 23:38:16 +08:00
zuiidea ff84e3df7d Merge branch '3.0' of github.com:zuiidea/antd-admin into 3.0 2017-01-24 19:33:46 +08:00
zuiidea 3b4040e481 browser row-key 2017-01-23 20:48:39 +08:00
zuiidea 570bc90d6a Merge pull request #47 from zhongjie-chen/3.0
users page modal title
2017-01-22 16:06:57 +08:00
Jason e440013cee 标题区分(新建用户 修改用户) 2017-01-22 15:45:04 +08:00
zuiidea 964e702b6d Merge branch '3.0' of github.com:zuiidea/antd-admin into 3.0 2017-01-22 10:04:32 +08:00
zuiidea e2b27696c8 Merge pull request #45 from pmg1989/3.0
新增了页面刷新后,首次请求数据可以同时带关键字搜索与页码过滤功能
2017-01-21 17:27:26 +08:00
zuiidea d6dbde5382 pagination 2017-01-21 17:26:22 +08:00
felixpan 91b86a3b6c 修复了带关键字搜索时分页跳转bug;新增了页面刷新后,首次请求数据可以同时带关键字搜索与页码过滤功能 2017-01-21 00:47:28 +08:00
felixpan 26fba238a0 修复嵌套路由注销失败bug 2017-01-21 00:13:15 +08:00
zuiidea eb78f6c226 request 2017-01-18 22:10:13 +08:00
zuiidea ff2005d262 fix upgrad dva@1.2 2017-01-17 22:00:30 +08:00
zuiidea 60ff1ec0bb fix:menu vertical 2017-01-13 22:42:54 +08:00
zuiidea f41f4bcf29 Merge branch '3.0' of github.com:zuiidea/antd-admin into 3.0 2017-01-12 21:57:02 +08:00
zuiidea 78eab2b15c add demo.gif 2017-01-12 21:54:44 +08:00
zuiidea 4a68e64e81 logout loading 2017-01-12 21:39:35 +08:00
zuiidea 5e5a8f86eb page 2017-01-12 13:38:45 +08:00
zuiidea bcf9307b22 Merge pull request #29 from 0neSe7en/dev
feat(Search): add "onPressEnter" callback to search input.
2017-01-11 21:26:23 +08:00
wsy 428bfcb3a5 feat(Search): add "onPressEnter" callback to search input. 2017-01-11 16:17:58 +08:00
zuiidea 142b059fc3 fix:#28登录后点击回车跳到登录页 2017-01-10 18:26:05 +08:00
zuiidea 9452df8e0e siderMenu scrollbar 2017-01-10 14:27:34 +08:00
zuiidea 4944844522 Merge branch '3.0' of github.com:zuiidea/antd-admin into 3.0 2017-01-10 14:14:12 +08:00
zuiidea 59b40a2286 Merge pull request #26 from paranoidjk/3.0
fix: 修复侧边栏菜单超长无法滚动的问题
2017-01-10 14:13:00 +08:00
zuiidea e2f0c9b5cb Merge branch '3.0' of github.com:zuiidea/antd-admin into 3.0 2017-01-10 14:12:08 +08:00
zuiidea 7a98a485ab Merge pull request #23 from YehanZhou/patch-1
Update app.js
2017-01-10 14:11:17 +08:00
paranoidjk c1f5384d41 fix: 修复侧边栏菜单超长无法滚动的问题 2017-01-10 12:17:15 +08:00
zuiidea e78a487984 table width 2017-01-09 23:14:43 +08:00
zuiidea 9963e753d4 Search Doc 2017-01-09 23:02:21 +08:00
zuiidea 41ed8233b7 switchMenuPopover 2017-01-09 22:19:41 +08:00
zuiidea bf9e720500 less styles 2017-01-09 21:57:01 +08:00
zuiidea d0974b22bd search components 2017-01-08 22:56:29 +08:00
zuiidea bb1b7e9d4b search 2017-01-07 21:30:48 +08:00
zuiidea 0e7c34a8d4 search 2017-01-05 18:45:52 +08:00
Yehan Zhou 03db3a3bcf Update app.js 2017-01-05 17:16:20 +08:00
zuiidea dd3b4a8a80 theme 2017-01-04 14:24:12 +08:00
zuiidea 44037be442 antd upgrad 2017-01-04 10:33:55 +08:00
zuiidea 27ac1b0583 login 2017-01-03 16:34:43 +08:00
zuiidea 87af30c4b6 😪 js-standard 2017-01-03 16:15:38 +08:00
zuiidea 49d700a5c8 table 2016-12-30 20:18:03 +08:00
zuiidea 960ab30a33 navbar 2016-12-29 23:38:25 +08:00
zuiidea 75884305a0 😪 2016-12-29 22:34:05 +08:00
zuiidea e4bda2c3ae letter 2016-12-29 22:18:03 +08:00
zuiidea 61861789da letter 2016-12-29 22:17:33 +08:00
zuiidea 85b0069199 readme 2016-12-28 19:18:32 +08:00
zuiidea 9c5b3fdc68 dy-router 2016-12-28 00:16:25 +08:00
zuiidea 6af4dc9303 dy-router 2016-12-28 00:10:24 +08:00
zuiidea 6b108497cc dy-router 2016-12-28 00:03:59 +08:00
zuiidea 5c0a9c0e08 disableNpmInstall 2016-12-27 22:57:28 +08:00
zuiidea 8e678bbae6 dva-loading 2016-12-27 22:06:08 +08:00
zuiidea 5aca96bde8 user 2016-12-26 19:40:00 +08:00
zuiidea cebb9c5180 cpu.less 2016-12-26 18:03:39 +08:00
zuiidea 586e716380 cpu 2016-12-26 17:56:52 +08:00
zuiidea 0a7bfcd478 props 2016-12-26 09:51:02 +08:00
zuiidea fdba67e613 ico 2016-12-25 18:57:57 +08:00
zuiidea a79139fbc9 ico 2016-12-25 18:15:07 +08:00
zuiidea 7e521d5f05 ico 2016-12-24 23:19:32 +08:00
zuiidea ffdde1815b readme 2016-12-24 22:30:34 +08:00
zuiidea 35dbf3772b config 2016-12-23 14:45:19 +08:00
zuiidea bd01b7e777 browser 2016-12-21 22:47:59 +08:00
zuiidea a9f3b3338d COMPLETED 2016-12-21 21:57:34 +08:00
zuiidea 4500218a1f commentd 2016-12-21 21:26:41 +08:00
zuiidea 8ce7a5d48b comments 2016-12-21 21:19:33 +08:00
zuiidea 2b267a086b add demo2.gif 2016-12-20 22:58:15 +08:00
zuiidea f325dfc79d recentsales 2016-12-20 22:46:15 +08:00
zuiidea 0e7c89c885 rencentsales 2016-12-20 22:04:48 +08:00
zuiidea e3b4ecc491 avatar 2016-12-15 23:02:34 +08:00
zuiidea 6c9fb19770 dashbord mockdata 2016-12-15 22:57:02 +08:00
zuiidea 7a1d157893 dashbord mock 2016-12-15 22:37:53 +08:00
zuiidea f49ed470bd weathe - [x] r 2016-12-15 00:07:18 +08:00
zuiidea 0e3dcb2c67 weather 2016-12-14 23:46:47 +08:00
zuiidea e9c361a8a9 weather 2016-12-14 23:11:56 +08:00
zuiidea 67dd886130 666 2016-12-14 14:05:36 +08:00
zuiidea 7fd8bcdcc6 mockStorge 2016-12-14 00:19:41 +08:00
zuiidea 5030c91f47 prefix 2016-12-13 23:37:48 +08:00
zuiidea 48894e63aa prefix 2016-12-13 23:34:04 +08:00
zuiidea ad0050ee54 mock storge 2016-12-13 09:31:29 +08:00
zuiidea ef61551341 get data 2016-12-12 23:36:59 +08:00
zuiidea f0ca3f172c Weather 2016-12-12 22:53:01 +08:00
zuiidea 83eebe75e5 weather 2016-12-12 22:27:02 +08:00
zuiidea 50ceb8c825 readme 2016-12-08 09:56:29 +08:00
zuiidea b2c675cc7a readme 2016-12-07 09:33:19 +08:00
zuiidea 79e1608ece readme 2016-12-06 23:34:11 +08:00
zuiidea 8021306124 Quote 2016-12-06 22:51:21 +08:00
zuiidea f27aca76b2 countup 2016-12-06 22:00:04 +08:00
zuiidea dd1dd9390a readme 2016-12-06 21:41:52 +08:00
zuiidea 5c196033d9 LICENSE 2016-12-06 10:30:26 +08:00
zuiidea f8f3b78aa1 delete dist 2016-12-05 22:37:44 +08:00
zuiidea 0c78dd3f96 ignore 2016-12-05 22:35:03 +08:00
zuiidea 9c2a906b58 dist 2016-12-05 22:17:08 +08:00
zuiidea c011849708 siderconfig 2016-12-05 21:18:45 +08:00
zuiidea a77817673e demo.gif 2016-12-04 00:13:41 +08:00
zuiidea f8e64d2dc6 logs 2016-12-03 23:59:50 +08:00
zuiidea 5258670e86 url active menu 2016-12-03 23:46:22 +08:00
zuiidea 3d8a952f5f enter login 2016-12-03 23:38:47 +08:00
zuiidea ff7c26ced6 update 2016-12-03 23:10:47 +08:00
zuiidea 63d214e45a light theme 2016-12-03 22:38:07 +08:00
zuiidea 186f19cb65 sider 2016-11-30 18:57:19 +08:00
zuiidea e5bdd3b986 header 2016-11-30 15:18:43 +08:00
zuiidea 9838392c43 sider fold 2016-11-30 10:30:58 +08:00
zuiidea 3178f2d864 side gold 2016-11-29 23:05:26 +08:00
zuiidea 82e98b5838 side fold 2016-11-29 23:04:01 +08:00
zuiidea 3fdeb2fa0b sider fold 2016-11-29 22:53:04 +08:00
zuiidea 56f8ffe389 side fold 2016-11-29 22:06:35 +08:00
zuiidea b781d9d4c8 switchSIder 2016-11-28 23:59:53 +08:00
zuiidea 6a54b616ec dashbord layout 2016-11-28 23:31:15 +08:00
zuiidea 7f0e81d8b9 sales charts 2016-11-28 22:46:16 +08:00
zuiidea 6c50086d92 dashbord chart 2016-11-25 23:02:13 +08:00
zuiidea 4b55dbccb9 theme 2016-11-24 23:58:27 +08:00
zuiidea 2320fe5af5 add theme 2016-11-24 23:08:58 +08:00
zuiidea ace8c2568f dashbord 2016-11-24 14:19:23 +08:00
zuiidea 5cf8923442 number card 2016-11-23 23:51:09 +08:00
zuiidea 7f1bd1c08a numbercard 2016-11-23 23:16:53 +08:00
zuiidea 5150f5f84b yarn and assets 2016-11-22 22:18:18 +08:00
zuiidea dce274a8a1 spin 2016-11-07 23:50:33 +08:00
zuiidea c45d6c969b update antd 2016-11-07 23:36:05 +08:00
zuiidea bdb6142776 readme 2016-11-04 21:07:19 +08:00
zuiidea ca92904714 loading 2016-11-03 23:39:27 +08:00
zuiidea f4a108cfc1 logout 2016-11-02 22:35:07 +08:00
zuiidea bf6724f7f1 loginOut 2016-11-02 22:27:39 +08:00
zuiidea 5e8c9875d0 getUserInfo 2016-11-01 00:44:03 +08:00
zuiidea 113c81a627 login 2016-10-31 23:52:30 +08:00
zuiidea 7332df42c5 login 2016-10-29 22:13:22 +08:00
zuiidea 8960d4666e login 2016-10-28 00:29:49 +08:00
zuiidea 4642ba700c login 2016-10-27 22:27:45 +08:00
zuiidea 3081a9d4b9 login.js 2016-10-25 21:09:50 +08:00
zuiidea d20c0ddb26 fixed data type 2016-10-24 14:05:01 +08:00
zuiidea e94c26f06f userdatalist 2016-10-23 21:56:39 +08:00
zuiidea 912eb205b7 666 2016-10-22 23:29:54 +08:00
zuiidea ee5e064573 README 2016-10-21 17:14:05 +08:00
zuiidea a8643a6e55 update README 2016-10-20 21:40:56 +08:00
zuiidea 00a28000e4 users 2016-10-20 21:33:30 +08:00
zuiidea 6307e31366 local mock data 2016-10-19 17:25:46 +08:00
zuiidea 1918e132e7 update packge.json 2016-10-19 15:07:42 +08:00
zuiidea 9bd3d71ea9 local mock data 2016-10-19 15:06:23 +08:00
zuiidea e50cd23c81 loacl mosk 2016-10-18 22:54:33 +08:00
zuiidea 7797bf1487 build 2016-10-17 23:23:20 +08:00
zuiidea 042b4c728e user datas 2016-10-17 20:40:36 +08:00
zuiidea 23b32b332e error page 2016-10-16 23:05:18 +08:00
zuiidea 0255f197f4 bread 2016-10-15 01:53:58 +08:00
zuiidea 8c12c1690d bread 2016-10-15 01:25:55 +08:00
zuiidea c95eb1c888 sider menu color 2016-10-14 22:03:30 +08:00
zuiidea 2a750ea65b sider menu 2016-10-14 21:21:24 +08:00
zuiidea 1a3ea0f244 main layout 2016-10-14 00:26:36 +08:00
zuiidea 0119dfdc2a layout 2016-10-14 00:02:33 +08:00
zuiidea 019074d42c remove ; 2016-10-13 01:12:44 +08:00
zuiidea f2176ba3a7 update pakge.json 2016-10-13 00:57:29 +08:00
zuiidea 8b158a29e3 users 2016-10-12 20:37:43 +08:00
zuiidea 24998e990f user-dashbord 2016-10-11 22:16:28 +08:00
zuiidea 34ccb19f4c user 2016-10-10 22:25:56 +08:00
zuiidea fda47b362c 66 2016-10-10 22:11:11 +08:00
zuiidea f4c670bb20 readme 2016-10-10 21:53:42 +08:00
zuiidea 4f666a6a6d dva-init 2016-10-08 22:42:16 +08:00
zuiidea 62cccd4154 dva 2016-10-08 22:31:19 +08:00
zuiidea ad92f03f2d repair breadcrumb and routers 2016-09-19 23:09:41 +08:00
zuiidea 62f37008d3 listdata 2016-09-19 19:58:17 +08:00
zuiidea edd028b1bb datalist 2016-09-17 23:36:15 +08:00
zuiidea c204c95bba rewrite ajax 2016-09-17 16:17:20 +08:00
zuiidea 0740f644a4 some clean 2016-09-17 14:45:49 +08:00
zuiidea c576487d22 add fetchjs 2016-09-16 23:37:43 +08:00
zuiidea a0c6554d46 production config 2016-09-16 18:33:04 +08:00
zuiidea eaa1f283ac list page 2016-09-13 23:57:20 +08:00
zuiidea 9889415034 list page 2016-09-13 23:50:47 +08:00
zuiidea 1d368d724d add login loading 2016-09-12 23:27:11 +08:00
zuiidea 4ded5dbf91 main layout 2016-09-12 00:24:41 +08:00
zuiidea 02babf74f7 some layout 2016-09-11 22:55:25 +08:00
zuiidea 8a4d961124 css-loder 2016-09-11 19:01:59 +08:00
zuiidea 85e350107d some config 2016-09-11 18:08:34 +08:00
zuiidea 0daeffe463 eslint view 2016-09-11 17:12:05 +08:00
zuiidea c64478239a add demo address 2016-09-11 16:32:47 +08:00
zuiidea da50a1ac70 signIn 2016-09-11 01:31:53 +08:00
zuiidea cbab25a4fe signIn 2016-09-11 00:30:35 +08:00
zuiidea 4ec30d9cd7 signIn 2016-09-10 23:42:56 +08:00
zuiidea da3a989b29 new App 2016-09-10 23:25:21 +08:00
zuiidea 6a03018ab5 readme 2016-09-09 22:56:40 +08:00
zuiidea 07ebd3d862 6666 2016-09-09 22:54:55 +08:00
zuiidea 9077b5ff30 reinit 2016-09-08 21:49:18 +08:00
zuiidea 9838869517 admin-demo 2016-01-27 16:56:51 +08:00
zuiidea c42024fbed ant-demo
ant-demo
2016-01-25 15:45:58 +08:00
266 changed files with 42741 additions and 372 deletions
@@ -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 }}
+44 -5
View File
@@ -1,7 +1,46 @@
*.log
.cache
.DS_Store
.idea
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Dependencies
node_modules
.pnp
.pnp.js
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage
# Turbo
.turbo
# Vercel
.vercel
# Build Outputs
.next/
out/
build
dist
node_modules
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
.DS_Store
*.pem
.pnpm-store
# Git worktrees
.worktrees/
.cursor
./docs/superpowers
-1
View File
@@ -1 +0,0 @@
+22
View File
@@ -0,0 +1,22 @@
MIT LICENSE
Copyright (c) 2016 zuiidea (zuiiidea@gmail.com)
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+257
View File
@@ -0,0 +1,257 @@
# Antd Admin
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.
[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)
## 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) |
| 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) |
## 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
- **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`
| 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). |
Pick one app directory, install, and run Vite+ from there:
```bash
cd apps/basic && vp install && vp dev
# or
cd apps/with-lingui && vp install && vp dev
```
### 文档站(Nextra)
中文使用文档位于 `**apps/docs**`(Next.js + Nextra)。在仓库根或该目录安装依赖后:
```bash
cd apps/docs && pnpm install && pnpm dev
```
浏览器打开 [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
cd apps/basic && pnpm run test:e2e:core
```
## 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
cd apps/with-lingui # or: 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
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
| 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
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
webpackJsonp([10],{49:126,55:function(e,t,l){"use strict";function a(e){return e&&e.__esModule?e:{"default":e}}Object.defineProperty(t,"__esModule",{value:!0});var n=l(9),d=a(n),r=l(19),u=a(r),c=l(1),s=a(c),o=l(3),i=a(o),f=function(e,t){var l={};for(var a in e)Object.prototype.hasOwnProperty.call(e,a)&&t.indexOf(a)<0&&(l[a]=e[a]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols)for(var n=0,a=Object.getOwnPropertySymbols(e);n<a.length;n++)t.indexOf(a[n])<0&&(l[a[n]]=e[a[n]]);return l};t["default"]=function(e){var t,l=e.prefixCls,a=void 0===l?"ant-card":l,n=e.className,r=e.extra,c=e.bodyStyle,o=e.title,m=e.loading,p=e.bordered,y=void 0===p||p,E=f(e,["prefixCls","className","extra","bodyStyle","title","loading","bordered"]),b=e.children,g=(0,i["default"])(a,n,(t={},(0,u["default"])(t,a+"-loading",m),(0,u["default"])(t,a+"-bordered",y),t));m&&(b=s["default"].createElement("div",null,s["default"].createElement("p",{className:a+"-loading-block",style:{width:"94%"}}),s["default"].createElement("p",null,s["default"].createElement("span",{className:a+"-loading-block",style:{width:"28%"}}),s["default"].createElement("span",{className:a+"-loading-block",style:{width:"62%"}})),s["default"].createElement("p",null,s["default"].createElement("span",{className:a+"-loading-block",style:{width:"22%"}}),s["default"].createElement("span",{className:a+"-loading-block",style:{width:"66%"}})),s["default"].createElement("p",null,s["default"].createElement("span",{className:a+"-loading-block",style:{width:"56%"}}),s["default"].createElement("span",{className:a+"-loading-block",style:{width:"39%"}})),s["default"].createElement("p",null,s["default"].createElement("span",{className:a+"-loading-block",style:{width:"21%"}}),s["default"].createElement("span",{className:a+"-loading-block",style:{width:"15%"}}),s["default"].createElement("span",{className:a+"-loading-block",style:{width:"40%"}}))));var k=void 0;return k=o?"string"==typeof o?s["default"].createElement("div",{className:a+"-head"},s["default"].createElement("h3",{className:a+"-head-title"},o)):s["default"].createElement("div",{className:a+"-head"},s["default"].createElement("div",{className:a+"-head-title"},o)):null,s["default"].createElement("div",(0,d["default"])({},E,{className:g}),k,r?s["default"].createElement("div",{className:a+"-extra"},r):null,s["default"].createElement("div",{className:a+"-body",style:c},b))},e.exports=t["default"]},56:[1570,63],63:20,1454:function(e,t,l){"use strict";function a(e){return e&&e.__esModule?e:{"default":e}}Object.defineProperty(t,"__esModule",{value:!0});var n=(l(196),l(195)),d=a(n),r=(l(126),l(115)),u=a(r),c=(l(753),l(752)),s=a(c),o=(l(49),l(125)),i=a(o),f=(l(56),l(55)),m=a(f),p=l(1),y=a(p),E=l(207),b=function(){return y["default"].createElement("div",{className:"content-inner"},y["default"].createElement(u["default"],{gutter:32},y["default"].createElement(i["default"],{lg:8,md:12},y["default"].createElement(m["default"],{title:"\u9ed8\u8ba4"},y["default"].createElement(E.DropOption,{menuOptions:[{key:"1",name:"\u7f16\u8f91"},{key:"2",name:"\u5220\u9664"}]}))),y["default"].createElement(i["default"],{lg:8,md:12},y["default"].createElement(m["default"],{title:"\u6837\u5f0f"},y["default"].createElement(E.DropOption,{menuOptions:[{key:"1",name:"\u7f16\u8f91"},{key:"2",name:"\u5220\u9664"}],buttonStyle:{border:"solid 1px #eee",width:60}}))),y["default"].createElement(i["default"],{lg:8,md:12},y["default"].createElement(m["default"],{title:"\u4e8b\u4ef6"},y["default"].createElement(E.DropOption,{menuOptions:[{key:"1",name:"\u7f16\u8f91"},{key:"2",name:"\u5220\u9664"}],buttonStyle:{border:"solid 1px #eee",width:60},onMenuClick:function(e){var t=e.key;switch(t){case"1":s["default"].success("\u70b9\u51fb\u4e86\u7f16\u8f91");break;case"2":s["default"].success("\u70b9\u51fb\u4e86\u5220\u9664")}}})))),y["default"].createElement("h2",{style:{margin:"16px 0"}},"Props"),y["default"].createElement(u["default"],null,y["default"].createElement(i["default"],{lg:18,md:24},y["default"].createElement(d["default"],{rowKey:function(e,t){return t},pagination:!1,bordered:!0,scroll:{x:800},columns:[{title:"\u53c2\u6570",dataIndex:"props"},{title:"\u8bf4\u660e",dataIndex:"desciption"},{title:"\u7c7b\u578b",dataIndex:"type"},{title:"\u9ed8\u8ba4\u503c",dataIndex:"default"}],dataSource:[{props:"menuOptions",desciption:"\u4e0b\u62c9\u64cd\u4f5c\u7684\u9009\u9879\uff0c\u683c\u5f0f\u4e3a[{name:string,key:string}]",type:"Array","default":"\u5fc5\u9009"},{props:"onMenuClick",desciption:"\u70b9\u51fb menuitem \u8c03\u7528\u6b64\u51fd\u6570\uff0c\u53c2\u6570\u4e3a {item, key, keyPath}",type:"Function","default":"-"},{props:"buttonStyle",desciption:"\u6309\u94ae\u7684\u6837\u5f0f",type:"Object","default":"-"},{props:"dropdownProps",desciption:"\u4e0b\u62c9\u83dc\u5355\u7684\u53c2\u6570\uff0c\u53ef\u53c2\u8003antd\u7684\u3010Dropdown\u3011\u7ec4\u4ef6",type:"Object","default":"-"}]}))))};t["default"]=b,e.exports=t["default"]}});
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
webpackJsonp([12],{49:126,928:function(e,t){e.exports={list:"list___NsVSt",name:"name___2l5wL"}},1456:function(e,t,a){"use strict";function l(e){return e&&e.__esModule?e:{"default":e}}Object.defineProperty(t,"__esModule",{value:!0});var n=(a(126),a(115)),d=l(n),r=(a(49),a(125)),u=l(r),c=(a(196),a(195)),s=l(c),f=a(1),o=l(f),i=a(207),p=a(928),m=l(p),_=["Cherry","Cheese","Bread","Beer","Beet","Bacon","Banana","Asparagus","Apple"],y=function(){return o["default"].createElement("div",{className:"content-inner"},o["default"].createElement("ul",{className:m["default"].list},_.map(function(e){return o["default"].createElement("li",{key:e},o["default"].createElement(i.Iconfont,{className:m["default"].icon,type:e}),o["default"].createElement("span",{className:m["default"].name},e))})),o["default"].createElement("h2",{style:{margin:"16px 0"}},"Props"),o["default"].createElement(d["default"],null,o["default"].createElement(u["default"],{lg:18,md:24},o["default"].createElement(s["default"],{rowKey:function(e,t){return t},pagination:!1,bordered:!0,scroll:{x:800},columns:[{title:"\u53c2\u6570",dataIndex:"props"},{title:"\u8bf4\u660e",dataIndex:"desciption"},{title:"\u7c7b\u578b",dataIndex:"type"},{title:"\u9ed8\u8ba4\u503c",dataIndex:"default"}],dataSource:[{props:"type",desciption:"\u56fe\u6807\u7c7b\u578b",type:"String","default":"-"}]}))))};t["default"]=y,e.exports=t["default"]}});
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
webpackJsonp([8],{49:126,55:function(e,t,l){"use strict";function a(e){return e&&e.__esModule?e:{"default":e}}Object.defineProperty(t,"__esModule",{value:!0});var d=l(9),n=a(d),r=l(19),c=a(r),s=l(1),u=a(s),o=l(3),i=a(o),f=function(e,t){var l={};for(var a in e)Object.prototype.hasOwnProperty.call(e,a)&&t.indexOf(a)<0&&(l[a]=e[a]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols)for(var d=0,a=Object.getOwnPropertySymbols(e);d<a.length;d++)t.indexOf(a[d])<0&&(l[a[d]]=e[a[d]]);return l};t["default"]=function(e){var t,l=e.prefixCls,a=void 0===l?"ant-card":l,d=e.className,r=e.extra,s=e.bodyStyle,o=e.title,m=e.loading,p=e.bordered,E=void 0===p||p,y=f(e,["prefixCls","className","extra","bodyStyle","title","loading","bordered"]),g=e.children,b=(0,i["default"])(a,d,(t={},(0,c["default"])(t,a+"-loading",m),(0,c["default"])(t,a+"-bordered",E),t));m&&(g=u["default"].createElement("div",null,u["default"].createElement("p",{className:a+"-loading-block",style:{width:"94%"}}),u["default"].createElement("p",null,u["default"].createElement("span",{className:a+"-loading-block",style:{width:"28%"}}),u["default"].createElement("span",{className:a+"-loading-block",style:{width:"62%"}})),u["default"].createElement("p",null,u["default"].createElement("span",{className:a+"-loading-block",style:{width:"22%"}}),u["default"].createElement("span",{className:a+"-loading-block",style:{width:"66%"}})),u["default"].createElement("p",null,u["default"].createElement("span",{className:a+"-loading-block",style:{width:"56%"}}),u["default"].createElement("span",{className:a+"-loading-block",style:{width:"39%"}})),u["default"].createElement("p",null,u["default"].createElement("span",{className:a+"-loading-block",style:{width:"21%"}}),u["default"].createElement("span",{className:a+"-loading-block",style:{width:"15%"}}),u["default"].createElement("span",{className:a+"-loading-block",style:{width:"40%"}}))));var h=void 0;return h=o?"string"==typeof o?u["default"].createElement("div",{className:a+"-head"},u["default"].createElement("h3",{className:a+"-head-title"},o)):u["default"].createElement("div",{className:a+"-head"},u["default"].createElement("div",{className:a+"-head-title"},o)):null,u["default"].createElement("div",(0,n["default"])({},y,{className:b}),h,r?u["default"].createElement("div",{className:a+"-extra"},r):null,u["default"].createElement("div",{className:a+"-body",style:s},g))},e.exports=t["default"]},56:[1570,63],63:20,1458:function(e,t,l){"use strict";function a(e){return e&&e.__esModule?e:{"default":e}}Object.defineProperty(t,"__esModule",{value:!0});var d=(l(196),l(195)),n=a(d),r=(l(126),l(115)),c=a(r),s=(l(49),l(125)),u=a(s),o=(l(56),l(55)),i=a(o),f=l(1),m=a(f),p=l(207),E=function(){return m["default"].createElement("div",{className:"content-inner"},m["default"].createElement(c["default"],{gutter:32},m["default"].createElement(u["default"],{lg:8,md:12},m["default"].createElement(i["default"],{title:"\u9ed8\u8ba4"},m["default"].createElement(p.Search,null))),m["default"].createElement(u["default"],{lg:8,md:12},m["default"].createElement(i["default"],{title:"\u9644\u5e26\u9009\u62e9"},m["default"].createElement(p.Search,{select:!0,selectOptions:[{value:"components",name:"\u7ec4\u4ef6"},{value:"page",name:"\u9875\u9762"}],selectProps:{defaultValue:"components"}}))),m["default"].createElement(u["default"],{lg:8,md:12},m["default"].createElement(i["default"],{title:"\u5927\u5c0f"},m["default"].createElement(p.Search,{size:"large",style:{marginBottom:16}}),m["default"].createElement(p.Search,{size:"small"})))),m["default"].createElement("h2",{style:{margin:"16px 0"}},"Props"),m["default"].createElement(c["default"],null,m["default"].createElement(u["default"],{lg:18,md:24},m["default"].createElement(n["default"],{rowKey:function(e,t){return t},pagination:!1,bordered:!0,scroll:{x:800},columns:[{title:"\u53c2\u6570",dataIndex:"props"},{title:"\u8bf4\u660e",dataIndex:"desciption"},{title:"\u7c7b\u578b",dataIndex:"type"},{title:"\u9ed8\u8ba4\u503c",dataIndex:"default"}],dataSource:[{props:"size",desciption:"\u8bbe\u7f6eSearch\u5927\u5c0f\uff0c\u53ef\u9009\u503c\u4e3a \u3010small\u3011 \u3010large\u3011 \u6216\u8005\u4e0d\u8bbe",type:"String","default":"-"},{props:"select",desciption:"\u8bbe\u7f6e\u662f\u5426\u6709\u9009\u62e9\u5668",type:"Boolean","default":"false"},{props:"selectOptions",desciption:'\u9009\u62e9\u5668\u7684\u9009\u9879\uff0c\u683c\u5f0f\u4e3a[{name:"",value:""}]\u6216\u8005[{value:""}]',type:"Array","default":"-"},{props:"selectProps",desciption:"\u9009\u62e9\u5668\u7684\u5c5e\u6027\uff0c\u53ef\u53c2\u8003antd\u7684\u3010Select\u3011\u7ec4\u4ef6",type:"Object","default":"-"},{props:"onSearch",desciption:"\u70b9\u51fb\u641c\u7d22\u6309\u94ae, \u6309Enter\u952e\u6216\u8005\u70b9\u51fb\u6e05\u9664\u65f6\u7684\u56de\u8c03",type:"Function({keyword:string,field:string})","default":"-"}]}))))};t["default"]=E,e.exports=t["default"]}});
-20
View File
@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<link rel="stylesheet" href="index.css" />
<!--[if lte IE 10]>
<script
src="https://as.alipayobjects.com/g/component/??console-polyfill/0.2.2/index.js,media-match/2.0.2/media.match.min.js"></script>
<![endif]-->
</head>
<body>
<div id="root"></div>
<script src="index.js"></script>
</body>
</html>
+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
+24
View File
@@ -0,0 +1,24 @@
<svg width="169px" height="141px" viewBox="0 0 169 141" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 51.3 (57544) - http://www.bohemiancoding.com/sketch -->
<desc>Created with Sketch.</desc>
<defs>
<linearGradient x1="54.0428975%" y1="4.39752391%" x2="54.0428975%" y2="108.456714%" id="linearGradient-1">
<stop stop-color="#29CDFF" offset="0%"></stop>
<stop stop-color="#148EFF" offset="62.3089445%"></stop>
<stop stop-color="#0A60FF" offset="100%"></stop>
</linearGradient>
<linearGradient x1="50%" y1="14.2201464%" x2="50%" y2="113.263844%" id="linearGradient-2">
<stop stop-color="#FA816E" offset="0%"></stop>
<stop stop-color="#F74A5C" offset="65.9092442%"></stop>
<stop stop-color="#F51D2C" offset="100%"></stop>
</linearGradient>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Group" transform="translate(0.000000, -5.000000)">
<rect id="Rectangle" fill="url(#linearGradient-1)" transform="translate(83.718923, 75.312358) rotate(-24.000000) translate(-83.718923, -75.312358) " x="68.7189234" y="0.312357954" width="30" height="150" rx="15"></rect>
<rect id="Rectangle" fill="url(#linearGradient-1)" transform="translate(129.009910, 75.580213) rotate(-24.000000) translate(-129.009910, -75.580213) " x="114.00991" y="0.580212739" width="30" height="150" rx="15"></rect>
<circle id="Oval" fill="url(#linearGradient-2)" cx="25" cy="120" r="25"></circle>
</g>
</g>
</svg>

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" });
},
});
+7
View File
@@ -0,0 +1,7 @@
@supports not (backdrop-filter: blur(1px)) {
.login-page__card.ant-card {
background: var(--login-card-fallback-bg) !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
}
+226
View File
@@ -0,0 +1,226 @@
import { Link, createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { Form, Input, Button, Card, App, theme, Typography, Flex, Checkbox, Space } from "antd";
import type { CSSProperties } from "react";
import { useMutation } from "@tanstack/react-query";
import { httpClient } from "@/utils/http";
import { useAuthStore } from "@/stores/auth";
import { useSettingsStore } from "@/stores/settings";
import { AUTH_ENDPOINTS } from "@/api/auth";
import { LoginRequestSchema, AuthTokensSchema } from "@/api/schemas";
import { fetchSessionAndApplyToStore } from "@/utils/session";
import type { LoginRequest } from "@/api/schemas";
import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants";
import { Theme } from "@/components/Icon";
import { AppFooter } from "@/components/Layout/AppFooter";
import { Aurora } from "@/components/Aurora";
import "./index.css";
export const Route = createFileRoute("/login/")({
beforeLoad: () => {
const { isAuthenticated } = useAuthStore.getState();
if (isAuthenticated) {
throw redirect({ to: "/dashboard" });
}
},
component: LoginPage,
});
function LoginPage() {
const navigate = useNavigate();
const { message } = App.useApp();
const setTokens = useAuthStore((s) => s.setTokens);
const toggleDarkMode = useSettingsStore((s) => s.toggleDarkMode);
const darkMode = useSettingsStore((s) => s.darkMode);
const { token } = theme.useToken();
const loginMutation = useMutation({
mutationFn: async (values: LoginRequest) => {
const parsed = LoginRequestSchema.parse(values);
const tokens = await httpClient.post(AUTH_ENDPOINTS.login, parsed);
const validTokens = AuthTokensSchema.parse(tokens);
setTokens(validTokens);
await fetchSessionAndApplyToStore();
},
onSuccess: () => {
message.success("Login successful");
void navigate({ to: "/dashboard" });
},
onError: (err) => {
message.error(err instanceof Error ? err.message : "Login failed");
},
});
const shellStyle: CSSProperties = {
position: "relative",
isolation: "isolate",
minHeight: "100vh",
backgroundColor: token.colorBgLayout,
color: token.colorText,
};
const contentStyle: CSSProperties = {
position: "relative",
zIndex: 1,
flex: "1 1 0%",
minWidth: 0,
minHeight: 0,
overflow: "hidden",
};
const glassMix = darkMode ? "52%" : "42%";
const backdrop = darkMode ? "blur(22px) saturate(1.2)" : "blur(18px) saturate(1.35)";
const cardStyle: CSSProperties = {
width: "100%",
maxWidth: 384,
background: `color-mix(in srgb, ${token.colorBgContainer} ${glassMix}, transparent)`,
backdropFilter: backdrop,
WebkitBackdropFilter: backdrop,
borderColor: token.colorBorderSecondary,
boxShadow: token.boxShadow,
["--login-card-fallback-bg" as string]: token.colorBgElevated,
};
return (
<Flex vertical style={shellStyle}>
<Aurora />
<Flex vertical style={contentStyle}>
<Flex
flex={1}
align="center"
justify="center"
style={{ padding: token.padding, minHeight: 0 }}
>
<Card
className="login-page__card"
style={cardStyle}
styles={{
body: { padding: token.paddingLG, background: "transparent" },
}}
>
<Flex
align="center"
justify="center"
gap={token.margin}
wrap="wrap"
style={{ marginBottom: token.marginLG }}
>
<img
src={APP_FAVICON_SRC}
alt=""
width={32}
height={32}
draggable={false}
style={{ display: "block", flexShrink: 0 }}
/>
<Typography.Title
level={3}
style={{
margin: 0,
fontWeight: "bold",
letterSpacing: "-0.025em",
lineHeight: 1.2,
textTransform: "uppercase",
}}
>
{APP_BRAND_NAME}
</Typography.Title>
</Flex>
<Form
layout="vertical"
onFinish={(values) => {
loginMutation.mutate(LoginRequestSchema.parse(values));
}}
initialValues={{ username: "admin", password: "admin", remember: true }}
requiredMark={false}
>
<Form.Item
name="username"
label={<span style={{ fontWeight: 500 }}>Username</span>}
rules={[{ required: true, message: "Please enter username" }]}
>
<Input
id="login-username"
aria-label="Username"
placeholder="admin"
size="large"
/>
</Form.Item>
<Form.Item
name="password"
label={<span style={{ fontWeight: 500 }}>Password</span>}
rules={[{ required: true, message: "Please enter password" }]}
style={{ marginBottom: token.marginLG }}
>
<Input.Password
id="login-password"
aria-label="Password"
placeholder="admin"
size="large"
/>
</Form.Item>
<Flex
justify="space-between"
align="center"
style={{ marginBottom: token.marginLG }}
wrap="wrap"
>
<Form.Item name="remember" valuePropName="checked" noStyle>
<Checkbox>Auto login</Checkbox>
</Form.Item>
<Typography.Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
Demo: admin/admin or guest/guest
</Typography.Text>
</Flex>
<Form.Item style={{ marginBottom: 0, marginTop: token.marginLG }}>
<Button
type="primary"
htmlType="submit"
loading={loginMutation.isPending}
block
size="large"
>
Sign In
</Button>
</Form.Item>
<Flex justify="center" style={{ marginTop: token.margin }}>
<Typography.Text type="secondary">
No account?{" "}
<Link to="/register" style={{ color: token.colorPrimary }}>
Create one
</Link>
</Typography.Text>
</Flex>
</Form>
</Card>
</Flex>
<Flex
vertical
align="center"
style={{
padding: `${token.paddingSM}px ${token.padding}px`,
textAlign: "center",
}}
>
<Flex justify="center" style={{ marginBottom: token.marginSM }}>
<Space>
<Button
type="text"
size="small"
onClick={toggleDarkMode}
icon={<Theme size={token.size} />}
aria-label="Toggle Theme"
/>
</Space>
</Flex>
<AppFooter />
</Flex>
</Flex>
</Flex>
);
}

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