mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 14:06:28 +00:00
feat(new-frontend): use shadcn-vue UI
This commit is contained in:
@@ -16,9 +16,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"reka-ui": "^2.9.6",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"vue": "^3.5.33"
|
||||
|
||||
Generated
+160
-2
@@ -11,6 +11,9 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.4
|
||||
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:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -20,6 +23,9 @@ importers:
|
||||
lucide-vue-next:
|
||||
specifier: ^1.0.0
|
||||
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:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
@@ -139,6 +145,18 @@ packages:
|
||||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
||||
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':
|
||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -159,6 +177,12 @@ packages:
|
||||
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
||||
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':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
@@ -289,6 +313,9 @@ packages:
|
||||
'@rolldown/pluginutils@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==}
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
|
||||
|
||||
'@tailwindcss/node@4.2.4':
|
||||
resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==}
|
||||
|
||||
@@ -383,6 +410,14 @@ packages:
|
||||
peerDependencies:
|
||||
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':
|
||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||
|
||||
@@ -398,6 +433,9 @@ packages:
|
||||
'@types/node@25.6.0':
|
||||
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
'@typescript-eslint/parser@8.59.1':
|
||||
resolution: {integrity: sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -500,6 +538,19 @@ packages:
|
||||
vue:
|
||||
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:
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
peerDependencies:
|
||||
@@ -523,6 +574,10 @@ packages:
|
||||
argparse@2.0.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
@@ -589,6 +644,9 @@ packages:
|
||||
deep-is@0.1.4:
|
||||
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:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -912,6 +970,9 @@ packages:
|
||||
nth-check@2.1.1:
|
||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||
|
||||
ohash@2.0.11:
|
||||
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -971,6 +1032,11 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
reka-ui@2.9.6:
|
||||
resolution: {integrity: sha512-K6bL457owpvWONc7hsjFxo3HDC9s6IzhRqShW0w9JSKelPGfRbkHD558UQTn/NH1cvrXVHygKyC7fExFmRketg==}
|
||||
peerDependencies:
|
||||
vue: '>= 3.4.0'
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -1099,6 +1165,17 @@ packages:
|
||||
vscode-uri@3.1.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1213,6 +1290,26 @@ snapshots:
|
||||
'@eslint/core': 0.17.0
|
||||
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':
|
||||
dependencies:
|
||||
'@humanfs/types': 0.15.0
|
||||
@@ -1229,6 +1326,14 @@ snapshots:
|
||||
|
||||
'@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':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -1312,6 +1417,10 @@ snapshots:
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.17': {}
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@tailwindcss/node@4.2.4':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
@@ -1380,6 +1489,13 @@ snapshots:
|
||||
tailwindcss: 4.2.4
|
||||
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':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -1395,6 +1511,8 @@ snapshots:
|
||||
dependencies:
|
||||
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)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.59.1
|
||||
@@ -1543,6 +1661,19 @@ snapshots:
|
||||
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):
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
@@ -1564,6 +1695,10 @@ snapshots:
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
@@ -1616,6 +1751,8 @@ snapshots:
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
defu@6.1.7: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
enhanced-resolve@5.21.0:
|
||||
@@ -1900,6 +2037,8 @@ snapshots:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
ohash@2.0.11: {}
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -1952,6 +2091,22 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
rolldown@1.0.0-rc.17:
|
||||
@@ -2010,8 +2165,7 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
tslib@2.8.1:
|
||||
optional: true
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tw-animate-css@1.4.0: {}
|
||||
|
||||
@@ -2043,6 +2197,10 @@ snapshots:
|
||||
|
||||
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)):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
|
||||
@@ -108,7 +108,7 @@ export function useTheme() {
|
||||
modeLabel: computed(() => {
|
||||
if (state.mode === 'light') return '亮色'
|
||||
if (state.mode === 'dark') return '暗色'
|
||||
return state.resolved === 'dark' ? '跟随系统:暗色' : '跟随系统:亮色'
|
||||
return '设备'
|
||||
}),
|
||||
setThemeMode,
|
||||
cycleThemeMode,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ScrollText,
|
||||
Settings,
|
||||
Shield,
|
||||
Sun,
|
||||
UserRound,
|
||||
Users,
|
||||
X,
|
||||
@@ -20,7 +21,9 @@ import {
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAuth } from '@/app/auth'
|
||||
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 router = useRouter()
|
||||
@@ -47,6 +50,18 @@ const isAdminRoute = computed(() => router.state.path.startsWith('/admin'))
|
||||
const approvalLabel = computed(() => (authState.user?.is_approved ? '已审批' : '待审批'))
|
||||
const roleLabel = computed(() => (isAdmin.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) {
|
||||
mobileOpen.value = false
|
||||
@@ -64,16 +79,18 @@ function signOut() {
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<button
|
||||
<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"
|
||||
>
|
||||
<X v-if="mobileOpen" 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')">
|
||||
<span
|
||||
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 }}
|
||||
</div>
|
||||
</div>
|
||||
<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 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="`切换主题,当前${themeLabel}`"
|
||||
:title="`切换主题,当前${themeLabel}`"
|
||||
@click="theme.cycleThemeMode"
|
||||
>
|
||||
<MoonStar v-if="theme.state.resolved === 'dark'" class="size-4" />
|
||||
<Monitor v-else class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
@click="signOut"
|
||||
>
|
||||
<TooltipProvider>
|
||||
<div
|
||||
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}`"
|
||||
>
|
||||
<Tooltip v-for="item in themeModes" :key="item.mode">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:class="themeModeButtonClass(item.mode)"
|
||||
:aria-label="`切换为${item.label}模式`"
|
||||
: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" />
|
||||
<span class="hidden sm:inline">退出</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
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'"
|
||||
>
|
||||
<div
|
||||
@@ -179,14 +209,14 @@ function signOut() {
|
||||
</div>
|
||||
</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
|
||||
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="{
|
||||
'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
|
||||
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"
|
||||
@@ -194,27 +224,31 @@ function signOut() {
|
||||
<Shield class="size-3" />
|
||||
管理员
|
||||
</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 }}
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{{
|
||||
isAdminRoute
|
||||
? '管理用户、模板、记录、日志和系统统计。'
|
||||
: '管理打卡任务、授权状态和系统记录。'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="inline-flex items-center gap-2 rounded-full border bg-white px-3 py-1 text-xs font-medium dark:bg-zinc-950"
|
||||
:class="
|
||||
authState.user?.is_approved
|
||||
? '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 }}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div
|
||||
class="inline-flex items-center gap-2 rounded-full border bg-white px-3 py-1 text-xs font-medium dark:bg-zinc-950"
|
||||
:class="
|
||||
authState.user?.is_approved
|
||||
? '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 }}
|
||||
</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>
|
||||
<slot />
|
||||
|
||||
@@ -15,10 +15,10 @@ defineEmits<{
|
||||
|
||||
<template>
|
||||
<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
|
||||
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="{
|
||||
'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',
|
||||
@@ -30,14 +30,16 @@ defineEmits<{
|
||||
<AlertCircle v-else-if="type === 'error'" class="size-5" />
|
||||
<Search v-else class="size-5" />
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ title }}</div>
|
||||
<p v-if="description" class="mx-auto mt-1 max-w-md text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{{ description }}
|
||||
</p>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ title }}</div>
|
||||
<p v-if="description" class="mt-1 text-sm leading-5 text-zinc-500 dark:text-zinc-400">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="actionLabel"
|
||||
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')"
|
||||
>
|
||||
{{ actionLabel }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 TemplateFieldNode from './TemplateFieldNode.vue'
|
||||
import {
|
||||
@@ -124,10 +124,10 @@ function handleNodeError(message: string) {
|
||||
<template>
|
||||
<div class="grid gap-4">
|
||||
<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
|
||||
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
|
||||
type="button"
|
||||
@@ -156,10 +156,6 @@ function handleNodeError(message: string) {
|
||||
JSON
|
||||
</button>
|
||||
</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 v-if="error" :class="alertClass.danger">{{ error }}</div>
|
||||
@@ -208,9 +204,9 @@ function handleNodeError(message: string) {
|
||||
</div>
|
||||
<div
|
||||
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>
|
||||
|
||||
|
||||
@@ -343,37 +343,42 @@ function removeOption(index: number) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="grid gap-1.5">
|
||||
<span :class="labelClass">占位提示</span>
|
||||
<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"
|
||||
<details
|
||||
class="rounded-md border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-800 dark:bg-zinc-950"
|
||||
>
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
:checked="Boolean(fieldNode.required)"
|
||||
:disabled="Boolean(fieldNode.hidden)"
|
||||
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>
|
||||
<summary class="cursor-pointer text-sm font-medium">更多字段属性</summary>
|
||||
<div class="mt-3 grid gap-3">
|
||||
<label class="grid gap-1.5">
|
||||
<span :class="labelClass">占位提示</span>
|
||||
<input
|
||||
:class="inputClass"
|
||||
:value="fieldNode.placeholder ?? ''"
|
||||
placeholder="表单占位文字"
|
||||
@input="updateField('placeholder', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
:checked="Boolean(fieldNode.required)"
|
||||
:disabled="Boolean(fieldNode.hidden)"
|
||||
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 class="flex items-center justify-between gap-2">
|
||||
@@ -435,7 +440,7 @@ function removeOption(index: number) {
|
||||
</div>
|
||||
<div
|
||||
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 }}为空
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export type Tone = 'neutral' | 'success' | 'warning' | 'danger' | 'info'
|
||||
|
||||
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 = {
|
||||
primary:
|
||||
@@ -17,18 +17,18 @@ export const buttonTone = {
|
||||
}
|
||||
|
||||
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 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 =
|
||||
'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 =
|
||||
'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 =
|
||||
'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 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:
|
||||
'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:
|
||||
'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:
|
||||
'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) {
|
||||
|
||||
@@ -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'
|
||||
@@ -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 selectedTask = computed(() => tasks.value.find((task) => task.id === selectedTaskId.value))
|
||||
const lastRecord = computed(() => records.value[0] ?? null)
|
||||
const nextActiveTask = computed(() => tasks.value.find((task) => task.is_active) ?? null)
|
||||
const successToday = computed(
|
||||
() => records.value.filter((record) => record.status === 'success').length,
|
||||
)
|
||||
@@ -150,180 +151,179 @@ onMounted(load)
|
||||
@action="load"
|
||||
/>
|
||||
<div v-else class="grid gap-5">
|
||||
<div class="grid gap-3">
|
||||
<div v-if="needsEmail" :class="alertClass.info">
|
||||
您还未设置邮箱地址,设置后可以接收打卡任务通知。
|
||||
<div
|
||||
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
|
||||
class="ml-2 font-semibold hover:text-sky-950"
|
||||
class="font-semibold hover:text-sky-950 dark:hover:text-sky-100"
|
||||
type="button"
|
||||
@click="router.navigate('/settings')"
|
||||
>
|
||||
立即前往设置
|
||||
设置
|
||||
</button>
|
||||
</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
|
||||
class="ml-2 font-semibold hover:text-sky-950"
|
||||
class="font-semibold hover:text-sky-950 dark:hover:text-sky-100"
|
||||
type="button"
|
||||
@click="router.navigate('/settings')"
|
||||
>
|
||||
立即前往设置
|
||||
设置
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
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" />
|
||||
<div>
|
||||
打卡凭证已过期,无法自动打卡。请回到登录页使用扫码登录刷新 Token。
|
||||
<button
|
||||
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">
|
||||
您还没有打卡任务。
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<AlertTriangle class="size-4 shrink-0" />
|
||||
打卡凭证已过期
|
||||
</span>
|
||||
<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"
|
||||
@click="router.navigate('/tasks')"
|
||||
>
|
||||
立即创建
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section :class="[cardClass, 'overflow-hidden']">
|
||||
<div :class="sectionHeaderClass">
|
||||
<div class="flex items-center gap-2">
|
||||
<KeyRound class="size-4 text-emerald-700" />
|
||||
<h2 class="font-semibold">Token 状态</h2>
|
||||
<section class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div :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-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>
|
||||
<span :class="toneClass(tokenTone)">{{ tokenLabel }}</span>
|
||||
</div>
|
||||
<div class="grid gap-4 p-5 md:grid-cols-2">
|
||||
<div class="grid gap-3 text-sm">
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2"
|
||||
>
|
||||
<span class="text-zinc-500">Token 状态</span>
|
||||
<span :class="toneClass(tokenTone)">{{ tokenLabel }}</span>
|
||||
|
||||
<div :class="[cardClass, 'overflow-hidden']">
|
||||
<div :class="sectionHeaderClass">
|
||||
<div class="flex items-center gap-2">
|
||||
<KeyRound class="size-4 text-emerald-700" />
|
||||
<h2 class="font-semibold">授权</h2>
|
||||
</div>
|
||||
<div
|
||||
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>{{
|
||||
tokenStatus?.days_until_expiry == null
|
||||
? '未知'
|
||||
: `${tokenStatus.days_until_expiry} 天`
|
||||
}}</span>
|
||||
<span :class="toneClass(tokenTone)">{{ tokenLabel }}</span>
|
||||
</div>
|
||||
<div class="grid gap-3 p-4 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-zinc-500">剩余</span>
|
||||
<span class="font-medium">
|
||||
{{
|
||||
tokenStatus?.days_until_expiry == null
|
||||
? '未知'
|
||||
: `${tokenStatus.days_until_expiry} 天`
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
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>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-zinc-500">预警</span>
|
||||
<span>{{ tokenStatus?.expiring_soon ? '是' : '否' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-600">
|
||||
<p>{{ tokenDetail }}</p>
|
||||
<div class="text-sm text-zinc-500 dark:text-zinc-400">{{ tokenDetail }}</div>
|
||||
<button
|
||||
:class="[
|
||||
buttonBase,
|
||||
tokenStatus?.is_valid ? buttonTone.secondary : buttonTone.primary,
|
||||
'mt-4',
|
||||
]"
|
||||
:class="[buttonBase, tokenStatus?.is_valid ? buttonTone.secondary : buttonTone.primary]"
|
||||
type="button"
|
||||
@click="router.navigate('/login')"
|
||||
>
|
||||
<QrCode class="size-4" />
|
||||
扫码刷新 Token
|
||||
扫码刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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']">
|
||||
<div :class="sectionHeaderClass">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -338,12 +338,18 @@ onMounted(load)
|
||||
个人设置
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid gap-3 p-5 text-sm md:grid-cols-2">
|
||||
<div class="rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2">
|
||||
<div class="grid gap-3 p-4 text-sm md:grid-cols-4">
|
||||
<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="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 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="mt-1">
|
||||
<span :class="toneClass(auth.state.user?.role === 'admin' ? 'danger' : 'info')">
|
||||
@@ -351,13 +357,19 @@ onMounted(load)
|
||||
</span>
|
||||
</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="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 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="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) }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -368,10 +380,6 @@ onMounted(load)
|
||||
<div :class="sectionHeaderClass">
|
||||
<div>
|
||||
<h2 class="font-semibold">任务概览</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
{{ activeTasks }} 个启用,{{ inactiveTasks }} 个停用,最近记录成功
|
||||
{{ successToday }} 条。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.secondary]"
|
||||
@@ -381,32 +389,40 @@ onMounted(load)
|
||||
管理任务
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid gap-4 p-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div :class="[cardClass, 'p-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-zinc-50 p-3 dark:border-zinc-800 dark:bg-zinc-950"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-zinc-500">任务总数</span>
|
||||
<CheckCircle2 class="size-4 text-emerald-600" />
|
||||
</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 :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">
|
||||
<span class="text-sm text-zinc-500">最近成功</span>
|
||||
<Activity class="size-4 text-zinc-700" />
|
||||
</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 :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">
|
||||
<span class="text-sm text-zinc-500">下次定时</span>
|
||||
<Clock class="size-4 text-amber-600" />
|
||||
</div>
|
||||
<div class="mt-3 text-lg font-semibold">
|
||||
{{ cronLabel(tasks.find((task) => task.is_active)?.cron_expression) }}
|
||||
{{ cronLabel(nextActiveTask?.cron_expression) }}
|
||||
</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>
|
||||
</section>
|
||||
@@ -415,7 +431,6 @@ onMounted(load)
|
||||
<div :class="sectionHeaderClass">
|
||||
<div>
|
||||
<h2 class="font-semibold">最近记录</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">最近的打卡结果和状态变化会先出现在这里。</p>
|
||||
</div>
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.secondary]"
|
||||
@@ -425,12 +440,8 @@ onMounted(load)
|
||||
查看全部
|
||||
</button>
|
||||
</div>
|
||||
<StateBlock
|
||||
v-if="records.length === 0"
|
||||
title="暂无记录"
|
||||
description="手动或定时打卡后会生成记录。"
|
||||
/>
|
||||
<div v-else class="divide-y divide-zinc-200">
|
||||
<StateBlock v-if="records.length === 0" title="暂无记录" />
|
||||
<div v-else class="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
<div v-for="record in records" :key="record.id" class="px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="font-medium">{{ record.task_name || `任务 #${record.task_id}` }}</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 { authApi } from '@/api'
|
||||
import { useAuth } from '@/app/auth'
|
||||
@@ -20,9 +20,6 @@ const qrSessionId = ref('')
|
||||
const loginMode = ref<'qrcode' | 'password'>('qrcode')
|
||||
let pollTimer: number | undefined
|
||||
|
||||
const currentSubtitle = computed(() =>
|
||||
loginMode.value === 'qrcode' ? 'QQ 扫码登录/注册' : '用户名密码登录',
|
||||
)
|
||||
const canSubmitPassword = computed(
|
||||
() => Boolean(alias.value.trim()) && Boolean(password.value) && !loading.value,
|
||||
)
|
||||
@@ -119,25 +116,24 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<section class="w-full max-w-md">
|
||||
<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
|
||||
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" />
|
||||
</div>
|
||||
<h1 class="text-xl font-semibold tracking-normal text-zinc-950 dark:text-zinc-50">
|
||||
接龙自动打卡系统
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">{{ currentSubtitle }}</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="p-4">
|
||||
<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
|
||||
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="
|
||||
loginMode === 'qrcode'
|
||||
? 'bg-white text-zinc-900 shadow-sm dark:bg-zinc-800 dark:text-zinc-50'
|
||||
@@ -149,7 +145,7 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
<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="
|
||||
loginMode === 'password'
|
||||
? 'bg-white text-zinc-900 shadow-sm dark:bg-zinc-800 dark:text-zinc-50'
|
||||
@@ -163,7 +159,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<form
|
||||
v-if="loginMode === 'password'"
|
||||
class="mt-6 grid gap-4"
|
||||
class="mt-5 grid gap-4"
|
||||
@submit.prevent="loginWithPassword"
|
||||
>
|
||||
<label class="grid gap-2">
|
||||
@@ -212,16 +208,9 @@ onBeforeUnmount(() => {
|
||||
<KeyRound class="size-4" />
|
||||
{{ loading ? '登录中' : '登录' }}
|
||||
</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>
|
||||
|
||||
<div v-else class="mt-6 grid gap-4">
|
||||
<div v-else class="mt-5 grid gap-4">
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">用户名</span>
|
||||
<div class="relative">
|
||||
@@ -275,26 +264,6 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -48,7 +48,6 @@ onMounted(load)
|
||||
<div :class="[sectionHeaderClass, 'md:grid-cols-[1fr_180px_180px_auto]']">
|
||||
<div>
|
||||
<h2 class="font-semibold">个人打卡记录</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">按状态和触发方式查看最近的打卡结果。</p>
|
||||
</div>
|
||||
<select v-model="filters.status" :class="inputClass">
|
||||
<option value="">全部状态</option>
|
||||
@@ -77,31 +76,27 @@ onMounted(load)
|
||||
action-label="重试"
|
||||
@action="load"
|
||||
/>
|
||||
<StateBlock
|
||||
v-else-if="records.length === 0"
|
||||
title="暂无记录"
|
||||
description="当前筛选条件下没有打卡记录。"
|
||||
/>
|
||||
<StateBlock v-else-if="records.length === 0" title="暂无记录" />
|
||||
<div v-else>
|
||||
<div class="divide-y divide-zinc-200">
|
||||
<div class="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
<article
|
||||
v-for="record in records"
|
||||
: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="text-sm font-semibold">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold">
|
||||
{{ record.task_name || `任务 #${record.task_id}` }}
|
||||
</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) }}
|
||||
</div>
|
||||
</div>
|
||||
<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 || '无响应内容' }}
|
||||
</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) }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -113,7 +108,7 @@ onMounted(load)
|
||||
</article>
|
||||
</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
|
||||
>共 {{ total }} 条,当前 {{ filters.skip + 1 }} -
|
||||
|
||||
@@ -86,20 +86,19 @@ onMounted(load)
|
||||
<form :class="[cardClass, 'overflow-hidden']" @submit.prevent="save">
|
||||
<div :class="sectionHeaderClass">
|
||||
<h2 class="font-semibold">个人资料</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">更新别名、邮箱和登录密码。</p>
|
||||
</div>
|
||||
<div class="grid gap-4 p-5">
|
||||
<div class="grid gap-4 p-4">
|
||||
<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 />
|
||||
</label>
|
||||
<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="用于打卡通知" />
|
||||
</label>
|
||||
<div class="grid gap-4 md:grid-cols-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.current_password"
|
||||
:class="inputClass"
|
||||
@@ -108,7 +107,7 @@ onMounted(load)
|
||||
/>
|
||||
</label>
|
||||
<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.new_password"
|
||||
:class="inputClass"
|
||||
@@ -132,16 +131,22 @@ onMounted(load)
|
||||
|
||||
<aside :class="[cardClass, 'h-fit overflow-hidden']">
|
||||
<div
|
||||
class="border-b px-5 py-4"
|
||||
class="grid gap-2 border-b px-4 py-3"
|
||||
: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>
|
||||
<p class="mt-1 text-sm text-zinc-600">这里检查的是打卡业务 token,不是网站登录状态。</p>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h2 class="font-semibold">授权状态</h2>
|
||||
<span :class="toneClass(token?.is_valid ? 'success' : 'danger')">{{
|
||||
token?.is_valid ? '可用' : '不可用'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="mt-4 grid gap-3 text-sm">
|
||||
<div class="p-4">
|
||||
<div class="grid gap-3 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-zinc-500">状态</span>
|
||||
<span :class="toneClass(token?.is_valid ? 'success' : 'danger')">{{
|
||||
|
||||
@@ -83,23 +83,23 @@ onMounted(load)
|
||||
action-label="重试"
|
||||
@action="load"
|
||||
/>
|
||||
<StateBlock
|
||||
v-else-if="records.length === 0"
|
||||
title="暂无记录"
|
||||
description="当前任务还没有符合条件的记录。"
|
||||
/>
|
||||
<StateBlock v-else-if="records.length === 0" title="暂无记录" />
|
||||
<div v-else class="divide-y divide-zinc-200">
|
||||
<article
|
||||
v-for="record in records"
|
||||
: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>
|
||||
<div class="text-sm text-zinc-700">
|
||||
<div class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{{ formatFullDateTime(record.check_in_time) }}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm text-zinc-700 dark:text-zinc-200">
|
||||
{{ record.response_text || record.error_message || '无响应内容' }}
|
||||
</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 class="md:text-right">
|
||||
<span :class="toneClass(statusTone(record.status))">{{
|
||||
@@ -107,7 +107,9 @@ onMounted(load)
|
||||
}}</span>
|
||||
</div>
|
||||
</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 }} 条记录
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -213,7 +213,6 @@ onMounted(load)
|
||||
<div :class="sectionHeaderClass">
|
||||
<div>
|
||||
<h2 class="font-semibold">从模板创建任务</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">选择启用模板,填写接龙 ID 和字段值后创建任务。</p>
|
||||
</div>
|
||||
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load">
|
||||
<RefreshCw class="size-4" />
|
||||
@@ -221,10 +220,10 @@ onMounted(load)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="grid gap-4 p-5" @submit.prevent="createTask">
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<form class="grid gap-4 p-4" @submit.prevent="createTask">
|
||||
<div class="grid gap-3 md:grid-cols-[220px_minmax(0,1fr)_220px]">
|
||||
<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">
|
||||
<option v-for="template in templates" :key="template.id" :value="template.id">
|
||||
{{ template.name }}
|
||||
@@ -232,25 +231,37 @@ onMounted(load)
|
||||
</select>
|
||||
</label>
|
||||
<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="可选" />
|
||||
</label>
|
||||
<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 />
|
||||
</label>
|
||||
</div>
|
||||
<label class="grid gap-2 md:max-w-xs">
|
||||
<span class="text-xs font-semibold text-zinc-500">Cron 表达式</span>
|
||||
<input
|
||||
v-model="createForm.cron_expression"
|
||||
:class="inputClass"
|
||||
placeholder="0 20 * * *"
|
||||
/>
|
||||
</label>
|
||||
<div v-if="fieldEntries.length" class="grid gap-4 md:grid-cols-2">
|
||||
<div class="grid gap-3 md:grid-cols-[220px_minmax(0,1fr)] md:items-end">
|
||||
<label class="grid gap-2">
|
||||
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">Cron 表达式</span>
|
||||
<input
|
||||
v-model="createForm.cron_expression"
|
||||
:class="inputClass"
|
||||
placeholder="0 20 * * *"
|
||||
/>
|
||||
</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">
|
||||
<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
|
||||
}}</span>
|
||||
<select
|
||||
@@ -290,14 +301,6 @@ onMounted(load)
|
||||
<div v-if="message" :class="alertClass.success">
|
||||
{{ message }}
|
||||
</div>
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.primary, 'w-fit']"
|
||||
:disabled="creating || !selectedTemplateId || !createForm.thread_id"
|
||||
type="submit"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
{{ creating ? '创建中' : '创建任务' }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -310,29 +313,24 @@ onMounted(load)
|
||||
action-label="重试"
|
||||
@action="load"
|
||||
/>
|
||||
<StateBlock
|
||||
v-else-if="tasks.length === 0"
|
||||
title="暂无任务"
|
||||
description="先从模板创建一个任务。"
|
||||
/>
|
||||
<StateBlock v-else-if="tasks.length === 0" title="暂无任务" />
|
||||
<section v-else :class="[cardClass, 'overflow-hidden']">
|
||||
<div :class="sectionHeaderClass">
|
||||
<div>
|
||||
<h2 class="font-semibold">任务列表</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">查看启停、最近状态,并执行手动打卡或维护操作。</p>
|
||||
</div>
|
||||
<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 }} 个任务
|
||||
</span>
|
||||
</div>
|
||||
<div class="divide-y divide-zinc-200">
|
||||
<article v-for="task in tasks" :key="task.id" class="p-4">
|
||||
<div class="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
<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>
|
||||
<div class="min-w-0">
|
||||
<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')">{{
|
||||
task.is_active ? '启用' : '停用'
|
||||
}}</span>
|
||||
@@ -352,9 +350,12 @@ onMounted(load)
|
||||
{{ statusLabel(status.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
ThreadId: {{ task.thread_id || '未解析' }} · {{ cronLabel(task.cron_expression) }}
|
||||
</p>
|
||||
<div
|
||||
class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-zinc-500 dark:text-zinc-400"
|
||||
>
|
||||
<span>ThreadId: {{ task.thread_id || '未解析' }}</span>
|
||||
<span>{{ cronLabel(task.cron_expression) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 lg:justify-end">
|
||||
<button
|
||||
@@ -404,25 +405,26 @@ onMounted(load)
|
||||
|
||||
<form
|
||||
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)"
|
||||
>
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-zinc-900">编辑任务</h4>
|
||||
<p class="mt-1 text-xs text-zinc-500">保存前会校验 Payload JSON。</p>
|
||||
<h4 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">编辑任务</h4>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-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" />
|
||||
</label>
|
||||
<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" />
|
||||
</label>
|
||||
</div>
|
||||
<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" />
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
||||
@@ -31,7 +31,6 @@ onMounted(load)
|
||||
<div :class="sectionHeaderClass">
|
||||
<div>
|
||||
<h2 class="font-semibold">系统日志</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">查看最近运行日志,适合排查打卡和后台任务状态。</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
@@ -56,6 +55,7 @@ onMounted(load)
|
||||
action-label="重试"
|
||||
@action="load"
|
||||
/>
|
||||
<StateBlock v-else-if="!logs" title="暂无日志" />
|
||||
<pre
|
||||
v-else
|
||||
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>
|
||||
<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>
|
||||
<h2 class="font-semibold">全量记录</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">按任务和状态快速定位系统记录。</p>
|
||||
</div>
|
||||
<input v-model="filters.task_id" :class="inputClass" placeholder="任务 ID" />
|
||||
<select v-model="filters.status" :class="inputClass">
|
||||
@@ -52,6 +51,7 @@ onMounted(load)
|
||||
<option value="failure">失败</option>
|
||||
<option value="out_of_time">超出时间</option>
|
||||
</select>
|
||||
<input v-model.number="filters.limit" :class="inputClass" type="number" min="1" max="200" />
|
||||
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load">
|
||||
<Search class="size-4" />
|
||||
筛选
|
||||
@@ -66,18 +66,20 @@ onMounted(load)
|
||||
action-label="重试"
|
||||
@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
|
||||
v-for="record in records"
|
||||
: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="font-medium">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-medium">
|
||||
{{ record.user_alias || record.user_email || `任务 #${record.task_id}` }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-zinc-500">
|
||||
{{ formatFullDateTime(record.check_in_time) }}
|
||||
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<span>{{ formatFullDateTime(record.check_in_time) }}</span>
|
||||
<span>{{ record.response_text || record.error_message || '无响应内容' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 md:justify-end">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi, type AdminStats } from '@/api'
|
||||
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'
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -38,26 +38,34 @@ onMounted(load)
|
||||
<div :class="sectionHeaderClass">
|
||||
<div>
|
||||
<h2 class="font-semibold">系统统计</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">总览用户、任务、记录和 Token 预警。</p>
|
||||
</div>
|
||||
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load">刷新</button>
|
||||
</div>
|
||||
<div class="grid gap-4 p-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-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-3 dark:border-zinc-800 dark:bg-zinc-950"
|
||||
>
|
||||
<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-1 text-sm text-zinc-500">已审批 {{ stats?.users.active }}</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="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>
|
||||
<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="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>
|
||||
<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="mt-2 font-mono text-3xl font-semibold">{{ stats?.tokens.expiring_soon }}</div>
|
||||
<div class="mt-1 text-sm text-zinc-500">7 天内过期</div>
|
||||
|
||||
@@ -11,13 +11,20 @@ import {
|
||||
} from '@/components/templates/template-config'
|
||||
import {
|
||||
alertClass,
|
||||
buttonBase,
|
||||
buttonTone,
|
||||
cardClass,
|
||||
inputClass,
|
||||
labelClass,
|
||||
sectionHeaderClass,
|
||||
toneClass,
|
||||
} 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'
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -40,6 +47,13 @@ const localPreviewPayload = computed(() => {
|
||||
if (!result.ok) return null
|
||||
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() {
|
||||
loading.value = true
|
||||
@@ -127,17 +141,16 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<div class="grid gap-5">
|
||||
<section :class="[cardClass, 'overflow-hidden']">
|
||||
<div :class="sectionHeaderClass">
|
||||
<div>
|
||||
<h2 class="font-semibold">模板管理</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">维护创建任务时可选择的字段配置和模板状态。</p>
|
||||
</div>
|
||||
<button :class="[buttonBase, buttonTone.primary]" type="button" @click="startCreate">
|
||||
<Button type="button" @click="startCreate">
|
||||
<Plus class="size-4" />
|
||||
新建模板
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<StateBlock v-if="loading" title="正在加载模板" type="loading" />
|
||||
<StateBlock
|
||||
@@ -148,121 +161,127 @@ onMounted(load)
|
||||
action-label="重试"
|
||||
@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
|
||||
v-for="template in templates"
|
||||
: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">
|
||||
<h3 class="font-semibold">{{ template.name }}</h3>
|
||||
<h3 class="truncate font-semibold">{{ template.name }}</h3>
|
||||
<span :class="toneClass(template.is_active ? 'success' : 'neutral')">{{
|
||||
template.is_active ? '启用' : '停用'
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
{{ template.description || '无描述' }} · {{ formatDateTime(template.created_at) }}
|
||||
</p>
|
||||
<div
|
||||
class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-zinc-500 dark:text-zinc-400"
|
||||
>
|
||||
<span>{{ template.description || '无描述' }}</span>
|
||||
<span>{{ formatDateTime(template.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.secondary]"
|
||||
type="button"
|
||||
@click="showPreview(template)"
|
||||
>
|
||||
<Button type="button" variant="outline" @click="showPreview(template)">
|
||||
<Eye class="size-4" />
|
||||
预览
|
||||
</button>
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.secondary]"
|
||||
type="button"
|
||||
@click="startEdit(template)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
:class="[buttonBase, buttonTone.danger]"
|
||||
</Button>
|
||||
<Button type="button" variant="outline" @click="startEdit(template)"> 编辑 </Button>
|
||||
<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)"
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
删除
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="grid gap-5">
|
||||
<form
|
||||
v-if="editingId"
|
||||
:class="[cardClass, 'grid gap-4 overflow-hidden']"
|
||||
@submit.prevent="save"
|
||||
>
|
||||
<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>
|
||||
<section v-if="preview" :class="[cardClass, 'p-5']">
|
||||
<details open>
|
||||
<summary class="cursor-pointer text-sm font-semibold">
|
||||
{{ preview.template_name }} 预览
|
||||
</summary>
|
||||
<pre
|
||||
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
|
||||
>
|
||||
</section>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section v-else-if="editingId && localPreviewPayload" :class="[cardClass, 'p-5']">
|
||||
<h2 class="font-semibold">当前配置预览</h2>
|
||||
<pre
|
||||
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
|
||||
<Dialog v-model:open="editorOpen">
|
||||
<DialogContent
|
||||
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))]"
|
||||
>
|
||||
<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>
|
||||
</aside>
|
||||
<DialogTitle>{{ editorTitle }}</DialogTitle>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
sectionHeaderClass,
|
||||
toneClass,
|
||||
} from '@/components/ui'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { extractErrorMessage, formatDateTime } from '@/utils/format'
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -94,18 +95,19 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<section :class="[cardClass, 'overflow-hidden']">
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start">
|
||||
<section :class="[cardClass, 'min-w-0 overflow-hidden']">
|
||||
<div :class="sectionHeaderClass">
|
||||
<div>
|
||||
<h2 class="font-semibold">用户审批与管理</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">优先处理待审批用户,再维护角色、邮箱和密码。</p>
|
||||
</div>
|
||||
<span :class="toneClass(users.some((user) => !user.is_approved) ? 'warning' : 'success')">
|
||||
{{ users.filter((user) => !user.is_approved).length }} 个待审批
|
||||
</span>
|
||||
</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="搜索别名" />
|
||||
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load">
|
||||
<Search class="size-4" />
|
||||
@@ -125,15 +127,16 @@ onMounted(load)
|
||||
action-label="重试"
|
||||
@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
|
||||
v-for="user in users"
|
||||
: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">
|
||||
<h3 class="font-semibold">{{ user.alias }}</h3>
|
||||
<h3 class="truncate font-semibold">{{ user.alias }}</h3>
|
||||
<span :class="toneClass(user.is_approved ? 'success' : 'warning')">{{
|
||||
user.is_approved ? '已审批' : '待审批'
|
||||
}}</span>
|
||||
@@ -141,9 +144,12 @@ onMounted(load)
|
||||
user.role
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
{{ user.email || '未设置邮箱' }} · {{ formatDateTime(user.created_at) }}
|
||||
</p>
|
||||
<div
|
||||
class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-zinc-500 dark:text-zinc-400"
|
||||
>
|
||||
<span>{{ user.email || '未设置邮箱' }}</span>
|
||||
<span>{{ formatDateTime(user.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -172,33 +178,61 @@ onMounted(load)
|
||||
</div>
|
||||
</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
|
||||
v-if="editingId"
|
||||
:class="[cardClass, 'grid h-fit gap-4 overflow-hidden']"
|
||||
v-else
|
||||
:class="[
|
||||
cardClass,
|
||||
'grid h-fit min-w-0 gap-4 overflow-hidden xl:sticky xl:top-20 xl:self-start',
|
||||
]"
|
||||
@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>
|
||||
<p class="mt-1 text-sm text-zinc-500">保存后会刷新用户列表。</p>
|
||||
</div>
|
||||
<div class="grid gap-4 p-5">
|
||||
<div class="grid gap-4 p-4">
|
||||
<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 />
|
||||
</label>
|
||||
<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" />
|
||||
</label>
|
||||
<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">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</label>
|
||||
<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.password"
|
||||
:class="inputClass"
|
||||
|
||||
Reference in New Issue
Block a user