mirror of
https://github.com/zuiidea/antd-admin.git
synced 2026-10-20 16:54:31 +00:00
Compare commits
512
Commits
4.3.2
...
feature/upgrade
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0d17c54a0 | ||
|
|
f87776a8da | ||
|
|
07dd3b0ff2 | ||
|
|
c18d288a4e | ||
|
|
3d4551c9a4 | ||
|
|
8673d8398e | ||
|
|
0726321552 | ||
|
|
f12539812e | ||
|
|
1cb0819011 | ||
|
|
6ac8a62770 | ||
|
|
335c77cc8c | ||
|
|
c8062e1299 | ||
|
|
b277f522bd | ||
|
|
39a6339c59 | ||
|
|
4406e57556 | ||
|
|
c5221c71be | ||
|
|
2d46d7bf27 | ||
|
|
0ecfefcd97 | ||
|
|
57cd84b5ce | ||
|
|
ca88d6567d | ||
|
|
f7de07dba6 | ||
|
|
e99220d619 | ||
|
|
73e53ea3ba | ||
|
|
70ee2eaa41 | ||
|
|
70beec4ec8 | ||
|
|
fa163c9456 | ||
|
|
a54300c588 | ||
|
|
2fce0a3e2a | ||
|
|
d4706457c8 | ||
|
|
fe322fae17 | ||
|
|
ab43016511 | ||
|
|
a2f23fbbe9 | ||
|
|
bef1ea8f18 | ||
|
|
bd26b035ac | ||
|
|
3730c874b5 | ||
|
|
b186628cb6 | ||
|
|
72e01b9641 | ||
|
|
0c3619f684 | ||
|
|
ca3cc71054 | ||
|
|
3579246c24 | ||
|
|
c872f80e55 | ||
|
|
a26e7f717e | ||
|
|
9eb67a091f | ||
|
|
56943a3a95 | ||
|
|
3e630785bb | ||
|
|
db696409d3 | ||
|
|
6031d691ea | ||
|
|
26b002e80a | ||
|
|
831aef8a7b | ||
|
|
0185201cbd | ||
|
|
578f7f5082 | ||
|
|
9b8fd96a7a | ||
|
|
02150a61a7 | ||
|
|
cc1a803bdb | ||
|
|
3938263979 | ||
|
|
3dd61607b5 | ||
|
|
67f12dbf2c | ||
|
|
bff4c9f905 | ||
|
|
81bb9e996c | ||
|
|
d188afadb0 | ||
|
|
432d67b28e | ||
|
|
b6f98b71b1 | ||
|
|
b47e932d92 | ||
|
|
a5d6189b53 | ||
|
|
b08775bad8 | ||
|
|
5913cd2f43 | ||
|
|
29888fd1ad | ||
|
|
47fd1aea90 | ||
|
|
ab24405469 | ||
|
|
1b4c8e2895 | ||
|
|
3994caea8e | ||
|
|
dd91ffcecd | ||
|
|
426518c70a | ||
|
|
458d63c020 | ||
|
|
6d4e1289a3 | ||
|
|
ccaf67076e | ||
|
|
8f7dd82e40 | ||
|
|
9989aa4de5 | ||
|
|
f983533d59 | ||
|
|
2182759d47 | ||
|
|
4d1851ddd2 | ||
|
|
0d82eca735 | ||
|
|
cfb6b271ee | ||
|
|
0b97964457 | ||
|
|
edfbfcf5db | ||
|
|
e5172432df | ||
|
|
741b04f120 | ||
|
|
83f2cc1344 | ||
|
|
152556d582 | ||
|
|
67fc31a008 | ||
|
|
23eace8350 | ||
|
|
93817e4840 | ||
|
|
bb97d982e4 | ||
|
|
700f58015d | ||
|
|
872ce3a825 | ||
|
|
385b31e7bb | ||
|
|
3d66a616ff | ||
|
|
b7e18a1214 | ||
|
|
3b0bae9c70 | ||
|
|
8b9343a320 | ||
|
|
8618c75459 | ||
|
|
711d2077e2 | ||
|
|
b25646b033 | ||
|
|
02bf7784b1 | ||
|
|
c164d60643 | ||
|
|
1bd4cfe73c | ||
|
|
f4e1eeb21c | ||
|
|
4b5fe62014 | ||
|
|
36c2be61ae | ||
|
|
e9206a9448 | ||
|
|
0bf28879c4 | ||
|
|
7c936712b9 | ||
|
|
e32a19fe3b | ||
|
|
c6382c6221 | ||
|
|
02280fbed9 | ||
|
|
d897eb205d | ||
|
|
b1f290ea32 | ||
|
|
0e36b6b7a0 | ||
|
|
d92f121cf4 | ||
|
|
0a73ba0f12 | ||
|
|
9e4e0efbab | ||
|
|
524dff07d2 | ||
|
|
8b3e5c08f7 | ||
|
|
c17444f5d6 | ||
|
|
df85ecdfff | ||
|
|
b8824141d6 | ||
|
|
aedd2ec3db | ||
|
|
5abad21082 | ||
|
|
1ae41c7ae5 | ||
|
|
e41671dbf9 | ||
|
|
eaeb9092e4 | ||
|
|
2f3ca0a006 | ||
|
|
1b65351be4 | ||
|
|
0422f6d51e | ||
|
|
8d050d4040 | ||
|
|
82b3a4b384 | ||
|
|
d2f391a881 | ||
|
|
9e778487d9 | ||
|
|
7938d71f2f | ||
|
|
422a6fed30 | ||
|
|
c6ea0454b3 | ||
|
|
467767a6c5 | ||
|
|
cf795c512a | ||
|
|
18b499030d | ||
|
|
d800d6f3ce | ||
|
|
185f706107 | ||
|
|
5a4661cdad | ||
|
|
7abdd94a0e | ||
|
|
22f0684ab0 | ||
|
|
80b2c63693 | ||
|
|
4b2a553bda | ||
|
|
ad30e73b10 | ||
|
|
15b62e302d | ||
|
|
9d5ec20f20 | ||
|
|
c878425fd0 | ||
|
|
90e80dda64 | ||
|
|
1db5bfeab5 | ||
|
|
b2b3fcd4a3 | ||
|
|
9cf97375c7 | ||
|
|
7839b6affb | ||
|
|
597064a07d | ||
|
|
0ea57d14c9 | ||
|
|
afd736d615 | ||
|
|
ce9b8c376f | ||
|
|
79776e6752 | ||
|
|
a2432022a1 | ||
|
|
ea5ee96bdc | ||
|
|
fe954092a4 | ||
|
|
5e148fec92 | ||
|
|
5daa2a9548 | ||
|
|
f1913f7b18 | ||
|
|
65a453553e | ||
|
|
29da8f24da | ||
|
|
3443ad9968 | ||
|
|
838dc93eff | ||
|
|
07ef68ec1f | ||
|
|
3cb7f86086 | ||
|
|
fc9565c157 | ||
|
|
557f6022f3 | ||
|
|
d0a56169e3 | ||
|
|
f7d6e2b4ed | ||
|
|
df82ee42fd | ||
|
|
a29d761483 | ||
|
|
713a86b9d6 | ||
|
|
d6250f16b0 | ||
|
|
1c5d5c3788 | ||
|
|
699e9d9ff1 | ||
|
|
fad0cb931a | ||
|
|
5d7ff55869 | ||
|
|
642ccb5b82 | ||
|
|
51a7ce0827 | ||
|
|
157a54b11d | ||
|
|
be083d81fc | ||
|
|
f6c869ad47 | ||
|
|
bef5b26509 | ||
|
|
59cfe24178 | ||
|
|
87d0d58ae8 | ||
|
|
de8b6c972d | ||
|
|
0ac4839e94 | ||
|
|
565bce6d8b | ||
|
|
b278b028cf | ||
|
|
33abd9369e | ||
|
|
e6b2f7f271 | ||
|
|
0bd1e9cf59 | ||
|
|
693625bf2c | ||
|
|
3a6c587569 | ||
|
|
bd85eaf0a0 | ||
|
|
29aa0f416e | ||
|
|
5b32607911 | ||
|
|
76e5359752 | ||
|
|
5ec4efbb6e | ||
|
|
f8bb7b421a | ||
|
|
887c169aa8 | ||
|
|
8c10dcc6c2 | ||
|
|
671da6b5fa | ||
|
|
7fdb2f4ea8 | ||
|
|
67388b31f5 | ||
|
|
2379cca765 | ||
|
|
34be6651e5 | ||
|
|
10f8e3e8ef | ||
|
|
0c6ea8eb5c | ||
|
|
6e69b06e61 | ||
|
|
98ba383a68 | ||
|
|
4e6d81a9bf | ||
|
|
0e2c206023 | ||
|
|
285f06d1e7 | ||
|
|
d03787a871 | ||
|
|
6aead03f3e | ||
|
|
0949a298c9 | ||
|
|
94c8e2168f | ||
|
|
358134f417 | ||
|
|
7eb9e06452 | ||
|
|
fab06d5005 | ||
|
|
7483572b1d | ||
|
|
d1ce8e0739 | ||
|
|
16860f93ad | ||
|
|
6ba45d5bc3 | ||
|
|
f230429ddd | ||
|
|
78c3b6d597 | ||
|
|
cdbc2782ff | ||
|
|
108aca99b9 | ||
|
|
7dd5dfb1bf | ||
|
|
ee7a0fffc2 | ||
|
|
6999871860 | ||
|
|
6742207c77 | ||
|
|
d71c29ee2e | ||
|
|
6917d6b89a | ||
|
|
c3f369f0b3 | ||
|
|
01abc5ca38 | ||
|
|
08a348090c | ||
|
|
63b1ecf79f | ||
|
|
efe7e74375 | ||
|
|
6d552d22d9 | ||
|
|
017af0ddfc | ||
|
|
c2161ceca9 | ||
|
|
140af31bec | ||
|
|
1199e7e618 | ||
|
|
3fa24d00fa | ||
|
|
5d23c77100 | ||
|
|
286178fc5e | ||
|
|
f6621572df | ||
|
|
d32b09d802 | ||
|
|
dbe86ec0a8 | ||
|
|
5f8141148a | ||
|
|
c7c24983cc | ||
|
|
f80b6d366f | ||
|
|
0b360eeebb | ||
|
|
c92a1e1342 | ||
|
|
c0963cbcd9 | ||
|
|
4979d5cbc4 | ||
|
|
ff85c1447b | ||
|
|
df608b2dc1 | ||
|
|
f38e0ce7e2 | ||
|
|
0499b70218 | ||
|
|
f654b277fa | ||
|
|
95eeebb09e | ||
|
|
bb61d7f7dc | ||
|
|
d821a7cf59 | ||
|
|
9db8827d4a | ||
|
|
0b17931c62 | ||
|
|
c1df88c25f | ||
|
|
f6454e71f0 | ||
|
|
66a6e55191 | ||
|
|
0a3fac2e72 | ||
|
|
2f928329d1 | ||
|
|
9a5ff4aa98 | ||
|
|
dd3c191139 | ||
|
|
9a702fb774 | ||
|
|
d0c2713f20 | ||
|
|
a6bf4286dc | ||
|
|
08f40efd2b | ||
|
|
4e7795ec7c | ||
|
|
5aa187334a | ||
|
|
abfcf2460e | ||
|
|
bcd6248833 | ||
|
|
baa5869fe5 | ||
|
|
58f62d8145 | ||
|
|
2044beb7f1 | ||
|
|
5f8a298ece | ||
|
|
487b39f97c | ||
|
|
c5724f208a | ||
|
|
03a23bab9a | ||
|
|
d58b6ce948 | ||
|
|
7c83c80d5d | ||
|
|
0801d83856 | ||
|
|
8aa55330b4 | ||
|
|
ee10965f43 | ||
|
|
755e19ed12 | ||
|
|
40d3914b3c | ||
|
|
47fd515944 | ||
|
|
ba426defdd | ||
|
|
a8bd1a587a | ||
|
|
237efb4e72 | ||
|
|
0de24b8251 | ||
|
|
7077ef15f3 | ||
|
|
b3d5d11539 | ||
|
|
2522b971f1 | ||
|
|
c6be37ca09 | ||
|
|
ae8ac62938 | ||
|
|
349eb3216d | ||
|
|
d1d559b1d6 | ||
|
|
7d5ad95cff | ||
|
|
e09c22fc18 | ||
|
|
a7e84edad3 | ||
|
|
0f466b2abe | ||
|
|
0ccdcdbc92 | ||
|
|
3d3f8e70dd | ||
|
|
417215cfcc | ||
|
|
ae8aba68f2 | ||
|
|
cd53b36e76 | ||
|
|
a44f5795fd | ||
|
|
556169871a | ||
|
|
261fbb8428 | ||
|
|
f02d39ace0 | ||
|
|
4d32829445 | ||
|
|
5438e04fc5 | ||
|
|
54bbe4d70a | ||
|
|
78ece0c9fb | ||
|
|
3407f9a0c1 | ||
|
|
f1b78e8d1f | ||
|
|
df2fc9c32e | ||
|
|
cc5c319332 | ||
|
|
43ef09dfed | ||
|
|
0c95751b8d | ||
|
|
843bd1a088 | ||
|
|
a0db71e571 | ||
|
|
7fc14eddd9 | ||
|
|
7f2fdc383b | ||
|
|
ff2caecb3a | ||
|
|
d01df1ef13 | ||
|
|
cd55df3a15 | ||
|
|
ce59f3e7b2 | ||
|
|
4d722f103a | ||
|
|
c1bc6f27b1 | ||
|
|
ed88c087bf | ||
|
|
4e337caa07 | ||
|
|
ea3929b4c2 | ||
|
|
9eff4ca129 | ||
|
|
d6d94e2121 | ||
|
|
737c0236d6 | ||
|
|
69571f2916 | ||
|
|
6dffa7378c | ||
|
|
c2a7bc7106 | ||
|
|
e752750c9e | ||
|
|
32bde8f69e | ||
|
|
b5f9ef51f1 | ||
|
|
8c10be6ee6 | ||
|
|
c1e3872792 | ||
|
|
c3eef53e4d | ||
|
|
a1b9c0c33d | ||
|
|
c49b0edbe9 | ||
|
|
0fd8824ad7 | ||
|
|
973199135f | ||
|
|
43b7b21ad3 | ||
|
|
ba96ebb874 | ||
|
|
c66f086f42 | ||
|
|
07b2175e92 | ||
|
|
d245a1eabc | ||
|
|
f1e2fd3b18 | ||
|
|
a6b3dba92e | ||
|
|
9fd0abe6ea | ||
|
|
d06ee20c47 | ||
|
|
2ef1fa0367 | ||
|
|
9d459106c1 | ||
|
|
cd5a52a8fc | ||
|
|
d89320f578 | ||
|
|
df5ce2991e | ||
|
|
668c9c7dfe | ||
|
|
9bd13055c0 | ||
|
|
9f3adda59d | ||
|
|
bfa7aaecb8 | ||
|
|
9d4310d8e0 | ||
|
|
b3555b01f3 | ||
|
|
74895fbb0d | ||
|
|
5f04d19d2d | ||
|
|
0fef06e0c6 | ||
|
|
89d22451c5 | ||
|
|
fb49056c73 | ||
|
|
a3071819c4 | ||
|
|
d4e10ad5c2 | ||
|
|
59e9a1053e | ||
|
|
308fc09b33 | ||
|
|
99ec9facd3 | ||
|
|
2a96d7a5f6 | ||
|
|
195e1d17f8 | ||
|
|
580ff59b3d | ||
|
|
cfa5aae5da | ||
|
|
761125735a | ||
|
|
d1b12ff0e1 | ||
|
|
a47176a4e5 | ||
|
|
bc3d7fb39a | ||
|
|
458548150c | ||
|
|
f6e11bb464 | ||
|
|
479fc647c3 | ||
|
|
9a4c63390d | ||
|
|
42cd1221fa | ||
|
|
98b773fdc3 | ||
|
|
cd54fcbf52 | ||
|
|
bd68f8c631 | ||
|
|
3fde5aaa3c | ||
|
|
2f34de5602 | ||
|
|
2de1e81661 | ||
|
|
cea60c5683 | ||
|
|
f16e104f73 | ||
|
|
c397f552c3 | ||
|
|
11c4ac803e | ||
|
|
09b9a100de | ||
|
|
0e59df39ee | ||
|
|
30254a2ec7 | ||
|
|
cd796a6db9 | ||
|
|
6ba75de576 | ||
|
|
a3637b3a99 | ||
|
|
85ec09ec9a | ||
|
|
eb683b98e7 | ||
|
|
9221173309 | ||
|
|
55a96f41c6 | ||
|
|
ce510e79c7 | ||
|
|
3d4f2a8814 | ||
|
|
38e24edd7b | ||
|
|
841c987a1a | ||
|
|
dc26f7d25e | ||
|
|
bc754e683e | ||
|
|
c7acc8db4b | ||
|
|
057acc514f | ||
|
|
53ce7d08fb | ||
|
|
8ffd68a4e2 | ||
|
|
766653ee7a | ||
|
|
9dedcc68c3 | ||
|
|
5ea2b3c9b8 | ||
|
|
d2ac91839a | ||
|
|
bfca2746d8 | ||
|
|
b417065ac3 | ||
|
|
abdc97f087 | ||
|
|
07016c6c26 | ||
|
|
b8162060cd | ||
|
|
36fb5d293c | ||
|
|
429280a97a | ||
|
|
7ac36c779e | ||
|
|
d2e7b464e3 | ||
|
|
25839ea99f | ||
|
|
9c99581aa6 | ||
|
|
e42c21f0eb | ||
|
|
782bf214e5 | ||
|
|
a8a4496c7a | ||
|
|
f5f013ff23 | ||
|
|
8cff3af897 | ||
|
|
322d5a6d0e | ||
|
|
2ee26f51a7 | ||
|
|
e670ed2f96 | ||
|
|
601a03b5b4 | ||
|
|
00582e70ff | ||
|
|
fa4385055b | ||
|
|
01f4de55df | ||
|
|
dedd34e8e8 | ||
|
|
d1b373201e | ||
|
|
dbcc90e7ad | ||
|
|
87fce74d5b | ||
|
|
5c51679ad7 | ||
|
|
6d17b5b876 | ||
|
|
1288853112 | ||
|
|
d3e7e17789 | ||
|
|
6077d74f10 | ||
|
|
7bf031699a | ||
|
|
a6eb693636 | ||
|
|
39c377e0e2 | ||
|
|
67b90c4df0 | ||
|
|
0e2727f9c8 | ||
|
|
0cf02e7324 | ||
|
|
68c9f2b65f | ||
|
|
587b1f6b9d | ||
|
|
d94580a253 | ||
|
|
531e3c0fcc | ||
|
|
d68de7a30c | ||
|
|
45cb70f006 | ||
|
|
af85384f11 | ||
|
|
208539134f | ||
|
|
a3f41e3d64 | ||
|
|
3855c402c9 | ||
|
|
6a58d8434b | ||
|
|
d26a92ce79 | ||
|
|
0d20717c3c | ||
|
|
a0d81dcda9 | ||
|
|
4490af8553 | ||
|
|
6ae39bfd55 | ||
|
|
d86f0ebd78 | ||
|
|
a320c77ecb | ||
|
|
7f091d7642 | ||
|
|
d8f222f312 | ||
|
|
38637ef167 | ||
|
|
fe8527e72e | ||
|
|
da64cac414 | ||
|
|
f6021cbaa4 |
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`。
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
src/**/*-test.js
|
||||
@@ -1,52 +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,
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
Executable
+34
@@ -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: 10.33.2
|
||||
|
||||
- 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 }}
|
||||
@@ -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.33.2
|
||||
|
||||
- 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
|
||||
|
||||
- name: Build
|
||||
working-directory: apps/with-lingui
|
||||
run: pnpm run build
|
||||
+50
-4
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
// "target": "http://api.zuiidea.com",
|
||||
// "changeOrigin": true,
|
||||
// "pathRewrite": { "^/api/v1" : "/v1" }
|
||||
// },
|
||||
// "/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"],
|
||||
include: ["dva/router", "dva/saga", "dva/fetch"]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,160 +1,78 @@
|
||||
# Antd Admin
|
||||
|
||||
[](https://github.com/facebook/react)
|
||||
[](https://github.com/ant-design/ant-design)
|
||||
[](https://github.com/dvajs/dva)
|
||||
## Introduction
|
||||
|
||||
[](https://github.com/zuiidea/antd-admin)
|
||||
[](https://github.com/zuiidea/antd-admin/pulls)
|
||||
[](http://opensource.org/licenses/MIT)
|
||||
[](http://standardjs.com)
|
||||
Antd Admin provides production-style admin templates with modern frontend tooling, mock-first development, and practical testing coverage.
|
||||
|
||||
演示地址 <http://zuiidea.github.io/antd-admin> \| [备用地址](http://47.92.30.98:8000)
|
||||
Available templates:
|
||||
|
||||
## 特性
|
||||
- `basic`: English-only setup
|
||||
- `with-lingui`: Bilingual setup (`en` + `zh`) with Lingui
|
||||
|
||||
- 基于[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功能实现脱离后端独立开发。
|
||||
- 浅度响应式设计。
|
||||
Scaffolding CLI:
|
||||
|
||||
## 更新日志
|
||||
- `init-antd-admin` (from `packages/create`)
|
||||
|
||||
### 4.3.0
|
||||
## Key Features
|
||||
|
||||
`2017-07-30`
|
||||
- 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
|
||||
|
||||
- 更新[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`。
|
||||
## Quick Start
|
||||
|
||||
### 4.2.3
|
||||
Use `init-antd-admin` to scaffold a standalone app from official templates.
|
||||
|
||||
`2017-07-07`
|
||||
|
||||
- 新增用户按权限访问。[#384](https://github.com/zuiidea/antd-admin/issues/384)
|
||||
|
||||
### 4.2.2
|
||||
|
||||
`2017-06-01`
|
||||
|
||||
- 新增用户管理批量删除。[#320](https://github.com/zuiidea/antd-admin/issues/320)
|
||||
|
||||
### 4.2.1
|
||||
|
||||
`2017-05-12`
|
||||
|
||||
- 新增IconFont扩展方案,本地使用方案。[#270](https://github.com/zuiidea/antd-admin/issues/270) [Live](http://47.92.30.98:666/UIElement/iconfont)
|
||||
- 新增常见场景使用loading。 [dva-loading](https://github.com/dvajs/dva-loading)
|
||||
|
||||
### 4.2
|
||||
|
||||
`2017-04-28`
|
||||
|
||||
- 修改user相关API使用`Restful`风格。
|
||||
- 增加user页面多条件查询。[#266](https://github.com/zuiidea/antd-admin/issues/226)
|
||||
- 修复菜单默认高亮。[#201](https://github.com/zuiidea/antd-admin/issues/201)
|
||||
|
||||
[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`)。
|
||||
|
||||
### 快速开始
|
||||
|
||||
克隆项目文件:
|
||||
|
||||
git clone https://github.com/zuiidea/antd-admin.git
|
||||
|
||||
进入目录安装依赖:
|
||||
|
||||
npm i 或者 yarn install
|
||||
|
||||
开发:
|
||||
### Non-interactive examples
|
||||
|
||||
```bash
|
||||
npm run build:dll #第一次npm run dev时需运行此命令,使开发时编译更快
|
||||
npm run dev
|
||||
打开 http://localhost:8000
|
||||
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
|
||||
```
|
||||
|
||||
构建:
|
||||
[详情](https://github.com/zuiidea/antd-admin/issues/269)
|
||||
### Common options
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
|
||||
将会打包至dist/{version}目录 #package.json里version字段
|
||||
| Option | Description |
|
||||
| ---------------------------- | -------------------------------- |
|
||||
| `[project-directory]` | Target folder |
|
||||
| `-e, --example <name | url>` |
|
||||
| `--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 |
|
||||
|
||||
npm run build:new
|
||||
|
||||
将会打包至dist/{version增加1}目录 #package.json里version字段
|
||||
```
|
||||
For detailed CLI behavior, see `[packages/create/README.md](./packages/create/README.md)`.
|
||||
|
||||
代码检测:
|
||||
Production docs: [https://antd-admin-doc.zuiidea.top](https://antd-admin-doc.zuiidea.top)
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
## Links
|
||||
|
||||
## FAQ
|
||||
- [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)
|
||||
|
||||
- 项目打包后如何部署? [#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)
|
||||
## License
|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
移动
|
||||
|
||||

|
||||
MIT
|
||||
+17
@@ -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.
|
||||
@@ -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"`
|
||||
@@ -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`
|
||||
@@ -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
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
vp staged
|
||||
@@ -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.
|
||||
@@ -0,0 +1,188 @@
|
||||
<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/).
|
||||
|
||||
[](https://github.com/ant-design/ant-design)
|
||||
[](https://react.dev)
|
||||
[](https://viteplus.dev)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://github.com/zuiidea/antd-admin/issues)
|
||||
[](https://github.com/zuiidea/antd-admin/stargazers)
|
||||
[](http://opensource.org/licenses/MIT)
|
||||
[](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
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:5173](http://localhost:5173) — you'll be redirected to the login page.
|
||||
|
||||
**Default credentials:** `admin` / `admin`
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm run preview
|
||||
```
|
||||
|
||||
### Project Setup
|
||||
|
||||
```bash
|
||||
pnpm run prepare
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
pnpm run fmt
|
||||
pnpm run lint
|
||||
pnpm run check
|
||||
```
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
pnpm run test:unit
|
||||
```
|
||||
|
||||
### E2E Tests
|
||||
|
||||
```bash
|
||||
pnpm run test:e2e
|
||||
pnpm run test:e2e:core
|
||||
pnpm run test:e2e:ui # interactive UI mode
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
src/
|
||||
├── api/ # Zod models + endpoint contracts
|
||||
├── components/ # Reusable UI primitives and layout shells
|
||||
│ ├── Layout/ # MainLayout, Header, Sidebar, UserMenu, AppFooter
|
||||
│ ├── DataTable/ # Shared table frame/skeleton/empty state
|
||||
│ ├── FormModal/ # Generic modal form wrapper
|
||||
│ ├── FilterToolbar/ # Reusable list page toolbar
|
||||
│ ├── Auth/ # Permission gate component
|
||||
│ ├── Aurora/ # Login page background effect
|
||||
│ ├── Icon/ # Shared icons (GitHub, Theme, etc.)
|
||||
│ ├── NotFound/ # Not-found visual component
|
||||
│ └── RouteError.tsx # Route-level error boundary UI
|
||||
├── hooks/ # Shared hooks (theme, CRUD, URL state, permissions)
|
||||
│ ├── useAppTheme.ts
|
||||
│ ├── usePermission.ts
|
||||
│ ├── useResourceCRUD.ts
|
||||
│ ├── useCrudToasts.ts
|
||||
│ ├── useUrlSearchState.ts
|
||||
│ ├── useTableFitHeight.ts
|
||||
│ └── tokenBuilders.ts
|
||||
├── mocks/ # MSW bootstrap, handlers, seed data, test helpers
|
||||
│ ├── browser.ts
|
||||
│ ├── createHandler.ts
|
||||
│ ├── createHandler.test.ts
|
||||
│ ├── data.ts
|
||||
│ ├── utils.ts
|
||||
│ └── handlers/
|
||||
├── routes/ # TanStack Router file routes
|
||||
│ ├── __root.tsx # QueryClient + ConfigProvider (en_US) wiring
|
||||
│ ├── _auth.tsx # Protected route layout
|
||||
│ ├── _auth/dashboard/index.tsx
|
||||
│ ├── _auth/users/index.tsx
|
||||
│ ├── _auth/users/-Toolbar.tsx
|
||||
│ ├── _auth/users/-FormModal.tsx
|
||||
│ ├── _auth/403/index.tsx
|
||||
│ ├── login/index.tsx
|
||||
│ ├── register/index.tsx
|
||||
│ ├── 404/index.tsx
|
||||
│ └── index.tsx
|
||||
├── stores/ # Persisted auth/settings stores
|
||||
├── utils/ # HTTP client, constants, session/menu helpers
|
||||
├── main.tsx # App bootstrap (MSW init + React render)
|
||||
└── routeTree.gen.ts # Generated TanStack route tree
|
||||
|
||||
e2e/ # Playwright E2E suites
|
||||
├── helpers.ts
|
||||
├── login.spec.ts
|
||||
├── users.spec.ts
|
||||
├── auth-refresh.spec.ts
|
||||
├── rbac.spec.ts
|
||||
└── url-state.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
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"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: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",
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Generated
+4876
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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"],
|
||||
}));
|
||||
@@ -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);
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,4 @@
|
||||
import { authHandlers } from "./auth";
|
||||
import { userHandlers } from "./user";
|
||||
|
||||
export const handlers = [...authHandlers, ...userHandlers];
|
||||
@@ -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();
|
||||
}),
|
||||
];
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/* 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>()
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { NotFound } from "@/components/NotFound";
|
||||
|
||||
export const Route = createFileRoute("/404/")({
|
||||
component: NotFound,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/login" });
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
Reference in New Issue
Block a user