feat(new-frontend): use shadcn-vue UI

This commit is contained in:
2026-05-04 17:52:39 +08:00
parent b242f69c8d
commit 72329baff4
37 changed files with 1204 additions and 515 deletions
+2
View File
@@ -16,9 +16,11 @@
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.2.4",
"@vueuse/core": "^14.3.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-vue-next": "^1.0.0", "lucide-vue-next": "^1.0.0",
"reka-ui": "^2.9.6",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4", "tailwindcss": "^4.2.4",
"vue": "^3.5.33" "vue": "^3.5.33"
+160 -2
View File
@@ -11,6 +11,9 @@ importers:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.2.4 specifier: ^4.2.4
version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)) version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1))
'@vueuse/core':
specifier: ^14.3.0
version: 14.3.0(vue@3.5.33(typescript@6.0.3))
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@@ -20,6 +23,9 @@ importers:
lucide-vue-next: lucide-vue-next:
specifier: ^1.0.0 specifier: ^1.0.0
version: 1.0.0(vue@3.5.33(typescript@6.0.3)) version: 1.0.0(vue@3.5.33(typescript@6.0.3))
reka-ui:
specifier: ^2.9.6
version: 2.9.6(vue@3.5.33(typescript@6.0.3))
tailwind-merge: tailwind-merge:
specifier: ^3.5.0 specifier: ^3.5.0
version: 3.5.0 version: 3.5.0
@@ -139,6 +145,18 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@floating-ui/core@1.7.5':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
'@floating-ui/dom@1.7.6':
resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@floating-ui/vue@1.1.11':
resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==}
'@humanfs/core@0.19.2': '@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
@@ -159,6 +177,12 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'} engines: {node: '>=18.18'}
'@internationalized/date@3.12.1':
resolution: {integrity: sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==}
'@internationalized/number@3.6.6':
resolution: {integrity: sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==}
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -289,6 +313,9 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.17': '@rolldown/pluginutils@1.0.0-rc.17':
resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==}
'@swc/helpers@0.5.21':
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
'@tailwindcss/node@4.2.4': '@tailwindcss/node@4.2.4':
resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==}
@@ -383,6 +410,14 @@ packages:
peerDependencies: peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8 vite: ^5.2.0 || ^6 || ^7 || ^8
'@tanstack/virtual-core@3.14.0':
resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==}
'@tanstack/vue-virtual@3.13.24':
resolution: {integrity: sha512-A0k2qF0zFSUStXSZkGXABouXr2Tw2Ztl/cVIYG9qy84uR8W7UNjAcX3DvzBS3YnDcwvLxab8v7dbmYBZ39itDA==}
peerDependencies:
vue: ^2.7.0 || ^3.0.0
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -398,6 +433,9 @@ packages:
'@types/node@25.6.0': '@types/node@25.6.0':
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@typescript-eslint/parser@8.59.1': '@typescript-eslint/parser@8.59.1':
resolution: {integrity: sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==} resolution: {integrity: sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -500,6 +538,19 @@ packages:
vue: vue:
optional: true optional: true
'@vueuse/core@14.3.0':
resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==}
peerDependencies:
vue: ^3.5.0
'@vueuse/metadata@14.3.0':
resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==}
'@vueuse/shared@14.3.0':
resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==}
peerDependencies:
vue: ^3.5.0
acorn-jsx@5.3.2: acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:
@@ -523,6 +574,10 @@ packages:
argparse@2.0.1: argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -589,6 +644,9 @@ packages:
deep-is@0.1.4: deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
detect-libc@2.1.2: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -912,6 +970,9 @@ packages:
nth-check@2.1.1: nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
optionator@0.9.4: optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -971,6 +1032,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
reka-ui@2.9.6:
resolution: {integrity: sha512-K6bL457owpvWONc7hsjFxo3HDC9s6IzhRqShW0w9JSKelPGfRbkHD558UQTn/NH1cvrXVHygKyC7fExFmRketg==}
peerDependencies:
vue: '>= 3.4.0'
resolve-from@4.0.0: resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -1099,6 +1165,17 @@ packages:
vscode-uri@3.1.0: vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-eslint-parser@10.4.0: vue-eslint-parser@10.4.0:
resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==} resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1213,6 +1290,26 @@ snapshots:
'@eslint/core': 0.17.0 '@eslint/core': 0.17.0
levn: 0.4.1 levn: 0.4.1
'@floating-ui/core@1.7.5':
dependencies:
'@floating-ui/utils': 0.2.11
'@floating-ui/dom@1.7.6':
dependencies:
'@floating-ui/core': 1.7.5
'@floating-ui/utils': 0.2.11
'@floating-ui/utils@0.2.11': {}
'@floating-ui/vue@1.1.11(vue@3.5.33(typescript@6.0.3))':
dependencies:
'@floating-ui/dom': 1.7.6
'@floating-ui/utils': 0.2.11
vue-demi: 0.14.10(vue@3.5.33(typescript@6.0.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@humanfs/core@0.19.2': '@humanfs/core@0.19.2':
dependencies: dependencies:
'@humanfs/types': 0.15.0 '@humanfs/types': 0.15.0
@@ -1229,6 +1326,14 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {} '@humanwhocodes/retry@0.4.3': {}
'@internationalized/date@3.12.1':
dependencies:
'@swc/helpers': 0.5.21
'@internationalized/number@3.6.6':
dependencies:
'@swc/helpers': 0.5.21
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -1312,6 +1417,10 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.17': {} '@rolldown/pluginutils@1.0.0-rc.17': {}
'@swc/helpers@0.5.21':
dependencies:
tslib: 2.8.1
'@tailwindcss/node@4.2.4': '@tailwindcss/node@4.2.4':
dependencies: dependencies:
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
@@ -1380,6 +1489,13 @@ snapshots:
tailwindcss: 4.2.4 tailwindcss: 4.2.4
vite: 8.0.10(@types/node@25.6.0)(jiti@2.6.1) vite: 8.0.10(@types/node@25.6.0)(jiti@2.6.1)
'@tanstack/virtual-core@3.14.0': {}
'@tanstack/vue-virtual@3.13.24(vue@3.5.33(typescript@6.0.3))':
dependencies:
'@tanstack/virtual-core': 3.14.0
vue: 3.5.33(typescript@6.0.3)
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -1395,6 +1511,8 @@ snapshots:
dependencies: dependencies:
undici-types: 7.19.2 undici-types: 7.19.2
'@types/web-bluetooth@0.0.21': {}
'@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': '@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.59.1 '@typescript-eslint/scope-manager': 8.59.1
@@ -1543,6 +1661,19 @@ snapshots:
typescript: 6.0.3 typescript: 6.0.3
vue: 3.5.33(typescript@6.0.3) vue: 3.5.33(typescript@6.0.3)
'@vueuse/core@14.3.0(vue@3.5.33(typescript@6.0.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 14.3.0
'@vueuse/shared': 14.3.0(vue@3.5.33(typescript@6.0.3))
vue: 3.5.33(typescript@6.0.3)
'@vueuse/metadata@14.3.0': {}
'@vueuse/shared@14.3.0(vue@3.5.33(typescript@6.0.3))':
dependencies:
vue: 3.5.33(typescript@6.0.3)
acorn-jsx@5.3.2(acorn@8.16.0): acorn-jsx@5.3.2(acorn@8.16.0):
dependencies: dependencies:
acorn: 8.16.0 acorn: 8.16.0
@@ -1564,6 +1695,10 @@ snapshots:
argparse@2.0.1: {} argparse@2.0.1: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
balanced-match@4.0.4: {} balanced-match@4.0.4: {}
@@ -1616,6 +1751,8 @@ snapshots:
deep-is@0.1.4: {} deep-is@0.1.4: {}
defu@6.1.7: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
enhanced-resolve@5.21.0: enhanced-resolve@5.21.0:
@@ -1900,6 +2037,8 @@ snapshots:
dependencies: dependencies:
boolbase: 1.0.0 boolbase: 1.0.0
ohash@2.0.11: {}
optionator@0.9.4: optionator@0.9.4:
dependencies: dependencies:
deep-is: 0.1.4 deep-is: 0.1.4
@@ -1952,6 +2091,22 @@ snapshots:
punycode@2.3.1: {} punycode@2.3.1: {}
reka-ui@2.9.6(vue@3.5.33(typescript@6.0.3)):
dependencies:
'@floating-ui/dom': 1.7.6
'@floating-ui/vue': 1.1.11(vue@3.5.33(typescript@6.0.3))
'@internationalized/date': 3.12.1
'@internationalized/number': 3.6.6
'@tanstack/vue-virtual': 3.13.24(vue@3.5.33(typescript@6.0.3))
'@vueuse/core': 14.3.0(vue@3.5.33(typescript@6.0.3))
'@vueuse/shared': 14.3.0(vue@3.5.33(typescript@6.0.3))
aria-hidden: 1.2.6
defu: 6.1.7
ohash: 2.0.11
vue: 3.5.33(typescript@6.0.3)
transitivePeerDependencies:
- '@vue/composition-api'
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
rolldown@1.0.0-rc.17: rolldown@1.0.0-rc.17:
@@ -2010,8 +2165,7 @@ snapshots:
dependencies: dependencies:
typescript: 6.0.3 typescript: 6.0.3
tslib@2.8.1: tslib@2.8.1: {}
optional: true
tw-animate-css@1.4.0: {} tw-animate-css@1.4.0: {}
@@ -2043,6 +2197,10 @@ snapshots:
vscode-uri@3.1.0: {} vscode-uri@3.1.0: {}
vue-demi@0.14.10(vue@3.5.33(typescript@6.0.3)):
dependencies:
vue: 3.5.33(typescript@6.0.3)
vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1)): vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1)):
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
+1 -1
View File
@@ -108,7 +108,7 @@ export function useTheme() {
modeLabel: computed(() => { modeLabel: computed(() => {
if (state.mode === 'light') return '亮色' if (state.mode === 'light') return '亮色'
if (state.mode === 'dark') return '暗色' if (state.mode === 'dark') return '暗色'
return state.resolved === 'dark' ? '跟随系统:暗色' : '跟随系统:亮色' return '设备'
}), }),
setThemeMode, setThemeMode,
cycleThemeMode, cycleThemeMode,
+78 -44
View File
@@ -13,6 +13,7 @@ import {
ScrollText, ScrollText,
Settings, Settings,
Shield, Shield,
Sun,
UserRound, UserRound,
Users, Users,
X, X,
@@ -20,7 +21,9 @@ import {
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useAuth } from '@/app/auth' import { useAuth } from '@/app/auth'
import { useRouter } from '@/app/router' import { useRouter } from '@/app/router'
import { useTheme } from '@/app/theme' import { type ThemeMode, useTheme } from '@/app/theme'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
const { state: authState, isAdmin, logout } = useAuth() const { state: authState, isAdmin, logout } = useAuth()
const router = useRouter() const router = useRouter()
@@ -47,6 +50,18 @@ const isAdminRoute = computed(() => router.state.path.startsWith('/admin'))
const approvalLabel = computed(() => (authState.user?.is_approved ? '已审批' : '待审批')) const approvalLabel = computed(() => (authState.user?.is_approved ? '已审批' : '待审批'))
const roleLabel = computed(() => (isAdmin.value ? '管理员' : '普通用户')) const roleLabel = computed(() => (isAdmin.value ? '管理员' : '普通用户'))
const themeLabel = computed(() => theme.modeLabel.value) const themeLabel = computed(() => theme.modeLabel.value)
const themeModes = [
{ mode: 'light', label: '亮色', icon: Sun },
{ mode: 'dark', label: '暗色', icon: MoonStar },
{ mode: 'system', label: '设备', icon: Monitor },
] as const
function themeModeButtonClass(mode: ThemeMode) {
if (theme.state.mode === mode) {
return 'bg-zinc-900 text-white shadow-sm hover:bg-zinc-900 hover:text-white dark:bg-zinc-100 dark:text-zinc-950 dark:hover:bg-zinc-100 dark:hover:text-zinc-950'
}
return 'text-zinc-500 hover:bg-zinc-100 hover:text-zinc-950 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50'
}
function go(path: string) { function go(path: string) {
mobileOpen.value = false mobileOpen.value = false
@@ -64,16 +79,18 @@ function signOut() {
<header <header
class="sticky top-0 z-20 border-b border-zinc-200 bg-white/95 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/90" class="sticky top-0 z-20 border-b border-zinc-200 bg-white/95 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/90"
> >
<div class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4"> <div class="flex h-14 w-full items-center justify-between px-4 sm:px-6 lg:px-8">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <Button
type="button" type="button"
class="inline-flex size-9 items-center justify-center rounded-md border border-zinc-200 bg-white text-zinc-700 shadow-sm lg:hidden dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200" variant="outline"
size="icon"
class="lg:hidden"
@click="mobileOpen = !mobileOpen" @click="mobileOpen = !mobileOpen"
> >
<X v-if="mobileOpen" class="size-4" /> <X v-if="mobileOpen" class="size-4" />
<Menu v-else class="size-4" /> <Menu v-else class="size-4" />
</button> </Button>
<button class="flex items-center gap-3 text-left" type="button" @click="go('/dashboard')"> <button class="flex items-center gap-3 text-left" type="button" @click="go('/dashboard')">
<span <span
class="hidden size-9 items-center justify-center rounded-lg bg-emerald-700 text-white shadow-sm sm:inline-flex" class="hidden size-9 items-center justify-center rounded-lg bg-emerald-700 text-white shadow-sm sm:inline-flex"
@@ -94,31 +111,44 @@ function signOut() {
{{ roleLabel }} · {{ approvalLabel }} {{ roleLabel }} · {{ approvalLabel }}
</div> </div>
</div> </div>
<button <TooltipProvider>
type="button" <div
class="inline-flex size-9 items-center justify-center rounded-md border border-zinc-200 bg-white text-zinc-700 shadow-sm transition hover:bg-zinc-50 active:translate-y-px dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800" class="inline-flex items-center rounded-lg border border-zinc-200 bg-white p-0.5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
:aria-label="`切换主题,当前${themeLabel}`" :aria-label="`主题模式,当前${themeLabel}`"
:title="`切换主题,当前${themeLabel}`" >
@click="theme.cycleThemeMode" <Tooltip v-for="item in themeModes" :key="item.mode">
> <TooltipTrigger as-child>
<MoonStar v-if="theme.state.resolved === 'dark'" class="size-4" /> <Button
<Monitor v-else class="size-4" /> type="button"
</button> variant="ghost"
<button size="icon-sm"
type="button" :class="themeModeButtonClass(item.mode)"
class="inline-flex min-h-9 items-center gap-2 rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm font-medium text-zinc-700 shadow-sm transition hover:bg-zinc-50 active:translate-y-px dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800" :aria-label="`切换为${item.label}模式`"
@click="signOut" :aria-pressed="theme.state.mode === item.mode"
> @click="theme.setThemeMode(item.mode)"
>
<component :is="item.icon" class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{{ item.label }}
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<Button type="button" variant="outline" @click="signOut">
<LogOut class="size-4" /> <LogOut class="size-4" />
<span class="hidden sm:inline">退出</span> <span class="hidden sm:inline">退出</span>
</button> </Button>
</div> </div>
</div> </div>
</header> </header>
<div class="mx-auto grid max-w-7xl grid-cols-1 lg:grid-cols-[220px_minmax(0,1fr)]"> <div
class="grid min-h-[calc(100dvh-3.5rem)] w-full grid-cols-1 lg:grid-cols-[220px_minmax(0,1fr)]"
>
<aside <aside
class="border-b border-zinc-200 bg-white px-4 py-3 shadow-sm lg:min-h-[calc(100dvh-3.5rem)] lg:border-b-0 lg:border-r lg:shadow-none dark:border-zinc-800 dark:bg-zinc-950" class="border-b border-zinc-200 bg-white px-3 py-3 shadow-sm lg:min-h-[calc(100dvh-3.5rem)] lg:border-b-0 lg:border-r lg:shadow-none dark:border-zinc-800 dark:bg-zinc-950"
:class="mobileOpen ? 'block' : 'hidden lg:block'" :class="mobileOpen ? 'block' : 'hidden lg:block'"
> >
<div <div
@@ -179,14 +209,14 @@ function signOut() {
</div> </div>
</aside> </aside>
<main class="min-w-0 px-4 py-5 sm:px-6 lg:px-8"> <main class="min-w-0 px-4 py-4 sm:px-6 lg:px-8">
<div <div
class="mb-5 flex flex-wrap items-end justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-none" class="mb-5 grid gap-3 rounded-xl border border-zinc-200 bg-white px-4 py-3 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-none"
:class="{ :class="{
'border-sky-200 bg-sky-50/70 dark:border-sky-900/70 dark:bg-sky-950/30': isAdminRoute, 'border-sky-200 bg-sky-50/70 dark:border-sky-900/70 dark:bg-sky-950/30': isAdminRoute,
}" }"
> >
<div> <div class="min-w-0">
<div <div
v-if="isAdminRoute" v-if="isAdminRoute"
class="mb-1 inline-flex items-center gap-1 rounded-full border border-sky-200 bg-white px-2 py-0.5 text-xs font-medium text-sky-700 dark:border-sky-900/70 dark:bg-sky-950/70 dark:text-sky-200" class="mb-1 inline-flex items-center gap-1 rounded-full border border-sky-200 bg-white px-2 py-0.5 text-xs font-medium text-sky-700 dark:border-sky-900/70 dark:bg-sky-950/70 dark:text-sky-200"
@@ -194,27 +224,31 @@ function signOut() {
<Shield class="size-3" /> <Shield class="size-3" />
管理员 管理员
</div> </div>
<h1 class="text-2xl font-semibold tracking-normal text-zinc-950 dark:text-zinc-50"> <h1
class="truncate text-2xl font-semibold tracking-normal text-zinc-950 dark:text-zinc-50"
>
{{ title }} {{ title }}
</h1> </h1>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
{{
isAdminRoute
? '管理用户、模板、记录、日志和系统统计。'
: '管理打卡任务、授权状态和系统记录。'
}}
</p>
</div> </div>
<div <div class="flex flex-wrap items-center gap-2">
class="inline-flex items-center gap-2 rounded-full border bg-white px-3 py-1 text-xs font-medium dark:bg-zinc-950" <div
:class=" class="inline-flex items-center gap-2 rounded-full border bg-white px-3 py-1 text-xs font-medium dark:bg-zinc-950"
authState.user?.is_approved :class="
? 'border-emerald-200 text-emerald-700 dark:border-emerald-900/70 dark:text-emerald-300' authState.user?.is_approved
: 'border-amber-200 text-amber-700 dark:border-amber-900/70 dark:text-amber-300' ? 'border-emerald-200 text-emerald-700 dark:border-emerald-900/70 dark:text-emerald-300'
" : 'border-amber-200 text-amber-700 dark:border-amber-900/70 dark:text-amber-300'
> "
<UserRound class="size-3.5" /> >
{{ approvalLabel }} <UserRound class="size-3.5" />
{{ approvalLabel }}
</div>
<div
v-if="isAdminRoute"
class="inline-flex items-center gap-2 rounded-full border border-sky-200 bg-white px-3 py-1 text-xs font-medium text-sky-700 dark:border-sky-900/70 dark:bg-zinc-950 dark:text-sky-300"
>
<Shield class="size-3.5" />
管理员工作区
</div>
</div> </div>
</div> </div>
<slot /> <slot />
@@ -15,10 +15,10 @@ defineEmits<{
<template> <template>
<div <div
class="rounded-lg border border-dashed border-zinc-200 bg-white p-6 text-center shadow-sm dark:border-zinc-800 dark:bg-zinc-900" class="grid gap-4 rounded-xl border border-dashed border-zinc-200 bg-white p-4 text-left shadow-sm sm:grid-cols-[auto_minmax(0,1fr)_auto] sm:items-center dark:border-zinc-800 dark:bg-zinc-900"
> >
<div <div
class="mx-auto mb-3 flex size-10 items-center justify-center rounded-md border border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-300" class="flex size-10 items-center justify-center rounded-lg border border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-300"
:class="{ :class="{
'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/70 dark:bg-rose-950/50 dark:text-rose-300': 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/70 dark:bg-rose-950/50 dark:text-rose-300':
type === 'error', type === 'error',
@@ -30,14 +30,16 @@ defineEmits<{
<AlertCircle v-else-if="type === 'error'" class="size-5" /> <AlertCircle v-else-if="type === 'error'" class="size-5" />
<Search v-else class="size-5" /> <Search v-else class="size-5" />
</div> </div>
<div class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ title }}</div> <div class="min-w-0">
<p v-if="description" class="mx-auto mt-1 max-w-md text-sm text-zinc-500 dark:text-zinc-400"> <div class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ title }}</div>
{{ description }} <p v-if="description" class="mt-1 text-sm leading-5 text-zinc-500 dark:text-zinc-400">
</p> {{ description }}
</p>
</div>
<button <button
v-if="actionLabel" v-if="actionLabel"
type="button" type="button"
class="mt-4 inline-flex min-h-9 items-center rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm font-medium text-zinc-700 shadow-sm transition hover:bg-zinc-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700/20 active:translate-y-px dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800" class="inline-flex min-h-9 items-center justify-center rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm font-medium text-zinc-700 shadow-sm transition hover:bg-zinc-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700/20 active:translate-y-px sm:justify-self-end dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
@click="$emit('action')" @click="$emit('action')"
> >
{{ actionLabel }} {{ actionLabel }}
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Braces, Eye, Plus, TreePine } from 'lucide-vue-next' import { Braces, Plus, TreePine } from 'lucide-vue-next'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import TemplateFieldNode from './TemplateFieldNode.vue' import TemplateFieldNode from './TemplateFieldNode.vue'
import { import {
@@ -124,10 +124,10 @@ function handleNodeError(message: string) {
<template> <template>
<div class="grid gap-4"> <div class="grid gap-4">
<div <div
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-800 dark:bg-zinc-950" class="rounded-lg border border-zinc-200 bg-zinc-50 p-2 dark:border-zinc-800 dark:bg-zinc-950"
> >
<div <div
class="inline-grid grid-cols-2 rounded-md border border-zinc-200 bg-white p-1 text-sm dark:border-zinc-700 dark:bg-zinc-900" class="grid grid-cols-2 rounded-md border border-zinc-200 bg-white p-1 text-sm dark:border-zinc-700 dark:bg-zinc-900"
> >
<button <button
type="button" type="button"
@@ -156,10 +156,6 @@ function handleNodeError(message: string) {
JSON JSON
</button> </button>
</div> </div>
<div class="flex items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400">
<Eye class="size-3.5" />
预览使用当前有效配置
</div>
</div> </div>
<div v-if="error" :class="alertClass.danger">{{ error }}</div> <div v-if="error" :class="alertClass.danger">{{ error }}</div>
@@ -208,9 +204,9 @@ function handleNodeError(message: string) {
</div> </div>
<div <div
v-else v-else
class="rounded-lg border border-dashed border-zinc-200 bg-zinc-50 p-6 text-center text-sm text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400" class="rounded-lg border border-dashed border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400"
> >
暂无字段配置先添加根字段 暂无字段配置
</div> </div>
</div> </div>
@@ -343,37 +343,42 @@ function removeOption(index: number) {
</label> </label>
</div> </div>
<label class="grid gap-1.5"> <details
<span :class="labelClass">占位提示</span> class="rounded-md border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-800 dark:bg-zinc-950"
<input
:class="inputClass"
:value="fieldNode.placeholder ?? ''"
placeholder="用户填写时看到的提示"
@input="updateField('placeholder', ($event.target as HTMLInputElement).value)"
/>
</label>
<div
class="grid gap-2 rounded-md border border-zinc-200 bg-zinc-50 p-3 text-sm sm:grid-cols-2 dark:border-zinc-800 dark:bg-zinc-950"
> >
<label class="flex items-center gap-2"> <summary class="cursor-pointer text-sm font-medium">更多字段属性</summary>
<input <div class="mt-3 grid gap-3">
:checked="Boolean(fieldNode.required)" <label class="grid gap-1.5">
:disabled="Boolean(fieldNode.hidden)" <span :class="labelClass">占位提示</span>
type="checkbox" <input
@change="updateField('required', ($event.target as HTMLInputElement).checked)" :class="inputClass"
/> :value="fieldNode.placeholder ?? ''"
必填 placeholder="表单占位文字"
</label> @input="updateField('placeholder', ($event.target as HTMLInputElement).value)"
<label class="flex items-center gap-2"> />
<input </label>
:checked="Boolean(fieldNode.hidden)"
type="checkbox" <div class="grid gap-2 text-sm sm:grid-cols-2">
@change="updateField('hidden', ($event.target as HTMLInputElement).checked)" <label class="flex items-center gap-2">
/> <input
隐藏 :checked="Boolean(fieldNode.required)"
</label> :disabled="Boolean(fieldNode.hidden)"
</div> type="checkbox"
@change="updateField('required', ($event.target as HTMLInputElement).checked)"
/>
必填
</label>
<label class="flex items-center gap-2">
<input
:checked="Boolean(fieldNode.hidden)"
type="checkbox"
@change="updateField('hidden', ($event.target as HTMLInputElement).checked)"
/>
隐藏
</label>
</div>
</div>
</details>
<div v-if="fieldNode.field_type === 'select'" class="grid gap-2"> <div v-if="fieldNode.field_type === 'select'" class="grid gap-2">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
@@ -435,7 +440,7 @@ function removeOption(index: number) {
</div> </div>
<div <div
v-else v-else
class="rounded-md border border-dashed border-zinc-200 bg-zinc-50 p-4 text-center text-sm text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400" class="rounded-md border border-dashed border-zinc-200 bg-zinc-50 p-3 text-sm text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400"
> >
当前{{ kindBadge }}为空 当前{{ kindBadge }}为空
</div> </div>
+9 -9
View File
@@ -1,7 +1,7 @@
export type Tone = 'neutral' | 'success' | 'warning' | 'danger' | 'info' export type Tone = 'neutral' | 'success' | 'warning' | 'danger' | 'info'
export const buttonBase = export const buttonBase =
'inline-flex min-h-9 items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm font-medium shadow-sm transition duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700/20 active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-emerald-400/30' 'inline-flex min-h-9 items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium shadow-sm transition duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700/20 active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-emerald-400/30'
export const buttonTone = { export const buttonTone = {
primary: primary:
@@ -17,18 +17,18 @@ export const buttonTone = {
} }
export const inputClass = export const inputClass =
'w-full min-h-9 rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none transition placeholder:text-zinc-400 focus:border-emerald-700 focus:ring-2 focus:ring-emerald-700/10 disabled:cursor-not-allowed disabled:bg-zinc-100 disabled:text-zinc-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus:border-emerald-500 dark:focus:ring-emerald-400/10 dark:disabled:bg-zinc-900 dark:disabled:text-zinc-500' 'w-full min-h-9 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none transition placeholder:text-zinc-400 focus:border-emerald-700 focus:ring-2 focus:ring-emerald-700/10 disabled:cursor-not-allowed disabled:bg-zinc-100 disabled:text-zinc-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus:border-emerald-500 dark:focus:ring-emerald-400/10 dark:disabled:bg-zinc-900 dark:disabled:text-zinc-500'
export const textareaClass = `${inputClass} min-h-24 resize-y font-mono text-xs leading-5` export const textareaClass = `${inputClass} min-h-24 resize-y font-mono text-xs leading-5`
export const cardClass = export const cardClass =
'rounded-lg border border-zinc-200/80 bg-white shadow-[0_14px_32px_-28px_rgba(24,24,27,0.45)] dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-none' 'rounded-xl border border-zinc-200/80 bg-white shadow-[0_12px_28px_-24px_rgba(24,24,27,0.42)] dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-none'
export const sectionHeaderClass = export const sectionHeaderClass =
'flex flex-wrap items-start justify-between gap-3 border-b border-zinc-200 bg-zinc-50/70 px-4 py-3 dark:border-zinc-800 dark:bg-zinc-950/50' 'grid gap-2 border-b border-zinc-200 bg-zinc-50/65 px-4 py-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center dark:border-zinc-800 dark:bg-zinc-950/50'
export const actionBarClass = export const actionBarClass =
'flex flex-wrap items-center gap-2 border-b border-zinc-200 bg-zinc-50/70 p-4 dark:border-zinc-800 dark:bg-zinc-950/50' 'grid gap-2 border-b border-zinc-200 bg-zinc-50/65 px-4 py-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center dark:border-zinc-800 dark:bg-zinc-950/50'
export const labelClass = export const labelClass =
'text-xs font-semibold uppercase tracking-normal text-zinc-500 dark:text-zinc-400' 'text-xs font-semibold uppercase tracking-normal text-zinc-500 dark:text-zinc-400'
@@ -36,13 +36,13 @@ export const labelClass =
export const mutedText = 'text-sm text-zinc-500 dark:text-zinc-400' export const mutedText = 'text-sm text-zinc-500 dark:text-zinc-400'
export const alertClass = { export const alertClass = {
info: 'rounded-md border border-sky-200 bg-sky-50 px-3 py-2 text-sm text-sky-800 dark:border-sky-900/70 dark:bg-sky-950/50 dark:text-sky-200', info: 'rounded-lg border border-sky-200 bg-sky-50 px-3 py-2 text-sm leading-5 text-sky-800 dark:border-sky-900/70 dark:bg-sky-950/50 dark:text-sky-200',
success: success:
'rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-900/70 dark:bg-emerald-950/50 dark:text-emerald-200', 'rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm leading-5 text-emerald-800 dark:border-emerald-900/70 dark:bg-emerald-950/50 dark:text-emerald-200',
warning: warning:
'rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/70 dark:bg-amber-950/50 dark:text-amber-200', 'rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-5 text-amber-800 dark:border-amber-900/70 dark:bg-amber-950/50 dark:text-amber-200',
danger: danger:
'rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-800 dark:border-rose-900/70 dark:bg-rose-950/50 dark:text-rose-200', 'rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm leading-5 text-rose-800 dark:border-rose-900/70 dark:bg-rose-950/50 dark:text-rose-200',
} }
export function toneClass(tone: Tone) { export function toneClass(tone: Tone) {
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
variant: 'default',
size: 'default',
class: undefined,
})
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,35 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot v-slot="slotProps" data-slot="dialog" v-bind="forwarded">
<slot v-bind="slotProps" />
</DialogRoot>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { DialogCloseProps } from 'reka-ui'
import { DialogClose } from 'reka-ui'
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose data-slot="dialog-close" v-bind="props">
<slot />
</DialogClose>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { X } from 'lucide-vue-next'
import { DialogClose, DialogContent, DialogPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
import DialogOverlay from './DialogOverlay.vue'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<
DialogContentProps & { class?: HTMLAttributes['class']; showCloseButton?: boolean }
>(),
{
class: undefined,
showCloseButton: true,
},
)
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)
"
>
<slot />
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<X />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogDescription, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
data-slot="dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</DialogDescription>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { DialogClose } from 'reka-ui'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
const props = withDefaults(
defineProps<{
class?: HTMLAttributes['class']
showCloseButton?: boolean
}>(),
{
class: undefined,
showCloseButton: false,
},
)
</script>
<template>
<div
data-slot="dialog-footer"
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
>
<slot />
<DialogClose v-if="showCloseButton" as-child>
<Button variant="outline"> Close </Button>
</DialogClose>
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { DialogOverlayProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogOverlay } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="
cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
props.class,
)
"
>
<slot />
</DialogOverlay>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { X } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="
(event) => {
const originalEvent = event.detail.originalEvent
const target = originalEvent.target as HTMLElement
if (
originalEvent.offsetX > target.clientWidth ||
originalEvent.offsetY > target.clientHeight
) {
event.preventDefault()
}
}
"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogTitleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogTitle, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
data-slot="dialog-title"
v-bind="forwardedProps"
:class="cn('text-lg leading-none font-semibold', props.class)"
>
<slot />
</DialogTitle>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { DialogTriggerProps } from 'reka-ui'
import { DialogTrigger } from 'reka-ui'
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger data-slot="dialog-trigger" v-bind="props">
<slot />
</DialogTrigger>
</template>
@@ -0,0 +1,10 @@
export { default as Dialog } from './Dialog.vue'
export { default as DialogClose } from './DialogClose.vue'
export { default as DialogContent } from './DialogContent.vue'
export { default as DialogDescription } from './DialogDescription.vue'
export { default as DialogFooter } from './DialogFooter.vue'
export { default as DialogHeader } from './DialogHeader.vue'
export { default as DialogOverlay } from './DialogOverlay.vue'
export { default as DialogScrollContent } from './DialogScrollContent.vue'
export { default as DialogTitle } from './DialogTitle.vue'
export { default as DialogTrigger } from './DialogTrigger.vue'
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui'
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<TooltipRootProps>()
const emits = defineEmits<TooltipRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<TooltipRoot v-slot="slotProps" data-slot="tooltip" v-bind="forwarded">
<slot v-bind="slotProps" />
</TooltipRoot>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(),
{
class: undefined,
sideOffset: 4,
},
)
const emits = defineEmits<TooltipContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<TooltipPortal>
<TooltipContent
data-slot="tooltip-content"
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance',
props.class,
)
"
>
<slot />
<TooltipArrow
class="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]"
/>
</TooltipContent>
</TooltipPortal>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { TooltipProviderProps } from 'reka-ui'
import { TooltipProvider } from 'reka-ui'
const props = withDefaults(defineProps<TooltipProviderProps>(), {
delayDuration: 0,
})
</script>
<template>
<TooltipProvider v-bind="props">
<slot />
</TooltipProvider>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { TooltipTriggerProps } from 'reka-ui'
import { TooltipTrigger } from 'reka-ui'
const props = defineProps<TooltipTriggerProps>()
</script>
<template>
<TooltipTrigger data-slot="tooltip-trigger" v-bind="props">
<slot />
</TooltipTrigger>
</template>
@@ -0,0 +1,4 @@
export { default as Tooltip } from './Tooltip.vue'
export { default as TooltipContent } from './TooltipContent.vue'
export { default as TooltipProvider } from './TooltipProvider.vue'
export { default as TooltipTrigger } from './TooltipTrigger.vue'
+173 -162
View File
@@ -56,6 +56,7 @@ const activeTasks = computed(() => tasks.value.filter((task) => task.is_active).
const inactiveTasks = computed(() => Math.max(0, tasks.value.length - activeTasks.value)) const inactiveTasks = computed(() => Math.max(0, tasks.value.length - activeTasks.value))
const selectedTask = computed(() => tasks.value.find((task) => task.id === selectedTaskId.value)) const selectedTask = computed(() => tasks.value.find((task) => task.id === selectedTaskId.value))
const lastRecord = computed(() => records.value[0] ?? null) const lastRecord = computed(() => records.value[0] ?? null)
const nextActiveTask = computed(() => tasks.value.find((task) => task.is_active) ?? null)
const successToday = computed( const successToday = computed(
() => records.value.filter((record) => record.status === 'success').length, () => records.value.filter((record) => record.status === 'success').length,
) )
@@ -150,180 +151,179 @@ onMounted(load)
@action="load" @action="load"
/> />
<div v-else class="grid gap-5"> <div v-else class="grid gap-5">
<div class="grid gap-3"> <div
<div v-if="needsEmail" :class="alertClass.info"> v-if="
您还未设置邮箱地址设置后可以接收打卡任务通知 needsEmail || needsPassword || (tokenStatus && !tokenStatus.is_valid) || tasks.length === 0
"
class="grid gap-2"
>
<div
v-if="needsEmail"
:class="[alertClass.info, 'flex flex-wrap items-center justify-between gap-2']"
>
<span>未设置邮箱</span>
<button <button
class="ml-2 font-semibold hover:text-sky-950" class="font-semibold hover:text-sky-950 dark:hover:text-sky-100"
type="button" type="button"
@click="router.navigate('/settings')" @click="router.navigate('/settings')"
> >
立即前往设置 设置
</button> </button>
</div> </div>
<div v-if="needsPassword" :class="alertClass.info"> <div
您还未设置登录密码设置后可以使用用户名和密码快速登录 v-if="needsPassword"
:class="[alertClass.info, 'flex flex-wrap items-center justify-between gap-2']"
>
<span>未设置登录密码</span>
<button <button
class="ml-2 font-semibold hover:text-sky-950" class="font-semibold hover:text-sky-950 dark:hover:text-sky-100"
type="button" type="button"
@click="router.navigate('/settings')" @click="router.navigate('/settings')"
> >
立即前往设置 设置
</button> </button>
</div> </div>
<div <div
v-if="tokenStatus && !tokenStatus.is_valid" v-if="tokenStatus && !tokenStatus.is_valid"
:class="[alertClass.warning, 'flex items-start gap-2']" :class="[alertClass.warning, 'flex flex-wrap items-center justify-between gap-2']"
> >
<AlertTriangle class="mt-0.5 size-4 shrink-0" /> <span class="inline-flex items-center gap-2">
<div> <AlertTriangle class="size-4 shrink-0" />
打卡凭证已过期无法自动打卡请回到登录页使用扫码登录刷新 Token 打卡凭证已过期
<button </span>
class="ml-2 font-semibold hover:text-amber-950"
type="button"
@click="router.navigate('/login')"
>
立即刷新
</button>
</div>
</div>
<div v-if="tasks.length === 0" :class="alertClass.info">
您还没有打卡任务
<button <button
class="ml-2 font-semibold hover:text-sky-950" class="font-semibold hover:text-amber-950 dark:hover:text-amber-100"
type="button"
@click="router.navigate('/login')"
>
刷新
</button>
</div>
<div
v-if="tasks.length === 0"
:class="[alertClass.info, 'flex flex-wrap items-center justify-between gap-2']"
>
<span>暂无打卡任务</span>
<button
class="font-semibold hover:text-sky-950 dark:hover:text-sky-100"
type="button" type="button"
@click="router.navigate('/tasks')" @click="router.navigate('/tasks')"
> >
立即创建 创建
</button> </button>
</div> </div>
</div> </div>
<section :class="[cardClass, 'overflow-hidden']"> <section class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
<div :class="sectionHeaderClass"> <div :class="[cardClass, 'overflow-hidden']">
<div class="flex items-center gap-2"> <div :class="sectionHeaderClass">
<KeyRound class="size-4 text-emerald-700" /> <div class="flex items-center gap-2">
<h2 class="font-semibold">Token 状态</h2> <CalendarDays class="size-4 text-emerald-700" />
<h2 class="font-semibold">手动打卡</h2>
</div>
<span class="text-sm text-zinc-500">{{ activeTasks }} 个启用</span>
</div>
<div class="p-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<select v-model.number="selectedTaskId" :class="[inputClass, 'sm:max-w-md']">
<option v-for="task in tasks" :key="task.id" :value="task.id">
{{ task.name || `任务 #${task.id}` }} · {{ task.is_active ? '启用' : '停用' }}
</option>
</select>
<button
:class="[buttonBase, buttonTone.primary]"
:disabled="!selectedTaskId || checkInLoading"
type="button"
@click="manualCheckIn"
>
<CalendarDays class="size-4" />
{{ checkInLoading ? '打卡中' : '立即打卡' }}
</button>
</div>
<div
v-if="selectedTask"
class="mt-4 rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm dark:border-zinc-800 dark:bg-zinc-950"
>
<div class="font-medium text-zinc-900 dark:text-zinc-100">
{{ selectedTask.name || `任务 #${selectedTask.id}` }}
</div>
<div class="mt-1 text-zinc-500 dark:text-zinc-400">
ThreadId: {{ selectedTask.thread_id || '未解析' }} ·
{{ cronLabel(selectedTask.cron_expression) }}
</div>
</div>
<div v-if="message" :class="[alertClass.success, 'mt-4']">{{ message }}</div>
<div v-if="error" :class="[alertClass.danger, 'mt-4']">{{ error }}</div>
<div
v-if="latestStatus"
class="mt-4 rounded-lg border border-zinc-200 bg-white p-4 text-sm dark:border-zinc-800 dark:bg-zinc-950"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-semibold text-zinc-900 dark:text-zinc-100">本次打卡</span>
<span :class="toneClass(statusTone(latestStatus.status))">{{
statusLabel(latestStatus.status)
}}</span>
</div>
<p class="mt-2 text-zinc-500 dark:text-zinc-400">
{{
latestStatus.response_text || latestStatus.error_message || '正在等待后端返回结果。'
}}
</p>
</div>
<div
v-else-if="lastRecord"
class="mt-4 rounded-lg border border-zinc-200 bg-white p-4 text-sm dark:border-zinc-800 dark:bg-zinc-950"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-semibold text-zinc-900 dark:text-zinc-100">上次打卡</span>
<span :class="toneClass(statusTone(lastRecord.status))">{{
statusLabel(lastRecord.status)
}}</span>
</div>
<p class="mt-2 text-zinc-500 dark:text-zinc-400">
{{ formatDateTime(lastRecord.check_in_time) }} ·
{{ lastRecord.response_text || lastRecord.error_message || '无响应内容' }}
</p>
</div>
</div> </div>
<span :class="toneClass(tokenTone)">{{ tokenLabel }}</span>
</div> </div>
<div class="grid gap-4 p-5 md:grid-cols-2">
<div class="grid gap-3 text-sm"> <div :class="[cardClass, 'overflow-hidden']">
<div <div :class="sectionHeaderClass">
class="flex items-center justify-between rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2" <div class="flex items-center gap-2">
> <KeyRound class="size-4 text-emerald-700" />
<span class="text-zinc-500">Token 状态</span> <h2 class="font-semibold">授权</h2>
<span :class="toneClass(tokenTone)">{{ tokenLabel }}</span>
</div> </div>
<div <span :class="toneClass(tokenTone)">{{ tokenLabel }}</span>
class="flex items-center justify-between rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2" </div>
> <div class="grid gap-3 p-4 text-sm">
<span class="text-zinc-500">剩余时间</span> <div class="flex items-center justify-between">
<span>{{ <span class="text-zinc-500">剩余</span>
tokenStatus?.days_until_expiry == null <span class="font-medium">
? '未知' {{
: `${tokenStatus.days_until_expiry}` tokenStatus?.days_until_expiry == null
}}</span> ? '未知'
: `${tokenStatus.days_until_expiry} 天`
}}
</span>
</div> </div>
<div <div class="flex items-center justify-between">
class="flex items-center justify-between rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2" <span class="text-zinc-500">预警</span>
>
<span class="text-zinc-500">即将过期</span>
<span>{{ tokenStatus?.expiring_soon ? '是' : '否' }}</span> <span>{{ tokenStatus?.expiring_soon ? '是' : '否' }}</span>
</div> </div>
</div> <div class="text-sm text-zinc-500 dark:text-zinc-400">{{ tokenDetail }}</div>
<div class="rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-600">
<p>{{ tokenDetail }}</p>
<button <button
:class="[ :class="[buttonBase, tokenStatus?.is_valid ? buttonTone.secondary : buttonTone.primary]"
buttonBase,
tokenStatus?.is_valid ? buttonTone.secondary : buttonTone.primary,
'mt-4',
]"
type="button" type="button"
@click="router.navigate('/login')" @click="router.navigate('/login')"
> >
<QrCode class="size-4" /> <QrCode class="size-4" />
扫码刷新 Token 扫码刷新
</button> </button>
</div> </div>
</div> </div>
</section> </section>
<section :class="[cardClass, 'overflow-hidden']">
<div :class="sectionHeaderClass">
<div class="flex items-center gap-2">
<CalendarDays class="size-4 text-emerald-700" />
<h2 class="font-semibold">手动打卡</h2>
</div>
<span class="text-sm text-zinc-500">{{ activeTasks }} 个启用任务</span>
</div>
<div class="p-5">
<p class="text-sm text-zinc-500">选择任务并点击下方按钮立即执行打卡操作</p>
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
<select v-model.number="selectedTaskId" :class="[inputClass, 'sm:max-w-md']">
<option v-for="task in tasks" :key="task.id" :value="task.id">
{{ task.name || `任务 #${task.id}` }} · {{ task.is_active ? '启用' : '停用' }}
</option>
</select>
<button
:class="[buttonBase, buttonTone.primary]"
:disabled="!selectedTaskId || checkInLoading"
type="button"
@click="manualCheckIn"
>
<CalendarDays class="size-4" />
{{ checkInLoading ? '打卡中' : '立即打卡' }}
</button>
</div>
<div
v-if="selectedTask"
class="mt-4 rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm"
>
<div class="font-medium text-zinc-900">
{{ selectedTask.name || `任务 #${selectedTask.id}` }}
</div>
<div class="mt-1 text-zinc-500">
ThreadId: {{ selectedTask.thread_id || '未解析' }} ·
{{ cronLabel(selectedTask.cron_expression) }}
</div>
</div>
<div v-if="message" :class="[alertClass.success, 'mt-4']">{{ message }}</div>
<div v-if="error" :class="[alertClass.danger, 'mt-4']">{{ error }}</div>
<div
v-if="latestStatus"
class="mt-4 rounded-lg border border-zinc-200 bg-white p-4 text-sm"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-semibold text-zinc-900">本次打卡</span>
<span :class="toneClass(statusTone(latestStatus.status))">{{
statusLabel(latestStatus.status)
}}</span>
</div>
<p class="mt-2 text-zinc-500">
{{
latestStatus.response_text || latestStatus.error_message || '正在等待后端返回结果。'
}}
</p>
</div>
<div
v-else-if="lastRecord"
class="mt-4 rounded-lg border border-zinc-200 bg-white p-4 text-sm"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-semibold text-zinc-900">上次打卡</span>
<span :class="toneClass(statusTone(lastRecord.status))">{{
statusLabel(lastRecord.status)
}}</span>
</div>
<p class="mt-2 text-zinc-500">
{{ formatDateTime(lastRecord.check_in_time) }} ·
{{ lastRecord.response_text || lastRecord.error_message || '无响应内容' }}
</p>
</div>
</div>
</section>
<section :class="[cardClass, 'overflow-hidden']"> <section :class="[cardClass, 'overflow-hidden']">
<div :class="sectionHeaderClass"> <div :class="sectionHeaderClass">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -338,12 +338,18 @@ onMounted(load)
个人设置 个人设置
</button> </button>
</div> </div>
<div class="grid gap-3 p-5 text-sm md:grid-cols-2"> <div class="grid gap-3 p-4 text-sm md:grid-cols-4">
<div class="rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2"> <div
class="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-950"
>
<div class="text-zinc-500">用户名</div> <div class="text-zinc-500">用户名</div>
<div class="mt-1 font-medium text-zinc-900">{{ auth.state.user?.alias || '未登录' }}</div> <div class="mt-1 font-medium text-zinc-900 dark:text-zinc-100">
{{ auth.state.user?.alias || '未登录' }}
</div>
</div> </div>
<div class="rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2"> <div
class="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-950"
>
<div class="text-zinc-500">角色</div> <div class="text-zinc-500">角色</div>
<div class="mt-1"> <div class="mt-1">
<span :class="toneClass(auth.state.user?.role === 'admin' ? 'danger' : 'info')"> <span :class="toneClass(auth.state.user?.role === 'admin' ? 'danger' : 'info')">
@@ -351,13 +357,19 @@ onMounted(load)
</span> </span>
</div> </div>
</div> </div>
<div class="rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2"> <div
class="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-950"
>
<div class="text-zinc-500">邮箱</div> <div class="text-zinc-500">邮箱</div>
<div class="mt-1 font-medium text-zinc-900">{{ auth.state.user?.email || '未设置' }}</div> <div class="mt-1 font-medium text-zinc-900 dark:text-zinc-100">
{{ auth.state.user?.email || '未设置' }}
</div>
</div> </div>
<div class="rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2"> <div
class="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-950"
>
<div class="text-zinc-500">注册时间</div> <div class="text-zinc-500">注册时间</div>
<div class="mt-1 font-medium text-zinc-900"> <div class="mt-1 font-medium text-zinc-900 dark:text-zinc-100">
{{ formatDateTime(auth.state.user?.created_at) }} {{ formatDateTime(auth.state.user?.created_at) }}
</div> </div>
</div> </div>
@@ -368,10 +380,6 @@ onMounted(load)
<div :class="sectionHeaderClass"> <div :class="sectionHeaderClass">
<div> <div>
<h2 class="font-semibold">任务概览</h2> <h2 class="font-semibold">任务概览</h2>
<p class="mt-1 text-sm text-zinc-500">
{{ activeTasks }} 个启用{{ inactiveTasks }} 个停用最近记录成功
{{ successToday }}
</p>
</div> </div>
<button <button
:class="[buttonBase, buttonTone.secondary]" :class="[buttonBase, buttonTone.secondary]"
@@ -381,32 +389,40 @@ onMounted(load)
管理任务 管理任务
</button> </button>
</div> </div>
<div class="grid gap-4 p-4 md:grid-cols-2 xl:grid-cols-4"> <div class="grid gap-3 p-4 md:grid-cols-2 xl:grid-cols-4">
<div :class="[cardClass, 'p-4']"> <div
class="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-800 dark:bg-zinc-950"
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-zinc-500">任务总数</span> <span class="text-sm text-zinc-500">任务总数</span>
<CheckCircle2 class="size-4 text-emerald-600" /> <CheckCircle2 class="size-4 text-emerald-600" />
</div> </div>
<div class="mt-3 text-3xl font-semibold">{{ tasks.length }}</div> <div class="mt-3 text-3xl font-semibold">{{ tasks.length }}</div>
<p class="mt-1 text-sm text-zinc-500">{{ activeTasks }} 个启用</p> <p class="mt-1 text-sm text-zinc-500">
{{ activeTasks }} 启用 · {{ inactiveTasks }} 停用
</p>
</div> </div>
<div :class="[cardClass, 'p-4']"> <div
class="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-800 dark:bg-zinc-950"
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-zinc-500">最近成功</span> <span class="text-sm text-zinc-500">最近成功</span>
<Activity class="size-4 text-zinc-700" /> <Activity class="size-4 text-zinc-700" />
</div> </div>
<div class="mt-3 text-3xl font-semibold">{{ successToday }}</div> <div class="mt-3 text-3xl font-semibold">{{ successToday }}</div>
<p class="mt-1 text-sm text-zinc-500">最近记录中的成功数</p> <p class="mt-1 text-sm text-zinc-500">最近记录</p>
</div> </div>
<div :class="[cardClass, 'p-4 md:col-span-2']"> <div
class="rounded-lg border border-zinc-200 bg-zinc-50 p-3 md:col-span-2 dark:border-zinc-800 dark:bg-zinc-950"
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-zinc-500">下次定时</span> <span class="text-sm text-zinc-500">下次定时</span>
<Clock class="size-4 text-amber-600" /> <Clock class="size-4 text-amber-600" />
</div> </div>
<div class="mt-3 text-lg font-semibold"> <div class="mt-3 text-lg font-semibold">
{{ cronLabel(tasks.find((task) => task.is_active)?.cron_expression) }} {{ cronLabel(nextActiveTask?.cron_expression) }}
</div> </div>
<p class="mt-1 text-sm text-zinc-500">来自首个启用任务</p> <p class="mt-1 text-sm text-zinc-500">{{ nextActiveTask?.name || '无启用任务' }}</p>
</div> </div>
</div> </div>
</section> </section>
@@ -415,7 +431,6 @@ onMounted(load)
<div :class="sectionHeaderClass"> <div :class="sectionHeaderClass">
<div> <div>
<h2 class="font-semibold">最近记录</h2> <h2 class="font-semibold">最近记录</h2>
<p class="mt-1 text-sm text-zinc-500">最近的打卡结果和状态变化会先出现在这里</p>
</div> </div>
<button <button
:class="[buttonBase, buttonTone.secondary]" :class="[buttonBase, buttonTone.secondary]"
@@ -425,12 +440,8 @@ onMounted(load)
查看全部 查看全部
</button> </button>
</div> </div>
<StateBlock <StateBlock v-if="records.length === 0" title="暂无记录" />
v-if="records.length === 0" <div v-else class="divide-y divide-zinc-200 dark:divide-zinc-800">
title="暂无记录"
description="手动或定时打卡后会生成记录。"
/>
<div v-else class="divide-y divide-zinc-200">
<div v-for="record in records" :key="record.id" class="px-4 py-3"> <div v-for="record in records" :key="record.id" class="px-4 py-3">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<span class="font-medium">{{ record.task_name || `任务 #${record.task_id}` }}</span> <span class="font-medium">{{ record.task_name || `任务 #${record.task_id}` }}</span>
+9 -40
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Info, KeyRound, QrCode, RotateCw, UserRound } from 'lucide-vue-next' import { KeyRound, QrCode, RotateCw, UserRound } from 'lucide-vue-next'
import { computed, onBeforeUnmount, ref } from 'vue' import { computed, onBeforeUnmount, ref } from 'vue'
import { authApi } from '@/api' import { authApi } from '@/api'
import { useAuth } from '@/app/auth' import { useAuth } from '@/app/auth'
@@ -20,9 +20,6 @@ const qrSessionId = ref('')
const loginMode = ref<'qrcode' | 'password'>('qrcode') const loginMode = ref<'qrcode' | 'password'>('qrcode')
let pollTimer: number | undefined let pollTimer: number | undefined
const currentSubtitle = computed(() =>
loginMode.value === 'qrcode' ? 'QQ 扫码登录/注册' : '用户名密码登录',
)
const canSubmitPassword = computed( const canSubmitPassword = computed(
() => Boolean(alias.value.trim()) && Boolean(password.value) && !loading.value, () => Boolean(alias.value.trim()) && Boolean(password.value) && !loading.value,
) )
@@ -119,25 +116,24 @@ onBeforeUnmount(() => {
> >
<section class="w-full max-w-md"> <section class="w-full max-w-md">
<div :class="[cardClass, 'overflow-hidden']"> <div :class="[cardClass, 'overflow-hidden']">
<div class="border-b border-zinc-200 px-6 py-5 text-center dark:border-zinc-800"> <div class="border-b border-zinc-200 px-4 py-3 text-center dark:border-zinc-800">
<div <div
class="mx-auto mb-3 flex size-11 items-center justify-center rounded-lg bg-emerald-700 text-white shadow-sm" class="mx-auto mb-3 flex size-10 items-center justify-center rounded-lg bg-emerald-700 text-white shadow-sm"
> >
<QrCode class="size-5" /> <QrCode class="size-5" />
</div> </div>
<h1 class="text-xl font-semibold tracking-normal text-zinc-950 dark:text-zinc-50"> <h1 class="text-xl font-semibold tracking-normal text-zinc-950 dark:text-zinc-50">
接龙自动打卡系统 接龙自动打卡系统
</h1> </h1>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">{{ currentSubtitle }}</p>
</div> </div>
<div class="p-6"> <div class="p-4">
<div <div
class="grid grid-cols-2 rounded-md border border-zinc-200 bg-zinc-50 p-1 text-sm dark:border-zinc-700 dark:bg-zinc-950" class="grid grid-cols-2 rounded-lg border border-zinc-200 bg-zinc-50 p-1 text-sm dark:border-zinc-700 dark:bg-zinc-950"
> >
<button <button
type="button" type="button"
class="rounded px-3 py-2 text-center font-medium transition" class="rounded-md px-3 py-2 text-center font-medium transition"
:class=" :class="
loginMode === 'qrcode' loginMode === 'qrcode'
? 'bg-white text-zinc-900 shadow-sm dark:bg-zinc-800 dark:text-zinc-50' ? 'bg-white text-zinc-900 shadow-sm dark:bg-zinc-800 dark:text-zinc-50'
@@ -149,7 +145,7 @@ onBeforeUnmount(() => {
</button> </button>
<button <button
type="button" type="button"
class="rounded px-3 py-2 text-center font-medium transition" class="rounded-md px-3 py-2 text-center font-medium transition"
:class=" :class="
loginMode === 'password' loginMode === 'password'
? 'bg-white text-zinc-900 shadow-sm dark:bg-zinc-800 dark:text-zinc-50' ? 'bg-white text-zinc-900 shadow-sm dark:bg-zinc-800 dark:text-zinc-50'
@@ -163,7 +159,7 @@ onBeforeUnmount(() => {
<form <form
v-if="loginMode === 'password'" v-if="loginMode === 'password'"
class="mt-6 grid gap-4" class="mt-5 grid gap-4"
@submit.prevent="loginWithPassword" @submit.prevent="loginWithPassword"
> >
<label class="grid gap-2"> <label class="grid gap-2">
@@ -212,16 +208,9 @@ onBeforeUnmount(() => {
<KeyRound class="size-4" /> <KeyRound class="size-4" />
{{ loading ? '登录中' : '登录' }} {{ loading ? '登录中' : '登录' }}
</button> </button>
<button
class="text-center text-sm font-medium text-zinc-600 transition hover:text-zinc-950 dark:text-zinc-300 dark:hover:text-zinc-50"
type="button"
@click="switchMode('qrcode')"
>
没有密码使用扫码登录
</button>
</form> </form>
<div v-else class="mt-6 grid gap-4"> <div v-else class="mt-5 grid gap-4">
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">用户名</span> <span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">用户名</span>
<div class="relative"> <div class="relative">
@@ -275,26 +264,6 @@ onBeforeUnmount(() => {
</button> </button>
</div> </div>
</div> </div>
<div :class="[alertClass.info, 'mt-5 flex items-start gap-2']">
<Info class="mt-0.5 size-4 shrink-0" />
<div>
<div class="font-semibold">
{{ loginMode === 'qrcode' ? '扫码登录提示' : '密码登录提示' }}
</div>
<div v-if="loginMode === 'qrcode'" class="mt-1 space-y-1 text-sm">
<p>1. 输入您的用户名用于标识身份</p>
<p>2. 点击扫码登录/注册按钮</p>
<p>3. 使用手机 QQ 扫描二维码</p>
<p>4. 新用户首次扫码会自动注册账户</p>
</div>
<div v-else class="mt-1 space-y-1 text-sm">
<p>1. 输入您的用户名和密码</p>
<p>2. 点击登录按钮直接进入系统</p>
<p>3. 首次使用请先扫码登录/注册然后在设置中设置密码</p>
</div>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>
+9 -14
View File
@@ -48,7 +48,6 @@ onMounted(load)
<div :class="[sectionHeaderClass, 'md:grid-cols-[1fr_180px_180px_auto]']"> <div :class="[sectionHeaderClass, 'md:grid-cols-[1fr_180px_180px_auto]']">
<div> <div>
<h2 class="font-semibold">个人打卡记录</h2> <h2 class="font-semibold">个人打卡记录</h2>
<p class="mt-1 text-sm text-zinc-500">按状态和触发方式查看最近的打卡结果</p>
</div> </div>
<select v-model="filters.status" :class="inputClass"> <select v-model="filters.status" :class="inputClass">
<option value="">全部状态</option> <option value="">全部状态</option>
@@ -77,31 +76,27 @@ onMounted(load)
action-label="重试" action-label="重试"
@action="load" @action="load"
/> />
<StateBlock <StateBlock v-else-if="records.length === 0" title="暂无记录" />
v-else-if="records.length === 0"
title="暂无记录"
description="当前筛选条件下没有打卡记录。"
/>
<div v-else> <div v-else>
<div class="divide-y divide-zinc-200"> <div class="divide-y divide-zinc-200 dark:divide-zinc-800">
<article <article
v-for="record in records" v-for="record in records"
:key="record.id" :key="record.id"
class="grid gap-3 p-4 lg:grid-cols-[180px_minmax(0,1fr)_auto]" class="grid gap-3 p-3 lg:grid-cols-[180px_minmax(0,1fr)_auto] lg:items-center"
> >
<div> <div class="min-w-0">
<div class="text-sm font-semibold"> <div class="truncate text-sm font-semibold">
{{ record.task_name || `任务 #${record.task_id}` }} {{ record.task_name || `任务 #${record.task_id}` }}
</div> </div>
<div class="mt-1 text-xs text-zinc-500"> <div class="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
{{ formatFullDateTime(record.check_in_time) }} {{ formatFullDateTime(record.check_in_time) }}
</div> </div>
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate text-sm text-zinc-700"> <p class="truncate text-sm text-zinc-700 dark:text-zinc-200">
{{ record.response_text || record.error_message || '无响应内容' }} {{ record.response_text || record.error_message || '无响应内容' }}
</p> </p>
<p class="mt-1 text-xs text-zinc-500"> <p class="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
触发方式{{ statusLabel(record.trigger_type) }} 触发方式{{ statusLabel(record.trigger_type) }}
</p> </p>
</div> </div>
@@ -113,7 +108,7 @@ onMounted(load)
</article> </article>
</div> </div>
<div <div
class="flex flex-wrap items-center justify-between gap-3 border-t border-zinc-200 bg-zinc-50/70 px-4 py-3 text-sm text-zinc-500" class="flex flex-wrap items-center justify-between gap-3 border-t border-zinc-200 bg-zinc-50/70 px-4 py-3 text-sm text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950/50 dark:text-zinc-400"
> >
<span <span
> {{ total }} 当前 {{ filters.skip + 1 }} - > {{ total }} 当前 {{ filters.skip + 1 }} -
+17 -12
View File
@@ -86,20 +86,19 @@ onMounted(load)
<form :class="[cardClass, 'overflow-hidden']" @submit.prevent="save"> <form :class="[cardClass, 'overflow-hidden']" @submit.prevent="save">
<div :class="sectionHeaderClass"> <div :class="sectionHeaderClass">
<h2 class="font-semibold">个人资料</h2> <h2 class="font-semibold">个人资料</h2>
<p class="mt-1 text-sm text-zinc-500">更新别名邮箱和登录密码</p>
</div> </div>
<div class="grid gap-4 p-5"> <div class="grid gap-4 p-4">
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500">别名</span> <span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">别名</span>
<input v-model="form.alias" :class="inputClass" required /> <input v-model="form.alias" :class="inputClass" required />
</label> </label>
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500">邮箱</span> <span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">邮箱</span>
<input v-model="form.email" :class="inputClass" type="email" placeholder="用于打卡通知" /> <input v-model="form.email" :class="inputClass" type="email" placeholder="用于打卡通知" />
</label> </label>
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500">当前密码</span> <span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">当前密码</span>
<input <input
v-model="form.current_password" v-model="form.current_password"
:class="inputClass" :class="inputClass"
@@ -108,7 +107,7 @@ onMounted(load)
/> />
</label> </label>
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500">新密码</span> <span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">新密码</span>
<input <input
v-model="form.new_password" v-model="form.new_password"
:class="inputClass" :class="inputClass"
@@ -132,16 +131,22 @@ onMounted(load)
<aside :class="[cardClass, 'h-fit overflow-hidden']"> <aside :class="[cardClass, 'h-fit overflow-hidden']">
<div <div
class="border-b px-5 py-4" class="grid gap-2 border-b px-4 py-3"
:class=" :class="
token?.is_valid ? 'border-emerald-200 bg-emerald-50/70' : 'border-rose-200 bg-rose-50/70' token?.is_valid
? 'border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/70 dark:bg-emerald-950/30'
: 'border-rose-200 bg-rose-50/70 dark:border-rose-900/70 dark:bg-rose-950/30'
" "
> >
<h2 class="font-semibold">授权状态</h2> <div class="flex items-center justify-between gap-2">
<p class="mt-1 text-sm text-zinc-600">这里检查的是打卡业务 token不是网站登录状态</p> <h2 class="font-semibold">授权状态</h2>
<span :class="toneClass(token?.is_valid ? 'success' : 'danger')">{{
token?.is_valid ? '可用' : '不可用'
}}</span>
</div>
</div> </div>
<div class="p-5"> <div class="p-4">
<div class="mt-4 grid gap-3 text-sm"> <div class="grid gap-3 text-sm">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-zinc-500">状态</span> <span class="text-zinc-500">状态</span>
<span :class="toneClass(token?.is_valid ? 'success' : 'danger')">{{ <span :class="toneClass(token?.is_valid ? 'success' : 'danger')">{{
+13 -11
View File
@@ -83,23 +83,23 @@ onMounted(load)
action-label="重试" action-label="重试"
@action="load" @action="load"
/> />
<StateBlock <StateBlock v-else-if="records.length === 0" title="暂无记录" />
v-else-if="records.length === 0"
title="暂无记录"
description="当前任务还没有符合条件的记录。"
/>
<div v-else class="divide-y divide-zinc-200"> <div v-else class="divide-y divide-zinc-200">
<article <article
v-for="record in records" v-for="record in records"
:key="record.id" :key="record.id"
class="grid gap-3 p-4 md:grid-cols-[180px_minmax(0,1fr)_auto]" class="grid gap-3 p-3 md:grid-cols-[180px_minmax(0,1fr)_auto] md:items-center"
> >
<div class="text-sm text-zinc-500">{{ formatFullDateTime(record.check_in_time) }}</div> <div class="text-sm text-zinc-500 dark:text-zinc-400">
<div> {{ formatFullDateTime(record.check_in_time) }}
<div class="text-sm text-zinc-700"> </div>
<div class="min-w-0">
<div class="truncate text-sm text-zinc-700 dark:text-zinc-200">
{{ record.response_text || record.error_message || '无响应内容' }} {{ record.response_text || record.error_message || '无响应内容' }}
</div> </div>
<div class="mt-1 text-xs text-zinc-500">触发{{ statusLabel(record.trigger_type) }}</div> <div class="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
触发{{ statusLabel(record.trigger_type) }}
</div>
</div> </div>
<div class="md:text-right"> <div class="md:text-right">
<span :class="toneClass(statusTone(record.status))">{{ <span :class="toneClass(statusTone(record.status))">{{
@@ -107,7 +107,9 @@ onMounted(load)
}}</span> }}</span>
</div> </div>
</article> </article>
<div class="border-t border-zinc-200 px-4 py-3 text-sm text-zinc-500"> <div
class="border-t border-zinc-200 bg-zinc-50/70 px-4 py-3 text-sm text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950/50 dark:text-zinc-400"
>
{{ total }} 条记录 {{ total }} 条记录
</div> </div>
</div> </div>
+46 -44
View File
@@ -213,7 +213,6 @@ onMounted(load)
<div :class="sectionHeaderClass"> <div :class="sectionHeaderClass">
<div> <div>
<h2 class="font-semibold">从模板创建任务</h2> <h2 class="font-semibold">从模板创建任务</h2>
<p class="mt-1 text-sm text-zinc-500">选择启用模板填写接龙 ID 和字段值后创建任务</p>
</div> </div>
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load"> <button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load">
<RefreshCw class="size-4" /> <RefreshCw class="size-4" />
@@ -221,10 +220,10 @@ onMounted(load)
</button> </button>
</div> </div>
<form class="grid gap-4 p-5" @submit.prevent="createTask"> <form class="grid gap-4 p-4" @submit.prevent="createTask">
<div class="grid gap-4 md:grid-cols-3"> <div class="grid gap-3 md:grid-cols-[220px_minmax(0,1fr)_220px]">
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500">模板</span> <span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">模板</span>
<select v-model.number="selectedTemplateId" :class="inputClass"> <select v-model.number="selectedTemplateId" :class="inputClass">
<option v-for="template in templates" :key="template.id" :value="template.id"> <option v-for="template in templates" :key="template.id" :value="template.id">
{{ template.name }} {{ template.name }}
@@ -232,25 +231,37 @@ onMounted(load)
</select> </select>
</label> </label>
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500">任务名称</span> <span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">任务名称</span>
<input v-model="createForm.task_name" :class="inputClass" placeholder="可选" /> <input v-model="createForm.task_name" :class="inputClass" placeholder="可选" />
</label> </label>
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500">接龙 ThreadId</span> <span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400"
>接龙 ThreadId</span
>
<input v-model="createForm.thread_id" :class="inputClass" required /> <input v-model="createForm.thread_id" :class="inputClass" required />
</label> </label>
</div> </div>
<label class="grid gap-2 md:max-w-xs"> <div class="grid gap-3 md:grid-cols-[220px_minmax(0,1fr)] md:items-end">
<span class="text-xs font-semibold text-zinc-500">Cron 表达式</span> <label class="grid gap-2">
<input <span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">Cron 表达式</span>
v-model="createForm.cron_expression" <input
:class="inputClass" v-model="createForm.cron_expression"
placeholder="0 20 * * *" :class="inputClass"
/> placeholder="0 20 * * *"
</label> />
<div v-if="fieldEntries.length" class="grid gap-4 md:grid-cols-2"> </label>
<button
:class="[buttonBase, buttonTone.primary, 'w-full md:w-fit']"
:disabled="creating || !selectedTemplateId || !createForm.thread_id"
type="submit"
>
<Plus class="size-4" />
{{ creating ? '创建中' : '创建任务' }}
</button>
</div>
<div v-if="fieldEntries.length" class="grid gap-3 md:grid-cols-2">
<label v-for="[key, field] in fieldEntries" :key="key" class="grid gap-2"> <label v-for="[key, field] in fieldEntries" :key="key" class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500">{{ <span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">{{
field?.display_name ?? key field?.display_name ?? key
}}</span> }}</span>
<select <select
@@ -290,14 +301,6 @@ onMounted(load)
<div v-if="message" :class="alertClass.success"> <div v-if="message" :class="alertClass.success">
{{ message }} {{ message }}
</div> </div>
<button
:class="[buttonBase, buttonTone.primary, 'w-fit']"
:disabled="creating || !selectedTemplateId || !createForm.thread_id"
type="submit"
>
<Plus class="size-4" />
{{ creating ? '创建中' : '创建任务' }}
</button>
</form> </form>
</section> </section>
@@ -310,29 +313,24 @@ onMounted(load)
action-label="重试" action-label="重试"
@action="load" @action="load"
/> />
<StateBlock <StateBlock v-else-if="tasks.length === 0" title="暂无任务" />
v-else-if="tasks.length === 0"
title="暂无任务"
description="先从模板创建一个任务。"
/>
<section v-else :class="[cardClass, 'overflow-hidden']"> <section v-else :class="[cardClass, 'overflow-hidden']">
<div :class="sectionHeaderClass"> <div :class="sectionHeaderClass">
<div> <div>
<h2 class="font-semibold">任务列表</h2> <h2 class="font-semibold">任务列表</h2>
<p class="mt-1 text-sm text-zinc-500">查看启停最近状态并执行手动打卡或维护操作</p>
</div> </div>
<span <span
class="rounded-full border border-zinc-200 bg-white px-3 py-1 text-xs font-medium text-zinc-600" class="rounded-full border border-zinc-200 bg-white px-3 py-1 text-xs font-medium text-zinc-600 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-300"
> >
{{ tasks.length }} 个任务 {{ tasks.length }} 个任务
</span> </span>
</div> </div>
<div class="divide-y divide-zinc-200"> <div class="divide-y divide-zinc-200 dark:divide-zinc-800">
<article v-for="task in tasks" :key="task.id" class="p-4"> <article v-for="task in tasks" :key="task.id" class="p-3 sm:p-4">
<div class="grid gap-3 lg:grid-cols-[1fr_auto]"> <div class="grid gap-3 lg:grid-cols-[1fr_auto]">
<div> <div class="min-w-0">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<h3 class="font-semibold">{{ task.name || `任务 #${task.id}` }}</h3> <h3 class="truncate font-semibold">{{ task.name || `任务 #${task.id}` }}</h3>
<span :class="toneClass(task.is_active ? 'success' : 'neutral')">{{ <span :class="toneClass(task.is_active ? 'success' : 'neutral')">{{
task.is_active ? '启用' : '停用' task.is_active ? '启用' : '停用'
}}</span> }}</span>
@@ -352,9 +350,12 @@ onMounted(load)
{{ statusLabel(status.status) }} {{ statusLabel(status.status) }}
</span> </span>
</div> </div>
<p class="mt-1 text-sm text-zinc-500"> <div
ThreadId: {{ task.thread_id || '未解析' }} · {{ cronLabel(task.cron_expression) }} class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-zinc-500 dark:text-zinc-400"
</p> >
<span>ThreadId: {{ task.thread_id || '未解析' }}</span>
<span>{{ cronLabel(task.cron_expression) }}</span>
</div>
</div> </div>
<div class="flex flex-wrap gap-2 lg:justify-end"> <div class="flex flex-wrap gap-2 lg:justify-end">
<button <button
@@ -404,25 +405,26 @@ onMounted(load)
<form <form
v-if="editingTaskId === task.id" v-if="editingTaskId === task.id"
class="mt-4 grid gap-3 rounded-lg border border-emerald-200 bg-emerald-50/60 p-4" class="mt-4 grid gap-3 rounded-lg border border-emerald-200 bg-emerald-50/60 p-3 dark:border-emerald-900/70 dark:bg-emerald-950/30"
@submit.prevent="saveEdit(task.id)" @submit.prevent="saveEdit(task.id)"
> >
<div> <div>
<h4 class="text-sm font-semibold text-zinc-900">编辑任务</h4> <h4 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">编辑任务</h4>
<p class="mt-1 text-xs text-zinc-500">保存前会校验 Payload JSON。</p>
</div> </div>
<div class="grid gap-3 md:grid-cols-2"> <div class="grid gap-3 md:grid-cols-2">
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500">任务名称</span> <span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">任务名称</span>
<input v-model="editForm.name" :class="inputClass" /> <input v-model="editForm.name" :class="inputClass" />
</label> </label>
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500">Cron</span> <span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">Cron</span>
<input v-model="editForm.cron_expression" :class="inputClass" /> <input v-model="editForm.cron_expression" :class="inputClass" />
</label> </label>
</div> </div>
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500">Payload JSON</span> <span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400"
>Payload JSON</span
>
<textarea v-model="editForm.payload_config" :class="textareaClass" /> <textarea v-model="editForm.payload_config" :class="textareaClass" />
</label> </label>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@@ -31,7 +31,6 @@ onMounted(load)
<div :class="sectionHeaderClass"> <div :class="sectionHeaderClass">
<div> <div>
<h2 class="font-semibold">系统日志</h2> <h2 class="font-semibold">系统日志</h2>
<p class="mt-1 text-sm text-zinc-500">查看最近运行日志适合排查打卡和后台任务状态</p>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<input <input
@@ -56,6 +55,7 @@ onMounted(load)
action-label="重试" action-label="重试"
@action="load" @action="load"
/> />
<StateBlock v-else-if="!logs" title="暂无日志" />
<pre <pre
v-else v-else
class="max-h-[70vh] overflow-auto bg-zinc-950 p-4 font-mono text-xs leading-5 text-zinc-100" class="max-h-[70vh] overflow-auto bg-zinc-950 p-4 font-mono text-xs leading-5 text-zinc-100"
@@ -40,10 +40,9 @@ onMounted(load)
<template> <template>
<section :class="[cardClass, 'overflow-hidden']"> <section :class="[cardClass, 'overflow-hidden']">
<div :class="[sectionHeaderClass, 'md:grid-cols-[1fr_160px_160px_auto]']"> <div :class="[sectionHeaderClass, 'lg:grid-cols-[1fr_120px_160px_120px_auto]']">
<div> <div>
<h2 class="font-semibold">全量记录</h2> <h2 class="font-semibold">全量记录</h2>
<p class="mt-1 text-sm text-zinc-500">按任务和状态快速定位系统记录</p>
</div> </div>
<input v-model="filters.task_id" :class="inputClass" placeholder="任务 ID" /> <input v-model="filters.task_id" :class="inputClass" placeholder="任务 ID" />
<select v-model="filters.status" :class="inputClass"> <select v-model="filters.status" :class="inputClass">
@@ -52,6 +51,7 @@ onMounted(load)
<option value="failure">失败</option> <option value="failure">失败</option>
<option value="out_of_time">超出时间</option> <option value="out_of_time">超出时间</option>
</select> </select>
<input v-model.number="filters.limit" :class="inputClass" type="number" min="1" max="200" />
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load"> <button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load">
<Search class="size-4" /> <Search class="size-4" />
筛选 筛选
@@ -66,18 +66,20 @@ onMounted(load)
action-label="重试" action-label="重试"
@action="load" @action="load"
/> />
<div v-else class="divide-y divide-zinc-200"> <StateBlock v-else-if="records.length === 0" title="暂无记录" />
<div v-else class="divide-y divide-zinc-200 dark:divide-zinc-800">
<article <article
v-for="record in records" v-for="record in records"
:key="record.id" :key="record.id"
class="grid gap-3 p-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-center" class="grid gap-3 p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"
> >
<div> <div class="min-w-0">
<div class="font-medium"> <div class="truncate font-medium">
{{ record.user_alias || record.user_email || `任务 #${record.task_id}` }} {{ record.user_alias || record.user_email || `任务 #${record.task_id}` }}
</div> </div>
<div class="mt-1 text-sm text-zinc-500"> <div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-zinc-500 dark:text-zinc-400">
{{ formatFullDateTime(record.check_in_time) }} <span>{{ formatFullDateTime(record.check_in_time) }}</span>
<span>{{ record.response_text || record.error_message || '无响应内容' }}</span>
</div> </div>
</div> </div>
<div class="flex flex-wrap items-center gap-2 md:justify-end"> <div class="flex flex-wrap items-center gap-2 md:justify-end">
@@ -2,7 +2,7 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { adminApi, type AdminStats } from '@/api' import { adminApi, type AdminStats } from '@/api'
import StateBlock from '@/components/StateBlock.vue' import StateBlock from '@/components/StateBlock.vue'
import { cardClass, sectionHeaderClass } from '@/components/ui' import { buttonBase, buttonTone, cardClass, sectionHeaderClass } from '@/components/ui'
import { extractErrorMessage } from '@/utils/format' import { extractErrorMessage } from '@/utils/format'
const loading = ref(true) const loading = ref(true)
@@ -38,26 +38,34 @@ onMounted(load)
<div :class="sectionHeaderClass"> <div :class="sectionHeaderClass">
<div> <div>
<h2 class="font-semibold">系统统计</h2> <h2 class="font-semibold">系统统计</h2>
<p class="mt-1 text-sm text-zinc-500">总览用户任务记录和 Token 预警</p>
</div> </div>
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load">刷新</button>
</div> </div>
<div class="grid gap-4 p-4 md:grid-cols-2 xl:grid-cols-4"> <div class="grid gap-3 p-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-lg border border-zinc-200 bg-white p-4"> <div
class="rounded-lg border border-zinc-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-950"
>
<div class="text-sm text-zinc-500">用户</div> <div class="text-sm text-zinc-500">用户</div>
<div class="mt-2 font-mono text-3xl font-semibold">{{ stats?.users.total }}</div> <div class="mt-2 font-mono text-3xl font-semibold">{{ stats?.users.total }}</div>
<div class="mt-1 text-sm text-zinc-500">已审批 {{ stats?.users.active }}</div> <div class="mt-1 text-sm text-zinc-500">已审批 {{ stats?.users.active }}</div>
</div> </div>
<div class="rounded-lg border border-zinc-200 bg-white p-4"> <div
class="rounded-lg border border-zinc-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-950"
>
<div class="text-sm text-zinc-500">任务</div> <div class="text-sm text-zinc-500">任务</div>
<div class="mt-2 font-mono text-3xl font-semibold">{{ stats?.tasks.total }}</div> <div class="mt-2 font-mono text-3xl font-semibold">{{ stats?.tasks.total }}</div>
<div class="mt-1 text-sm text-zinc-500">启用 {{ stats?.tasks.active }}</div> <div class="mt-1 text-sm text-zinc-500">启用 {{ stats?.tasks.active }}</div>
</div> </div>
<div class="rounded-lg border border-zinc-200 bg-white p-4"> <div
class="rounded-lg border border-zinc-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-950"
>
<div class="text-sm text-zinc-500">记录</div> <div class="text-sm text-zinc-500">记录</div>
<div class="mt-2 font-mono text-3xl font-semibold">{{ stats?.check_in_records.total }}</div> <div class="mt-2 font-mono text-3xl font-semibold">{{ stats?.check_in_records.total }}</div>
<div class="mt-1 text-sm text-zinc-500">今日 {{ stats?.check_in_records.today }}</div> <div class="mt-1 text-sm text-zinc-500">今日 {{ stats?.check_in_records.today }}</div>
</div> </div>
<div class="rounded-lg border border-amber-200 bg-amber-50/70 p-4"> <div
class="rounded-lg border border-amber-200 bg-amber-50/70 p-3 dark:border-amber-900/70 dark:bg-amber-950/30"
>
<div class="text-sm text-zinc-500">Token 预警</div> <div class="text-sm text-zinc-500">Token 预警</div>
<div class="mt-2 font-mono text-3xl font-semibold">{{ stats?.tokens.expiring_soon }}</div> <div class="mt-2 font-mono text-3xl font-semibold">{{ stats?.tokens.expiring_soon }}</div>
<div class="mt-1 text-sm text-zinc-500">7 天内过期</div> <div class="mt-1 text-sm text-zinc-500">7 天内过期</div>
@@ -11,13 +11,20 @@ import {
} from '@/components/templates/template-config' } from '@/components/templates/template-config'
import { import {
alertClass, alertClass,
buttonBase,
buttonTone,
cardClass, cardClass,
inputClass, inputClass,
labelClass,
sectionHeaderClass, sectionHeaderClass,
toneClass, toneClass,
} from '@/components/ui' } from '@/components/ui'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { extractErrorMessage, formatDateTime, stringifyJson } from '@/utils/format' import { extractErrorMessage, formatDateTime, stringifyJson } from '@/utils/format'
const loading = ref(true) const loading = ref(true)
@@ -40,6 +47,13 @@ const localPreviewPayload = computed(() => {
if (!result.ok) return null if (!result.ok) return null
return buildTemplatePreviewPayload(result.config) return buildTemplatePreviewPayload(result.config)
}) })
const editorOpen = computed({
get: () => editingId.value !== null,
set: (open: boolean) => {
if (!open) editingId.value = null
},
})
const editorTitle = computed(() => (editingId.value === 'new' ? '新建模板' : '编辑模板'))
async function load() { async function load() {
loading.value = true loading.value = true
@@ -127,17 +141,16 @@ onMounted(load)
</script> </script>
<template> <template>
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_420px]"> <div class="grid gap-5">
<section :class="[cardClass, 'overflow-hidden']"> <section :class="[cardClass, 'overflow-hidden']">
<div :class="sectionHeaderClass"> <div :class="sectionHeaderClass">
<div> <div>
<h2 class="font-semibold">模板管理</h2> <h2 class="font-semibold">模板管理</h2>
<p class="mt-1 text-sm text-zinc-500">维护创建任务时可选择的字段配置和模板状态</p>
</div> </div>
<button :class="[buttonBase, buttonTone.primary]" type="button" @click="startCreate"> <Button type="button" @click="startCreate">
<Plus class="size-4" /> <Plus class="size-4" />
新建模板 新建模板
</button> </Button>
</div> </div>
<StateBlock v-if="loading" title="正在加载模板" type="loading" /> <StateBlock v-if="loading" title="正在加载模板" type="loading" />
<StateBlock <StateBlock
@@ -148,121 +161,127 @@ onMounted(load)
action-label="重试" action-label="重试"
@action="load" @action="load"
/> />
<div v-else class="divide-y divide-zinc-200"> <StateBlock
v-else-if="templates.length === 0"
title="暂无模板"
action-label="新建模板"
@action="startCreate"
/>
<div v-else class="divide-y divide-zinc-200 dark:divide-zinc-800">
<article <article
v-for="template in templates" v-for="template in templates"
:key="template.id" :key="template.id"
class="flex flex-wrap items-center justify-between gap-3 p-4" class="flex flex-wrap items-center justify-between gap-3 p-3 sm:p-4"
> >
<div> <div class="min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<h3 class="font-semibold">{{ template.name }}</h3> <h3 class="truncate font-semibold">{{ template.name }}</h3>
<span :class="toneClass(template.is_active ? 'success' : 'neutral')">{{ <span :class="toneClass(template.is_active ? 'success' : 'neutral')">{{
template.is_active ? '启用' : '停用' template.is_active ? '启用' : '停用'
}}</span> }}</span>
</div> </div>
<p class="mt-1 text-sm text-zinc-500"> <div
{{ template.description || '无描述' }} · {{ formatDateTime(template.created_at) }} class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-zinc-500 dark:text-zinc-400"
</p> >
<span>{{ template.description || '无描述' }}</span>
<span>{{ formatDateTime(template.created_at) }}</span>
</div>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <Button type="button" variant="outline" @click="showPreview(template)">
:class="[buttonBase, buttonTone.secondary]"
type="button"
@click="showPreview(template)"
>
<Eye class="size-4" /> <Eye class="size-4" />
预览 预览
</button> </Button>
<button <Button type="button" variant="outline" @click="startEdit(template)"> 编辑 </Button>
:class="[buttonBase, buttonTone.secondary]" <Button
type="button"
@click="startEdit(template)"
>
编辑
</button>
<button
:class="[buttonBase, buttonTone.danger]"
type="button" type="button"
variant="outline"
class="border-rose-200 bg-rose-50 text-rose-700 hover:border-rose-300 hover:bg-rose-100 dark:border-rose-900/70 dark:bg-rose-950/50 dark:text-rose-300 dark:hover:border-rose-800 dark:hover:bg-rose-900/40"
@click="remove(template)" @click="remove(template)"
> >
<Trash2 class="size-4" /> <Trash2 class="size-4" />
删除 删除
</button> </Button>
</div> </div>
</article> </article>
</div> </div>
</section> </section>
<aside class="grid gap-5"> <section v-if="preview" :class="[cardClass, 'p-5']">
<form <details open>
v-if="editingId" <summary class="cursor-pointer text-sm font-semibold">
:class="[cardClass, 'grid gap-4 overflow-hidden']" {{ preview.template_name }} 预览
@submit.prevent="save" </summary>
>
<div
class="border-b border-zinc-200 bg-zinc-50/70 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-950/50"
>
<h2 class="font-semibold">{{ editingId === 'new' ? '新建模板' : '编辑模板' }}</h2>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
使用结构化字段编辑器维护配置必要时切换 JSON 修复
</p>
</div>
<div class="grid gap-4 p-5">
<label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">名称</span>
<input v-model="form.name" :class="inputClass" required />
</label>
<label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">描述</span>
<input v-model="form.description" :class="inputClass" />
</label>
<label class="flex items-center gap-2 text-sm">
<input v-model="form.is_active" type="checkbox" />
启用模板
</label>
<TemplateConfigEditor v-model="form.field_config" @valid="editorValid = $event" />
<div v-if="error" :class="alertClass.danger">
{{ error }}
</div>
<div v-if="message" :class="alertClass.success">
{{ message }}
</div>
<div class="flex gap-2">
<button
:class="[buttonBase, buttonTone.primary]"
:disabled="!editorValid"
type="submit"
>
<Save class="size-4" />
保存
</button>
<button
:class="[buttonBase, buttonTone.secondary]"
type="button"
@click="editingId = null"
>
取消
</button>
</div>
</div>
</form>
<section v-if="preview" :class="[cardClass, 'p-5']">
<h2 class="font-semibold">{{ preview.template_name }} 预览</h2>
<pre <pre
class="mt-3 max-h-96 overflow-auto rounded-md bg-zinc-950 p-3 text-xs leading-5 text-zinc-100" class="mt-3 max-h-96 overflow-auto rounded-md bg-zinc-950 p-3 text-xs leading-5 text-zinc-100"
>{{ stringifyJson(preview.preview_payload) }}</pre >{{ stringifyJson(preview.preview_payload) }}</pre
> >
</section> </details>
</section>
<section v-else-if="editingId && localPreviewPayload" :class="[cardClass, 'p-5']"> <Dialog v-model:open="editorOpen">
<h2 class="font-semibold">当前配置预览</h2> <DialogContent
<pre class="grid max-h-[calc(100dvh-2rem)] grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden p-0 sm:max-w-[min(960px,calc(100vw-2rem))] lg:max-w-[min(1120px,calc(100vw-3rem))]"
class="mt-3 max-h-96 overflow-auto rounded-md bg-zinc-950 p-3 text-xs leading-5 text-zinc-100" >
>{{ stringifyJson(localPreviewPayload) }}</pre <DialogHeader
class="border-b border-zinc-200 bg-zinc-50/70 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-950/50"
> >
</section> <DialogTitle>{{ editorTitle }}</DialogTitle>
</aside> </DialogHeader>
<form class="min-h-0 overflow-y-auto" @submit.prevent="save">
<div class="grid gap-4 p-4 sm:p-5">
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] lg:items-end">
<label class="grid gap-2">
<span :class="labelClass">名称</span>
<input v-model="form.name" :class="inputClass" required />
</label>
<label class="grid gap-2">
<span :class="labelClass">描述</span>
<input v-model="form.description" :class="inputClass" />
</label>
<label
class="flex min-h-9 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-950"
>
<input v-model="form.is_active" type="checkbox" />
启用模板
</label>
</div>
<TemplateConfigEditor v-model="form.field_config" @valid="editorValid = $event" />
<div
v-if="localPreviewPayload"
class="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800"
>
<details>
<summary class="cursor-pointer text-sm font-semibold">当前配置预览</summary>
<pre
class="mt-3 max-h-64 overflow-auto rounded-md bg-zinc-950 p-3 text-xs leading-5 text-zinc-100"
>{{ stringifyJson(localPreviewPayload) }}</pre
>
</details>
</div>
<div v-if="error" :class="alertClass.danger">
{{ error }}
</div>
<div v-if="message" :class="alertClass.success">
{{ message }}
</div>
</div>
<DialogFooter
class="sticky bottom-0 border-t border-zinc-200 bg-white/95 px-5 py-4 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/95"
>
<Button type="button" variant="outline" @click="editingId = null">取消</Button>
<Button :disabled="!editorValid" type="submit">
<Save class="size-4" />
保存
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div> </div>
</template> </template>
@@ -12,6 +12,7 @@ import {
sectionHeaderClass, sectionHeaderClass,
toneClass, toneClass,
} from '@/components/ui' } from '@/components/ui'
import { Button } from '@/components/ui/button'
import { extractErrorMessage, formatDateTime } from '@/utils/format' import { extractErrorMessage, formatDateTime } from '@/utils/format'
const loading = ref(true) const loading = ref(true)
@@ -94,18 +95,19 @@ onMounted(load)
</script> </script>
<template> <template>
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]"> <div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start">
<section :class="[cardClass, 'overflow-hidden']"> <section :class="[cardClass, 'min-w-0 overflow-hidden']">
<div :class="sectionHeaderClass"> <div :class="sectionHeaderClass">
<div> <div>
<h2 class="font-semibold">用户审批与管理</h2> <h2 class="font-semibold">用户审批与管理</h2>
<p class="mt-1 text-sm text-zinc-500">优先处理待审批用户再维护角色邮箱和密码</p>
</div> </div>
<span :class="toneClass(users.some((user) => !user.is_approved) ? 'warning' : 'success')"> <span :class="toneClass(users.some((user) => !user.is_approved) ? 'warning' : 'success')">
{{ users.filter((user) => !user.is_approved).length }} 个待审批 {{ users.filter((user) => !user.is_approved).length }} 个待审批
</span> </span>
</div> </div>
<div class="flex flex-wrap items-center gap-3 border-b border-zinc-200 bg-zinc-50/70 p-4"> <div
class="grid gap-3 border-b border-zinc-200 bg-zinc-50/70 p-4 sm:grid-cols-[minmax(0,1fr)_auto_auto] dark:border-zinc-800 dark:bg-zinc-950/50"
>
<input v-model="search" :class="inputClass" class="max-w-sm" placeholder="搜索别名" /> <input v-model="search" :class="inputClass" class="max-w-sm" placeholder="搜索别名" />
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load"> <button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load">
<Search class="size-4" /> <Search class="size-4" />
@@ -125,15 +127,16 @@ onMounted(load)
action-label="重试" action-label="重试"
@action="load" @action="load"
/> />
<div v-else class="divide-y divide-zinc-200"> <StateBlock v-else-if="users.length === 0" title="暂无用户" />
<div v-else class="divide-y divide-zinc-200 dark:divide-zinc-800">
<article <article
v-for="user in users" v-for="user in users"
:key="user.id" :key="user.id"
class="flex flex-wrap items-center justify-between gap-3 p-4" class="flex flex-wrap items-center justify-between gap-3 p-3 sm:p-4"
> >
<div> <div class="min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<h3 class="font-semibold">{{ user.alias }}</h3> <h3 class="truncate font-semibold">{{ user.alias }}</h3>
<span :class="toneClass(user.is_approved ? 'success' : 'warning')">{{ <span :class="toneClass(user.is_approved ? 'success' : 'warning')">{{
user.is_approved ? '已审批' : '待审批' user.is_approved ? '已审批' : '待审批'
}}</span> }}</span>
@@ -141,9 +144,12 @@ onMounted(load)
user.role user.role
}}</span> }}</span>
</div> </div>
<p class="mt-1 text-sm text-zinc-500"> <div
{{ user.email || '未设置邮箱' }} · {{ formatDateTime(user.created_at) }} class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-zinc-500 dark:text-zinc-400"
</p> >
<span>{{ user.email || '未设置邮箱' }}</span>
<span>{{ formatDateTime(user.created_at) }}</span>
</div>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
@@ -172,33 +178,61 @@ onMounted(load)
</div> </div>
</section> </section>
<aside
v-if="!editingId"
:class="[
cardClass,
'grid h-fit min-h-72 min-w-0 place-items-center border-dashed p-6 text-center xl:sticky xl:top-20',
]"
>
<div class="grid justify-items-center gap-4">
<span
class="inline-flex size-12 items-center justify-center rounded-xl border border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/70 dark:bg-emerald-950/50 dark:text-emerald-300"
>
<UserPlus class="size-5" />
</span>
<div class="grid gap-1">
<h2 class="font-semibold">未选择用户</h2>
<p class="text-sm text-zinc-500 dark:text-zinc-400">创建或从列表编辑</p>
</div>
<Button type="button" @click="startCreate">
<UserPlus class="size-4" />
创建用户
</Button>
</div>
</aside>
<form <form
v-if="editingId" v-else
:class="[cardClass, 'grid h-fit gap-4 overflow-hidden']" :class="[
cardClass,
'grid h-fit min-w-0 gap-4 overflow-hidden xl:sticky xl:top-20 xl:self-start',
]"
@submit.prevent="save" @submit.prevent="save"
> >
<div class="border-b border-zinc-200 bg-zinc-50/70 px-5 py-4"> <div
class="border-b border-zinc-200 bg-zinc-50/70 px-4 py-3 dark:border-zinc-800 dark:bg-zinc-950/50"
>
<h2 class="font-semibold">{{ editingId === 'new' ? '创建用户' : '编辑用户' }}</h2> <h2 class="font-semibold">{{ editingId === 'new' ? '创建用户' : '编辑用户' }}</h2>
<p class="mt-1 text-sm text-zinc-500">保存后会刷新用户列表</p>
</div> </div>
<div class="grid gap-4 p-5"> <div class="grid gap-4 p-4">
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500">别名</span> <span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">别名</span>
<input v-model="form.alias" :class="inputClass" required /> <input v-model="form.alias" :class="inputClass" required />
</label> </label>
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500">邮箱</span> <span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">邮箱</span>
<input v-model="form.email" :class="inputClass" type="email" /> <input v-model="form.email" :class="inputClass" type="email" />
</label> </label>
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500">角色</span> <span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">角色</span>
<select v-model="form.role" :class="inputClass"> <select v-model="form.role" :class="inputClass">
<option value="user">user</option> <option value="user">user</option>
<option value="admin">admin</option> <option value="admin">admin</option>
</select> </select>
</label> </label>
<label class="grid gap-2"> <label class="grid gap-2">
<span class="text-xs font-semibold text-zinc-500">密码</span> <span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">密码</span>
<input <input
v-model="form.password" v-model="form.password"
:class="inputClass" :class="inputClass"