20260810172235
This commit is contained in:
1 parent
c4891538a7
commit
ed5ab3885d
35 files changed
+1314
-653
No files matched your search
@@ -20,6 +20,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"embla-carousel-vue": "^8.6.0",
|
"embla-carousel-vue": "^8.6.0",
|
||||||
|
"nprogress": "^0.2.0",
|
||||||
"pinia": "^4.0.2",
|
"pinia": "^4.0.2",
|
||||||
"reka-ui": "^2.10.1",
|
"reka-ui": "^2.10.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
|||||||
Generated
+84
-31
@@ -32,6 +32,9 @@ importers:
|
|||||||
embla-carousel-vue:
|
embla-carousel-vue:
|
||||||
specifier: ^8.6.0
|
specifier: ^8.6.0
|
||||||
version: 8.6.0(vue@3.5.41(typescript@6.0.3))
|
version: 8.6.0(vue@3.5.41(typescript@6.0.3))
|
||||||
|
nprogress:
|
||||||
|
specifier: ^0.2.0
|
||||||
|
version: 0.2.0
|
||||||
pinia:
|
pinia:
|
||||||
specifier: ^4.0.2
|
specifier: ^4.0.2
|
||||||
version: 4.0.2(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
|
version: 4.0.2(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
|
||||||
@@ -152,8 +155,12 @@ packages:
|
|||||||
'@internationalized/number@3.6.7':
|
'@internationalized/number@3.6.7':
|
||||||
resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==}
|
resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.12':
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
|
||||||
|
|
||||||
|
'@jridgewell/gen-mapping@0.3.5':
|
||||||
|
resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
'@jridgewell/remapping@2.3.5':
|
'@jridgewell/remapping@2.3.5':
|
||||||
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
|
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
|
||||||
@@ -162,6 +169,19 @@ packages:
|
|||||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
'@jridgewell/set-array@1.2.1':
|
||||||
|
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.4.10':
|
||||||
|
resolution: {integrity: sha512-Ht8wIW5v165atIX1p+JvKR5ONzUyF4Ac8DZIQ5kZs9zrb6M8SJNXpx1zn04rn65VjBMygRoMXcyYwNK0fT7bEg==}
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.4.14':
|
||||||
|
resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==}
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.5.0':
|
||||||
|
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
|
||||||
|
|
||||||
'@jridgewell/sourcemap-codec@1.5.5':
|
'@jridgewell/sourcemap-codec@1.5.5':
|
||||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||||
|
|
||||||
@@ -703,8 +723,8 @@ packages:
|
|||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
agent-base@6.0.2:
|
agent-base@6.0.0:
|
||||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
resolution: {integrity: sha512-j1Q7cSCqN+AwrmDd+pzgqc0/NpC655x2bUf5ZjRIO77DcNBFmh+OgRNzF6OKdCC9RSCb19fGd99+bhXFdkRNqw==}
|
||||||
engines: {node: '>= 6.0.0'}
|
engines: {node: '>= 6.0.0'}
|
||||||
|
|
||||||
alien-signals@3.2.1:
|
alien-signals@3.2.1:
|
||||||
@@ -714,6 +734,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
ast-kit@2.1.2:
|
||||||
|
resolution: {integrity: sha512-cl76xfBQM6pztbrFWRnxbrDm9EOqDr1BF6+qQnnDZG2Co2LjyUktkN9GTJfBAfdae+DbT2nJf2nCGAdDDN7W2g==}
|
||||||
|
engines: {node: '>=20.18.0'}
|
||||||
|
|
||||||
ast-kit@2.2.0:
|
ast-kit@2.2.0:
|
||||||
resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==}
|
resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
@@ -728,8 +752,11 @@ packages:
|
|||||||
axios@1.19.0:
|
axios@1.19.0:
|
||||||
resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==}
|
resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==}
|
||||||
|
|
||||||
birpc@2.9.0:
|
birpc@2.3.0:
|
||||||
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
|
resolution: {integrity: sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g==}
|
||||||
|
|
||||||
|
birpc@2.6.1:
|
||||||
|
resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==}
|
||||||
|
|
||||||
call-bind-apply-helpers@1.0.2:
|
call-bind-apply-helpers@1.0.2:
|
||||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||||
@@ -1075,9 +1102,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==}
|
resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
magic-string-ast@1.0.3:
|
magic-string-ast@1.0.2:
|
||||||
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
|
resolution: {integrity: sha512-8ngQgLhcT0t3YBdn9CGkZqCYlvwW9pm7aWJwd7AxseVWf1RU8ZHCQvG1mt3N5vvUme+pXTcHB8G/7fE666U8Vw==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.18.0'}
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
@@ -1111,12 +1138,15 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
node-addon-api@7.1.1:
|
node-addon-api@7.0.0:
|
||||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==}
|
||||||
|
|
||||||
nostics@1.2.0:
|
nostics@1.2.0:
|
||||||
resolution: {integrity: sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==}
|
resolution: {integrity: sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==}
|
||||||
|
|
||||||
|
nprogress@0.2.0:
|
||||||
|
resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==}
|
||||||
|
|
||||||
ohash@2.0.11:
|
ohash@2.0.11:
|
||||||
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||||
|
|
||||||
@@ -1254,8 +1284,8 @@ packages:
|
|||||||
tw-animate-css@1.4.0:
|
tw-animate-css@1.4.0:
|
||||||
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
|
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
|
||||||
|
|
||||||
type-fest@4.41.0:
|
type-fest@4.8.3:
|
||||||
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
|
resolution: {integrity: sha512-//BaTm14Q/gHBn09xlnKNqfI8t6bmdzx2DXYfPBNofN0WUybCEUDcbCWcTa0oF09lzLjZgPphXAsvRiMK0V6Bw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
typescript@6.0.3:
|
typescript@6.0.3:
|
||||||
@@ -1436,7 +1466,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@babel/parser': 8.0.4
|
'@babel/parser': 8.0.4
|
||||||
'@babel/types': 8.0.4
|
'@babel/types': 8.0.4
|
||||||
'@jridgewell/gen-mapping': 0.3.13
|
'@jridgewell/gen-mapping': 0.3.12
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
'@types/jsesc': 2.5.1
|
'@types/jsesc': 2.5.1
|
||||||
jsesc: 3.1.0
|
jsesc: 3.1.0
|
||||||
@@ -1495,24 +1525,38 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@swc/helpers': 0.5.23
|
'@swc/helpers': 0.5.23
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.12':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.0
|
||||||
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
|
||||||
|
'@jridgewell/gen-mapping@0.3.5':
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/set-array': 1.2.1
|
||||||
|
'@jridgewell/sourcemap-codec': 1.4.10
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
|
||||||
'@jridgewell/remapping@2.3.5':
|
'@jridgewell/remapping@2.3.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/gen-mapping': 0.3.13
|
'@jridgewell/gen-mapping': 0.3.5
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
|
||||||
'@jridgewell/resolve-uri@3.1.2': {}
|
'@jridgewell/resolve-uri@3.1.2': {}
|
||||||
|
|
||||||
|
'@jridgewell/set-array@1.2.1': {}
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.4.10': {}
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.4.14': {}
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.5.0': {}
|
||||||
|
|
||||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||||
|
|
||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.4.14
|
||||||
|
|
||||||
'@lucide/vue@1.30.0(vue@3.5.41(typescript@6.0.3))':
|
'@lucide/vue@1.30.0(vue@3.5.41(typescript@6.0.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1617,7 +1661,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
detect-libc: 2.1.2
|
detect-libc: 2.1.2
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
node-addon-api: 7.1.1
|
node-addon-api: 7.0.0
|
||||||
picomatch: 4.0.5
|
picomatch: 4.0.5
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@parcel/watcher-android-arm64': 2.6.0
|
'@parcel/watcher-android-arm64': 2.6.0
|
||||||
@@ -1767,7 +1811,7 @@ snapshots:
|
|||||||
|
|
||||||
'@vee-validate/zod@4.15.1(vue@3.5.41(typescript@6.0.3))(zod@3.25.76)':
|
'@vee-validate/zod@4.15.1(vue@3.5.41(typescript@6.0.3))(zod@3.25.76)':
|
||||||
dependencies:
|
dependencies:
|
||||||
type-fest: 4.41.0
|
type-fest: 4.8.3
|
||||||
vee-validate: 4.15.1(vue@3.5.41(typescript@6.0.3))
|
vee-validate: 4.15.1(vue@3.5.41(typescript@6.0.3))
|
||||||
zod: 3.25.76
|
zod: 3.25.76
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -1794,9 +1838,9 @@ snapshots:
|
|||||||
'@vue-macros/common@3.1.4(vue@3.5.41(typescript@6.0.3))':
|
'@vue-macros/common@3.1.4(vue@3.5.41(typescript@6.0.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/compiler-sfc': 3.5.41
|
'@vue/compiler-sfc': 3.5.41
|
||||||
ast-kit: 2.2.0
|
ast-kit: 2.1.2
|
||||||
local-pkg: 1.2.1
|
local-pkg: 1.2.1
|
||||||
magic-string-ast: 1.0.3
|
magic-string-ast: 1.0.2
|
||||||
unplugin-utils: 0.3.2
|
unplugin-utils: 0.3.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vue: 3.5.41(typescript@6.0.3)
|
vue: 3.5.41(typescript@6.0.3)
|
||||||
@@ -1842,7 +1886,7 @@ snapshots:
|
|||||||
'@vue/devtools-kit@7.7.10':
|
'@vue/devtools-kit@7.7.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/devtools-shared': 7.7.10
|
'@vue/devtools-shared': 7.7.10
|
||||||
birpc: 2.9.0
|
birpc: 2.3.0
|
||||||
hookable: 5.5.3
|
hookable: 5.5.3
|
||||||
mitt: 3.0.1
|
mitt: 3.0.1
|
||||||
perfect-debounce: 1.0.0
|
perfect-debounce: 1.0.0
|
||||||
@@ -1852,7 +1896,7 @@ snapshots:
|
|||||||
'@vue/devtools-kit@8.2.1':
|
'@vue/devtools-kit@8.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/devtools-shared': 8.2.1
|
'@vue/devtools-shared': 8.2.1
|
||||||
birpc: 2.9.0
|
birpc: 2.6.1
|
||||||
hookable: 5.5.3
|
hookable: 5.5.3
|
||||||
perfect-debounce: 2.1.0
|
perfect-debounce: 2.1.0
|
||||||
|
|
||||||
@@ -1933,7 +1977,7 @@ snapshots:
|
|||||||
|
|
||||||
acorn@8.18.0: {}
|
acorn@8.18.0: {}
|
||||||
|
|
||||||
agent-base@6.0.2:
|
agent-base@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -1945,6 +1989,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
ast-kit@2.1.2:
|
||||||
|
dependencies:
|
||||||
|
'@babel/parser': 7.29.8
|
||||||
|
pathe: 2.0.3
|
||||||
|
|
||||||
ast-kit@2.2.0:
|
ast-kit@2.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/parser': 7.29.8
|
'@babel/parser': 7.29.8
|
||||||
@@ -1968,7 +2017,9 @@ snapshots:
|
|||||||
- debug
|
- debug
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
birpc@2.9.0: {}
|
birpc@2.3.0: {}
|
||||||
|
|
||||||
|
birpc@2.6.1: {}
|
||||||
|
|
||||||
call-bind-apply-helpers@1.0.2:
|
call-bind-apply-helpers@1.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2108,7 +2159,7 @@ snapshots:
|
|||||||
|
|
||||||
https-proxy-agent@5.0.1:
|
https-proxy-agent@5.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 6.0.2
|
agent-base: 6.0.0
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -2235,7 +2286,7 @@ snapshots:
|
|||||||
pkg-types: 2.3.1
|
pkg-types: 2.3.1
|
||||||
quansync: 0.2.11
|
quansync: 0.2.11
|
||||||
|
|
||||||
magic-string-ast@1.0.3:
|
magic-string-ast@1.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
|
|
||||||
@@ -2266,11 +2317,13 @@ snapshots:
|
|||||||
|
|
||||||
nanoid@3.3.18: {}
|
nanoid@3.3.18: {}
|
||||||
|
|
||||||
node-addon-api@7.1.1:
|
node-addon-api@7.0.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
nostics@1.2.0: {}
|
nostics@1.2.0: {}
|
||||||
|
|
||||||
|
nprogress@0.2.0: {}
|
||||||
|
|
||||||
ohash@2.0.11: {}
|
ohash@2.0.11: {}
|
||||||
|
|
||||||
oxfmt@0.58.0:
|
oxfmt@0.58.0:
|
||||||
@@ -2420,7 +2473,7 @@ snapshots:
|
|||||||
|
|
||||||
tw-animate-css@1.4.0: {}
|
tw-animate-css@1.4.0: {}
|
||||||
|
|
||||||
type-fest@4.41.0: {}
|
type-fest@4.8.3: {}
|
||||||
|
|
||||||
typescript@6.0.3: {}
|
typescript@6.0.3: {}
|
||||||
|
|
||||||
@@ -2445,7 +2498,7 @@ snapshots:
|
|||||||
vee-validate@4.15.1(vue@3.5.41(typescript@6.0.3)):
|
vee-validate@4.15.1(vue@3.5.41(typescript@6.0.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/devtools-api': 7.7.10
|
'@vue/devtools-api': 7.7.10
|
||||||
type-fest: 4.41.0
|
type-fest: 4.8.3
|
||||||
vue: 3.5.41(typescript@6.0.3)
|
vue: 3.5.41(typescript@6.0.3)
|
||||||
|
|
||||||
vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0):
|
vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0):
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<router-view />
|
<router-view />
|
||||||
|
<Toaster position="top-center" />
|
||||||
</template>
|
</template>
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { useTimeoutFn } from "@vueuse/core";
|
||||||
|
import { Check, ChevronDown, LoaderCircle, X } from "@lucide/vue";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useLookupOptions } from "@/composables/useLookupOptions";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
viewName: string;
|
||||||
|
labelKey?: string;
|
||||||
|
valueKey?: string;
|
||||||
|
searchCondition?: string;
|
||||||
|
orderBy?: string;
|
||||||
|
searchColumns?: string[];
|
||||||
|
pageSize?: number;
|
||||||
|
searchDelay?: number;
|
||||||
|
allowClear?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
labelKey: "b_name",
|
||||||
|
valueKey: "b_id",
|
||||||
|
orderBy: "",
|
||||||
|
searchCondition: "",
|
||||||
|
pageSize: 20,
|
||||||
|
searchDelay: 300,
|
||||||
|
allowClear: true,
|
||||||
|
placeholder: "",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const modelValue = defineModel<string>({ default: "" });
|
||||||
|
const emit = defineEmits<{ change: [record: Record<string, any> | null] }>();
|
||||||
|
|
||||||
|
const { rows, loading, search, immediate, loadMore, reset } = useLookupOptions({
|
||||||
|
viewName: props.viewName,
|
||||||
|
labelKey: props.labelKey,
|
||||||
|
valueKey: props.valueKey,
|
||||||
|
searchCondition: props.searchCondition,
|
||||||
|
orderBy: props.orderBy,
|
||||||
|
searchColumns: props.searchColumns,
|
||||||
|
pageSize: props.pageSize,
|
||||||
|
searchDelay: props.searchDelay,
|
||||||
|
});
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
const keyword = ref("");
|
||||||
|
const highlighted = ref(-1);
|
||||||
|
const hasClearValue = computed(() => Boolean(modelValue.value || keyword.value));
|
||||||
|
|
||||||
|
const { start: startBlurTimer, stop: stopBlurTimer } = useTimeoutFn(
|
||||||
|
() => {
|
||||||
|
open.value = false;
|
||||||
|
reset();
|
||||||
|
},
|
||||||
|
150,
|
||||||
|
{ immediate: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const options = computed(() =>
|
||||||
|
rows.value.map((row: any) => {
|
||||||
|
const label = String(row[props.labelKey] ?? "");
|
||||||
|
return { value: label, label, record: row };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
function onFocus() {
|
||||||
|
stopBlurTimer();
|
||||||
|
open.value = true;
|
||||||
|
// 输入框保留当前值,但拉全量列表
|
||||||
|
keyword.value = modelValue.value;
|
||||||
|
highlighted.value = -1;
|
||||||
|
immediate("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInputClick() {
|
||||||
|
if (open.value) return;
|
||||||
|
onFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInput(v: string | number) {
|
||||||
|
keyword.value = String(v);
|
||||||
|
modelValue.value = keyword.value;
|
||||||
|
highlighted.value = -1;
|
||||||
|
if (!open.value) open.value = true;
|
||||||
|
search(keyword.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlur() {
|
||||||
|
startBlurTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(opt: { value: string; label: string; record: Record<string, any> }) {
|
||||||
|
stopBlurTimer();
|
||||||
|
modelValue.value = opt.label;
|
||||||
|
keyword.value = "";
|
||||||
|
open.value = false;
|
||||||
|
reset();
|
||||||
|
emit("change", opt.record);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
stopBlurTimer();
|
||||||
|
modelValue.value = "";
|
||||||
|
keyword.value = "";
|
||||||
|
open.value = false;
|
||||||
|
reset();
|
||||||
|
emit("change", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Enter" && !open.value) {
|
||||||
|
e.preventDefault();
|
||||||
|
onFocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!open.value || !options.value.length) return;
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
highlighted.value = Math.min(highlighted.value + 1, options.value.length - 1);
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
highlighted.value = Math.max(highlighted.value - 1, 0);
|
||||||
|
} else if (e.key === "Enter" && highlighted.value >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
select(options.value[highlighted.value]!);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
open.value = false;
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScroll(e: Event) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target || loading.value) return;
|
||||||
|
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 24) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(modelValue, (v) => {
|
||||||
|
if (!v) keyword.value = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(options, (list) => {
|
||||||
|
if (!open.value || !list.length) {
|
||||||
|
highlighted.value = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (highlighted.value < 0 || highlighted.value >= list.length) {
|
||||||
|
highlighted.value = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="group relative w-full">
|
||||||
|
<Input
|
||||||
|
:model-value="open ? keyword : modelValue"
|
||||||
|
class="w-full pr-8"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
@focus="onFocus"
|
||||||
|
@click="onInputClick"
|
||||||
|
@update:model-value="onInput"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
@blur="onBlur"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="allowClear && hasClearValue && !loading"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="pointer-events-none absolute right-1 top-1/2 size-6 -translate-y-1/2 cursor-pointer opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100"
|
||||||
|
@pointerdown.prevent="clear"
|
||||||
|
>
|
||||||
|
<X class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<LoaderCircle
|
||||||
|
v-if="loading"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 size-4 animate-spin text-muted-foreground pointer-events-none"
|
||||||
|
/>
|
||||||
|
<ChevronDown
|
||||||
|
v-else
|
||||||
|
class="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||||
|
:class="allowClear && hasClearValue ? 'transition-opacity group-hover:opacity-0' : ''"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="absolute left-0 top-full z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md"
|
||||||
|
>
|
||||||
|
<div role="listbox" class="max-h-[200px] overflow-y-auto" @scroll.passive="handleScroll">
|
||||||
|
<div
|
||||||
|
v-if="!loading && !options.length"
|
||||||
|
class="py-6 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(opt, i) in options"
|
||||||
|
:key="opt.value"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="opt.value === modelValue"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm',
|
||||||
|
i === highlighted && 'bg-accent text-accent-foreground',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@mouseenter="highlighted = i"
|
||||||
|
@mousedown.prevent="select(opt)"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{{ opt.label }}</span>
|
||||||
|
<span class="inline-flex size-4 shrink-0 items-center justify-center">
|
||||||
|
<Check v-if="opt.value === modelValue" class="size-4 text-primary" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { useTimeoutFn } from "@vueuse/core";
|
||||||
|
import { Check, ChevronDown, LoaderCircle, X } from "@lucide/vue";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useLookupOptions } from "@/composables/useLookupOptions";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
viewName: string;
|
||||||
|
label?: string;
|
||||||
|
labelKey?: string;
|
||||||
|
valueKey?: string;
|
||||||
|
searchCondition?: string;
|
||||||
|
orderBy?: string;
|
||||||
|
searchColumns?: string[];
|
||||||
|
pageSize?: number;
|
||||||
|
searchDelay?: number;
|
||||||
|
allowClear?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
label: "",
|
||||||
|
labelKey: "b_name",
|
||||||
|
valueKey: "b_id",
|
||||||
|
orderBy: "",
|
||||||
|
searchCondition: "",
|
||||||
|
pageSize: 20,
|
||||||
|
searchDelay: 300,
|
||||||
|
allowClear: false,
|
||||||
|
placeholder: "",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const modelValue = defineModel<string>({ default: "" });
|
||||||
|
const modelLabel = defineModel<string>("label", { default: "" });
|
||||||
|
|
||||||
|
const emit = defineEmits<{ change: [record: Record<string, any> | null] }>();
|
||||||
|
|
||||||
|
const { rows, loading, search, immediate, loadMore, reset } = useLookupOptions({
|
||||||
|
viewName: props.viewName,
|
||||||
|
labelKey: props.labelKey,
|
||||||
|
valueKey: props.valueKey,
|
||||||
|
searchCondition: props.searchCondition,
|
||||||
|
orderBy: props.orderBy,
|
||||||
|
searchColumns: props.searchColumns,
|
||||||
|
pageSize: props.pageSize,
|
||||||
|
searchDelay: props.searchDelay,
|
||||||
|
});
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
const keyword = ref("");
|
||||||
|
const highlighted = ref(-1);
|
||||||
|
const hasClearValue = computed(() => Boolean(modelValue.value || keyword.value));
|
||||||
|
|
||||||
|
const { start: startBlurTimer, stop: stopBlurTimer } = useTimeoutFn(
|
||||||
|
() => {
|
||||||
|
open.value = false;
|
||||||
|
reset();
|
||||||
|
},
|
||||||
|
150,
|
||||||
|
{ immediate: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayLabel = computed(() => modelLabel.value || props.label || "");
|
||||||
|
|
||||||
|
const options = computed(() =>
|
||||||
|
rows.value.map((row: any) => {
|
||||||
|
const value = String(row[props.valueKey] ?? "");
|
||||||
|
const label = String(row[props.labelKey] ?? "");
|
||||||
|
return { value, label, record: row };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const displays = computed(() => {
|
||||||
|
const list = [...options.value];
|
||||||
|
const extLabel = props.label || modelLabel.value;
|
||||||
|
const extValue = modelValue.value;
|
||||||
|
if (extValue && extLabel && !list.some((o) => o.value === extValue)) {
|
||||||
|
list.unshift({
|
||||||
|
value: extValue,
|
||||||
|
label: extLabel,
|
||||||
|
record: { [props.valueKey]: extValue, [props.labelKey]: extLabel },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onFocus() {
|
||||||
|
stopBlurTimer();
|
||||||
|
open.value = true;
|
||||||
|
// Keep the selected label visible and use an empty query for the full list.
|
||||||
|
keyword.value = displayLabel.value;
|
||||||
|
highlighted.value = -1;
|
||||||
|
immediate("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInputClick() {
|
||||||
|
if (open.value) return;
|
||||||
|
onFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInput(v: string | number) {
|
||||||
|
keyword.value = String(v);
|
||||||
|
highlighted.value = -1;
|
||||||
|
if (!open.value) open.value = true;
|
||||||
|
if (!keyword.value) {
|
||||||
|
const hadSelection = Boolean(modelValue.value || modelLabel.value);
|
||||||
|
modelValue.value = "";
|
||||||
|
modelLabel.value = "";
|
||||||
|
if (hadSelection) emit("change", null);
|
||||||
|
immediate("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
search(keyword.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlur() {
|
||||||
|
startBlurTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(opt: { value: string; label: string; record: Record<string, any> }) {
|
||||||
|
stopBlurTimer();
|
||||||
|
modelValue.value = opt.value;
|
||||||
|
modelLabel.value = opt.label;
|
||||||
|
keyword.value = "";
|
||||||
|
open.value = false;
|
||||||
|
reset();
|
||||||
|
emit("change", opt.record);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
stopBlurTimer();
|
||||||
|
modelValue.value = "";
|
||||||
|
modelLabel.value = "";
|
||||||
|
keyword.value = "";
|
||||||
|
open.value = false;
|
||||||
|
reset();
|
||||||
|
emit("change", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
const list = displays.value;
|
||||||
|
if (e.key === "Enter" && !open.value) {
|
||||||
|
e.preventDefault();
|
||||||
|
onFocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!open.value || !list.length) return;
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
highlighted.value = Math.min(highlighted.value + 1, list.length - 1);
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
highlighted.value = Math.max(highlighted.value - 1, 0);
|
||||||
|
} else if (e.key === "Enter" && highlighted.value >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
select(list[highlighted.value]!);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
open.value = false;
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScroll(e: Event) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target || loading.value) return;
|
||||||
|
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 24) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(displays, (list) => {
|
||||||
|
if (!open.value || !list.length) {
|
||||||
|
highlighted.value = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (highlighted.value < 0 || highlighted.value >= list.length) {
|
||||||
|
highlighted.value = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="group relative w-full">
|
||||||
|
<Input
|
||||||
|
:model-value="open ? keyword : displayLabel"
|
||||||
|
class="w-full pr-8"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
@focus="onFocus"
|
||||||
|
@click="onInputClick"
|
||||||
|
@update:model-value="onInput"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
@blur="onBlur"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="allowClear && hasClearValue && !loading"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="pointer-events-none absolute right-1 top-1/2 size-6 -translate-y-1/2 cursor-pointer opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100"
|
||||||
|
@pointerdown.prevent="clear"
|
||||||
|
>
|
||||||
|
<X class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<LoaderCircle
|
||||||
|
v-if="loading"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 size-4 animate-spin text-muted-foreground pointer-events-none"
|
||||||
|
/>
|
||||||
|
<ChevronDown
|
||||||
|
v-else
|
||||||
|
class="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||||
|
:class="allowClear && hasClearValue ? 'transition-opacity group-hover:opacity-0' : ''"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="absolute left-0 top-full z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md"
|
||||||
|
>
|
||||||
|
<div role="listbox" class="max-h-[200px] overflow-y-auto" @scroll.passive="handleScroll">
|
||||||
|
<div
|
||||||
|
v-if="!loading && !displays.length"
|
||||||
|
class="py-6 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(opt, i) in displays"
|
||||||
|
:key="opt.value"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="opt.value === modelValue"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm',
|
||||||
|
i === highlighted && 'bg-accent text-accent-foreground',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@mouseenter="highlighted = i"
|
||||||
|
@mousedown.prevent="select(opt)"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{{ opt.label }}</span>
|
||||||
|
<span class="inline-flex size-4 shrink-0 items-center justify-center">
|
||||||
|
<Check v-if="opt.value === modelValue" class="size-4 text-primary" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { HTMLAttributes } from "vue"
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { Building2, KeyRound, User } from "@lucide/vue"
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
} from '@/components/ui/field'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
class?: HTMLAttributes["class"]
|
|
||||||
orgId?: string
|
|
||||||
userId?: string
|
|
||||||
password?: string
|
|
||||||
submitting?: boolean
|
|
||||||
error?: string
|
|
||||||
hint?: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:orgId', v: string): void
|
|
||||||
(e: 'update:userId', v: string): void
|
|
||||||
(e: 'update:password', v: string): void
|
|
||||||
(e: 'submit'): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function onOrgIdInput(e: Event) {
|
|
||||||
emit('update:orgId', (e.target as HTMLInputElement).value)
|
|
||||||
}
|
|
||||||
function onUserIdInput(e: Event) {
|
|
||||||
emit('update:userId', (e.target as HTMLInputElement).value)
|
|
||||||
}
|
|
||||||
function onPasswordInput(e: Event) {
|
|
||||||
emit('update:password', (e.target as HTMLInputElement).value)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<form :class="cn('flex flex-col gap-6', props.class)" @submit.prevent="$emit('submit')">
|
|
||||||
<FieldGroup>
|
|
||||||
<div class="flex flex-col items-center gap-2 text-center">
|
|
||||||
<h1 class="text-2xl font-bold">登录</h1>
|
|
||||||
<p class="text-muted-foreground text-sm text-balance">
|
|
||||||
输入登录信息以进入系统
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hint banner -->
|
|
||||||
<div
|
|
||||||
v-if="hint"
|
|
||||||
class="rounded-md bg-amber-50 px-4 py-2.5 text-center text-sm text-amber-700 dark:bg-amber-950 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
{{ hint }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error banner -->
|
|
||||||
<div
|
|
||||||
v-if="error"
|
|
||||||
class="rounded-md bg-red-50 px-4 py-2.5 text-center text-sm text-red-700 dark:bg-red-950 dark:text-red-300"
|
|
||||||
>
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel for="orgId">机构码</FieldLabel>
|
|
||||||
<div class="relative">
|
|
||||||
<span class="absolute inset-y-0 left-3 flex items-center text-muted-foreground">
|
|
||||||
<Building2 class="size-4" />
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
id="orgId"
|
|
||||||
:value="orgId"
|
|
||||||
placeholder="请输入机构码"
|
|
||||||
class="pl-9"
|
|
||||||
:disabled="submitting"
|
|
||||||
@input="onOrgIdInput"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel for="userId">账号</FieldLabel>
|
|
||||||
<div class="relative">
|
|
||||||
<span class="absolute inset-y-0 left-3 flex items-center text-muted-foreground">
|
|
||||||
<User class="size-4" />
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
id="userId"
|
|
||||||
:value="userId"
|
|
||||||
placeholder="请输入账号"
|
|
||||||
class="pl-9"
|
|
||||||
:disabled="submitting"
|
|
||||||
@input="onUserIdInput"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel for="password">密码</FieldLabel>
|
|
||||||
<div class="relative">
|
|
||||||
<span class="absolute inset-y-0 left-3 flex items-center text-muted-foreground">
|
|
||||||
<KeyRound class="size-4" />
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
:value="password"
|
|
||||||
type="password"
|
|
||||||
placeholder="请输入密码"
|
|
||||||
class="pl-9"
|
|
||||||
:disabled="submitting"
|
|
||||||
@input="onPasswordInput"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<Button type="submit" :disabled="submitting" class="w-full cursor-pointer">
|
|
||||||
{{ submitting ? "登录中..." : "登录" }}
|
|
||||||
</Button>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { HTMLAttributes } from "vue"
|
|
||||||
|
|
||||||
import { GalleryVerticalEnd } from "@lucide/vue"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldDescription,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
FieldSeparator,
|
|
||||||
} from '@/components/ui/field'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
class?: HTMLAttributes["class"]
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div :class="cn('flex flex-col gap-6', props.class)">
|
|
||||||
<form>
|
|
||||||
<FieldGroup>
|
|
||||||
<div class="flex flex-col items-center gap-2 text-center">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
class="flex flex-col items-center gap-2 font-medium"
|
|
||||||
>
|
|
||||||
<div class="flex size-8 items-center justify-center rounded-md">
|
|
||||||
<GalleryVerticalEnd class="size-6" />
|
|
||||||
</div>
|
|
||||||
<span class="sr-only">Acme Inc.</span>
|
|
||||||
</a>
|
|
||||||
<h1 class="text-xl font-bold">
|
|
||||||
Welcome to Acme Inc.
|
|
||||||
</h1>
|
|
||||||
<FieldDescription>
|
|
||||||
Don't have an account?
|
|
||||||
<a href="#">
|
|
||||||
Sign up
|
|
||||||
</a>
|
|
||||||
</FieldDescription>
|
|
||||||
</div>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel for="email">
|
|
||||||
Email
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="m@example.com"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<Button type="submit">
|
|
||||||
Login
|
|
||||||
</Button>
|
|
||||||
</Field>
|
|
||||||
<FieldSeparator>Or</FieldSeparator>
|
|
||||||
<Field class="grid gap-4 sm:grid-cols-2">
|
|
||||||
<Button variant="outline" type="button">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Continue with Apple
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" type="button">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Continue with Google
|
|
||||||
</Button>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
</form>
|
|
||||||
<FieldDescription class="px-6 text-center">
|
|
||||||
By clicking continue, you agree to our <a href="#">Terms of Service</a>
|
|
||||||
and <a href="#">Privacy Policy</a>.
|
|
||||||
</FieldDescription>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FormContext, GenericSchema } from "vee-validate"
|
||||||
|
import { useForm } from "vee-validate"
|
||||||
|
|
||||||
|
interface FormProps<T extends GenericSchema = GenericSchema> {
|
||||||
|
form?: FormContext<T>
|
||||||
|
validationSchema?: T
|
||||||
|
initialValues?: Record<string, unknown>
|
||||||
|
keepValuesOnUnmount?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<FormProps>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [values: Record<string, unknown>]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = props.form ?? useForm({
|
||||||
|
validationSchema: props.validationSchema,
|
||||||
|
initialValues: props.initialValues,
|
||||||
|
keepValuesOnUnmount: props.keepValuesOnUnmount,
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit((values) => {
|
||||||
|
emit("submit", values)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form data-slot="form" @submit="onSubmit">
|
||||||
|
<slot />
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { Field } from "vee-validate"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Field v-slot="{ field, errors: fieldErrors, meta }" v-bind="$attrs">
|
||||||
|
<slot v-bind="{ componentField: field, errors: fieldErrors, meta }" />
|
||||||
|
</Field>
|
||||||
|
</template>
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
export { default as Form } from "./Form.vue"
|
||||||
|
export { default as FormField } from "./FormField.vue"
|
||||||
export { default as FormControl } from "./FormControl.vue"
|
export { default as FormControl } from "./FormControl.vue"
|
||||||
export { default as FormDescription } from "./FormDescription.vue"
|
export { default as FormDescription } from "./FormDescription.vue"
|
||||||
export { default as FormItem } from "./FormItem.vue"
|
export { default as FormItem } from "./FormItem.vue"
|
||||||
export { default as FormLabel } from "./FormLabel.vue"
|
export { default as FormLabel } from "./FormLabel.vue"
|
||||||
export { default as FormMessage } from "./FormMessage.vue"
|
export { default as FormMessage } from "./FormMessage.vue"
|
||||||
export { FORM_ITEM_INJECTION_KEY } from "./injectionKeys"
|
export { FORM_ITEM_INJECTION_KEY } from "./injectionKeys"
|
||||||
export { Form, Field as FormField, FieldArray as FormFieldArray } from "vee-validate"
|
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { ref, watch } from "vue";
|
||||||
|
import { useDebounceFn } from "@vueuse/core";
|
||||||
|
import { pageDataApi } from "@/services/api";
|
||||||
|
|
||||||
|
export interface LookupOptionsConfig {
|
||||||
|
/** 后端表名/视图名(必填) */
|
||||||
|
viewName: string;
|
||||||
|
/** 显示文本字段名,默认 "b_name" */
|
||||||
|
labelKey?: string;
|
||||||
|
/** 取值字段名,默认 "b_id" */
|
||||||
|
valueKey?: string;
|
||||||
|
/** 额外的固定查询条件 */
|
||||||
|
searchCondition?: string;
|
||||||
|
/** 排序规则 */
|
||||||
|
orderBy?: string;
|
||||||
|
/** 需要返回的字段列表 */
|
||||||
|
searchColumns?: string[];
|
||||||
|
/** 每页加载数量,默认 20 */
|
||||||
|
pageSize?: number;
|
||||||
|
/** 搜索防抖延迟(ms),默认 300 */
|
||||||
|
searchDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLookupOptions(config: LookupOptionsConfig) {
|
||||||
|
const {
|
||||||
|
viewName,
|
||||||
|
labelKey = "b_name",
|
||||||
|
valueKey = "b_id",
|
||||||
|
searchCondition = "",
|
||||||
|
orderBy = "",
|
||||||
|
searchColumns = [],
|
||||||
|
pageSize = 20,
|
||||||
|
searchDelay = 300,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const rows = ref<Record<string, any>[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const total = ref(0);
|
||||||
|
const keyword = ref("");
|
||||||
|
// Keep the unfiltered first page available while a later open is refreshing.
|
||||||
|
const defaultRows = ref<Record<string, any>[]>([]);
|
||||||
|
const defaultTotal = ref(0);
|
||||||
|
|
||||||
|
let requestVersion = 0;
|
||||||
|
let currentPage = 1;
|
||||||
|
|
||||||
|
function buildSearchCondition() {
|
||||||
|
const k = keyword.value.trim();
|
||||||
|
if (!k) return searchCondition;
|
||||||
|
const escaped = k.replaceAll("'", "''");
|
||||||
|
const like = `${labelKey} LIKE '%${escaped}%'`;
|
||||||
|
return searchCondition ? `${searchCondition} AND ${like}` : like;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(reset = false, preserveRows = false) {
|
||||||
|
if (reset) {
|
||||||
|
currentPage = 1;
|
||||||
|
if (!preserveRows) {
|
||||||
|
rows.value = [];
|
||||||
|
total.value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = ++requestVersion;
|
||||||
|
loading.value = true;
|
||||||
|
const requestedKeyword = keyword.value.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const condition = buildSearchCondition();
|
||||||
|
const result = await pageDataApi({
|
||||||
|
viewName,
|
||||||
|
orderBy: orderBy || `${labelKey} ASC`,
|
||||||
|
searchColumns,
|
||||||
|
searchCondition: condition,
|
||||||
|
pageNo: currentPage,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (version !== requestVersion) return;
|
||||||
|
|
||||||
|
const data = result.data;
|
||||||
|
if (reset) {
|
||||||
|
rows.value = data.rows;
|
||||||
|
} else {
|
||||||
|
rows.value.push(...data.rows);
|
||||||
|
}
|
||||||
|
total.value = data.total;
|
||||||
|
|
||||||
|
if (!requestedKeyword) {
|
||||||
|
defaultRows.value = [...rows.value];
|
||||||
|
defaultTotal.value = total.value;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (version === requestVersion) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索:防抖后重置并加载首页
|
||||||
|
*/
|
||||||
|
const search = useDebounceFn((q: string) => {
|
||||||
|
keyword.value = q;
|
||||||
|
load(true);
|
||||||
|
}, searchDelay);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 立即搜索(focus/打开时使用,不防抖)
|
||||||
|
*/
|
||||||
|
function immediate(q: string) {
|
||||||
|
keyword.value = q;
|
||||||
|
if (!q && defaultRows.value.length) {
|
||||||
|
rows.value = [...defaultRows.value];
|
||||||
|
total.value = defaultTotal.value;
|
||||||
|
load(true, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开下拉:若尚无数据则首页加载
|
||||||
|
*/
|
||||||
|
function open() {
|
||||||
|
if (!rows.value.length) {
|
||||||
|
load(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滚动触底时加载下一页
|
||||||
|
*/
|
||||||
|
function loadMore() {
|
||||||
|
if (loading.value) return;
|
||||||
|
if (rows.value.length >= total.value) return;
|
||||||
|
currentPage++;
|
||||||
|
load(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置:清空数据、关键词、分页
|
||||||
|
*/
|
||||||
|
function reset(clearCache = false) {
|
||||||
|
requestVersion++;
|
||||||
|
rows.value = [];
|
||||||
|
total.value = 0;
|
||||||
|
keyword.value = "";
|
||||||
|
currentPage = 1;
|
||||||
|
loading.value = false;
|
||||||
|
if (clearCache) {
|
||||||
|
defaultRows.value = [];
|
||||||
|
defaultTotal.value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键 props 变化时自动重置
|
||||||
|
watch(
|
||||||
|
() => [viewName, labelKey, valueKey, orderBy, searchCondition, searchColumns],
|
||||||
|
() => reset(true),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
loading,
|
||||||
|
total,
|
||||||
|
keyword,
|
||||||
|
search,
|
||||||
|
immediate,
|
||||||
|
open,
|
||||||
|
loadMore,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useEventListener } from '@vueuse/core'
|
import { useEventListener, usePreferredReducedMotion } from '@vueuse/core'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const preferredReducedMotion = usePreferredReducedMotion()
|
||||||
const routeInstanceKey = computed(
|
const routeInstanceKey = computed(
|
||||||
() => `${route.fullPath}:${appStore.getRouteRefreshKey(route.fullPath)}`,
|
() => `${route.fullPath}:${appStore.getRouteRefreshKey(route.fullPath)}`,
|
||||||
)
|
)
|
||||||
@@ -59,7 +60,10 @@ function onRailMouseDown(e: MouseEvent) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SidebarProvider class="group/sidebar bg-sidebar" :style="{ '--sidebar-width': '14rem' }">
|
<SidebarProvider
|
||||||
|
class="group/sidebar h-svh min-w-0 overflow-hidden bg-sidebar"
|
||||||
|
:style="{ '--sidebar-width': '14rem' }"
|
||||||
|
>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
|
|
||||||
<!-- Drag rail: hidden when collapsed, follows sidebar width when expanded -->
|
<!-- Drag rail: hidden when collapsed, follows sidebar width when expanded -->
|
||||||
@@ -70,13 +74,17 @@ function onRailMouseDown(e: MouseEvent) {
|
|||||||
@mousedown="onRailMouseDown"
|
@mousedown="onRailMouseDown"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SidebarInset class="m-2 ml-0 rounded-xl shadow z-10">
|
<SidebarInset class="m-2 ml-0 min-w-0 overflow-hidden rounded-xl shadow z-10">
|
||||||
<AppTopbar />
|
<AppTopbar />
|
||||||
<AppTabs />
|
<AppTabs />
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-4 p-4 pt-2">
|
<div class="flex min-h-0 min-w-0 flex-1 flex-col gap-4 overflow-auto p-4 pt-2">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<transition name="slide" mode="out-in">
|
<transition
|
||||||
|
name="page"
|
||||||
|
mode="out-in"
|
||||||
|
:css="preferredReducedMotion !== 'reduce'"
|
||||||
|
>
|
||||||
<keep-alive :max="8">
|
<keep-alive :max="8">
|
||||||
<component :is="Component" :key="routeInstanceKey" />
|
<component :is="Component" :key="routeInstanceKey" />
|
||||||
</keep-alive>
|
</keep-alive>
|
||||||
@@ -88,16 +96,19 @@ function onRailMouseDown(e: MouseEvent) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.slide-enter-active,
|
.page-enter-active {
|
||||||
.slide-leave-active {
|
transition:
|
||||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
opacity 160ms ease-out,
|
||||||
|
transform 160ms ease-out;
|
||||||
}
|
}
|
||||||
.slide-enter-from {
|
.page-leave-active {
|
||||||
opacity: 0;
|
transition: opacity 80ms ease-in;
|
||||||
transform: translateX(12px);
|
|
||||||
}
|
}
|
||||||
.slide-leave-to {
|
.page-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
.page-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-12px);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SidebarProps } from '@/components/ui/sidebar'
|
import type { SidebarProps } from '@/components/ui/sidebar'
|
||||||
|
import type { NavItem } from '@/layouts/components/NavMenuItem.vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AudioWaveform, ChevronsUpDown, Command,
|
AudioWaveform, ChevronsUpDown, Command,
|
||||||
GalleryVerticalEnd, LayoutGrid, Palette, Plus,
|
GalleryVerticalEnd, LayoutGrid, Plus,
|
||||||
} from "@lucide/vue"
|
} from "@lucide/vue"
|
||||||
import { ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { usePermissionStore } from '@/stores/permissions'
|
||||||
|
import { getIcon } from '@/utils/lucideIcons'
|
||||||
|
import { buildTree, sortTree } from '@/utils/tree'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel,
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel,
|
||||||
@@ -22,6 +26,7 @@ const props = withDefaults(defineProps<SidebarProps>(), {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const { isMobile: isSidebarMobile } = useSidebar()
|
const { isMobile: isSidebarMobile } = useSidebar()
|
||||||
|
const permissionStore = usePermissionStore()
|
||||||
|
|
||||||
// ---- teams ----
|
// ---- teams ----
|
||||||
const teams = [
|
const teams = [
|
||||||
@@ -31,21 +36,66 @@ const teams = [
|
|||||||
]
|
]
|
||||||
const activeTeam = ref(teams[0]!)
|
const activeTeam = ref(teams[0]!)
|
||||||
|
|
||||||
// ---- nav items ----
|
// ---- dynamic nav items ----
|
||||||
const navMain = [
|
const navMain = computed(() => {
|
||||||
{
|
const items: NavItem[] = [
|
||||||
title: "工作台", url: "/dashboard", icon: LayoutGrid, isActive: true,
|
{ title: "工作台", url: "/dashboard", icon: LayoutGrid },
|
||||||
},
|
]
|
||||||
{
|
|
||||||
title: "Demo 页面", url: "#", icon: Palette,
|
const menuModules = permissionStore.modules.filter(
|
||||||
items: [
|
(m: any) =>
|
||||||
{ title: "表格示例", url: "/demo/table" },
|
m.b_canmenu === 1 &&
|
||||||
{ title: "表单示例", url: "/demo/form" },
|
m.b_canuse === 1 &&
|
||||||
{ title: "卡片示例", url: "/demo/cards" },
|
["directory", "page"].includes(m.b_module_type),
|
||||||
{ title: "设置示例", url: "/demo/settings" },
|
)
|
||||||
],
|
|
||||||
},
|
if (!menuModules.length) return items
|
||||||
]
|
|
||||||
|
// Keep only modules whose parent (if any) is also in the menu
|
||||||
|
const allowedIds = new Set(menuModules.map((m: any) => String(m.b_id)))
|
||||||
|
const valid = menuModules.filter(
|
||||||
|
(m: any) =>
|
||||||
|
m.b_parent_id == null || allowedIds.has(String(m.b_parent_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const cloned: any[] = JSON.parse(JSON.stringify(valid))
|
||||||
|
const tree = buildTree(cloned)
|
||||||
|
sortTree(tree)
|
||||||
|
|
||||||
|
for (const node of tree) {
|
||||||
|
const item = toNavItem(node)
|
||||||
|
if (item) items.push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
function toNavItem(node: any): NavItem | null {
|
||||||
|
const isDir = node.b_module_type === "directory"
|
||||||
|
const icon = isDir ? getIcon(node.b_icon) ?? undefined : undefined
|
||||||
|
|
||||||
|
const item: NavItem = {
|
||||||
|
title: node.b_name || node.b_code,
|
||||||
|
url: node.b_route ? String(node.b_route) : `#${node.b_code}`,
|
||||||
|
}
|
||||||
|
if (icon) item.icon = icon
|
||||||
|
|
||||||
|
if (!isDir && node.b_code) {
|
||||||
|
item.moduleId = String(node.b_code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children?.length) {
|
||||||
|
item.items = node.children
|
||||||
|
.map((c: any) => toNavItem(c))
|
||||||
|
.filter(Boolean) as NavItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
permissionStore.load()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ChevronLeft, ChevronRight, X } from "@lucide/vue"
|
import { ChevronLeft, ChevronRight, X } from "@lucide/vue"
|
||||||
|
import { useResizeObserver } from "@vueuse/core"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
import { computed, nextTick, onMounted, onBeforeUnmount, ref, watch } from "vue"
|
import { computed, nextTick, ref, watch } from "vue"
|
||||||
import { useAppStore } from "@/stores/app"
|
import { useAppStore } from "@/stores/app"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -15,12 +17,10 @@ const canScrollRight = ref(false)
|
|||||||
const activeKey = computed(() => route.fullPath)
|
const activeKey = computed(() => route.fullPath)
|
||||||
const tabs = computed(() => appStore.visitedTabs)
|
const tabs = computed(() => appStore.visitedTabs)
|
||||||
|
|
||||||
let resizeObserver: ResizeObserver | null = null
|
|
||||||
|
|
||||||
// Watch route changes to add tabs
|
// Watch route changes to add tabs
|
||||||
watch(
|
watch(
|
||||||
() => route.fullPath,
|
() => route.fullPath,
|
||||||
() => {
|
async () => {
|
||||||
const meta = route.meta as { title?: string }
|
const meta = route.meta as { title?: string }
|
||||||
if (meta.title) {
|
if (meta.title) {
|
||||||
appStore.addVisitedTab({
|
appStore.addVisitedTab({
|
||||||
@@ -29,6 +29,9 @@ watch(
|
|||||||
closable: route.fullPath !== "/dashboard",
|
closable: route.fullPath !== "/dashboard",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
await nextTick()
|
||||||
|
revealActiveTab()
|
||||||
|
updateScrollState()
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
@@ -39,13 +42,7 @@ watch(() => appStore.visitedTabs.length, async () => {
|
|||||||
updateScrollState()
|
updateScrollState()
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
useResizeObserver(trackRef, updateScrollState)
|
||||||
resizeObserver = new ResizeObserver(updateScrollState)
|
|
||||||
if (trackRef.value) resizeObserver.observe(trackRef.value)
|
|
||||||
updateScrollState()
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => resizeObserver?.disconnect())
|
|
||||||
|
|
||||||
function changeTab(key: string) {
|
function changeTab(key: string) {
|
||||||
if (key !== route.fullPath) router.push(key)
|
if (key !== route.fullPath) router.push(key)
|
||||||
@@ -87,6 +84,15 @@ function scrollTabs(direction: number) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function revealActiveTab(behavior: ScrollBehavior = "smooth") {
|
||||||
|
const track = trackRef.value
|
||||||
|
if (!track) return
|
||||||
|
const activeElement = Array.from(track.querySelectorAll<HTMLElement>("[data-tab-key]")).find(
|
||||||
|
element => element.dataset.tabKey === activeKey.value,
|
||||||
|
)
|
||||||
|
activeElement?.scrollIntoView({ behavior, block: "nearest", inline: "center" })
|
||||||
|
}
|
||||||
|
|
||||||
function handleWheel(e: WheelEvent) {
|
function handleWheel(e: WheelEvent) {
|
||||||
const track = trackRef.value
|
const track = trackRef.value
|
||||||
if (!track || track.scrollWidth <= track.clientWidth) return
|
if (!track || track.scrollWidth <= track.clientWidth) return
|
||||||
@@ -102,22 +108,29 @@ function refreshTab(key: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="tabs.length" class="flex shrink-0 items-center h-9 gap-0.5 px-2 border-b border-border/40">
|
<div
|
||||||
|
v-if="tabs.length"
|
||||||
|
class="flex h-10 w-full min-w-0 shrink-0 items-center gap-0.5 overflow-hidden px-2"
|
||||||
|
>
|
||||||
<!-- Left scroll button -->
|
<!-- Left scroll button -->
|
||||||
<button
|
<Button
|
||||||
v-if="overflowed"
|
v-if="overflowed"
|
||||||
type="button"
|
type="button"
|
||||||
class="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-30 disabled:cursor-default transition-colors"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="size-7 text-muted-foreground disabled:cursor-default disabled:opacity-30"
|
||||||
|
aria-label="向左滚动页签"
|
||||||
:disabled="!canScrollLeft"
|
:disabled="!canScrollLeft"
|
||||||
@click="scrollTabs(-1)"
|
@click="scrollTabs(-1)"
|
||||||
>
|
>
|
||||||
<ChevronLeft class="size-3.5" />
|
<ChevronLeft />
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<!-- Tab track -->
|
<!-- Tab track -->
|
||||||
<div
|
<div
|
||||||
ref="trackRef"
|
ref="trackRef"
|
||||||
class="flex flex-1 min-w-0 gap-1.5 overflow-x-auto overflow-y-visible scrollbar-none overscroll-contain"
|
class="tab-track flex min-w-0 flex-1 gap-1.5 overflow-x-auto overflow-y-hidden overscroll-contain"
|
||||||
|
role="tablist"
|
||||||
@scroll.passive="updateScrollState"
|
@scroll.passive="updateScrollState"
|
||||||
@wheel="handleWheel"
|
@wheel="handleWheel"
|
||||||
>
|
>
|
||||||
@@ -125,10 +138,14 @@ function refreshTab(key: string) {
|
|||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
:key="tab.key"
|
:key="tab.key"
|
||||||
type="button"
|
type="button"
|
||||||
class="group/tab relative flex shrink-0 items-center gap-1 h-7 rounded-md px-2.5 text-xs font-medium transition-colors cursor-pointer border"
|
role="tab"
|
||||||
:class="tab.key === activeKey
|
:data-tab-key="tab.key"
|
||||||
|
:aria-selected="tab.key === activeKey"
|
||||||
|
class="group/tab relative flex shrink-0 items-center gap-1 h-7 rounded-md pl-2.5 text-xs font-medium transition-colors cursor-pointer border"
|
||||||
|
:class="[tab.key === activeKey
|
||||||
? 'bg-muted text-foreground border-border'
|
? 'bg-muted text-foreground border-border'
|
||||||
: 'bg-background text-muted-foreground border-border/70 hover:border-border hover:text-foreground'"
|
: 'bg-background text-muted-foreground border-border/70 hover:border-border hover:text-foreground',
|
||||||
|
tab.closable ? 'pr-0' : 'pr-2.5']"
|
||||||
@click="changeTab(tab.key)"
|
@click="changeTab(tab.key)"
|
||||||
@dblclick.prevent="refreshTab(tab.key)"
|
@dblclick.prevent="refreshTab(tab.key)"
|
||||||
>
|
>
|
||||||
@@ -136,7 +153,7 @@ function refreshTab(key: string) {
|
|||||||
<!-- Close button -->
|
<!-- Close button -->
|
||||||
<span
|
<span
|
||||||
v-if="tab.closable"
|
v-if="tab.closable"
|
||||||
class="grid size-4 shrink-0 place-items-center rounded-sm text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/20 transition-colors"
|
class="flex flex-1 items-center justify-center self-stretch px-1 rounded-sm text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/20 transition-colors"
|
||||||
@click="closeTab(tab.key, $event)"
|
@click="closeTab(tab.key, $event)"
|
||||||
>
|
>
|
||||||
<X class="size-3" />
|
<X class="size-3" />
|
||||||
@@ -145,14 +162,27 @@ function refreshTab(key: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right scroll button -->
|
<!-- Right scroll button -->
|
||||||
<button
|
<Button
|
||||||
v-if="overflowed"
|
v-if="overflowed"
|
||||||
type="button"
|
type="button"
|
||||||
class="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-30 disabled:cursor-default transition-colors"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="size-7 text-muted-foreground disabled:cursor-default disabled:opacity-30"
|
||||||
|
aria-label="向右滚动页签"
|
||||||
:disabled="!canScrollRight"
|
:disabled="!canScrollRight"
|
||||||
@click="scrollTabs(1)"
|
@click="scrollTabs(1)"
|
||||||
>
|
>
|
||||||
<ChevronRight class="size-3.5" />
|
<ChevronRight />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.tab-track {
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,8 +3,10 @@ import {
|
|||||||
BadgeCheck, Bell, ChevronDown, CreditCard, Languages, LogOut, Moon, Palette, Sun,
|
BadgeCheck, Bell, ChevronDown, CreditCard, Languages, LogOut, Moon, Palette, Sun,
|
||||||
} from "@lucide/vue"
|
} from "@lucide/vue"
|
||||||
import { computed, onMounted, watchEffect } from 'vue'
|
import { computed, onMounted, watchEffect } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { toast } from "vue-sonner"
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import {
|
import {
|
||||||
Avatar, AvatarFallback, AvatarImage,
|
Avatar, AvatarFallback, AvatarImage,
|
||||||
} from '@/components/ui/avatar'
|
} from '@/components/ui/avatar'
|
||||||
@@ -20,12 +22,20 @@ import { Separator } from '@/components/ui/separator'
|
|||||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const user = {
|
const user = computed(() => ({
|
||||||
name: "张三",
|
name: authStore.user?.name ?? '用户',
|
||||||
email: "zhangsan@example.com",
|
email: authStore.user?.account ?? '',
|
||||||
avatar: "/avatars/shadcn.jpg",
|
avatar: '',
|
||||||
|
}))
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
authStore.clearSession()
|
||||||
|
toast.info('已退出登录')
|
||||||
|
router.replace('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
const breadcrumbTitle = computed(() => (route.meta as { title?: string }).title ?? String(route.name ?? ''))
|
const breadcrumbTitle = computed(() => (route.meta as { title?: string }).title ?? String(route.name ?? ''))
|
||||||
@@ -88,14 +98,14 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
|
|||||||
|
|
||||||
<!-- Right tools -->
|
<!-- Right tools -->
|
||||||
<div class="flex items-center gap-1 px-4">
|
<div class="flex items-center gap-1 px-4">
|
||||||
<Button variant="ghost" size="icon-sm" @click="store.toggleLocale" :title="store.locale === 'zh-CN' ? 'English' : '中文'">
|
<Button variant="ghost" size="icon-sm" class="cursor-pointer" @click="store.toggleLocale" :title="store.locale === 'zh-CN' ? 'English' : '中文'">
|
||||||
<Languages />
|
<Languages />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- Color scheme grid -->
|
<!-- Color scheme grid -->
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<Button variant="ghost" size="icon-sm" title="主题色切换">
|
<Button variant="ghost" size="icon-sm" class="cursor-pointer" title="主题色切换">
|
||||||
<Palette />
|
<Palette />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -113,11 +123,11 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
<Button variant="ghost" size="icon-sm" @click="store.toggleTheme" :title="store.isDark ? '浅色模式' : '深色模式'">
|
<Button variant="ghost" size="icon-sm" class="cursor-pointer" @click="store.toggleTheme" :title="store.isDark ? '浅色模式' : '深色模式'">
|
||||||
<Sun v-if="store.isDark" />
|
<Sun v-if="store.isDark" />
|
||||||
<Moon v-else />
|
<Moon v-else />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon-sm" title="通知">
|
<Button variant="ghost" size="icon-sm" class="cursor-pointer" title="通知">
|
||||||
<Bell />
|
<Bell />
|
||||||
</Button>
|
</Button>
|
||||||
<Separator orientation="vertical" class="mx-1 data-[orientation=vertical]:h-6" />
|
<Separator orientation="vertical" class="mx-1 data-[orientation=vertical]:h-6" />
|
||||||
@@ -125,7 +135,7 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
|
|||||||
<!-- User dropdown -->
|
<!-- User dropdown -->
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="gap-2 data-[state=open]:bg-accent">
|
<Button variant="ghost" size="sm" class="gap-2 cursor-pointer data-[state=open]:bg-accent">
|
||||||
<Avatar class="size-7 rounded-md">
|
<Avatar class="size-7 rounded-md">
|
||||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
<AvatarImage :src="user.avatar" :alt="user.name" />
|
||||||
<AvatarFallback class="rounded-md text-xs">{{ user.name.slice(0, 1) }}</AvatarFallback>
|
<AvatarFallback class="rounded-md text-xs">{{ user.name.slice(0, 1) }}</AvatarFallback>
|
||||||
@@ -143,7 +153,7 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span class="truncate font-semibold">{{ user.name }}</span>
|
<span class="truncate font-semibold">{{ user.name }}</span>
|
||||||
<span class="truncate text-xs text-muted-foreground">{{ user.email }}</span>
|
<span class="truncate text-xs text-muted-foreground">{{ authStore.orgId }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
@@ -154,7 +164,7 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
|
|||||||
<DropdownMenuItem><Bell /> 通知设置</DropdownMenuItem>
|
<DropdownMenuItem><Bell /> 通知设置</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem><LogOut /> 退出登录</DropdownMenuItem>
|
<DropdownMenuItem @click="logout"><LogOut /> 退出登录</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,17 +2,25 @@
|
|||||||
import type { LucideIcon } from "@lucide/vue"
|
import type { LucideIcon } from "@lucide/vue"
|
||||||
import { ChevronRight } from "@lucide/vue"
|
import { ChevronRight } from "@lucide/vue"
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
|
import { computed } from 'vue'
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from '@/components/ui/collapsible'
|
} from '@/components/ui/collapsible'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
import {
|
import {
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarMenuSub,
|
SidebarMenuSub,
|
||||||
SidebarMenuSubButton,
|
SidebarMenuSubButton,
|
||||||
SidebarMenuSubItem,
|
SidebarMenuSubItem,
|
||||||
|
useSidebar,
|
||||||
} from '@/components/ui/sidebar'
|
} from '@/components/ui/sidebar'
|
||||||
|
|
||||||
export type NavItem = {
|
export type NavItem = {
|
||||||
@@ -21,6 +29,7 @@ export type NavItem = {
|
|||||||
icon?: LucideIcon
|
icon?: LucideIcon
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
items?: NavItem[]
|
items?: NavItem[]
|
||||||
|
moduleId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
@@ -31,6 +40,9 @@ const props = withDefaults(defineProps<{
|
|||||||
})
|
})
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { state } = useSidebar()
|
||||||
|
|
||||||
|
const isCollapsed = computed(() => state.value === 'collapsed')
|
||||||
|
|
||||||
function isActive(url: string) {
|
function isActive(url: string) {
|
||||||
if (url === '#') return false
|
if (url === '#') return false
|
||||||
@@ -44,31 +56,73 @@ function hasActiveChild(item: NavItem): boolean {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- Has children: render as collapsible -->
|
<!-- Root-level parent with children -->
|
||||||
<Collapsible v-if="item.items?.length" as-child :default-open="hasActiveChild(item)" class="group/collapsible">
|
<template v-if="item.items?.length && props.depth === 0">
|
||||||
<!-- Root level: SidebarMenuItem + SidebarMenuButton -->
|
<!-- Collapsed mode: DropdownMenu -->
|
||||||
<SidebarMenuItem v-if="props.depth === 0">
|
<SidebarMenuItem v-if="isCollapsed">
|
||||||
<CollapsibleTrigger as-child>
|
<DropdownMenu>
|
||||||
<SidebarMenuButton :tooltip="item.title">
|
<DropdownMenuTrigger as-child>
|
||||||
<component :is="item.icon" v-if="item.icon" />
|
<SidebarMenuButton :tooltip="item.title">
|
||||||
<span class="group-data-[collapsible=icon]:hidden">{{ item.title }}</span>
|
<component :is="item.icon" v-if="item.icon" />
|
||||||
<ChevronRight class="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 group-data-[collapsible=icon]:hidden" />
|
<span>{{ item.title }}</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</CollapsibleTrigger>
|
</DropdownMenuTrigger>
|
||||||
<CollapsibleContent>
|
<DropdownMenuContent class="w-40 rounded-lg" side="right" :side-offset="8" align="start">
|
||||||
<SidebarMenuSub>
|
<template v-for="child in item.items" :key="child.title">
|
||||||
<NavMenuItem
|
<!-- Child with grandchildren: nested DropdownMenu -->
|
||||||
v-for="child in item.items"
|
<DropdownMenuItem v-if="child.items?.length" as-child>
|
||||||
:key="child.title"
|
<DropdownMenu>
|
||||||
:item="child"
|
<DropdownMenuTrigger class="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm outline-none cursor-pointer hover:bg-accent hover:text-accent-foreground">
|
||||||
:depth="1"
|
<span>{{ child.title }}</span>
|
||||||
/>
|
<ChevronRight class="size-3.5" />
|
||||||
</SidebarMenuSub>
|
</DropdownMenuTrigger>
|
||||||
</CollapsibleContent>
|
<DropdownMenuContent class="w-40 rounded-lg" side="right" :side-offset="4" align="start">
|
||||||
|
<DropdownMenuItem
|
||||||
|
v-for="grandchild in child.items"
|
||||||
|
:key="grandchild.title"
|
||||||
|
as-child
|
||||||
|
>
|
||||||
|
<RouterLink :to="grandchild.url" class="w-full">{{ grandchild.title }}</RouterLink>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<!-- Leaf child: direct link -->
|
||||||
|
<DropdownMenuItem v-else as-child>
|
||||||
|
<RouterLink :to="child.url" class="w-full">{{ child.title }}</RouterLink>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</template>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|
||||||
<!-- Sub level: SidebarMenuSubItem + SidebarMenuSubButton -->
|
<!-- Expanded mode: Collapsible -->
|
||||||
<SidebarMenuSubItem v-else>
|
<Collapsible v-else as-child :default-open="hasActiveChild(item)" class="group/collapsible">
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<CollapsibleTrigger as-child>
|
||||||
|
<SidebarMenuButton :tooltip="item.title">
|
||||||
|
<component :is="item.icon" v-if="item.icon" />
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
<ChevronRight class="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<SidebarMenuSub>
|
||||||
|
<NavMenuItem
|
||||||
|
v-for="child in item.items"
|
||||||
|
:key="child.title"
|
||||||
|
:item="child"
|
||||||
|
:depth="1"
|
||||||
|
/>
|
||||||
|
</SidebarMenuSub>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</Collapsible>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Sub-level parent with children -->
|
||||||
|
<Collapsible v-else-if="item.items?.length" as-child :default-open="hasActiveChild(item)" class="group/collapsible">
|
||||||
|
<SidebarMenuSubItem>
|
||||||
<CollapsibleTrigger as-child>
|
<CollapsibleTrigger as-child>
|
||||||
<SidebarMenuSubButton>
|
<SidebarMenuSubButton>
|
||||||
<span>{{ item.title }}</span>
|
<span>{{ item.title }}</span>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import '@/styles/index.css'
|
import '@/styles/index.css'
|
||||||
|
import 'vue-sonner/style.css'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import NProgress from 'nprogress'
|
||||||
|
import 'nprogress/nprogress.css'
|
||||||
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { usePermissionStore } from '@/stores/permissions'
|
||||||
|
|
||||||
const PlaceholderPage = () => import('@/views/base/PlaceholderPage.vue')
|
NProgress.configure({ showSpinner: false })
|
||||||
|
|
||||||
// Demo pages
|
|
||||||
const TablePage = () => import('@/views/demo/TableDemo.vue')
|
|
||||||
const FormPage = () => import('@/views/demo/FormDemo.vue')
|
|
||||||
const CardPage = () => import('@/views/demo/CardDemo.vue')
|
|
||||||
const SettingsPage = () => import('@/views/demo/SettingsDemo.vue')
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -16,7 +13,7 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'login',
|
name: 'login',
|
||||||
component: () => import('@/views/login/LoginView.vue'),
|
component: () => import('@/views/login/index.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -30,73 +27,49 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/dashboard/index.vue'),
|
component: () => import('@/views/dashboard/index.vue'),
|
||||||
meta: { title: '工作台' },
|
meta: { title: '工作台' },
|
||||||
},
|
},
|
||||||
// Demo pages
|
// Base
|
||||||
{
|
|
||||||
path: '/demo/table',
|
|
||||||
name: 'demo-table',
|
|
||||||
component: TablePage,
|
|
||||||
meta: { title: '表格示例', section: 'Demo 页面' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/demo/form',
|
|
||||||
name: 'demo-form',
|
|
||||||
component: FormPage,
|
|
||||||
meta: { title: '表单示例', section: 'Demo 页面' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/demo/cards',
|
|
||||||
name: 'demo-cards',
|
|
||||||
component: CardPage,
|
|
||||||
meta: { title: '卡片示例', section: 'Demo 页面' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/demo/settings',
|
|
||||||
name: 'demo-settings',
|
|
||||||
component: SettingsPage,
|
|
||||||
meta: { title: '设置示例', section: 'Demo 页面' },
|
|
||||||
},
|
|
||||||
// Business pages
|
|
||||||
{
|
{
|
||||||
path: '/base/othercompany',
|
path: '/base/othercompany',
|
||||||
name: 'base-othercompany',
|
name: 'base-othercompany',
|
||||||
component: PlaceholderPage,
|
component: () => import('@/views/base/OtherCompanyPage.vue'),
|
||||||
meta: { title: '合作伙伴' },
|
meta: { title: '合作伙伴', moduleId: 'v_b_othercompany' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/base/contact',
|
path: '/base/contact',
|
||||||
name: 'base-contact',
|
name: 'base-contact',
|
||||||
component: PlaceholderPage,
|
component: () => import('@/views/base/ContactPage.vue'),
|
||||||
meta: { title: '联系人' },
|
meta: { title: '联系人', moduleId: 'v_b_contact' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/base/otherdata',
|
path: '/base/otherdata',
|
||||||
name: 'base-otherdata',
|
name: 'base-otherdata',
|
||||||
component: PlaceholderPage,
|
component: () => import('@/views/base/OtherDataPage.vue'),
|
||||||
meta: { title: '其他数据' },
|
meta: { title: '其他数据', moduleId: 'v_b_otherdata' },
|
||||||
},
|
},
|
||||||
|
// System
|
||||||
{
|
{
|
||||||
path: '/system/module-management',
|
path: '/system/module-management',
|
||||||
name: 'system-module-management',
|
name: 'system-module-management',
|
||||||
component: PlaceholderPage,
|
component: () => import('@/views/system/ModuleManagementPage.vue'),
|
||||||
meta: { title: '模块管理' },
|
meta: { title: '模块管理', moduleId: 'm_metadata' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/system/i18n-type',
|
path: '/system/i18n-type',
|
||||||
name: 'system-i18n-type',
|
name: 'system-i18n-type',
|
||||||
component: PlaceholderPage,
|
component: () => import('@/views/system/I18nTypePage.vue'),
|
||||||
meta: { title: '多语言类型' },
|
meta: { title: '多语言类型', moduleId: 'm_i18n' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/system/login-log',
|
path: '/system/login-log',
|
||||||
name: 'system-login-log',
|
name: 'system-login-log',
|
||||||
component: PlaceholderPage,
|
component: () => import('@/views/system/LoginLogPage.vue'),
|
||||||
meta: { title: '登录日志' },
|
meta: { title: '登录日志', moduleId: 'd_login_log' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/system/user',
|
path: '/system/user',
|
||||||
name: 'system-user',
|
name: 'system-user',
|
||||||
component: PlaceholderPage,
|
component: () => import('@/views/system/UserPage.vue'),
|
||||||
meta: { title: '用户管理' },
|
meta: { title: '用户管理', moduleId: 'b_user' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -107,8 +80,9 @@ const router = createRouter({
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auth guard
|
// Auth + permission guard
|
||||||
router.beforeEach((to) => {
|
router.beforeEach(async (to) => {
|
||||||
|
NProgress.start()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
if (to.path === '/login') {
|
if (to.path === '/login') {
|
||||||
@@ -120,7 +94,21 @@ router.beforeEach((to) => {
|
|||||||
if (!authStore.isAuthenticated) {
|
if (!authStore.isAuthenticated) {
|
||||||
return { path: '/login', query: { redirect: to.fullPath } }
|
return { path: '/login', query: { redirect: to.fullPath } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Permission check
|
||||||
|
const moduleId = (to.meta as { moduleId?: string }).moduleId
|
||||||
|
if (moduleId) {
|
||||||
|
const permissionStore = usePermissionStore()
|
||||||
|
await permissionStore.load()
|
||||||
|
if (!permissionStore.canAccess(moduleId)) {
|
||||||
|
return '/dashboard'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.afterEach(() => {
|
||||||
|
NProgress.done()
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
@@ -42,8 +42,6 @@ export const useAuthStore = defineStore(
|
|||||||
|
|
||||||
function clearSession() {
|
function clearSession() {
|
||||||
token.value = ''
|
token.value = ''
|
||||||
orgId.value = ''
|
|
||||||
user.value = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -132,7 +132,39 @@
|
|||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#nprogress {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 99999 !important;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nprogress .bar {
|
||||||
|
z-index: 9999 !important;
|
||||||
|
background: var(--primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nprogress .peg {
|
||||||
|
z-index: 9999 !important;
|
||||||
|
box-shadow:
|
||||||
|
0 0 10px var(--primary),
|
||||||
|
0 0 5px var(--primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nprogress .spinner {
|
||||||
|
z-index: 9999 !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as LucideIcons from "@lucide/vue"
|
||||||
|
|
||||||
|
let iconMap: Record<string, any> | null = null
|
||||||
|
|
||||||
|
function loadIcons() {
|
||||||
|
if (iconMap) return
|
||||||
|
const map: Record<string, any> = {}
|
||||||
|
for (const [key, component] of Object.entries(LucideIcons)) {
|
||||||
|
if (key !== "default" && key !== "createLucideIcon" && typeof key === "string" && key.charAt(0) === key.charAt(0).toUpperCase()) {
|
||||||
|
map[key] = component
|
||||||
|
}
|
||||||
|
}
|
||||||
|
iconMap = map
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步获取图标组件,传入 null/空字符串时返回 null */
|
||||||
|
export function getIcon(name?: string | null) {
|
||||||
|
if (!iconMap) loadIcons()
|
||||||
|
if (!name || !iconMap) return null
|
||||||
|
return iconMap[name] || null
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export interface TreeNode<T> extends Record<string, unknown> {
|
||||||
|
children?: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 parentKey 将一维数组构建为树。
|
||||||
|
* @param list 原始列表
|
||||||
|
* @param idKey 节点 ID 字段名,默认 "b_id"
|
||||||
|
* @param parentKey 父节点关联字段名,默认 "b_parent_id"
|
||||||
|
* @param childrenKey 子节点字段名,默认 "children"
|
||||||
|
*/
|
||||||
|
export function buildTree<T extends TreeNode<T>>(
|
||||||
|
list: T[],
|
||||||
|
idKey = "b_id",
|
||||||
|
parentKey = "b_parent_id",
|
||||||
|
childrenKey = "children",
|
||||||
|
): T[] {
|
||||||
|
const map = new Map<string, T>()
|
||||||
|
const roots: T[] = []
|
||||||
|
|
||||||
|
for (const node of list) {
|
||||||
|
map.set(String((node as any)[idKey]), node)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of list) {
|
||||||
|
const pid = (node as any)[parentKey]
|
||||||
|
if (pid != null) {
|
||||||
|
const parent = map.get(String(pid))
|
||||||
|
if (parent) {
|
||||||
|
if (!(parent as any)[childrenKey]) {
|
||||||
|
;(parent as any)[childrenKey] = []
|
||||||
|
}
|
||||||
|
;(parent as any)[childrenKey].push(node)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roots.push(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 b_xh 升序、b_id 升序 递归排序树。
|
||||||
|
*/
|
||||||
|
export function sortTree<T extends TreeNode<T>>(
|
||||||
|
tree: T[],
|
||||||
|
xhKey = "b_xh",
|
||||||
|
idKey = "b_id",
|
||||||
|
): T[] {
|
||||||
|
tree.sort((a: any, b: any) => {
|
||||||
|
const xhDiff = (Number(a[xhKey]) || 0) - (Number(b[xhKey]) || 0)
|
||||||
|
if (xhDiff !== 0) return xhDiff
|
||||||
|
return String(a[idKey] || "").localeCompare(String(b[idKey] || ""))
|
||||||
|
})
|
||||||
|
for (const node of tree) {
|
||||||
|
if (node.children?.length) {
|
||||||
|
sortTree(node.children as T[], xhKey, idKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tree
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full items-center justify-center">
|
||||||
|
<h1 class="text-2xl font-bold text-muted-foreground">联系人</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full items-center justify-center">
|
||||||
|
<h1 class="text-2xl font-bold text-muted-foreground">合作伙伴</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full items-center justify-center">
|
||||||
|
<h1 class="text-2xl font-bold text-muted-foreground">其他数据</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,8 +1,80 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
|
import FmsSelect from "@/components/FmsSelect.vue"
|
||||||
|
import FmsAutoComplete from "@/components/FmsAutoComplete.vue"
|
||||||
|
|
||||||
|
// FmsSelect demo
|
||||||
|
const localeCode = ref("")
|
||||||
|
const localeLabel = ref("")
|
||||||
|
const localeRecord = ref<Record<string, any> | null>(null)
|
||||||
|
|
||||||
|
function onLocaleChange(record: Record<string, any> | null) {
|
||||||
|
localeRecord.value = record
|
||||||
|
}
|
||||||
|
|
||||||
|
// FmsAutoComplete demo
|
||||||
|
const autoValue = ref("")
|
||||||
|
const autoRecord = ref<Record<string, any> | null>(null)
|
||||||
|
|
||||||
|
function onAutoChange(record: Record<string, any> | null) {
|
||||||
|
autoRecord.value = record
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full items-center justify-center">
|
<div class="flex h-full flex-col items-center gap-8 p-8">
|
||||||
<h1 class="text-2xl font-bold text-muted-foreground">Dashboard</h1>
|
<h1 class="text-2xl font-bold text-muted-foreground">Dashboard</h1>
|
||||||
|
|
||||||
|
<div class="grid w-full max-w-2xl gap-8">
|
||||||
|
<!-- FmsSelect demo -->
|
||||||
|
<div class="rounded-lg border p-6 space-y-3">
|
||||||
|
<h2 class="text-sm font-semibold">FmsSelect(多语言类型)</h2>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
v-model + v-model:label 双绑定,支持远程搜索、滚动分页、清除、未匹配值回显
|
||||||
|
</p>
|
||||||
|
<div class="w-64">
|
||||||
|
<FmsSelect
|
||||||
|
v-model="localeCode"
|
||||||
|
v-model:label="localeLabel"
|
||||||
|
view-name="b_i18n_type"
|
||||||
|
label-key="b_name"
|
||||||
|
value-key="b_code"
|
||||||
|
order-by="b_code ASC"
|
||||||
|
:page-size="10"
|
||||||
|
allow-clear
|
||||||
|
placeholder="选择语言类型..."
|
||||||
|
@change="onLocaleChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground space-y-0.5">
|
||||||
|
<div>value(b_code):<code class="text-foreground">{{ localeCode || '-' }}</code></div>
|
||||||
|
<div>label(b_name):<code class="text-foreground">{{ localeLabel || '-' }}</code></div>
|
||||||
|
<div>record:<code class="text-foreground">{{ localeRecord || '-' }}</code></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FmsAutoComplete demo -->
|
||||||
|
<div class="rounded-lg border p-6 space-y-3">
|
||||||
|
<h2 class="text-sm font-semibold">FmsAutoComplete(语言类型搜索)</h2>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
单 v-model 绑定文本,回车/选中后拿到完整记录
|
||||||
|
</p>
|
||||||
|
<div class="w-64">
|
||||||
|
<FmsAutoComplete
|
||||||
|
v-model="autoValue"
|
||||||
|
view-name="b_i18n_type"
|
||||||
|
label-key="b_name"
|
||||||
|
value-key="b_code"
|
||||||
|
order-by="b_code ASC"
|
||||||
|
placeholder="输入语言关键字..."
|
||||||
|
@change="onAutoChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground space-y-0.5">
|
||||||
|
<div>v-model:<code class="text-foreground">{{ autoValue || '-' }}</code></div>
|
||||||
|
<div>record:<code class="text-foreground">{{ autoRecord || '-' }}</code></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
|
|
||||||
const stats = [
|
|
||||||
{ label: '总用户数', value: '12,345', trend: '+12.5%', up: true },
|
|
||||||
{ label: '活跃用户', value: '8,920', trend: '+8.2%', up: true },
|
|
||||||
{ label: '本月新增', value: '1,203', trend: '-3.1%', up: false },
|
|
||||||
{ label: '转化率', value: '24.8%', trend: '+5.6%', up: true },
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-xl font-semibold">卡片示例</h2>
|
|
||||||
<p class="mt-1 text-sm text-muted-foreground">统计卡片和内容卡片</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stat cards -->
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<Card v-for="stat in stats" :key="stat.label">
|
|
||||||
<CardHeader class="pb-2">
|
|
||||||
<CardDescription>{{ stat.label }}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="pb-2">
|
|
||||||
<div class="text-2xl font-bold">{{ stat.value }}</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter class="text-xs" :class="stat.up ? 'text-emerald-600' : 'text-red-500'">
|
|
||||||
{{ stat.trend }}
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content cards -->
|
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>最近活动</CardTitle>
|
|
||||||
<CardDescription>团队最近的操作记录</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="space-y-2 text-sm text-muted-foreground">
|
|
||||||
<p>• 张三 新增了一条客户记录</p>
|
|
||||||
<p>• 李四 更新了产品配置</p>
|
|
||||||
<p>• 王五 导出了本月报表</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>快捷操作</CardTitle>
|
|
||||||
<CardDescription>常用功能入口</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="flex flex-wrap gap-2">
|
|
||||||
<Button variant="outline">新建订单</Button>
|
|
||||||
<Button variant="outline">导入数据</Button>
|
|
||||||
<Button variant="outline">生成报表</Button>
|
|
||||||
<Button variant="outline">发送通知</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
|
|
||||||
const form = ref({ name: '', email: '', phone: '' })
|
|
||||||
const submitted = ref(false)
|
|
||||||
|
|
||||||
function handleSubmit() {
|
|
||||||
submitted.value = true
|
|
||||||
setTimeout(() => { submitted.value = false }, 3000)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-xl font-semibold">表单示例</h2>
|
|
||||||
<p class="mt-1 text-sm text-muted-foreground">基础的输入表单</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="max-w-lg rounded-lg border bg-card p-6">
|
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="name">姓名</Label>
|
|
||||||
<Input id="name" v-model="form.name" placeholder="请输入姓名" />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="email">邮箱</Label>
|
|
||||||
<Input id="email" v-model="form.email" type="email" placeholder="请输入邮箱地址" />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="phone">手机号</Label>
|
|
||||||
<Input id="phone" v-model="form.phone" placeholder="请输入手机号" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3 pt-2">
|
|
||||||
<Button type="submit">提交</Button>
|
|
||||||
<Button type="button" variant="outline">重置</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div v-if="submitted" class="mt-4 rounded-md bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
|
|
||||||
表单提交成功!提交的数据:{{ form }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
|
||||||
|
|
||||||
const settings = ref({
|
|
||||||
notifications: true,
|
|
||||||
autoSave: true,
|
|
||||||
compactMode: false,
|
|
||||||
analytics: true,
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-xl font-semibold">设置示例</h2>
|
|
||||||
<p class="mt-1 text-sm text-muted-foreground">系统设置与偏好</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card class="max-w-2xl">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>通知设置</CardTitle>
|
|
||||||
<CardDescription>管理系统通知偏好</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="space-y-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label>消息通知</Label>
|
|
||||||
<p class="text-sm text-muted-foreground">接收系统消息和提醒</p>
|
|
||||||
</div>
|
|
||||||
<Switch v-model="settings.notifications" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label>自动保存</Label>
|
|
||||||
<p class="text-sm text-muted-foreground">编辑时自动保存内容</p>
|
|
||||||
</div>
|
|
||||||
<Switch v-model="settings.autoSave" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label>紧凑模式</Label>
|
|
||||||
<p class="text-sm text-muted-foreground">缩小间距,显示更多内容</p>
|
|
||||||
</div>
|
|
||||||
<Switch v-model="settings.compactMode" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label>数据统计</Label>
|
|
||||||
<p class="text-sm text-muted-foreground">允许收集使用数据</p>
|
|
||||||
</div>
|
|
||||||
<Switch v-model="settings.analytics" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="max-w-2xl">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>危险操作</CardTitle>
|
|
||||||
<CardDescription>不可恢复的操作,请谨慎</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="flex gap-3">
|
|
||||||
<Button variant="outline" class="text-amber-600 border-amber-600 hover:bg-amber-50">导出所有数据</Button>
|
|
||||||
<Button variant="outline" class="text-red-600 border-red-600 hover:bg-red-50">删除账号</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
|
|
||||||
const data = [
|
|
||||||
{ id: 1, name: '张三', department: '技术部', role: '前端开发', status: '在职' },
|
|
||||||
{ id: 2, name: '李四', department: '产品部', role: '产品经理', status: '在职' },
|
|
||||||
{ id: 3, name: '王五', department: '设计部', role: 'UI 设计', status: '休假' },
|
|
||||||
{ id: 4, name: '赵六', department: '技术部', role: '后端开发', status: '在职' },
|
|
||||||
{ id: 5, name: '钱七', department: '市场部', role: '市场运营', status: '离职' },
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-xl font-semibold">表格示例</h2>
|
|
||||||
<p class="mt-1 text-sm text-muted-foreground">一个简单的数据表格页面</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Input placeholder="搜索..." class="w-48" />
|
|
||||||
<Button variant="outline">导出</Button>
|
|
||||||
<Button>新增</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border bg-card">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead class="border-b bg-muted/50">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 text-left font-medium">姓名</th>
|
|
||||||
<th class="px-4 py-3 text-left font-medium">部门</th>
|
|
||||||
<th class="px-4 py-3 text-left font-medium">角色</th>
|
|
||||||
<th class="px-4 py-3 text-left font-medium">状态</th>
|
|
||||||
<th class="px-4 py-3 text-right font-medium">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="row in data" :key="row.id" class="border-b last:border-b-0 hover:bg-muted/50 transition-colors">
|
|
||||||
<td class="px-4 py-3">{{ row.name }}</td>
|
|
||||||
<td class="px-4 py-3">{{ row.department }}</td>
|
|
||||||
<td class="px-4 py-3 text-muted-foreground">{{ row.role }}</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<span class="inline-flex items-center gap-1.5 text-xs font-medium" :class="row.status === '在职' ? 'text-emerald-600' : row.status === '休假' ? 'text-amber-600' : 'text-muted-foreground'">
|
|
||||||
<span class="size-1.5 rounded-full" :class="row.status === '在职' ? 'bg-emerald-500' : row.status === '休假' ? 'bg-amber-500' : 'bg-muted-foreground'" />
|
|
||||||
{{ row.status }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-right">
|
|
||||||
<Button variant="ghost" size="xs">编辑</Button>
|
|
||||||
<Button variant="ghost" size="xs">查看</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between text-sm text-muted-foreground">
|
|
||||||
<span>共 {{ data.length }} 条记录</span>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Button variant="outline" size="sm" disabled>上一页</Button>
|
|
||||||
<span>第 1 页</span>
|
|
||||||
<Button variant="outline" size="sm">下一页</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
+28
-11
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref } from "vue"
|
import { computed, onMounted, ref } from "vue"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { useForm } from "vee-validate"
|
||||||
|
import { toast } from "vue-sonner"
|
||||||
import { loginApi } from "@/services/api"
|
import { loginApi } from "@/services/api"
|
||||||
import { useAuthStore } from "@/stores/auth"
|
import { useAuthStore } from "@/stores/auth"
|
||||||
import { Building2, GalleryVerticalEnd, KeyRound, User } from "@lucide/vue"
|
import { Building2, GalleryVerticalEnd, KeyRound, LoaderCircle, User } from "@lucide/vue"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
@@ -36,22 +38,32 @@ const formSchema = toTypedSchema(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
validationSchema: formSchema,
|
||||||
|
})
|
||||||
|
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
const orgIdInput = ref<InstanceType<typeof Input> | null>(null)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
nextTick(() => {
|
form.setValues({
|
||||||
(document.getElementById("orgId") || document.getElementById("password"))?.focus()
|
orgId: authStore.orgId || '',
|
||||||
|
userId: authStore.user?.account || '',
|
||||||
|
password: '',
|
||||||
})
|
})
|
||||||
|
orgIdInput.value?.$el?.focus()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function onSubmit(values: Record<string, unknown>) {
|
async function onSubmit(values: Record<string, unknown>) {
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const res = await loginApi(values.orgId as string, values.userId as string, (values.password as string) ?? "")
|
const { orgId, userId, password } = values as { orgId: string; userId: string; password?: string }
|
||||||
|
const res = await loginApi(orgId, userId, password ?? "")
|
||||||
authStore.setSession(
|
authStore.setSession(
|
||||||
{ token: res.data.token, user: { ...res.data.user, account: res.data.user.id } },
|
{ token: res.data.token, user: { ...res.data.user, account: userId } },
|
||||||
res.data.orgid ?? (values.orgId as string),
|
res.data.orgid ?? orgId,
|
||||||
)
|
)
|
||||||
|
toast.success('登录成功')
|
||||||
router.replace(String(route.query.redirect || "/dashboard"))
|
router.replace(String(route.query.redirect || "/dashboard"))
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e instanceof Error) alert(e.message)
|
if (e instanceof Error) alert(e.message)
|
||||||
@@ -59,12 +71,16 @@ async function onSubmit(values: Record<string, unknown>) {
|
|||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function login() {
|
||||||
|
form.handleSubmit(onSubmit)()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-svh flex-col items-center justify-center bg-background p-6 md:p-10">
|
<div class="flex min-h-svh flex-col items-center justify-center bg-background p-6 md:p-10">
|
||||||
<div class="w-full max-w-sm">
|
<div class="w-full max-w-sm">
|
||||||
<Form class="flex flex-col gap-6" :validation-schema="formSchema" @submit="onSubmit">
|
<Form class="flex flex-col gap-6" :form="form">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex flex-col items-center gap-2 text-center">
|
<div class="flex flex-col items-center gap-2 text-center">
|
||||||
<a href="#" class="flex flex-col items-center gap-2 font-medium">
|
<a href="#" class="flex flex-col items-center gap-2 font-medium">
|
||||||
@@ -89,7 +105,7 @@ async function onSubmit(values: Record<string, unknown>) {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<span class="absolute inset-y-0 left-3 flex items-center text-muted-foreground"><Building2 class="size-4" /></span>
|
<span class="absolute inset-y-0 left-3 flex items-center text-muted-foreground"><Building2 class="size-4" /></span>
|
||||||
<Input id="orgId" v-bind="componentField" placeholder="请输入机构码" class="pl-9" :disabled="submitting" />
|
<Input id="orgId" ref="orgIdInput" v-bind="componentField" placeholder="请输入机构码" class="pl-9" :disabled="submitting" />
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -124,8 +140,9 @@ async function onSubmit(values: Record<string, unknown>) {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<Button type="submit" :disabled="submitting" class="w-full cursor-pointer">
|
<Button class="w-full cursor-pointer" :disabled="submitting" @click="login">
|
||||||
{{ submitting ? "登录中..." : "登录" }}
|
<LoaderCircle v-if="submitting" class="size-4 animate-spin" />
|
||||||
|
登录
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full items-center justify-center">
|
||||||
|
<h1 class="text-2xl font-bold text-muted-foreground">多语言类型</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full items-center justify-center">
|
||||||
|
<h1 class="text-2xl font-bold text-muted-foreground">登录日志</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full items-center justify-center">
|
||||||
|
<h1 class="text-2xl font-bold text-muted-foreground">模块管理</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full items-center justify-center">
|
||||||
|
<h1 class="text-2xl font-bold text-muted-foreground">用户管理</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in new issue
Block a user