Compare commits

...
472 Commits
Author SHA1 Message Date
zuiidea 3d4551c9a4 docs: standardize README structure and remove oxfmt configs
Reorganize root README sections for template onboarding, translate remaining with-lingui README Chinese notes, and drop redundant .oxfmt.toml files from both templates.

Made-with: Cursor
2026-04-28 17:50:17 +08:00
zuiidea 8673d8398e docs: refine README for template-first onboarding
Focus root docs on init-antd-admin usage and keep template setup guidance fully in English with clearer CLI options.

Made-with: Cursor
2026-04-28 17:43:29 +08:00
zuiidea 0726321552 docs: add localized docs routes and language content
Migrate docs to language-prefixed app routes and split content into zh/en trees, including translated English navigation and pages.

Made-with: Cursor
2026-04-28 17:31:50 +08:00
zuiidea f12539812e chore: add root .githooks dispatcher and sync with-lingui
Wire monorepo pre-commit by staged paths; document setup; update Lingui template.

Made-with: Cursor
2026-04-28 12:04:29 +08:00
zuiidea 1cb0819011 docs: restructure Nextra IA, migrate middleware to proxy
Sync apps/basic instructions, hooks, and vercel config; refresh lockfile.

Made-with: Cursor
2026-04-28 11:53:31 +08:00
zuiidea 6ac8a62770 chore: update with-lingui pre-commit hook and i18n translations 2026-04-28 10:23:25 +08:00
zuiidea 335c77cc8c feat(basic): register API, MSW, /register route, dashboard timeline
Made-with: Cursor
2026-04-27 18:45:26 +08:00
zuiidea c8062e1299 ci(with-lingui): run i18n check in workflow
Add i18n:check step to GitHub Actions; refresh pnpm-lock.yaml.

Made-with: Cursor
2026-04-27 18:40:38 +08:00
zuiidea b277f522bd feat(with-lingui): register route, Lingui refresh, and MSW auth
Add register page and endpoint wiring; optional email on register schema;
shared AppFooter for login/register; MSW register and session mocks;
dashboard timeline and styles; locale catalogs and Lingui config.

Made-with: Cursor
2026-04-27 18:38:11 +08:00
zuiidea 39a6339c59 feat(templates): remove orders sample, align basic with with-lingui
- Drop orders routes, API, MSW, and e2e; users-only CRUD template
- Port HTTP refresh, hooks, RouteError, and core e2e to basic
- Lingui extract --clean in script; refresh catalogs
- Update add-resource docs, cursor rule, and optimization spec

Made-with: Cursor
2026-04-27 15:53:47 +08:00
zuiidea 4406e57556 fix(with-lingui): run vp staged from app roots in pre-commit
Git core.hooksPath points at this app while commits use the repo root cwd, so plain vp staged could not find vite.config.ts. Dispatch into apps/with-lingui and apps/basic by staged path; run packages/create check-types for matching TS sources. Document behavior in AGENTS.md.

Made-with: Cursor
2026-04-27 15:04:51 +08:00
zuiidea c5221c71be feat(with-lingui): orders CRUD, shared hooks, auth refresh, and docs
- Add second sample resource (orders): API, schemas, MSW, routes, menu, Lingui, and E2E.

- Introduce useCrudToasts, useUrlSearchState, useTableFitHeight; extend useResourceCRUD with optimistic updates; add Vitest coverage for hooks and createHandler.

- Implement token refresh in http layer, RouteError, and new E2E specs (auth-refresh, rbac, url-state).

- Replace gen-resource codegen with English add-resource.instructions.md; fix Copilot instruction YAML in with-lingui and basic.

- Add with-lingui CI workflow, Cursor thin rules, optimization plan doc, and refresh README/AGENTS.

- Bump packages/create and lockfile where applicable.

Made-with: Cursor
2026-04-27 14:47:55 +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 6d17b5b876 Merge branch 'yangbin1994-dva2.0' 2017-09-08 14:51:51 +08:00
eric 6077d74f10 .gitignore 增加排除项 2017-09-07 11:00:00 +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
484 changed files with 41615 additions and 13150 deletions
+26
View File
@@ -0,0 +1,26 @@
---
description: All code comments must be written in English
alwaysApply: true
---
# English-only comments
- Write **all** new and updated comments in **English**: `//`, `/* */`, `///`, JSDoc, inline `<!-- -->` in templates, and similar.
- **Do not** add or keep Chinese (or other non-English) text in comments when editing files.
- User-facing copy (UI strings, i18n keys, docs meant for end users) is **not** covered by this rule—only **source comments**.
```typescript
// BAD — non-English comment
// Initialiser le cache (example: any non-English language)
// GOOD
// Initialize the cache from the last session snapshot.
```
```tsx
// BAD
{/* Chargement… */}
// GOOD
{/* Loading spinner */}
```
@@ -0,0 +1,15 @@
---
description: "Add a new CRUD resource / scaffold (with-lingui)."
globs: apps/with-lingui/src/api/**/*.ts,apps/with-lingui/src/mocks/**/*.ts,apps/with-lingui/src/routes/_auth/**/*.tsx,apps/with-lingui/e2e/**/*.spec.ts
alwaysApply: false
---
# with-lingui · Add resource (thin rule)
**Single source of truth:**
`apps/with-lingui/.github/instructions/add-resource.instructions.md`
When adding a resource, follow that recipe (mirror the `users` example). On conflict, the `.instructions.md` file wins.
`@apps/with-lingui/.github/instructions/add-resource.instructions.md`
+15
View File
@@ -0,0 +1,15 @@
---
description: "API clients, Zod schemas, HTTP, MSW handlers (with-lingui)."
globs: apps/with-lingui/src/api/**/*.ts,apps/with-lingui/src/utils/http.ts,apps/with-lingui/src/mocks/handlers/**/*.ts,apps/with-lingui/src/mocks/createHandler.ts
alwaysApply: false
---
# with-lingui · API(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/api.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的约定;冲突以 `.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/api.instructions.md`
+15
View File
@@ -0,0 +1,15 @@
---
description: "React UI, routes, components, styles, layout, i18n, Ant Design (with-lingui)."
globs: apps/with-lingui/src/**/*.{ts,tsx,css}
alwaysApply: false
---
# with-lingui · Frontend(薄规则)
**唯一细则来源**(勿在此文件重复维护长文):
`apps/with-lingui/.github/instructions/frontend.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的 Do / Do Not / Validation;如有冲突,以 `.instructions.md` 为准。
需要全文进上下文时,在对话里引用:`@apps/with-lingui/.github/instructions/frontend.instructions.md`。
+15
View File
@@ -0,0 +1,15 @@
---
description: "Refactors without behavior change — dedupe, naming, structure (with-lingui)."
globs: apps/with-lingui/src/**/*.{ts,tsx}
alwaysApply: false
---
# with-lingui · Refactor(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/refactor.instructions.md`
做行为保持式重构时遵守该文件;与 frontend 规则同时命中时,**重构意图**以本文件指向的 refactor 指令为准,其余 UI 细节仍以 `frontend.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/refactor.instructions.md`
+15
View File
@@ -0,0 +1,15 @@
---
description: "Playwright e2e, MSW mocks, test config (with-lingui)."
globs: apps/with-lingui/e2e/**/*.ts,apps/with-lingui/e2e/**/*.tsx,apps/with-lingui/src/mocks/**/*.ts,apps/with-lingui/playwright.config.ts
alwaysApply: false
---
# with-lingui · Testing(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/testing.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的约定;冲突以 `.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/testing.instructions.md`
-16
View File
@@ -1,16 +0,0 @@
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
-2
View File
@@ -1,2 +0,0 @@
src/**/*-test.js
src/public
-53
View File
@@ -1,53 +0,0 @@
{
"extends": "airbnb",
"rules": {
"semi": [2, "never"],
"no-console": 0,
"comma-dangle": [2, "always-multiline"],
"max-len": 0,
"react/jsx-first-prop-new-line": 0,
"react/jsx-filename-extension": 0,
"space-before-function-paren": [2, "always"],
"no-unused-expressions": [0, {
"allowShortCircuit": true,
"allowTernary": true
}],
"arrow-body-style": [0, "never"],
"func-names": 0,
"prefer-const": 0,
"no-extend-native": 0,
"no-param-reassign": 0,
"no-restricted-syntax": 0,
"no-eval": 0,
"no-continue": 0,
"react/jsx-no-bind": 0,
"no-unused-vars": [2, { "ignoreRestSiblings": true }],
"no-underscore-dangle": 0,
"global-require": 0,
"import/no-unresolved": 0,
"import/extensions": 0,
"jsx-a11y/href-no-hash": 0,
"react/no-array-index-key": 0,
"react/require-default-props": 0,
"react/forbid-prop-types": 0,
"react/no-string-refs": 0,
"react/no-find-dom-node": 0,
"react/prefer-stateless-function": 0,
"import/no-extraneous-dependencies": 0,
"import/prefer-default-export": 0,
"react/no-danger": 0,
"jsx-a11y/no-static-element-interactions": 0,
},
"parser": "babel-eslint",
"parserOptions": {
"sourceType": "module",
"ecmaVersion": 8,
"ecmaFeatures": {
"jsx": true,
"experimentalObjectRestSpread": true
}
},
"settings": {
"import/resolver": "node"
}
}
+26
View File
@@ -0,0 +1,26 @@
# Git hooks(仓库根)
本目录为 **monorepo 根级** Git 钩子。提交时根据暂存文件路径,在对应子项目目录执行检查(例如 `apps/with-lingui` 下执行 `vp staged`,以便正确读取该目录的 `vite.config.ts`)。
模板应用目录内的 `.vite-hooks/`(如 `apps/with-lingui/.vite-hooks/`)留给 **模板单独拷贝出去使用**;在本仓库开发时请使用根级钩子,避免在仓库根执行 `vp staged` 找不到 `vite.config.ts`。
## 启用方式(一次性)
在仓库根目录执行:
```bash
git config core.hooksPath .githooks
```
或使用:
```bash
pnpm run setup:git-hooks
```
验证:
```bash
git config core.hooksPath
# 应输出:.githooks
```
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env sh
# Monorepo pre-commit: run Vite+ / checks in the app that owns staged files.
# Template copies under apps/*/ keep their own .vite-hooks for standalone use; this repo uses root hooksPath instead.
set -e
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
STAGED=$(git diff --cached --name-only --diff-filter=ACM)
FAILED=0
run_in_dir() {
dir="$1"
shift
(cd "$ROOT/$dir" && "$@") || FAILED=1
}
if echo "$STAGED" | grep -q '^apps/with-lingui/'; then
run_in_dir apps/with-lingui vp staged
fi
if echo "$STAGED" | grep -q '^apps/basic/'; then
run_in_dir apps/basic vp staged
fi
if echo "$STAGED" | grep -q '^packages/create/'; then
run_in_dir . pnpm --dir packages/create run check-types
fi
if echo "$STAGED" | grep -q '^apps/docs/'; then
run_in_dir apps/docs pnpm run lint
fi
exit "$FAILED"
@@ -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 }}
+51
View File
@@ -0,0 +1,51 @@
name: with-lingui
on:
push:
paths:
- "apps/with-lingui/**"
- ".github/workflows/with-lingui-ci.yml"
- "pnpm-lock.yaml"
pull_request:
paths:
- "apps/with-lingui/**"
- ".github/workflows/with-lingui-ci.yml"
- "pnpm-lock.yaml"
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
working-directory: apps/with-lingui
run: pnpm exec playwright install chromium
- name: Typecheck and lint
working-directory: apps/with-lingui
run: pnpm exec vp check --no-fmt
- name: I18n check
working-directory: apps/with-lingui
run: pnpm run i18n:check
- name: E2E (core)
working-directory: apps/with-lingui
run: pnpm run test:e2e:core
- name: Build
working-directory: apps/with-lingui
run: pnpm run build
+50 -4
View File
@@ -1,4 +1,50 @@
dist
node_modules
yarn.lock
npm-debug.log
# 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
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
.DS_Store
*.pem
.pnpm-store
# Git worktrees
.worktrees/
# Cursor:仅共享项目规则;其余本机数据仍忽略(方案 A — 细则单源在 apps/with-lingui/.github/instructions)
.cursor/*
!.cursor/rules/
!.cursor/rules/**
./docs/superpowers
-57
View File
@@ -1,57 +0,0 @@
const path = require('path')
const { version } = require('./package.json')
const svgSpriteDirs = [
path.resolve(__dirname, 'src/svg/'),
require.resolve('antd').replace(/index\.js$/, '')
]
export default {
entry: 'src/index.js',
svgSpriteLoaderDirs: svgSpriteDirs,
theme: "./theme.config.js",
publicPath: `/${version}/`,
outputPath: `./dist/${version}`,
// 接口代理示例
proxy: {
"/api/v1/weather": {
"target": "https://api.seniverse.com/",
"changeOrigin": true,
"pathRewrite": { "^/api/v1/weather": "/v3/weather" }
},
// "/api/v2": {
// "target": "http://192.168.0.110",
// "changeOrigin": true,
// "pathRewrite": { "^/api/v2" : "/api/v2" }
// }
},
env: {
development: {
extraBabelPlugins: [
"dva-hmr",
"transform-runtime",
[
"import", {
"libraryName": "antd",
"style": true
}
]
]
},
production: {
extraBabelPlugins: [
"transform-runtime",
[
"import", {
"libraryName": "antd",
"style": true
}
]
]
}
},
dllPlugin: {
exclude: ["babel-runtime", "roadhog", "cross-env"],
include: ["dva/router", "dva/saga", "dva/fetch"]
}
}
-5
View File
@@ -1,5 +0,0 @@
const mock = {}
require('fs').readdirSync(require('path').join(__dirname + '/src/mock')).forEach(function(file) {
Object.assign(mock, require('./src/mock/' + file))
})
module.exports = mock
+49 -131
View File
@@ -1,156 +1,74 @@
# Antd Admin
[![React](https://img.shields.io/badge/react-^15.6.1-brightgreen.svg?style=flat-square)](https://github.com/facebook/react)
[![Ant Design](https://img.shields.io/badge/ant--design-^2.11.2-yellowgreen.svg?style=flat-square)](https://github.com/ant-design/ant-design)
[![dva](https://img.shields.io/badge/dva-^2.0.1-orange.svg?style=flat-square)](https://github.com/dvajs/dva)
## Introduction
[![GitHub issues](https://img.shields.io/github/issues/zuiidea/antd-admin.svg?style=flat-square)](https://github.com/zuiidea/antd-admin)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/zuiidea/antd-admin/pulls)
[![MIT](https://img.shields.io/dub/l/vibe-d.svg?style=flat-square)](http://opensource.org/licenses/MIT)
[![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg)](http://standardjs.com)
Antd Admin provides production-style admin templates with modern frontend tooling, mock-first development, and practical testing coverage.
演示地址 <http://antd-admin.zuiidea.com>
Available templates:
- `basic`: English-only setup
- `with-lingui`: Bilingual setup (`en` + `zh`) with Lingui
## 特性
Scaffolding CLI:
- `init-antd-admin` (from `packages/create`)
- 基于[react](https://github.com/facebook/react),[ant-design](https://github.com/ant-design/ant-design),[dva](https://github.com/dvajs/dva),[Mock](https://github.com/nuysoft/Mock) 企业级后台管理系统最佳实践。
- 基于Antd UI 设计语言,提供后台管理系统常见使用场景。
- 基于[dva](https://github.com/dvajs/dva)动态加载 Model 和路由,按需加载。
- 使用[roadhog](https://github.com/sorrycc/roadhog)本地调试和构建,其中Mock功能实现脱离后端独立开发。
- 浅度响应式设计。
## Key Features
## 更新日志
- JWT login with access/refresh flow
- Backend-driven menu and permission guards
- URL-synced table state (pagination, sorting, search)
- Dark mode theme switching
- Mock-first local development (no backend required)
- Typed API boundaries and reusable CRUD patterns
- React 19 + Ant Design 6 + TanStack Router/Query + Zustand + Zod
- Optional Lingui-based i18n workflow (`with-lingui`)
- E2E coverage with Playwright
## Quick Start
### 4.3.5
Use `init-antd-admin` to scaffold a standalone app from official templates.
`2017-09-04`
-   更新[dva](https://github.com/dvajs/dva)至`2.0.1`,感谢[@yangbin1994](https://github.com/yangbin1994)的[PR](https://github.com/zuiidea/antd-admin/pull/567)。
### 4.3.4
`2017-09-04`
-   更新[roadhog](https://github.com/sorrycc/roadhog)至`1.2.1`,修复闪屏问题。
-   修复build css未压缩[#529](https://github.com/zuiidea/antd-admin/issues/529),classname未Hash[#477](https://github.com/zuiidea/antd-admin/issues/477)。
-   更新登录成功使用router跳转[#479](https://github.com/zuiidea/antd-admin/issues/479)。
### 4.3.0
`2017-07-30`
-   更新[roadhog](https://github.com/sorrycc/roadhog)至`1.0.0-beta.7`,得益于`webpack@3.4`,编译速度不减。
-   基于使用roadhog的dll插件,开发时编译耗时减半。
-   新增打包时按项目版本号生成文件目录。[#449](https://github.com/zuiidea/antd-admin/issues/449)
-   更新`eslint`,并适当修改了`.eslintrc`。
[More Change Log](https://github.com/zuiidea/antd-admin/wiki/Change-Log)
## 开发构建
### 目录结构
### Interactive
```bash
├── /dist/ # 项目输出目录
├── /src/ # 项目源码目录
│ ├── /public/ # 公共文件,编译时copy至dist目录
│ ├── /components/ # UI组件及UI相关方法
│ │ ├── skin.less # 全局样式
│ │ └── vars.less # 全局样式变量
│ ├── /routes/ # 路由组件
│ │ └── app.js # 路由入口
│ ├── /models/ # 数据模型
│ ├── /services/ # 数据接口
│ ├── /themes/ # 项目样式
│ ├── /mock/ # 数据mock
│ ├── /utils/ # 工具函数
│ │ ├── config.js # 项目常规配置
│ │ ├── menu.js # 菜单及面包屑配置
│ │ ├── config.js # 项目常规配置
│ │ ├── request.js # 异步请求函数
│ │ └── theme.js # 项目需要在js中使用到样式变量
│ ├── route.js # 路由配置
│ ├── index.js # 入口文件
│ └── index.html
├── package.json # 项目信息
├── .eslintrc # Eslint配置
└── .roadhogrc.js # roadhog配置
npx init-antd-admin@latest
pnpm dlx init-antd-admin@latest
yarn dlx init-antd-admin@latest
bunx init-antd-admin@latest
```
文件夹命名说明:
- components:组件(方法)为单位以文件夹保存,文件夹名组件首字母大写(如`DataTable`),方法首字母小写(如`layer`),文件夹内主文件与文件夹同名,多文件以`index.js`导出对象(如`./src/components/Layout`)。
- routes:页面为单位以文件夹保存,文件夹名首字母小写(特殊除外,如`UIElement`),文件夹内主文件以`index.js`导出,多文件时可建立`components`文件夹(如`./src/routes/dashboard`),如果有子路由,依次按照路由层次建立文件夹(如`./src/routes/UIElement`)。
### 快速开始
克隆项目文件:
### Non-interactive examples
```bash
git clone https://github.com/zuiidea/antd-admin.git
pnpm dlx init-antd-admin@latest my-app --example basic -m pnpm
pnpm dlx init-antd-admin@latest my-app --example with-lingui --skip-install
```
进入目录安装依赖:
### Common options
```bash
#开始前请确保没有安装roadhog、webpack到NPM全局目录
npm i 或者 yarn install
```
| Option | Description |
| --- | --- |
| `[project-directory]` | Target folder |
| `-e, --example <name|url>` | Template source |
| `--example-path <path>` | Subpath inside remote repository |
| `-m, --package-manager <pm>` | `npm` / `pnpm` / `yarn` / `bun` |
| `--skip-install` | Skip dependency install |
| `--skip-transforms` | Skip rewrite transforms |
| `--no-git` | Skip git initialization |
开发:
For detailed CLI behavior, see [`packages/create/README.md`](./packages/create/README.md).
```bash
npm run build:dll #第一次npm run dev时需运行此命令,使开发时编译更快
npm run dev
打开 http://localhost:8000
```
Production docs: [https://antd-admin-doc.zuiidea.top](https://antd-admin-doc.zuiidea.top)
构建:
[详情](https://github.com/zuiidea/antd-admin/issues/269)
## Links
```bash
npm run build
- [Ant Design](https://github.com/ant-design/ant-design)
- [React](https://react.dev)
- [Vite+](https://viteplus.dev)
- [TypeScript](https://www.typescriptlang.org/)
- [Issues](https://github.com/zuiidea/antd-admin/issues)
- [Stars](https://github.com/zuiidea/antd-admin/stargazers)
- [PRs Welcome](https://github.com/zuiidea/antd-admin/pulls)
将会打包至dist/{version}目录 #package.json里version字段
## License
npm run build:new
将会打包至dist/{version增加1}目录 #package.json里version字段
```
代码检测:
```bash
npm run lint
```
## FAQ
- 项目打包后如何部署? [#269](https://github.com/zuiidea/antd-admin/issues/269)
- 如何做权限管理? [#384](https://github.com/zuiidea/antd-admin/issues/384)
- 如何使用mock.js模拟接口,怎么使用线上接口? [#348](https://github.com/zuiidea/antd-admin/issues/348)
- 如何使用Iconfont,如何使用本地的svg图标? [#270](https://github.com/zuiidea/antd-admin/issues/270)
- 怎么按版本打包,上线时不影响正在访问的用户? [#449](https://github.com/zuiidea/antd-admin/issues/449)
- windows处理CRLF?[参考](http://blog.csdn.net/lysc_forever/article/details/42835203)
```bash
git config --global core.autocrlf false
```
## 参考
用户列表:<https://github.com/dvajs/dva/tree/master/examples/user-dashboard>
dashboard设计稿:<https://dribbble.com/shots/3108122-Dashboard-Admin> (已征得作者同意)
## 截屏
web
![](assets/4.2.1-demo-1.gif)
移动
![](assets/4.2.1-demo-2.gif)
MIT
+17
View File
@@ -0,0 +1,17 @@
# AI Instruction Files
This directory contains scoped instruction files used by AI coding agents.
## Files
- `frontend.instructions.md`: UI/routes/components/style changes
- `testing.instructions.md`: Playwright/MSW/test changes
- `api.instructions.md`: API client/schema/handler changes
- `refactor.instructions.md`: behavior-preserving cleanup and deduplication
## Authoring Rules
- Keep each file focused on one domain.
- Put trigger phrases in `description` so agents can discover the file.
- Use narrow `applyTo` patterns to avoid unnecessary context load.
- Keep instructions executable: short Do/Do Not/Validation sections.
+30
View File
@@ -0,0 +1,30 @@
---
## 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,32 @@
---
## 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
+1
View File
@@ -0,0 +1 @@
vp staged
+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
+39
View File
@@ -0,0 +1,39 @@
import { test, expect } from "@playwright/test";
import { loginAsAdmin } from "./helpers";
const expiredAuthStorage = () =>
JSON.stringify({
state: {
tokens: {
accessToken: "__EXPIRED_ACCESS__",
refreshToken: "mock-refresh-token",
},
isAuthenticated: true,
},
version: 0,
});
test.describe("Auth refresh", () => {
test.describe.configure({ retries: 2 });
test("401 on list triggers refresh then succeeds", async ({ page }) => {
await loginAsAdmin(page);
await page.evaluate((payload) => {
localStorage.setItem("auth-storage", payload);
}, expiredAuthStorage());
await page.reload({ waitUntil: "load" });
const usersListOk = page.waitForResponse(
(res) => res.url().includes("/api/users") && res.request().method() === "GET" && res.ok(),
{ timeout: 60_000 },
);
await page.goto("/users", { waitUntil: "domcontentloaded" });
await usersListOk;
await expect(page).toHaveURL(/\/users(\/|\?|$)/);
const thead = page.locator(".ant-table-thead");
await expect(thead.getByText(/Username|用户名/)).toBeVisible({ timeout: 30_000 });
});
});
+24
View File
@@ -0,0 +1,24 @@
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/);
}
export async function gotoUsers(page: Page) {
await loginAsAdmin(page);
await page.goto("/users");
await expect(page).toHaveURL(/users/);
}
export async function loginAsGuest(page: Page) {
await page.goto("/login");
await page.getByLabel(/Username|用户名/).fill("guest");
await page.getByLabel(/Password|密码/).fill("guest");
await page.getByRole("button", { name: /Sign In|登录/ }).click();
await expect(page).toHaveURL(/dashboard/);
}
+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();
});
});
+10
View File
@@ -0,0 +1,10 @@
import { test, expect } from "@playwright/test";
import { loginAsGuest } from "./helpers";
test.describe("RBAC", () => {
test("guest without user:view cannot open /users", async ({ page }) => {
await loginAsGuest(page);
await page.goto("/users");
await expect(page).toHaveURL(/403/);
});
});
+19
View File
@@ -0,0 +1,19 @@
import { test, expect } from "@playwright/test";
import { loginAsAdmin } from "./helpers";
test.describe("URL search state", () => {
test("persists keyword after reload", async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/users");
await expect(page.getByPlaceholder(/Search User|搜索用户/)).toBeVisible({ timeout: 15_000 });
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
await searchInput.fill("zhao.ming");
await searchInput.press("Enter");
await expect(page).toHaveURL(/keyword=zhao\.ming/);
await page.reload();
await expect(page).toHaveURL(/keyword=zhao\.ming/);
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { test, expect } from "@playwright/test";
import { gotoUsers, loginAsAdmin } from "./helpers";
test.describe("User Management", () => {
test.beforeEach(async ({ page }) => {
await gotoUsers(page);
});
test("should display user table", async ({ page }) => {
const thead = page.locator(".ant-table-thead");
await expect(thead.getByText(/Username|用户名/)).toBeVisible();
await expect(thead.getByText(/Email|邮箱/)).toBeVisible();
await expect(thead.getByText(/Roles|角色/)).toBeVisible();
});
test("should search users", async ({ page }) => {
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
await searchInput.fill("zhao.ming");
await searchInput.press("Enter");
await expect(page).toHaveURL(/keyword=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.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/users?role=admin");
await expect(page.getByPlaceholder(/Search User|搜索用户/)).toBeVisible({ timeout: 15_000 });
});
test("should filter users by role", async ({ page }) => {
await expect(page).toHaveURL(/role=admin/);
await expect(page.getByRole("cell", { name: "ops.admin@northstar.io" })).toBeVisible();
});
});
+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>
+57
View File
@@ -0,0 +1,57 @@
{
"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:unit": "vitest run",
"test:e2e": "playwright test",
"test:e2e:core": "playwright test e2e/login.spec.ts e2e/users.spec.ts e2e/auth-refresh.spec.ts e2e/rbac.spec.ts e2e/url-state.spec.ts",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.167.4",
"antd": "^6.3.3",
"lucide-react": "^1.7.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.166.13",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.3.0",
"msw": "^2.12.12",
"typescript": "~5.9.3",
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vite-plus": "latest",
"vitest": "3.0.5"
},
"packageManager": "pnpm@10.32.1",
"pnpm": {
"overrides": {
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
},
"onlyBuiltDependencies": [
"esbuild",
"msw"
]
},
"msw": {
"workerDirectory": [
"public"
]
}
}
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig, devices } from "@playwright/test";
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,
};
}
+19
View File
@@ -0,0 +1,19 @@
import type {
AuthTokens,
LoginRequest,
User,
MenuItem,
PermissionsList,
RegisterRequest,
} 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, User, MenuItem, PermissionsList, RegisterRequest };
+155
View File
@@ -0,0 +1,155 @@
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()),
});
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 RefreshTokenRequestSchema = z.object({
refreshToken: z.string().min(1),
});
export type RefreshTokenRequest = z.infer<typeof RefreshTokenRequestSchema>;
export const LoginRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
/** Ant Design Form may submit `""` for an empty optional email */
const registerOptionalEmailSchema = z
.union([z.string(), z.undefined()])
.transform((v) => {
if (v === undefined) return undefined;
const t = String(v).trim();
return t === "" ? undefined : t;
})
.pipe(z.union([z.string().email(), z.undefined()]));
export const RegisterRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(6),
email: registerOptionalEmailSchema,
});
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
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,
roles: z.array(z.string()).min(1),
});
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,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,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,142 @@
import { Layout, Button, Space, theme, Breadcrumb, Flex, Divider, Grid } from "antd";
import type { ItemType } from "antd/es/breadcrumb/Breadcrumb";
import { useSettingsStore } from "@/stores/settings";
import { Link, useLocation, useMatches } from "@tanstack/react-router";
import { Home, PanelLeft, ShieldAlert, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Theme } from "@/components/Icon";
const { Header: AntHeader } = Layout;
const PATH_LABEL: Record<string, string> = {
"/dashboard": "Dashboard",
"/users": "Users",
"/403": "403",
};
function normalizePath(pathname: string): string {
if (pathname === "/") return pathname;
return pathname.replace(/\/+$/, "") || "/";
}
export type HeaderProps = {
/**
* When `false`, breadcrumb is hidden; left `Flex` still uses `flex={1}` so header actions stay right-aligned.
* Routes may also set `staticData: { hideBreadcrumb: true }` (deepest matching route wins).
*/
showBreadcrumb?: boolean;
};
export function Header({ showBreadcrumb: showBreadcrumbProp = true }: HeaderProps) {
const toggleSidebar = useSettingsStore((s) => s.toggleSidebar);
const toggleDarkMode = useSettingsStore((s) => s.toggleDarkMode);
const location = useLocation();
const matches = useMatches();
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = !screens.lg;
const iconSize = token.fontSize;
const crumb = (Icon: LucideIcon, label: ReactNode, linkTo?: "/dashboard") => {
const row = (
<>
<Icon size={iconSize} 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,
};
if (linkTo) {
return (
<Link to={linkTo} style={rowStyle}>
{row}
</Link>
);
}
return <span style={rowStyle}>{row}</span>;
};
const path = normalizePath(location.pathname);
const segments = path.split("/").filter(Boolean);
const firstSegmentPath = segments.length ? `/${segments[0]}` : "/dashboard";
const leafLabelKey = PATH_LABEL[firstSegmentPath] ?? segments[0] ?? "Dashboard";
const leafIcon: LucideIcon =
firstSegmentPath === "/users" ? Users : firstSegmentPath === "/403" ? ShieldAlert : Home;
const leafLabel = leafLabelKey;
const breadcrumbItems: ItemType[] = [];
const onDashboard = path === "/dashboard" || path === "/";
if (onDashboard) {
breadcrumbItems.push({
title: crumb(Home, "Dashboard"),
});
} else {
breadcrumbItems.push({
title: crumb(Home, "Dashboard", "/dashboard"),
});
breadcrumbItems.push({
title: crumb(leafIcon, leafLabel),
});
if (segments.length > 1) {
const tail = segments.slice(1).join(" / ");
if (tail) {
breadcrumbItems.push({ title: tail });
}
}
}
const leafStatic = matches.at(-1)?.staticData as { hideBreadcrumb?: boolean } | undefined;
const hideBreadcrumbFromRoute = leafStatic?.hideBreadcrumb === true;
const showBreadcrumb =
showBreadcrumbProp && !hideBreadcrumbFromRoute && breadcrumbItems.length > 0;
return (
<AntHeader
style={{
background: "transparent",
borderBottom: `1px solid ${token.colorBorderSecondary}`,
padding: `0 ${token.padding}px`,
gap: token.sizeLG,
display: "flex",
}}
>
<Flex align="center" flex={1} style={{ minWidth: 0 }}>
{isMobile ? (
<Button
type="text"
size="small"
onClick={toggleSidebar}
icon={<PanelLeft size={token.size} />}
aria-label="Toggle sidebar"
/>
) : null}
{showBreadcrumb ? (
<>
{isMobile ? <Divider vertical /> : null}
<Breadcrumb items={breadcrumbItems} />
</>
) : null}
</Flex>
<Space>
<Button
type="text"
onClick={toggleDarkMode}
icon={<Theme size={token.size} />}
aria-label="Toggle Theme"
/>
</Space>
</AntHeader>
);
}
@@ -0,0 +1,39 @@
import { Layout, theme, Flex } from "antd";
import { Outlet } from "@tanstack/react-router";
import { Sidebar } from "../Sidebar";
import { Header } from "../Header";
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>
</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,401 @@
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,
SlidersHorizontal,
Star,
User,
Users,
Zap,
} 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",
"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,
};
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>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { Button, Result, theme } from "antd";
import { useRouter, type ErrorComponentProps } from "@tanstack/react-router";
export function RouteError({ error }: ErrorComponentProps) {
const router = useRouter();
const { token } = theme.useToken();
const detail = error instanceof Error ? error.message : String(error);
return (
<div style={{ padding: token.paddingLG, maxWidth: 560, margin: "48px auto" }}>
<Result
status="error"
title="Something went wrong"
subTitle={detail || "Please try again or return to the dashboard."}
extra={
<Button type="primary" onClick={() => void router.invalidate()}>
Retry
</Button>
}
/>
</div>
);
}
+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]);
}
+66
View File
@@ -0,0 +1,66 @@
import { useMemo } from "react";
import type { MessageInstance } from "antd/es/message/interface";
type MutationLifecycle<TValues> = {
onMutate?: (values: TValues) => void;
onSuccess?: (values: TValues) => void;
onError?: (values: TValues) => void;
};
export type CrudToastLifecycles<TCreate, TUpdate> = {
createLifecycle?: MutationLifecycle<TCreate>;
updateLifecycle?: MutationLifecycle<TUpdate>;
deleteLifecycle?: MutationLifecycle<string>;
};
export function useCrudToasts<TCreate, TUpdate>(options: {
message: MessageInstance;
resourceKey: string;
}): CrudToastLifecycles<TCreate, TUpdate> {
const { message, resourceKey } = options;
const keys = useMemo(
() => ({
create: `${resourceKey}-mutation-create`,
update: `${resourceKey}-mutation-update`,
delete: `${resourceKey}-mutation-delete`,
}),
[resourceKey],
);
return useMemo(
() => ({
createLifecycle: {
onSuccess: () => {
message.success({ content: "Created successfully", key: keys.create });
},
onError: () => {
message.error({ content: "Create failed", key: keys.create });
},
},
updateLifecycle: {
onMutate: () => {
message.loading({ content: "Updating…", key: keys.update, duration: 0 });
},
onSuccess: () => {
message.success({ content: "Updated successfully", key: keys.update });
},
onError: () => {
message.error({ content: "Update failed", key: keys.update });
},
},
deleteLifecycle: {
onMutate: () => {
message.loading({ content: "Deleting…", key: keys.delete, duration: 0 });
},
onSuccess: () => {
message.success({ content: "Deleted successfully", key: keys.delete });
},
onError: () => {
message.error({ content: "Delete failed", key: keys.delete });
},
},
}),
[keys.create, keys.delete, keys.update, message],
);
}
+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;
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { applyOptimisticListDelete, applyOptimisticListUpdate } from "./useResourceCRUD";
type Row = { id: string; title: string; n: number };
describe("applyOptimisticListUpdate", () => {
it("merges patch into the matching row by id", () => {
const data: { list: Row[]; total: number } = {
list: [
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B", n: 2 },
],
total: 2,
};
const next = applyOptimisticListUpdate(data, { id: "2", title: "B2" });
expect(next.list).toEqual([
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B2", n: 2 },
]);
expect(next.total).toBe(2);
});
});
describe("applyOptimisticListDelete", () => {
it("removes row and decrements total", () => {
const data: { list: Row[]; total: number } = {
list: [
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B", n: 2 },
],
total: 2,
};
const next = applyOptimisticListDelete(data, "1");
expect(next.list).toEqual([{ id: "2", title: "B", n: 2 }]);
expect(next.total).toBe(1);
});
it("does not take total below zero", () => {
const data = { list: [{ id: "1", title: "A", n: 0 }] as Row[], total: 1 };
const next = applyOptimisticListDelete(data, "1");
expect(next.list).toEqual([]);
expect(next.total).toBe(0);
});
});
describe("rollback contract (QueryClient simulation)", () => {
it("restore snapshot after failed optimistic update", () => {
const key = ["items", 10, 0] as const;
const before = {
list: [
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B", n: 2 },
],
total: 2,
};
const snapshot = structuredClone(before);
let cache: typeof before = structuredClone(before);
const setData = (next: typeof before) => {
cache = next;
};
setData(applyOptimisticListUpdate(cache, { id: "2", title: "BAD" }));
expect(cache.list[1]?.title).toBe("BAD");
setData(snapshot);
expect(cache).toEqual(before);
expect(key[0]).toBe("items");
});
});
+179
View File
@@ -0,0 +1,179 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { UseMutationResult } from "@tanstack/react-query";
type MutationLifecycle<TValues> = {
onMutate?: (values: TValues) => void;
onSuccess?: (values: TValues) => void;
onError?: (values: TValues) => void;
};
export type ResourceListData<TItem extends { id: string }> = {
list: readonly TItem[];
total: number;
};
type OptimisticFlags = {
update?: boolean;
delete?: boolean;
};
type UseResourceCRUDOptions<
TListData extends ResourceListData<{ id: string }>,
TCreateValues,
TUpdateValues extends { id: string },
> = {
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>;
optimistic?: OptimisticFlags;
};
export type ResourceCRUDResult<TListData, TCreateValues, TUpdateValues> = {
data: TListData | undefined;
isLoading: boolean;
createMutation: UseMutationResult<unknown, Error, TCreateValues>;
updateMutation: UseMutationResult<unknown, Error, TUpdateValues>;
deleteMutation: UseMutationResult<unknown, Error, string>;
};
/** @deprecated Prefer `ResourceCRUDResult` — alias kept for plan wording compatibility */
export type CrudResult<TListData, TCreateValues, TUpdateValues> = ResourceCRUDResult<
TListData,
TCreateValues,
TUpdateValues
>;
type RollbackCtx<TListData> = { snapshot: TListData | undefined };
export function applyOptimisticListUpdate<
TItem extends { id: string },
TUpdate extends { id: string },
>(data: ResourceListData<TItem>, values: TUpdate): { list: TItem[]; total: number } {
const { id, ...patch } = values as TUpdate & Record<string, unknown>;
return {
list: data.list.map((row) =>
row.id === id ? ({ ...(row as object), ...patch } as TItem) : row,
),
total: data.total,
};
}
export function applyOptimisticListDelete<TItem extends { id: string }>(
data: ResourceListData<TItem>,
id: string,
): { list: TItem[]; total: number } {
return {
list: data.list.filter((row) => row.id !== id),
total: Math.max(0, data.total - 1),
};
}
export function useResourceCRUD<
TListData extends ResourceListData<{ id: string }>,
TCreateValues,
TUpdateValues extends { id: string },
>(
options: UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues>,
): ResourceCRUDResult<TListData, TCreateValues, TUpdateValues> {
const queryClient = useQueryClient();
const invalidateKey = options.invalidateKey ?? [options.queryKey[0]];
const queryKey = options.queryKey;
const opt = options.optimistic;
const query = useQuery({
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: invalidateKey });
options.createLifecycle?.onSuccess?.(values);
},
onError: (_error, values) => {
options.createLifecycle?.onError?.(values);
},
});
const updateMutation = useMutation({
mutationFn: options.updateFn,
onMutate: async (values): Promise<RollbackCtx<TListData>> => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<TListData>(queryKey);
const applied = Boolean(opt?.update && previous);
if (applied) {
queryClient.setQueryData<TListData>(
queryKey,
applyOptimisticListUpdate(
previous as ResourceListData<{ id: string }>,
values as { id: string } & Record<string, unknown>,
) as unknown as TListData,
);
}
options.updateLifecycle?.onMutate?.(values);
return { snapshot: applied ? previous : undefined };
},
onSuccess: (_data, values) => {
void queryClient.invalidateQueries({ queryKey: invalidateKey });
options.updateLifecycle?.onSuccess?.(values);
},
onError: (_error, values, context) => {
const snap = context?.snapshot;
if (snap !== undefined) {
queryClient.setQueryData(queryKey, snap);
}
options.updateLifecycle?.onError?.(values);
},
});
const deleteMutation = useMutation({
mutationFn: options.deleteFn,
onMutate: async (id): Promise<RollbackCtx<TListData>> => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<TListData>(queryKey);
const applied = Boolean(opt?.delete && previous);
if (applied) {
queryClient.setQueryData<TListData>(
queryKey,
applyOptimisticListDelete(
previous as ResourceListData<{ id: string }>,
id,
) as unknown as TListData,
);
}
options.deleteLifecycle?.onMutate?.(id);
return { snapshot: applied ? previous : undefined };
},
onSuccess: (_data, id) => {
void queryClient.invalidateQueries({ queryKey: invalidateKey });
options.deleteLifecycle?.onSuccess?.(id);
},
onError: (_error, id, context) => {
const snap = context?.snapshot;
if (snap !== undefined) {
queryClient.setQueryData(queryKey, snap);
}
options.deleteLifecycle?.onError?.(id);
},
});
return {
data: query.data,
isLoading: query.isLoading,
createMutation,
updateMutation,
deleteMutation,
};
}
+133
View File
@@ -0,0 +1,133 @@
import { useLayoutEffect, useRef, useState, type RefObject } from "react";
export type UseTableFitHeightArgs = {
pageShellRef: RefObject<HTMLDivElement | null>;
toolbarRef: RefObject<HTMLDivElement | null>;
middleRef: RefObject<HTMLDivElement | null>;
tableFrameRef: RefObject<HTMLDivElement | null>;
marginLG: number;
rowCount: number;
isLoading: boolean;
showPagination: boolean;
};
export function useTableFitHeight(args: UseTableFitHeightArgs) {
const {
pageShellRef,
toolbarRef,
middleRef,
tableFrameRef,
marginLG,
rowCount,
isLoading,
showPagination,
} = args;
const tableAvailableRef = useRef(0);
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);
});
useLayoutEffect(() => {
const shell = pageShellRef.current;
const toolbarEl = toolbarRef.current;
const mid = middleRef.current;
if (!shell || !toolbarEl || !mid) return;
const headReserve = 40;
const rowEstimate = 44;
const paginationBlock = 56;
let rafRetry = 0;
const maxRafRetries = 8;
const measure = () => {
const toolbarRect = toolbarEl.getBoundingClientRect();
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 - 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;
}
const frameInner = Math.max(0, Math.floor(midH));
tableAvailableRef.current = frameInner;
setTableAreaMaxHeight(frameInner > 0 ? frameInner : undefined);
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;
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,
rowCount,
isLoading,
marginLG,
pageShellRef,
toolbarRef,
middleRef,
tableFrameRef,
]);
return {
tableAreaMaxHeight,
tableScrollY,
lockScrollHeight: tableScrollY != null,
};
}
+29
View File
@@ -0,0 +1,29 @@
import { useCallback, useEffect, useState } from "react";
export function useUrlSearchState<TSearch extends { keyword: string; offset: number }>(args: {
search: TSearch;
setSearch: (next: TSearch) => void;
}) {
const { search, setSearch } = args;
const [keywordInput, setKeywordInput] = useState(search.keyword);
useEffect(() => {
setKeywordInput(search.keyword);
}, [search.keyword]);
const commitKeyword = useCallback(() => {
const trimmed = keywordInput.trim();
setSearch({ ...search, keyword: trimmed, offset: 0 });
}, [keywordInput, search, setSearch]);
const applyKeyword = useCallback(
(kw: string) => {
const trimmed = kw.trim();
setKeywordInput(trimmed);
setSearch({ ...search, keyword: trimmed, offset: 0 });
},
[search, setSearch],
);
return { keywordInput, setKeywordInput, commitKeyword, applyKeyword };
}
+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;
}
+46
View File
@@ -0,0 +1,46 @@
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 { useSettingsStore } from "./stores/settings";
import { fetchSessionAndApplyToStore } from "./utils/session";
import { installHttpRouter } from "./utils/http";
const router = createRouter({ routeTree });
installHttpRouter(router);
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");
return worker.start({ onUnhandledRequest: "bypass" });
}
enableMocking()
.then(async () => {
await Promise.all([useSettingsStore.persist.rehydrate(), 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);
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { z } from "zod/v4";
import {
paginatedListSchema,
paginatedWithSchema,
successWithNullBody,
successWithSchema,
} from "./createHandler";
import { UserSchema } from "@/api/schemas";
describe("successWithSchema", () => {
it("throws ZodError when payload does not match schema (mock drift surfaces here)", () => {
expect(() =>
successWithSchema(UserSchema, {
id: "1",
username: "x",
} as unknown),
).toThrow(z.ZodError);
});
it("returns JSON body with parsed data when valid", async () => {
const row = {
id: "1",
username: "u",
avatar: null,
email: null,
roles: [] as string[],
permissions: [] as string[],
};
const res = successWithSchema(UserSchema, row);
const body = (await res.json()) as { code: number; data: typeof row };
expect(body.code).toBe(0);
expect(body.data.username).toBe("u");
});
});
describe("paginatedListSchema / paginatedWithSchema", () => {
it("rejects invalid list shape", () => {
const schema = paginatedListSchema(UserSchema);
expect(() =>
schema.parse({
list: [{ id: "1" }],
total: 1,
}),
).toThrow(z.ZodError);
});
it("accepts valid paginated payload via paginatedWithSchema", async () => {
const row = {
id: "1",
username: "u",
avatar: null,
email: null,
roles: [] as string[],
permissions: [] as string[],
};
const res = paginatedWithSchema(UserSchema, { list: [row], total: 1 });
const body = (await res.json()) as { data: { list: (typeof row)[]; total: number } };
expect(body.data.total).toBe(1);
expect(body.data.list).toHaveLength(1);
});
});
describe("successWithNullBody", () => {
it("wraps null data with schema validation", async () => {
const res = successWithNullBody();
const body = (await res.json()) as { code: number; data: null };
expect(body.data).toBeNull();
expect(body.code).toBe(0);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { HttpResponse, delay as mswDelay } from "msw";
import { z } from "zod/v4";
import type { ZodTypeAny } from "zod/v4";
export interface ApiResponse<T> {
code: number;
data: T;
message: string;
}
export async function withDelay(ms: number = 200): Promise<void> {
await mswDelay(ms);
}
export function successResponse<T>(data: T, message: string = "ok") {
return HttpResponse.json({
code: 0,
data,
message,
});
}
/** Parse `data` with Zod before wrapping — catches mock ↔ contract drift early. */
export function successWithSchema<T extends ZodTypeAny>(
schema: T,
data: unknown,
message: string = "ok",
) {
const parsed = schema.parse(data);
return successResponse(parsed, message);
}
export function paginatedListSchema<T extends ZodTypeAny>(itemSchema: T) {
return z.object({
list: z.array(itemSchema),
total: z.number().int().nonnegative(),
});
}
export function paginatedWithSchema<T extends ZodTypeAny>(
itemSchema: T,
data: unknown,
message: string = "ok",
) {
return successWithSchema(paginatedListSchema(itemSchema), data, message);
}
const NullBodySchema = z.null();
export function successWithNullBody(message: string = "ok") {
return successWithSchema(NullBodySchema, null, message);
}
export function errorResponse(code: number, message: string, data: unknown = null) {
return HttpResponse.json({
code,
data,
message,
});
}
export const ERROR_CODES = {
INVALID_CREDENTIALS: 1001,
NOT_FOUND: 1002,
UNAUTHORIZED: 1003,
FORBIDDEN: 1004,
BAD_REQUEST: 1005,
INTERNAL_ERROR: 1006,
} as const;
+42
View File
@@ -0,0 +1,42 @@
import type { User } from "@/api/schemas";
import { vercelAvatarUrl } from "./utils";
const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [
["admin", "ops.admin@northstar.io"],
["zhao.ming", "zhao.ming@northstar.io"],
["sarah.chen", "sarah.chen@northstar.io"],
["james.park", "james.park@northstar.io"],
["emma.garcia", "emma.garcia@northstar.io"],
["ryan.kim", "ryan.kim@northstar.io"],
["olivia.tanaka", "olivia.tanaka@northstar.io"],
["liam.patel", "liam.patel@northstar.io"],
["ava.nguyen", "ava.nguyen@northstar.io"],
["noah.berg", "noah.berg@northstar.io"],
["mia.silva", "mia.silva@northstar.io"],
["lucas.rossi", "lucas.rossi@northstar.io"],
["isabella.ali", "isabella.ali@northstar.io"],
["ethan.kovacs", "ethan.kovacs@northstar.io"],
["sophia.dubois", "sophia.dubois@northstar.io"],
["mateo.santos", "mateo.santos@northstar.io"],
["harper.ivanov", "harper.ivanov@northstar.io"],
["elijah.mohamed", "elijah.mohamed@northstar.io"],
["amelia.schmidt", "amelia.schmidt@northstar.io"],
["henry.okafor", "henry.okafor@northstar.io"],
];
export const GUEST_AUTH_USER_BODY = {
id: "99",
username: "guest",
avatar: null as null,
email: "guest@example.com",
roles: [] as string[],
};
export const MOCK_USERS: User[] = MOCK_IDENTITIES.map(([username, email], i) => ({
id: String(i + 1),
username,
avatar: vercelAvatarUrl(username),
email,
roles: i === 0 ? ["admin"] : ["editor"],
permissions: i === 0 ? ["user:view", "user:create", "user:edit", "user:delete"] : ["user:view"],
}));
+159
View File
@@ -0,0 +1,159 @@
import { http } from "msw";
import { MOCK_USERS, GUEST_AUTH_USER_BODY } from "../data";
import {
withDelay,
errorResponse,
ERROR_CODES,
successWithSchema,
successWithNullBody,
} from "../createHandler";
import {
AuthTokensSchema,
AuthUserResponseSchema,
LoginRequestSchema,
PermissionsListSchema,
RefreshTokenRequestSchema,
RegisterRequestSchema,
} from "@/api/schemas";
const GUEST_REFRESH = "mock-guest-refresh";
const REGISTERED_ACCESS = "mock-registered-access";
const REGISTERED_REFRESH = "mock-registered-refresh";
/** Last successful register (in-memory) so /user matches the new account in MSW */
let mockRegisteredSession: { username: string; email: string | null } | null = null;
function isGuestAuth(authorization: string | null): boolean {
return (authorization ?? "").includes("mock-guest-access");
}
function isRegisteredAuth(authorization: string | null): boolean {
return (authorization ?? "").includes(REGISTERED_ACCESS);
}
export const authHandlers = [
http.post("/api/auth/login", async ({ request }) => {
await withDelay(300);
let json: unknown;
try {
json = await request.json();
} catch {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
}
const parsed = LoginRequestSchema.safeParse(json);
if (!parsed.success) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid login body");
}
const { username, password } = parsed.data;
if (username === "guest" && password === "guest") {
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-guest-access",
refreshToken: GUEST_REFRESH,
});
}
if (username === "admin" && password === "admin") {
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-access-token",
refreshToken: "mock-refresh-token",
});
}
return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password");
}),
http.post("/api/auth/register", async ({ request }) => {
await withDelay(300);
let json: unknown;
try {
json = await request.json();
} catch {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
}
const parsed = RegisterRequestSchema.safeParse(json);
if (!parsed.success) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid register body");
}
const { username, password, email } = parsed.data;
if (password.length < 6) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Password must be at least 6 characters");
}
const taken = username === "guest" || MOCK_USERS.some((u) => u.username === username);
if (taken) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Username already taken");
}
mockRegisteredSession = {
username,
email: email ?? null,
};
return successWithSchema(AuthTokensSchema, {
accessToken: REGISTERED_ACCESS,
refreshToken: REGISTERED_REFRESH,
});
}),
http.post("/api/auth/refresh", async ({ request }) => {
await withDelay(100);
let json: unknown;
try {
json = await request.json();
} catch {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
}
const parsed = RefreshTokenRequestSchema.safeParse(json);
if (!parsed.success) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "refreshToken is required");
}
if (parsed.data.refreshToken === GUEST_REFRESH) {
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-guest-access-refreshed",
refreshToken: "mock-guest-refresh-refreshed",
});
}
if (
parsed.data.refreshToken === REGISTERED_REFRESH ||
parsed.data.refreshToken.startsWith(`${REGISTERED_REFRESH}-`)
) {
return successWithSchema(AuthTokensSchema, {
accessToken: `${REGISTERED_ACCESS}-refreshed`,
refreshToken: `${REGISTERED_REFRESH}-refreshed`,
});
}
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-new-access-token",
refreshToken: "mock-new-refresh-token",
});
}),
http.post("/api/auth/logout", () => successWithNullBody()),
http.get("/api/auth/user", ({ request }) => {
const auth = request.headers.get("authorization");
if (isRegisteredAuth(auth)) {
const session = mockRegisteredSession;
if (session) {
return successWithSchema(AuthUserResponseSchema, {
id: "reg-mock",
username: session.username,
avatar: null,
email: session.email,
roles: ["editor"],
});
}
}
if (isGuestAuth(auth)) {
return successWithSchema(AuthUserResponseSchema, GUEST_AUTH_USER_BODY);
}
const { permissions: _permissions, ...userWithoutPermissions } = MOCK_USERS[0]!;
return successWithSchema(AuthUserResponseSchema, userWithoutPermissions);
}),
http.get("/api/auth/permissions", ({ request }) => {
const auth = request.headers.get("authorization");
if (isRegisteredAuth(auth) && mockRegisteredSession) {
return successWithSchema(PermissionsListSchema, ["user:view"] as string[]);
}
if (isGuestAuth(auth)) {
return successWithSchema(PermissionsListSchema, [] as string[]);
}
return successWithSchema(PermissionsListSchema, MOCK_USERS[0]!.permissions);
}),
];
+4
View File
@@ -0,0 +1,4 @@
import { authHandlers } from "./auth";
import { userHandlers } from "./user";
export const handlers = [...authHandlers, ...userHandlers];
+67
View File
@@ -0,0 +1,67 @@
import { http, HttpResponse } from "msw";
import { UserSchema } from "@/api/schemas";
import { MOCK_USERS } from "../data";
import { filterUsers, paginateList, parsePaginationParams } from "../utils";
import {
withDelay,
errorResponse,
ERROR_CODES,
paginatedWithSchema,
successWithSchema,
successWithNullBody,
} from "../createHandler";
let users = [...MOCK_USERS];
export const EXPIRED_ACCESS_TOKEN = "__EXPIRED_ACCESS__";
export const userHandlers = [
http.get("/api/users", async ({ request }) => {
await withDelay(200);
const auth = request.headers.get("authorization");
if (auth === `Bearer ${EXPIRED_ACCESS_TOKEN}`) {
return new HttpResponse(null, { status: 401 });
}
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(users, { keyword, role });
const list = paginateList(filtered, limit, offset);
return paginatedWithSchema(UserSchema, { list, total: filtered.length });
}),
http.post("/api/users", async ({ request }) => {
await withDelay(200);
const body = (await request.json()) as Record<string, unknown>;
const newUser = {
id: String(users.length + 1),
username: String(body.username),
avatar: null,
email: typeof body.email === "string" ? body.email : null,
roles: (body.roles as string[]) ?? [],
permissions: [] as string[],
};
users.push(newUser);
return successWithSchema(UserSchema, newUser);
}),
http.put("/api/users/:id", async ({ params, request }) => {
await withDelay(200);
const body = (await request.json()) as Record<string, unknown>;
const idx = users.findIndex((u) => u.id === params.id);
if (idx === -1) {
return errorResponse(ERROR_CODES.NOT_FOUND, "User not found");
}
users[idx] = { ...users[idx], ...body } as (typeof users)[number];
return successWithSchema(UserSchema, users[idx]);
}),
http.delete("/api/users/:id", async ({ params }) => {
await withDelay(200);
users = users.filter((u) => u.id !== params.id);
return successWithNullBody();
}),
];
+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 };
}
+199
View File
@@ -0,0 +1,199 @@
/* 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 AuthDashboardIndexRouteImport } from "./routes/_auth/dashboard/index";
import { Route as Auth403IndexRouteImport } from "./routes/_auth/403/index";
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 AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
id: "/dashboard/",
path: "/dashboard/",
getParentRoute: () => AuthRoute,
} as any);
const Auth403IndexRoute = Auth403IndexRouteImport.update({
id: "/403/",
path: "/403/",
getParentRoute: () => AuthRoute,
} as any);
export interface FileRoutesByFullPath {
"/": typeof IndexRoute;
"/404/": typeof R404IndexRoute;
"/login/": typeof LoginIndexRoute;
"/register/": typeof RegisterIndexRoute;
"/403/": typeof Auth403IndexRoute;
"/dashboard/": typeof AuthDashboardIndexRoute;
"/users/": typeof AuthUsersIndexRoute;
}
export interface FileRoutesByTo {
"/": typeof IndexRoute;
"/404": typeof R404IndexRoute;
"/login": typeof LoginIndexRoute;
"/register": typeof RegisterIndexRoute;
"/403": typeof Auth403IndexRoute;
"/dashboard": typeof AuthDashboardIndexRoute;
"/users": typeof AuthUsersIndexRoute;
}
export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof IndexRoute;
"/_auth": typeof AuthRouteWithChildren;
"/404/": typeof R404IndexRoute;
"/login/": typeof LoginIndexRoute;
"/register/": typeof RegisterIndexRoute;
"/_auth/403/": typeof Auth403IndexRoute;
"/_auth/dashboard/": typeof AuthDashboardIndexRoute;
"/_auth/users/": typeof AuthUsersIndexRoute;
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths: "/" | "/404/" | "/login/" | "/register/" | "/403/" | "/dashboard/" | "/users/";
fileRoutesByTo: FileRoutesByTo;
to: "/" | "/404" | "/login" | "/register" | "/403" | "/dashboard" | "/users";
id:
| "__root__"
| "/"
| "/_auth"
| "/404/"
| "/login/"
| "/register/"
| "/_auth/403/"
| "/_auth/dashboard/"
| "/_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/dashboard/": {
id: "/_auth/dashboard/";
path: "/dashboard";
fullPath: "/dashboard/";
preLoaderRoute: typeof AuthDashboardIndexRouteImport;
parentRoute: typeof AuthRoute;
};
"/_auth/403/": {
id: "/_auth/403/";
path: "/403";
fullPath: "/403/";
preLoaderRoute: typeof Auth403IndexRouteImport;
parentRoute: typeof AuthRoute;
};
}
}
interface AuthRouteChildren {
Auth403IndexRoute: typeof Auth403IndexRoute;
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute;
AuthUsersIndexRoute: typeof AuthUsersIndexRoute;
}
const AuthRouteChildren: AuthRouteChildren = {
Auth403IndexRoute: Auth403IndexRoute,
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
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,
});
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useMemo } 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 { RouteError } from "@/components/RouteError";
import "@/index.css";
function AppQueryBridge({ children }: { children: React.ReactNode }) {
const { message } = App.useApp();
const queryClient = useMemo(
() =>
new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false },
mutations: {
onError: (err) => {
message.error(err instanceof Error ? err.message : String(err));
},
},
},
}),
[message],
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
function RootComponent() {
const darkMode = useSettingsStore((s) => s.darkMode);
const configProviderProps = useAppTheme();
useEffect(() => {
document.documentElement.lang = "en";
}, []);
useEffect(() => {
document.documentElement.setAttribute("data-theme", darkMode ? "dark" : "light");
}, [darkMode]);
return (
<ConfigProvider {...configProviderProps} locale={enUS}>
<App>
<AppQueryBridge>
<Outlet />
</AppQueryBridge>
</App>
</ConfigProvider>
);
}
export const Route = createRootRoute({
component: RootComponent,
notFoundComponent: NotFound,
errorComponent: RouteError,
});
+32
View File
@@ -0,0 +1,32 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useAuthStore } from "@/stores/auth";
import { MainLayout } from "@/components/Layout/MainLayout";
import { canAccessPath, normalizeAppPath } from "@/utils/appMenu";
import { fetchSessionAndApplyToStore } from "@/utils/session";
export const Route = createFileRoute("/_auth")({
beforeLoad: async ({ location }) => {
const { isAuthenticated, user, tokens } = useAuthStore.getState();
if (!isAuthenticated) {
throw redirect({ to: "/login" });
}
if (tokens && !user) {
try {
await fetchSessionAndApplyToStore();
} catch {
useAuthStore.getState().logout();
throw redirect({ to: "/login" });
}
}
const { user: nextUser } = useAuthStore.getState();
const path = normalizeAppPath(location.pathname);
if (path === "/403") return;
if (!canAccessPath(location.pathname, nextUser?.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>
);
}
@@ -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,346 @@
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" as const,
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" as const,
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" as const,
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" as const,
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>
);
}
@@ -0,0 +1,59 @@
import { Form, Input, Select } 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="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="email" label="Email">
<Input />
</Form.Item>
</BaseFormModal>
);
}
@@ -0,0 +1,93 @@
import { Button, Input, Select, theme } from "antd";
import { Plus, UserRound } from "lucide-react";
import { forwardRef, useMemo } from "react";
import { FilterToolbar } from "@/components/FilterToolbar";
/** Search + role slot `minWidth` for FilterToolbar collapse math */
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;
onCreateClick: () => void;
};
export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar(
{
keywordInput,
onKeywordChange,
onSearch,
onClearSearch,
roleValue,
onRoleChange,
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}
/>
),
},
{
key: "role",
minWidth: FILTER_CONTROL_WIDTH,
children: (
<Select
allowClear
placeholder="Role"
style={{ width: FILTER_CONTROL_WIDTH }}
prefix={<UserRound 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}>
Create User
</Button>
}
moreFiltersLabel="More filters"
moreFiltersTitle="More filters"
/>
);
});
+359
View File
@@ -0,0 +1,359 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "antd";
import type { TablePaginationConfig } from "antd/es/table/interface";
import { 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 { useTableFitHeight } from "@/hooks/useTableFitHeight";
import { useCrudToasts } from "@/hooks/useCrudToasts";
import { useUrlSearchState } from "@/hooks/useUrlSearchState";
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().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
keyword: z.string().catch(""),
role: z.string().catch(""),
});
type UserSearch = z.infer<typeof UserSearchParamsSchema>;
export const Route = createFileRoute("/_auth/users/")({
validateSearch: (search) => UserSearchParamsSchema.parse(search),
component: UsersPage,
});
const paginatedUserSchema = PaginatedResponseSchema(UserSchema);
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 [editingUser, setEditingUser] = useState<User | null>(null);
const [form] = Form.useForm();
const currentPage = Math.floor(search.offset / search.limit) + 1;
const setSearch = (next: UserSearch) => {
void navigate({ search: next });
};
const { keywordInput, setKeywordInput, applyKeyword } = useUrlSearchState({
search,
setSearch,
});
const crudToasts = useCrudToasts<CreateUserRequest, CreateUserRequest & { id: string }>({
message,
resourceKey: "users",
});
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,
],
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)),
optimistic: { update: true, delete: true },
createLifecycle: {
onSuccess: (values) => {
crudToasts.createLifecycle?.onSuccess?.(values);
setModalOpen(false);
form.resetFields();
},
onError: crudToasts.createLifecycle?.onError,
},
updateLifecycle: {
onMutate: crudToasts.updateLifecycle?.onMutate,
onSuccess: (values) => {
crudToasts.updateLifecycle?.onSuccess?.(values);
setModalOpen(false);
setEditingUser(null);
form.resetFields();
},
onError: crudToasts.updateLifecycle?.onError,
},
deleteLifecycle: {
onMutate: crudToasts.deleteLifecycle?.onMutate,
onSuccess: crudToasts.deleteLifecycle?.onSuccess,
onError: crudToasts.deleteLifecycle?.onError,
},
});
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",
sorter: true,
sortOrder: search.sortField === "roles" ? search.sortOrder : null,
render: (roles: string[]) => (
<Space wrap>
{roles.map((role) => (
<Tag
key={role}
variant="outlined"
styles={{
root: {
borderRadius: 9999,
background: "transparent",
boxShadow: "none",
},
}}
>
{role}
</Tag>
))}
</Space>
),
},
{
title: "Actions",
key: "actions",
width: 60,
align: "right" as const,
sorter: false,
render: (_: unknown, record: User) => (
<Dropdown
menu={{
items: [
{
key: "edit",
icon: <Pencil size={token.fontSize} />,
label: "Edit",
onClick: () => {
setEditingUser(record);
form.setFieldsValue(record);
setModalOpen(true);
},
},
{
key: "delete",
icon: <Trash2 size={token.fontSize} />,
label: "Delete",
danger: true,
onClick: () => confirmDelete(record),
},
],
}}
placement="bottomRight"
>
<Button
type="text"
icon={<MoreVertical size={token.fontSize} />}
aria-label="Row actions"
/>
</Dropdown>
),
},
];
const showPagination = (data?.total ?? 0) > search.limit;
const { tableAreaMaxHeight, tableScrollY, lockScrollHeight } = useTableFitHeight({
pageShellRef,
toolbarRef: toolbarRowRef,
middleRef: middleSectionRef,
tableFrameRef,
marginLG: token.marginLG,
rowCount: data?.list?.length ?? 0,
isLoading,
showPagination,
});
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={applyKeyword}
onClearSearch={() => {
setKeywordInput("");
setSearch({ ...search, keyword: "", offset: 0 });
}}
roleValue={search.role || undefined}
onRoleChange={(role) =>
void navigate({
search: {
...search,
role,
offset: 0,
},
})
}
onCreateClick={() => {
setEditingUser(null);
form.resetFields();
setModalOpen(true);
}}
/>
<DataTable<User>
layoutRef={middleSectionRef}
frameRef={tableFrameRef}
lockScrollHeight={lockScrollHeight}
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;
const nextSortField = sorter.order ? String(sorter.field) : "username";
const nextSortOrder = sorter.order ? sorter.order : "descend";
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) {
updateMutation.mutate({ ...values, id: editingUser.id });
} else {
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" });
},
});
+21
View File
@@ -0,0 +1,21 @@
@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;
}
}
/* Keep browser autofill from painting a solid background over the glass card. */
.login-page__card .ant-input:-webkit-autofill,
.login-page__card .ant-input:-webkit-autofill:hover,
.login-page__card .ant-input:-webkit-autofill:focus,
.login-page__card .ant-input-affix-wrapper input:-webkit-autofill,
.login-page__card .ant-input-affix-wrapper input:-webkit-autofill:hover,
.login-page__card .ant-input-affix-wrapper input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--ant-color-text, currentColor) !important;
caret-color: var(--ant-color-text, currentColor);
-webkit-box-shadow: 0 0 0 1000px transparent inset !important;
box-shadow: 0 0 0 1000px transparent inset !important;
transition: background-color 9999s ease-out 0s;
}
+224
View File
@@ -0,0 +1,224 @@
import { createFileRoute, Link, 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.Link
href="#"
onClick={(e) => e.preventDefault()}
style={{ fontSize: token.fontSizeSM }}
>
Forgot password?
</Typography.Link>
</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>
</Form>
<Flex justify="center" style={{ marginTop: token.margin }}>
<Typography.Text type="secondary">
Don&apos;t have an account?{" "}
<Link to="/register" style={{ color: token.colorPrimary }}>
Sign up
</Link>
</Typography.Text>
</Flex>
</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>
);
}
+215
View File
@@ -0,0 +1,215 @@
import { Link, createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { Form, Input, Button, Card, App, theme, Typography, Flex } from "antd";
import type { CSSProperties } from "react";
import { useMutation } from "@tanstack/react-query";
import { httpClient } from "@/utils/http";
import { useAuthStore } from "@/stores/auth";
import { AUTH_ENDPOINTS } from "@/api/auth";
import { AuthTokensSchema, RegisterRequestSchema } from "@/api/schemas";
import { fetchSessionAndApplyToStore } from "@/utils/session";
import type { RegisterRequest } from "@/api/auth";
import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants";
import { Aurora } from "@/components/Aurora";
import { AppFooter } from "@/components/Layout/AppFooter";
import "../login/index.css";
export const Route = createFileRoute("/register/")({
beforeLoad: () => {
const { isAuthenticated } = useAuthStore.getState();
if (isAuthenticated) {
throw redirect({ to: "/dashboard" });
}
},
component: RegisterPage,
});
function RegisterPage() {
const navigate = useNavigate();
const { message } = App.useApp();
const setTokens = useAuthStore((s) => s.setTokens);
const { token } = theme.useToken();
const registerMutation = useMutation({
mutationFn: async (values: RegisterRequest) => {
const parsed = RegisterRequestSchema.parse(values);
const tokens = await httpClient.post(AUTH_ENDPOINTS.register, parsed);
const validTokens = AuthTokensSchema.parse(tokens);
setTokens(validTokens);
await fetchSessionAndApplyToStore();
},
onSuccess: () => {
message.success("Account created successfully");
void navigate({ to: "/dashboard" });
},
onError: (err) => {
message.error(err instanceof Error ? err.message : "Registration 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 cardStyle: CSSProperties = {
width: "100%",
maxWidth: 384,
background: `color-mix(in srgb, ${token.colorBgContainer} 44%, transparent)`,
backdropFilter: "blur(18px) saturate(1.35)",
WebkitBackdropFilter: "blur(18px) saturate(1.35)",
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) => {
const payload = RegisterRequestSchema.parse(values);
registerMutation.mutate(payload);
}}
initialValues={{ username: "", password: "", confirmPassword: "", email: "" }}
validateTrigger={"onBlur"}
requiredMark={false}
>
<Form.Item
name="username"
label={<span style={{ fontWeight: 500 }}>Username</span>}
rules={[{ required: true, message: "Please enter your username" }]}
>
<Input placeholder="new-user" size="large" />
</Form.Item>
<Form.Item
name="email"
label={<span style={{ fontWeight: 500 }}>Email address</span>}
rules={[{ type: "email", message: "Please enter a valid email address" }]}
>
<Input placeholder="you@example.com" size="large" />
</Form.Item>
<Form.Item
name="password"
label={<span style={{ fontWeight: 500 }}>Password</span>}
rules={[
{ required: true, message: "Please enter your password" },
{ min: 6, message: "Password must be at least 6 characters long" },
]}
>
<Input.Password placeholder="Enter at least 6 characters" size="large" />
</Form.Item>
<Form.Item
name="confirmPassword"
label={<span style={{ fontWeight: 500 }}>Confirm password</span>}
dependencies={["password"]}
rules={[
{ required: true, message: "Please confirm your password" },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue("password") === value) {
return Promise.resolve();
}
return Promise.reject(new Error("Passwords do not match"));
},
}),
]}
>
<Input.Password placeholder="Re-enter your password" size="large" />
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: token.marginLG }}>
<Button
type="primary"
htmlType="submit"
loading={registerMutation.isPending}
block
size="large"
>
Create account
</Button>
</Form.Item>
<Flex justify="center" style={{ marginTop: token.margin }}>
<Typography.Text type="secondary">
Already have an account?{" "}
<Link to="/login" style={{ color: token.colorPrimary }}>
Sign in
</Link>
</Typography.Text>
</Flex>
</Form>
</Card>
</Flex>
<Flex
vertical
align="center"
style={{
padding: `${token.paddingSM}px ${token.padding}px`,
textAlign: "center",
}}
>
<AppFooter />
</Flex>
</Flex>
</Flex>
);
}
+65
View File
@@ -0,0 +1,65 @@
import type { AuthTokens, User, MenuItem } from "@/api/schemas";
import { createPersistentStore } from "./createPersistentStore";
interface AuthState {
tokens: AuthTokens | null;
user: User | null;
menus: MenuItem[];
isAuthenticated: boolean;
setTokens: (tokens: AuthTokens) => void;
setUser: (user: User) => void;
setMenus: (menus: MenuItem[]) => void;
hasPermission: (point: string) => boolean;
logout: () => void;
}
export const useAuthStore = createPersistentStore<AuthState>(
(set, get) => ({
tokens: null,
user: null,
menus: [],
isAuthenticated: false,
setTokens: (tokens) => set({ tokens, isAuthenticated: true }),
setUser: (user) => set({ user }),
setMenus: (menus) => set({ menus }),
hasPermission: (point) => {
const { user } = get();
return user?.permissions.includes(point) ?? false;
},
logout: () =>
set({
tokens: null,
user: null,
menus: [],
isAuthenticated: false,
}),
}),
{
name: "auth-storage",
partialize: (state) => ({
tokens: state.tokens,
isAuthenticated: state.isAuthenticated,
}),
merge: (persistedState, currentState) => {
const p = persistedState as Partial<Pick<AuthState, "tokens" | "isAuthenticated">> | null;
return {
...currentState,
tokens: p?.tokens ?? currentState.tokens,
isAuthenticated: p?.isAuthenticated ?? currentState.isAuthenticated,
user: null,
menus: [],
};
},
},
);
export function getAuthTokens(): AuthTokens | null {
return useAuthStore.getState().tokens;
}
export function getAccessToken(): string | null {
return useAuthStore.getState().tokens?.accessToken ?? null;
}
@@ -0,0 +1,29 @@
import { create } from "zustand";
import type { StateCreator } from "zustand";
import { persist } from "zustand/middleware";
import type { PersistOptions } from "zustand/middleware";
export interface PersistConfig<T> {
name: string;
version?: number;
partialize?: PersistOptions<T, Partial<T>>["partialize"];
merge?: PersistOptions<T, Partial<T>>["merge"];
}
/**
* Factory function to create a Zustand store with persistence middleware
* Reduces boilerplate for stores that need automatic data persistence
*/
export function createPersistentStore<T extends object>(
initializer: StateCreator<T, [], []>,
config: PersistConfig<T>,
) {
const persistConfig: PersistOptions<T, Partial<T>> = {
name: config.name,
...(config.version !== undefined && { version: config.version }),
...(config.merge && { merge: config.merge }),
...(config.partialize && { partialize: config.partialize }),
};
return create<T>()(persist(initializer, persistConfig));
}
+33
View File
@@ -0,0 +1,33 @@
import { createPersistentStore } from "./createPersistentStore";
function getInitialDarkMode() {
if (typeof window === "undefined") {
return false;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
interface SettingsState {
darkMode: boolean;
sidebarCollapsed: boolean;
toggleDarkMode: () => void;
toggleSidebar: () => void;
setSidebarCollapsed: (collapsed: boolean) => void;
}
export const useSettingsStore = createPersistentStore<SettingsState>(
(set) => ({
darkMode: getInitialDarkMode(),
sidebarCollapsed: false,
toggleDarkMode: () => set((s) => ({ darkMode: !s.darkMode })),
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
}),
{
name: "settings-storage-basic",
partialize: (state) => ({
darkMode: state.darkMode,
sidebarCollapsed: state.sidebarCollapsed,
}),
},
);
+134
View File
@@ -0,0 +1,134 @@
import type { MenuItem } from "@/api/schemas";
export const APP_MENU_TREE: MenuItem[] = [
{
id: "g-platform",
kind: "group",
name: "Platform",
path: null,
icon: "IconLucideSparkles",
permissions: null,
sort: 0,
hidden: false,
children: [
{
id: "1",
kind: "item",
name: "Dashboard",
path: "/dashboard",
icon: "IconLucideLayoutDashboard",
children: null,
permissions: null,
sort: 0,
hidden: false,
},
{
id: "2",
kind: "item",
name: "Users",
path: "/users",
icon: "IconLucideUsers",
children: null,
permissions: ["user:view"],
sort: 1,
hidden: false,
},
],
},
{
id: "g-projects",
kind: "group",
name: "Projects",
path: null,
icon: "IconLucideFolderKanban",
children: [
{
id: "3",
kind: "item",
name: "Design Engineering",
path: "/design-engineering",
icon: "IconLucideBookOpen",
children: null,
permissions: null,
sort: 0,
hidden: false,
},
{
id: "4",
kind: "item",
name: "Sales & Marketing",
path: "/sales-marketing",
icon: "IconLucideBriefcase",
children: null,
permissions: ["user:view"],
sort: 1,
hidden: false,
},
],
permissions: null,
sort: 1,
hidden: false,
},
];
function hasRequiredPermissions(
required: string[] | null | undefined,
granted: Set<string>,
): boolean {
if (!required || required.length === 0) return true;
return required.every((p) => granted.has(p));
}
export function filterMenuTreeByPermissions(
nodes: MenuItem[],
permissionList: string[],
): MenuItem[] {
const granted = new Set(permissionList);
const walk = (list: MenuItem[]): MenuItem[] =>
list
.map((node) => {
if (!hasRequiredPermissions(node.permissions ?? null, granted)) return null;
if (node.kind === "group") {
const children = walk(node.children);
if (children.length === 0) return null;
return { ...node, children };
}
if (node.children?.length) {
const children = walk(node.children);
if (children.length === 0) return null;
return { ...node, children };
}
return node;
})
.filter((n): n is MenuItem => n != null);
return walk(nodes);
}
export function normalizeAppPath(pathname: string): string {
if (pathname === "/") return pathname;
return pathname.replace(/\/+$/, "") || "/";
}
export function requiredPermissionForPath(pathname: string): string | null {
const p = normalizeAppPath(pathname);
const map: Record<string, string | null> = {
"/dashboard": null,
"/users": "user:view",
"/design-engineering": null,
"/sales-marketing": "user:view",
"/403": null,
};
return map[p] ?? null;
}
export function canAccessPath(pathname: string, permissions: string[] | undefined): boolean {
const required = requiredPermissionForPath(pathname);
if (required == null) return true;
if (!permissions?.length) return false;
return permissions.includes(required);
}
+7
View File
@@ -0,0 +1,7 @@
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "";
/** Favicon path under `public/` (Vite serves as site root). */
export const APP_FAVICON_SRC = "/favicon.svg";
/** Product / brand name (login header, sidebar logo text, etc.). */
export const APP_BRAND_NAME = "Antd Admin";
+172
View File
@@ -0,0 +1,172 @@
import { API_BASE_URL } from "./constants";
import { AUTH_ENDPOINTS } from "@/api/auth";
import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/schemas";
import { getAccessToken, useAuthStore } from "@/stores/auth";
export class ApiError extends Error {
code: number;
constructor(code: number, message: string) {
super(message);
this.name = "ApiError";
this.code = code;
}
}
export class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "HttpError";
this.status = status;
}
}
type RequestOptions = Omit<RequestInit, "method" | "body"> & {
params?: Record<string, string | number | null | undefined>;
};
type HttpRouter = {
navigate: (opts: { to: string }) => void | Promise<void>;
};
let httpRouter: HttpRouter | null = null;
export function installHttpRouter(router: HttpRouter): void {
httpRouter = router;
}
let inflightRefresh: Promise<boolean> | null = null;
async function doRefreshSessionTokens(): Promise<boolean> {
const { tokens, setTokens, logout } = useAuthStore.getState();
const refreshToken = tokens?.refreshToken;
if (!refreshToken) {
logout();
navigateToLogin();
return false;
}
const body = RefreshTokenRequestSchema.parse({ refreshToken });
const url = buildUrl(AUTH_ENDPOINTS.refresh);
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
logout();
navigateToLogin();
return false;
}
const json: unknown = await res.json();
const envelope = json as { code?: number; data?: unknown; message?: string };
if (envelope.code !== undefined && envelope.code !== 0) {
logout();
navigateToLogin();
return false;
}
const next = AuthTokensSchema.parse(envelope.data ?? envelope);
setTokens(next);
return true;
} catch {
logout();
navigateToLogin();
return false;
}
}
async function refreshSessionTokens(): Promise<boolean> {
if (inflightRefresh) return inflightRefresh;
inflightRefresh = doRefreshSessionTokens().finally(() => {
inflightRefresh = null;
});
return inflightRefresh;
}
function buildUrl(path: string, params?: RequestOptions["params"]): string {
const base = path.startsWith("http") ? path : `${API_BASE_URL}${path}`;
if (!params) return base;
const url = new URL(base, window.location.origin);
for (const [key, value] of Object.entries(params)) {
if (value != null) url.searchParams.set(key, String(value));
}
return url.toString();
}
function getAuthHeaders(): Record<string, string> {
const token = getAccessToken();
if (token) return { Authorization: `Bearer ${token}` };
return {};
}
function navigateTo403(): void {
void httpRouter?.navigate({ to: "/403" });
}
function navigateToLogin(): void {
void httpRouter?.navigate({ to: "/login" });
}
async function request<T>(
method: string,
path: string,
body?: unknown,
options?: RequestOptions,
isAfterRefresh = false,
): Promise<T> {
const url = buildUrl(path, options?.params);
const headers: Record<string, string> = {
"Content-Type": "application/json",
...getAuthHeaders(),
...(options?.headers as Record<string, string>),
};
const res = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
...options,
});
if (res.status === 401 && !isAfterRefresh && path !== AUTH_ENDPOINTS.refresh) {
const refreshed = await refreshSessionTokens();
if (refreshed) {
return request<T>(method, path, body, options, true);
}
throw new HttpError(401, "Unauthorized");
}
if (res.status === 403) {
navigateTo403();
throw new HttpError(403, "Forbidden");
}
if (!res.ok) {
throw new HttpError(res.status, `HTTP ${res.status}: ${res.statusText}`);
}
const json: unknown = await res.json();
const envelope = json as { code?: number; data?: unknown; message?: string };
if (envelope.code !== undefined && envelope.code !== 0) {
throw new ApiError(envelope.code, envelope.message ?? "Unknown error");
}
return (envelope.data !== undefined ? envelope.data : json) as T;
}
export const httpClient = {
get: <T>(path: string, options?: RequestOptions) => request<T>("GET", path, undefined, options),
post: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>("POST", path, body, options),
put: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>("PUT", path, body, options),
delete: <T>(path: string, options?: RequestOptions) =>
request<T>("DELETE", path, undefined, options),
};

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