feat: add SingleRecordMnt component for student record maintenance with search, add, edit, view, and delete functionalities
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
# 1. 強制檢查 Node 版本
|
||||||
|
engine-strict=true
|
||||||
|
|
||||||
|
# 2. 自動安裝 Peer Dependencies
|
||||||
|
auto-install-peers=true
|
||||||
|
|
||||||
|
# 3. 提升特定套件 (選配)
|
||||||
|
# 如果遇到某些舊套件找不到 Vue 或 Vuetify,開啟這個可以模擬 npm 的扁平化結構
|
||||||
|
# shamefully-hoist=true
|
||||||
|
|
||||||
|
# 4. 鎖定版本 (如果您希望版本極度穩定)
|
||||||
|
# save-exact=true
|
||||||
|
|
||||||
|
# 5. 針對 WSL 的優化 (選配)
|
||||||
|
# 如果您在 WSL 存取 Windows 磁碟區(如 /mnt/c)時遇到權限問題,可以開啟
|
||||||
|
# node-linker=hoisted
|
||||||
+13
-1
@@ -4,5 +4,17 @@ import vuetify from 'eslint-config-vuetify'
|
|||||||
export default vuetify({
|
export default vuetify({
|
||||||
ts: true,
|
ts: true,
|
||||||
},{
|
},{
|
||||||
extends: [eslintConfigPrettier]
|
extends: [eslintConfigPrettier],
|
||||||
|
rules: {
|
||||||
|
'vue/no-required-prop-with-default': 'off',
|
||||||
|
'vue/attributes-order': 'off',
|
||||||
|
'vue/no-template-shadow': 'off',
|
||||||
|
'@typescript-eslint/unified-signatures': 'off',
|
||||||
|
'@typescript-eslint/member-ordering': 'off',
|
||||||
|
'unicorn/prefer-query-selector': 'off',
|
||||||
|
'unicorn/no-array-sort':'off',
|
||||||
|
"vue/no-mutating-props" : "off",
|
||||||
|
'unicorn/prefer-logical-operator-over-ternary': 'off',
|
||||||
|
'unicorn/prefer-structured-clone': 'off',
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
|
"axios": "^1.13.6",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.31",
|
"vue": "^3.5.31",
|
||||||
"vue-i18n": "^11.3.0",
|
"vue-i18n": "^11.3.0",
|
||||||
|
|||||||
Generated
+190
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@mdi/js':
|
'@mdi/js':
|
||||||
specifier: ^7.4.47
|
specifier: ^7.4.47
|
||||||
version: 7.4.47
|
version: 7.4.47
|
||||||
|
axios:
|
||||||
|
specifier: ^1.13.6
|
||||||
|
version: 1.13.6
|
||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.4
|
specifier: ^3.0.4
|
||||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.31(typescript@5.9.3))
|
version: 3.0.4(typescript@5.9.3)(vue@3.5.31(typescript@5.9.3))
|
||||||
@@ -647,6 +650,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==}
|
resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
||||||
|
asynckit@0.4.0:
|
||||||
|
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||||
|
|
||||||
|
axios@1.13.6:
|
||||||
|
resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==}
|
||||||
|
|
||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
@@ -681,6 +690,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==}
|
resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==}
|
||||||
engines: {node: '>=18.20'}
|
engines: {node: '>=18.20'}
|
||||||
|
|
||||||
|
call-bind-apply-helpers@1.0.2:
|
||||||
|
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
callsites@3.1.0:
|
callsites@3.1.0:
|
||||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -728,6 +741,10 @@ packages:
|
|||||||
colorjs.io@0.5.2:
|
colorjs.io@0.5.2:
|
||||||
resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==}
|
resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==}
|
||||||
|
|
||||||
|
combined-stream@1.0.8:
|
||||||
|
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
comment-parser@1.4.5:
|
comment-parser@1.4.5:
|
||||||
resolution: {integrity: sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==}
|
resolution: {integrity: sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -772,6 +789,10 @@ packages:
|
|||||||
deep-is@0.1.4:
|
deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0:
|
||||||
|
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||||
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
detect-libc@2.1.2:
|
detect-libc@2.1.2:
|
||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -780,6 +801,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==}
|
resolution: {integrity: sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==}
|
||||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||||
|
|
||||||
|
dunder-proto@1.0.1:
|
||||||
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
electron-to-chromium@1.5.325:
|
electron-to-chromium@1.5.325:
|
||||||
resolution: {integrity: sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==}
|
resolution: {integrity: sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==}
|
||||||
|
|
||||||
@@ -794,6 +819,22 @@ packages:
|
|||||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
es-define-property@1.0.1:
|
||||||
|
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-errors@1.3.0:
|
||||||
|
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-object-atoms@1.1.1:
|
||||||
|
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-set-tostringtag@2.1.0:
|
||||||
|
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
escalade@3.2.0:
|
escalade@3.2.0:
|
||||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1019,11 +1060,27 @@ packages:
|
|||||||
flatted@3.4.2:
|
flatted@3.4.2:
|
||||||
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
||||||
|
|
||||||
|
follow-redirects@1.15.11:
|
||||||
|
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
|
||||||
|
engines: {node: '>=4.0'}
|
||||||
|
peerDependencies:
|
||||||
|
debug: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
debug:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
form-data@4.0.5:
|
||||||
|
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
|
function-bind@1.1.2:
|
||||||
|
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||||
|
|
||||||
get-caller-file@2.0.5:
|
get-caller-file@2.0.5:
|
||||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||||
engines: {node: 6.* || 8.* || >= 10.*}
|
engines: {node: 6.* || 8.* || >= 10.*}
|
||||||
@@ -1032,6 +1089,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==}
|
resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
get-intrinsic@1.3.0:
|
||||||
|
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
get-proto@1.0.1:
|
||||||
|
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
glob-parent@6.0.2:
|
glob-parent@6.0.2:
|
||||||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@@ -1048,6 +1113,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==}
|
resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
gopd@1.2.0:
|
||||||
|
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
graphemer@1.4.0:
|
graphemer@1.4.0:
|
||||||
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
|
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
|
||||||
|
|
||||||
@@ -1055,6 +1124,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
has-symbols@1.1.0:
|
||||||
|
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
has-tostringtag@1.0.2:
|
||||||
|
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
hasown@2.0.2:
|
||||||
|
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
hookable@5.5.3:
|
hookable@5.5.3:
|
||||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||||
|
|
||||||
@@ -1248,10 +1329,22 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
math-intrinsics@1.1.0:
|
||||||
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
memorystream@0.3.1:
|
memorystream@0.3.1:
|
||||||
resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
|
resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
|
||||||
engines: {node: '>= 0.10.0'}
|
engines: {node: '>= 0.10.0'}
|
||||||
|
|
||||||
|
mime-db@1.52.0:
|
||||||
|
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
mime-types@2.1.35:
|
||||||
|
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
minimatch@10.2.4:
|
minimatch@10.2.4:
|
||||||
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
|
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
@@ -1394,6 +1487,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0:
|
||||||
|
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||||
|
|
||||||
punycode@2.3.1:
|
punycode@2.3.1:
|
||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2456,6 +2552,16 @@ snapshots:
|
|||||||
'@babel/parser': 7.29.2
|
'@babel/parser': 7.29.2
|
||||||
ast-kit: 2.2.0
|
ast-kit: 2.2.0
|
||||||
|
|
||||||
|
asynckit@0.4.0: {}
|
||||||
|
|
||||||
|
axios@1.13.6:
|
||||||
|
dependencies:
|
||||||
|
follow-redirects: 1.15.11
|
||||||
|
form-data: 4.0.5
|
||||||
|
proxy-from-env: 1.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
balanced-match@4.0.4: {}
|
balanced-match@4.0.4: {}
|
||||||
@@ -2485,6 +2591,11 @@ snapshots:
|
|||||||
|
|
||||||
builtin-modules@5.0.0: {}
|
builtin-modules@5.0.0: {}
|
||||||
|
|
||||||
|
call-bind-apply-helpers@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
es-errors: 1.3.0
|
||||||
|
function-bind: 1.1.2
|
||||||
|
|
||||||
callsites@3.1.0: {}
|
callsites@3.1.0: {}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001781: {}
|
caniuse-lite@1.0.30001781: {}
|
||||||
@@ -2527,6 +2638,10 @@ snapshots:
|
|||||||
|
|
||||||
colorjs.io@0.5.2: {}
|
colorjs.io@0.5.2: {}
|
||||||
|
|
||||||
|
combined-stream@1.0.8:
|
||||||
|
dependencies:
|
||||||
|
delayed-stream: 1.0.0
|
||||||
|
|
||||||
comment-parser@1.4.5: {}
|
comment-parser@1.4.5: {}
|
||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
@@ -2559,10 +2674,18 @@ snapshots:
|
|||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0: {}
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
diff-sequences@27.5.1: {}
|
diff-sequences@27.5.1: {}
|
||||||
|
|
||||||
|
dunder-proto@1.0.1:
|
||||||
|
dependencies:
|
||||||
|
call-bind-apply-helpers: 1.0.2
|
||||||
|
es-errors: 1.3.0
|
||||||
|
gopd: 1.2.0
|
||||||
|
|
||||||
electron-to-chromium@1.5.325: {}
|
electron-to-chromium@1.5.325: {}
|
||||||
|
|
||||||
emoji-regex@10.6.0: {}
|
emoji-regex@10.6.0: {}
|
||||||
@@ -2571,6 +2694,21 @@ snapshots:
|
|||||||
|
|
||||||
entities@7.0.1: {}
|
entities@7.0.1: {}
|
||||||
|
|
||||||
|
es-define-property@1.0.1: {}
|
||||||
|
|
||||||
|
es-errors@1.3.0: {}
|
||||||
|
|
||||||
|
es-object-atoms@1.1.1:
|
||||||
|
dependencies:
|
||||||
|
es-errors: 1.3.0
|
||||||
|
|
||||||
|
es-set-tostringtag@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
es-errors: 1.3.0
|
||||||
|
get-intrinsic: 1.3.0
|
||||||
|
has-tostringtag: 1.0.2
|
||||||
|
hasown: 2.0.2
|
||||||
|
|
||||||
escalade@3.2.0: {}
|
escalade@3.2.0: {}
|
||||||
|
|
||||||
escape-string-regexp@1.0.5: {}
|
escape-string-regexp@1.0.5: {}
|
||||||
@@ -2850,13 +2988,43 @@ snapshots:
|
|||||||
|
|
||||||
flatted@3.4.2: {}
|
flatted@3.4.2: {}
|
||||||
|
|
||||||
|
follow-redirects@1.15.11: {}
|
||||||
|
|
||||||
|
form-data@4.0.5:
|
||||||
|
dependencies:
|
||||||
|
asynckit: 0.4.0
|
||||||
|
combined-stream: 1.0.8
|
||||||
|
es-set-tostringtag: 2.1.0
|
||||||
|
hasown: 2.0.2
|
||||||
|
mime-types: 2.1.35
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
function-bind@1.1.2: {}
|
||||||
|
|
||||||
get-caller-file@2.0.5: {}
|
get-caller-file@2.0.5: {}
|
||||||
|
|
||||||
get-east-asian-width@1.5.0: {}
|
get-east-asian-width@1.5.0: {}
|
||||||
|
|
||||||
|
get-intrinsic@1.3.0:
|
||||||
|
dependencies:
|
||||||
|
call-bind-apply-helpers: 1.0.2
|
||||||
|
es-define-property: 1.0.1
|
||||||
|
es-errors: 1.3.0
|
||||||
|
es-object-atoms: 1.1.1
|
||||||
|
function-bind: 1.1.2
|
||||||
|
get-proto: 1.0.1
|
||||||
|
gopd: 1.2.0
|
||||||
|
has-symbols: 1.1.0
|
||||||
|
hasown: 2.0.2
|
||||||
|
math-intrinsics: 1.1.0
|
||||||
|
|
||||||
|
get-proto@1.0.1:
|
||||||
|
dependencies:
|
||||||
|
dunder-proto: 1.0.1
|
||||||
|
es-object-atoms: 1.1.1
|
||||||
|
|
||||||
glob-parent@6.0.2:
|
glob-parent@6.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
@@ -2867,10 +3035,22 @@ snapshots:
|
|||||||
|
|
||||||
globals@17.4.0: {}
|
globals@17.4.0: {}
|
||||||
|
|
||||||
|
gopd@1.2.0: {}
|
||||||
|
|
||||||
graphemer@1.4.0: {}
|
graphemer@1.4.0: {}
|
||||||
|
|
||||||
has-flag@4.0.0: {}
|
has-flag@4.0.0: {}
|
||||||
|
|
||||||
|
has-symbols@1.1.0: {}
|
||||||
|
|
||||||
|
has-tostringtag@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
has-symbols: 1.1.0
|
||||||
|
|
||||||
|
hasown@2.0.2:
|
||||||
|
dependencies:
|
||||||
|
function-bind: 1.1.2
|
||||||
|
|
||||||
hookable@5.5.3: {}
|
hookable@5.5.3: {}
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
@@ -3020,8 +3200,16 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
memorystream@0.3.1: {}
|
memorystream@0.3.1: {}
|
||||||
|
|
||||||
|
mime-db@1.52.0: {}
|
||||||
|
|
||||||
|
mime-types@2.1.35:
|
||||||
|
dependencies:
|
||||||
|
mime-db: 1.52.0
|
||||||
|
|
||||||
minimatch@10.2.4:
|
minimatch@10.2.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 5.0.5
|
brace-expansion: 5.0.5
|
||||||
@@ -3158,6 +3346,8 @@ snapshots:
|
|||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0: {}
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
quansync@0.2.11: {}
|
quansync@0.2.11: {}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" version="1.1" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5">
|
||||||
|
<rect height="7.5" width="12.5" y="5.75" x="1.75"/>
|
||||||
|
<path d="m10.75 8.75v1.5m-5.5-1.5v1.5m-.5-7.5 3.25 3 3.25-3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 439 B |
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<configuration>
|
||||||
|
<system.webServer>
|
||||||
|
<rewrite>
|
||||||
|
<rules>
|
||||||
|
<rule name="Vue SPA" stopProcessing="true">
|
||||||
|
<match url=".*" />
|
||||||
|
<conditions logicalGrouping="MatchAll">
|
||||||
|
<add input="{REQUEST_URI}" pattern="^/service" negate="true" />
|
||||||
|
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
|
||||||
|
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
|
||||||
|
</conditions>
|
||||||
|
<action type="Rewrite" url="/index.html" />
|
||||||
|
</rule>
|
||||||
|
</rules>
|
||||||
|
</rewrite>
|
||||||
|
</system.webServer>
|
||||||
|
</configuration>
|
||||||
+518
-7
@@ -1,11 +1,522 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-app>
|
<!-- 根據路由設定 meta.layout 動態切換佈局 -->
|
||||||
<v-main>
|
<component
|
||||||
<router-view />
|
:is="activeLayout" v-bind="layoutProps" v-model:breadcrumb-bar-visible="favoritesStore.breadcrumbBarVisible"
|
||||||
</v-main>
|
v-model:favorites-bar-visible="favoritesStore.favoritesBarVisible"
|
||||||
</v-app>
|
v-model:is-rail="menuStore.isRail" @action="handleLayoutAction" @logout="handleLogout"
|
||||||
|
@remove-favorite="handleRemoveFavorite" @search="handleSearch" @select="handleSelect">
|
||||||
|
<template #breadcrumb-actions>
|
||||||
|
<v-btn
|
||||||
|
color="secondary" :disabled="isFavoriteActionDisabled" size="small" variant="outlined"
|
||||||
|
@click="toggleFavorite">
|
||||||
|
<v-icon class="mr-1" size="14">{{ favoriteActionIcon }}</v-icon>
|
||||||
|
{{ favoriteActionLabel }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn class="ml-2" color="primary" size="small" variant="text" @click="goHome">
|
||||||
|
<v-icon class="mr-1" size="14">mdi-home</v-icon>
|
||||||
|
返回首頁
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<!-- 如果是預設佈局,顯示分頁標籤 -->
|
||||||
|
<template v-if="showTabs">
|
||||||
|
<div class="d-flex flex-column h-100">
|
||||||
|
<v-tabs
|
||||||
|
v-model="activeTab" bg-color="background" color="primary" density="compact" show-arrows
|
||||||
|
style="flex-shrink: 0;">
|
||||||
|
<v-tab v-for="tab in tabs" :key="tab.path" border="sm" :to="tab.path" :value="tab.path">
|
||||||
|
{{ tab.title }}
|
||||||
|
<v-btn
|
||||||
|
class="pl-2" color="grey" density="compact" :disabled="tabs.length <= 1" icon size="x-small"
|
||||||
|
variant="text" @click.prevent.stop="closeTab(tab.path)">
|
||||||
|
<v-icon>mdi-close</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-tab>
|
||||||
|
</v-tabs>
|
||||||
|
|
||||||
|
<div class="flex-grow-1 overflow-auto" style="min-height: 0;">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<keep-alive>
|
||||||
|
<component :is="Component" :key="route.fullPath" />
|
||||||
|
</keep-alive>
|
||||||
|
</router-view>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 其他佈局直接顯示內容 -->
|
||||||
|
<router-view v-else />
|
||||||
|
</component>
|
||||||
|
|
||||||
|
<v-dialog v-model="searchDialog" max-width="640">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-h6 bg-primary-variant pa-4">搜尋結果</v-card-title>
|
||||||
|
<v-card-subtitle v-if="searchKeyword" class="pt-4">關鍵字:{{ searchKeyword }}</v-card-subtitle>
|
||||||
|
<v-card-text class="pt-2">
|
||||||
|
<v-alert v-if="searchResults.length === 0" class="mt-2" density="compact" type="info" variant="tonal">
|
||||||
|
查無結果
|
||||||
|
</v-alert>
|
||||||
|
<v-list v-else density="compact">
|
||||||
|
<v-list-item v-for="item in searchResults" :key="item.path" class="mb-2" @click="handleSearchSelect(item)">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon v-if="item.icon" size="18">{{ item.icon }}</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle v-if="item.parents?.length">
|
||||||
|
{{ item.parents.join(' / ') }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="primary" variant="text" @click="searchDialog = false">關閉</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
訊息中心 Dialog:
|
||||||
|
放在 App.vue 的原因是要被「首頁卡片」與「頂部工具列訊息按鈕」共同觸發,
|
||||||
|
並且避免在 layout/template 層放入業務 UI,維持模板的純展示特性。
|
||||||
|
-->
|
||||||
|
<v-dialog v-model="messageStore.isOpen" max-width="720">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-h6 bg-primary-variant pa-4">訊息清單</v-card-title>
|
||||||
|
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis">僅示意資料,不含延伸功能</v-card-subtitle>
|
||||||
|
<v-card-text class="pa-4">
|
||||||
|
<!--
|
||||||
|
使用 v-data-iterator 進行資料展示,
|
||||||
|
這樣若未來要加排序或分頁,不需改動結構。
|
||||||
|
-->
|
||||||
|
<v-data-iterator item-key="id" :items="messageItems" :items-per-page="-1">
|
||||||
|
<template #default="{ items }">
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item v-for="wrapped in items" :key="resolveMessageItem(wrapped).id" border="sm" class="pa-2 mb-1">
|
||||||
|
<template #prepend>
|
||||||
|
<v-avatar color="primary" size="28" variant="tonal">
|
||||||
|
<v-icon size="16">{{ resolveMessageItem(wrapped).icon }}</v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title class="text-body-2 font-weight-medium">
|
||||||
|
{{ resolveMessageItem(wrapped).title }}
|
||||||
|
</v-list-item-title>
|
||||||
|
<v-list-item-subtitle class="text-caption text-medium-emphasis">
|
||||||
|
{{ resolveMessageItem(wrapped).meta }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</template>
|
||||||
|
</v-data-iterator>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions class="justify-end">
|
||||||
|
<v-btn color="primary" variant="text" @click="messageStore.close()">關閉</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-snackbar
|
||||||
|
v-model="snackbar.visible" :color="snackbar.color" :location="snackbar.location"
|
||||||
|
:timeout="snackbar.timeout" :variant="snackbar.variant">
|
||||||
|
{{ snackbar.message }}
|
||||||
|
</v-snackbar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script setup>
|
||||||
//
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { SKAdminLayout, SKEmptyLayout } from '../src'
|
||||||
|
import { HTTP_TOAST_EVENT } from './services/http-toast'
|
||||||
|
import { SESSION_FORCE_LOGOUT_EVENT } from './services/session'
|
||||||
|
import { useAuthStore } from './stores/auth'
|
||||||
|
import { useBreadcrumbStore } from './stores/breadcrumbs'
|
||||||
|
import { useFavoritesStore } from './stores/favorites'
|
||||||
|
import { useMenuStore } from './stores/menu'
|
||||||
|
import { useMessageStore } from './stores/messages'
|
||||||
|
import { useSnackbarStore } from './stores/snackbar'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const snackbar = useSnackbarStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const menuStore = useMenuStore()
|
||||||
|
const breadcrumbStore = useBreadcrumbStore()
|
||||||
|
const favoritesStore = useFavoritesStore()
|
||||||
|
// 訊息中心:集中控制 dialog 顯示狀態
|
||||||
|
const messageStore = useMessageStore()
|
||||||
|
|
||||||
|
// 固定選單(合併到 API 回傳的選單)
|
||||||
|
const _fixedMenuItems = [
|
||||||
|
{
|
||||||
|
title: '資料維護',
|
||||||
|
navigable: false,
|
||||||
|
subItems: [
|
||||||
|
{ title: '單筆資料維護', icon: 'mdi-file-document-outline', path: '/single-record-maintenance' },
|
||||||
|
{ title: '主從資料維護A', icon: 'mdi-file-tree-outline', path: '/master-detail-maintenance' },
|
||||||
|
{ title: '主從資料維護B', icon: 'mdi-file-tree-outline', path: '/master-detail-maintenance-b' },
|
||||||
|
{ title: '主從資料維護C', icon: 'mdi-file-tree-outline', path: '/master-detail-maintenance-c' },
|
||||||
|
{ title: '可編輯表格維護', icon: 'mdi-table-edit', path: '/editable-grid-maintenance' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ title: '登入頁', path: '/login' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 範例選單(用於 tab 顯示名稱的保底資料)
|
||||||
|
const _menuItemsExample = [
|
||||||
|
{ title: '首頁', icon: 'mdi-home', path: '/' },
|
||||||
|
{ title: '工作台', icon: 'mdi-view-dashboard-variant', path: '/dashboard' },
|
||||||
|
{ title: '分析頁', icon: 'mdi-chart-box-outline', path: '/analysis' },
|
||||||
|
{
|
||||||
|
title: '設定',
|
||||||
|
icon: 'mdi-cog',
|
||||||
|
path: '/settings',
|
||||||
|
navigable: false,
|
||||||
|
subItems: [
|
||||||
|
{ title: '角色管理', icon: 'mdi-account-group', path: '/role-management' },
|
||||||
|
{ title: '選單管理', icon: 'mdi-menu', path: '/menu-management' },
|
||||||
|
{ title: '部門管理', icon: 'mdi-domain', path: '/dept-management' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
..._fixedMenuItems,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 佈局對映表
|
||||||
|
*/
|
||||||
|
const layoutMap = {
|
||||||
|
default: SKAdminLayout,
|
||||||
|
none: SKEmptyLayout,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取得當前應使用的組件
|
||||||
|
const activeLayout = computed(() => {
|
||||||
|
return layoutMap[route.meta.layout] || SKAdminLayout
|
||||||
|
})
|
||||||
|
|
||||||
|
function buildMergedMenuItems (items) {
|
||||||
|
const flatPaths = new Set()
|
||||||
|
const collectPaths = (list) => {
|
||||||
|
for (const item of list || []) {
|
||||||
|
if (item?.path) flatPaths.add(item.path)
|
||||||
|
if (item?.subItems?.length) collectPaths(item.subItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collectPaths(items)
|
||||||
|
|
||||||
|
const mergeFixedItems = (list) => {
|
||||||
|
return (list || []).map((item) => {
|
||||||
|
if (!item?.subItems?.length) return item
|
||||||
|
const subItems = item.subItems.filter((sub) => !sub?.path || !flatPaths.has(sub.path))
|
||||||
|
return { ...item, subItems }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixedItems = mergeFixedItems(_fixedMenuItems).filter((item) => {
|
||||||
|
if (!item?.subItems?.length) return !item?.path || !flatPaths.has(item.path)
|
||||||
|
return item.subItems.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return [...(items || []), ...fixedItems]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根據不同 Layout 傳遞不同的 Props
|
||||||
|
const mergedMenuItems = computed(() => buildMergedMenuItems(menuStore.menuItems))
|
||||||
|
|
||||||
|
const mergedFavoriteItems = computed(() => {
|
||||||
|
const combined = [...menuStore.favoriteItems, ...favoritesStore.layoutItems]
|
||||||
|
const seen = new Set()
|
||||||
|
return combined.filter((item) => {
|
||||||
|
const key = item.path ?? item.title
|
||||||
|
if (!key) return false
|
||||||
|
if (seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const layoutProps = computed(() => {
|
||||||
|
const layout = route.meta.layout
|
||||||
|
if (layout === 'default') {
|
||||||
|
return {
|
||||||
|
systemTitle: '測試環境',
|
||||||
|
favoriteItems: mergedFavoriteItems.value,
|
||||||
|
menuItems: mergedMenuItems.value,
|
||||||
|
breadcrumbItems: breadcrumbStore.breadcrumbItems,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSelect (item) {
|
||||||
|
console.log('Selected:', item)
|
||||||
|
if (item.path) {
|
||||||
|
router.push(item.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchDialog = ref(false)
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const searchResults = ref([])
|
||||||
|
|
||||||
|
function buildSearchResults (items, keyword, parents = []) {
|
||||||
|
const results = []
|
||||||
|
for (const item of items || []) {
|
||||||
|
const currentParents = item?.title ? [...parents, item.title] : parents
|
||||||
|
if (item?.subItems?.length) {
|
||||||
|
results.push(...buildSearchResults(item.subItems, keyword, currentParents))
|
||||||
|
}
|
||||||
|
if (item?.path && item?.title) {
|
||||||
|
const hit = item.title.toLowerCase().includes(keyword)
|
||||||
|
if (hit) {
|
||||||
|
results.push({
|
||||||
|
title: item.title,
|
||||||
|
path: item.path,
|
||||||
|
icon: item.icon,
|
||||||
|
parents: parents,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// 接收 Layout 的搜尋事件:僅在「按鈕或 Enter」時觸發
|
||||||
|
function handleSearch (value) {
|
||||||
|
const keyword = String(value ?? '').trim()
|
||||||
|
searchKeyword.value = keyword
|
||||||
|
if (!keyword) {
|
||||||
|
// 空字串時不顯示結果彈窗
|
||||||
|
searchResults.value = []
|
||||||
|
searchDialog.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const lowered = keyword.toLowerCase()
|
||||||
|
// 依合併後的 menuItems 進行比對
|
||||||
|
searchResults.value = buildSearchResults(mergedMenuItems.value, lowered)
|
||||||
|
// 開啟彈窗顯示搜尋結果
|
||||||
|
searchDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 點擊搜尋結果後導頁(行為等同選單點擊)
|
||||||
|
function handleSearchSelect (item) {
|
||||||
|
searchDialog.value = false
|
||||||
|
handleSelect(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 訊息中心的示意資料,僅用於展示列表,不進行 API 呼叫
|
||||||
|
const messageItems = [
|
||||||
|
{ id: 1, title: '系統維護提醒', meta: '今天 09:00 · 資訊中心', icon: 'mdi-bell-outline' },
|
||||||
|
{ id: 2, title: '教務處公告', meta: '昨天 16:20 · 教務處', icon: 'mdi-school-outline' },
|
||||||
|
{ id: 3, title: '宿舍申請結果', meta: '昨天 13:05 · 學務處', icon: 'mdi-home-city-outline' },
|
||||||
|
{ id: 4, title: '課表異動通知', meta: '2 天前 · 教務處', icon: 'mdi-calendar-outline' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// v-data-iterator 會包裝 items,這裡取回原始資料物件
|
||||||
|
function resolveMessageItem (wrapped) {
|
||||||
|
if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
|
||||||
|
return wrapped.raw
|
||||||
|
}
|
||||||
|
return wrapped
|
||||||
|
}
|
||||||
|
|
||||||
|
// 由 layout 的 action 事件統一進入此處處理
|
||||||
|
// 目前只處理訊息中心,其他 action 可在此擴充
|
||||||
|
function handleLayoutAction (type) {
|
||||||
|
if (type === 'messages') {
|
||||||
|
messageStore.open()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function performLogout ({ message, color }) {
|
||||||
|
authStore.logout()
|
||||||
|
tabs.value = []
|
||||||
|
activeTab.value = null
|
||||||
|
snackbar.show({ message, color })
|
||||||
|
router.replace({ name: 'login' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout () {
|
||||||
|
performLogout({ message: '登出成功', color: 'success' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleForceLogout (event) {
|
||||||
|
const message = event?.detail?.message || '請重新登入'
|
||||||
|
performLogout({ message, color: 'warning' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHttpToast (event) {
|
||||||
|
const detail = event?.detail
|
||||||
|
const message = detail?.message
|
||||||
|
if (!message) return
|
||||||
|
|
||||||
|
const level = detail?.level
|
||||||
|
const color = level === 'error' ? 'error' : level === 'warning' ? 'warning' : 'info'
|
||||||
|
snackbar.show({ message, color, timeout: 3000, location: 'top right', variant: 'flat' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener(SESSION_FORCE_LOGOUT_EVENT, handleForceLogout)
|
||||||
|
window.addEventListener(HTTP_TOAST_EVENT, handleHttpToast)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener(SESSION_FORCE_LOGOUT_EVENT, handleForceLogout)
|
||||||
|
window.removeEventListener(HTTP_TOAST_EVENT, handleHttpToast)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Tabs Logic ---
|
||||||
|
|
||||||
|
const tabs = ref([])
|
||||||
|
const activeTab = ref(null)
|
||||||
|
|
||||||
|
const showTabs = computed(() => {
|
||||||
|
return route.meta.layout === 'default'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 遞迴尋找標題
|
||||||
|
function findTitle (path) {
|
||||||
|
const recursiveFind = (items) => {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.path === path) return item.title
|
||||||
|
if (item.subItems?.length) {
|
||||||
|
const found = recursiveFind(item.subItems)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 搜尋 Store 中的選單
|
||||||
|
let title = recursiveFind(menuStore.menuItems)
|
||||||
|
if (title) return title
|
||||||
|
|
||||||
|
// 2. 搜尋最愛選單
|
||||||
|
title = recursiveFind(menuStore.favoriteItems)
|
||||||
|
if (title) return title
|
||||||
|
|
||||||
|
// 3. 搜尋靜態範例選單
|
||||||
|
title = recursiveFind(_menuItemsExample)
|
||||||
|
if (title) return title
|
||||||
|
|
||||||
|
// 4. 特殊路徑處理
|
||||||
|
if (path === '/') return '首頁'
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMenuItem (path) {
|
||||||
|
const recursiveFind = (items) => {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.path === path) return item
|
||||||
|
if (item.subItems?.length) {
|
||||||
|
const found = recursiveFind(item.subItems)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return recursiveFind(mergedMenuItems.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentFavoriteInfo = computed(() => {
|
||||||
|
const path = route.path
|
||||||
|
const menuItem = findMenuItem(path)
|
||||||
|
const title =
|
||||||
|
menuItem?.title || (typeof route.meta?.title === 'string' ? route.meta.title : null) || findTitle(path)
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
path,
|
||||||
|
icon: menuItem?.icon,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isCurrentFavorite = computed(() => favoritesStore.isFavorite(route.path))
|
||||||
|
const isFavoriteActionDisabled = computed(() => !currentFavoriteInfo.value?.path || route.path === '/')
|
||||||
|
|
||||||
|
const favoriteActionLabel = computed(() => (isCurrentFavorite.value ? '移除常用' : '加入常用'))
|
||||||
|
const favoriteActionIcon = computed(() => (isCurrentFavorite.value ? 'mdi-close-circle' : 'mdi-plus-circle'))
|
||||||
|
|
||||||
|
function toggleFavoriteItem (item) {
|
||||||
|
if (!item?.path || item.path === '/') return
|
||||||
|
favoritesStore.toggle({
|
||||||
|
title: item.title || findTitle(item.path),
|
||||||
|
path: item.path,
|
||||||
|
icon: item.icon,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFavorite () {
|
||||||
|
toggleFavoriteItem(currentFavoriteInfo.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveFavorite (item) {
|
||||||
|
toggleFavoriteItem(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome () {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBreadcrumbs () {
|
||||||
|
const resolvedTitle = findTitle(route.path)
|
||||||
|
const fallbackTitle =
|
||||||
|
resolvedTitle && resolvedTitle !== route.path
|
||||||
|
? resolvedTitle
|
||||||
|
: typeof route.meta?.title === 'string'
|
||||||
|
? route.meta.title
|
||||||
|
: null
|
||||||
|
|
||||||
|
breadcrumbStore.setBreadcrumbs({
|
||||||
|
path: route.path,
|
||||||
|
menuItems: mergedMenuItems.value,
|
||||||
|
favoriteItems: mergedFavoriteItems.value,
|
||||||
|
fallbackTitle,
|
||||||
|
homeLabel: '首頁',
|
||||||
|
homeIcon: 'mdi-home',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => route.path, () => menuStore.menuItems, () => menuStore.favoriteItems, () => favoritesStore.items],
|
||||||
|
() => updateBreadcrumbs(),
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// 監聽路由變化,新增 Tab
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
(newPath) => {
|
||||||
|
if (!showTabs.value) return
|
||||||
|
|
||||||
|
const existingTab = tabs.value.find((t) => t.path === newPath)
|
||||||
|
if (!existingTab) {
|
||||||
|
const title = findTitle(newPath)
|
||||||
|
tabs.value.push({ title, path: newPath })
|
||||||
|
}
|
||||||
|
activeTab.value = newPath
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function closeTab (path) {
|
||||||
|
if (tabs.value.length <= 1) return
|
||||||
|
|
||||||
|
const index = tabs.value.findIndex((t) => t.path === path)
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
tabs.value.splice(index, 1)
|
||||||
|
|
||||||
|
// 如果關閉的是當前分頁,則跳轉到其他分頁
|
||||||
|
if (route.path === path) {
|
||||||
|
const nextTab = tabs.value[index] || tabs.value[index - 1]
|
||||||
|
if (nextTab) {
|
||||||
|
router.push(nextTab.path)
|
||||||
|
} else {
|
||||||
|
// 若無剩餘分頁,回到首頁
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,94 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container class="fill-height d-flex flex-column justify-center" max-width="1100">
|
<div class="d-flex align-center justify-center pa-8 text-body-1">
|
||||||
<div>
|
Hello World
|
||||||
<v-img
|
|
||||||
class="mb-4 font-weight-bold"
|
|
||||||
height="150"
|
|
||||||
src="@/assets/logo.png"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="mb-8 text-center">
|
|
||||||
<div class="text-body-medium font-weight-light mb-n1">Welcome to</div>
|
|
||||||
<div class="text-display-medium font-weight-bold">Vuetify</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-row>
|
|
||||||
<v-col cols="12">
|
|
||||||
<v-card
|
|
||||||
class="py-4"
|
|
||||||
color="surface-variant"
|
|
||||||
image="https://cdn.vuetifyjs.com/docs/images/one/create/feature.png"
|
|
||||||
rounded="lg"
|
|
||||||
variant="tonal"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
|
||||||
<v-avatar class="ml-2 mr-4" icon="mdi-rocket-launch-outline" size="60" variant="tonal" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #image>
|
|
||||||
<v-img position="top right" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #title>
|
|
||||||
<div class="my-title my-uppercase text-headline-medium font-weight-bold">Get started</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #subtitle>
|
|
||||||
<div class="text-body-large">
|
|
||||||
Change this page by updating <v-kbd>{{ `<HelloWorld />` }}</v-kbd> in <v-kbd>components/HelloWorld.vue</v-kbd>.
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</v-card>
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<v-col v-for="link in links" :key="link.href" cols="6">
|
|
||||||
<v-card
|
|
||||||
append-icon="mdi-open-in-new"
|
|
||||||
class="py-4"
|
|
||||||
color="surface-variant"
|
|
||||||
:href="link.href"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
rounded="lg"
|
|
||||||
:subtitle="link.subtitle"
|
|
||||||
target="_blank"
|
|
||||||
:title="link.title"
|
|
||||||
variant="tonal"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
|
||||||
<v-avatar class="ml-2 mr-4" :icon="link.icon" size="60" variant="tonal" />
|
|
||||||
</template>
|
|
||||||
</v-card>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</div>
|
|
||||||
</v-container>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const links = [
|
|
||||||
{
|
|
||||||
href: 'https://vuetifyjs.com/',
|
|
||||||
icon: 'mdi-text-box-outline',
|
|
||||||
subtitle: 'Learn about all things Vuetify in our documentation.',
|
|
||||||
title: 'Documentation',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'https://vuetifyjs.com/introduction/why-vuetify/#feature-guides',
|
|
||||||
icon: 'mdi-star-circle-outline',
|
|
||||||
subtitle: 'Explore available framework Features.',
|
|
||||||
title: 'Features',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'https://vuetifyjs.com/components/all',
|
|
||||||
icon: 'mdi-widgets-outline',
|
|
||||||
subtitle: 'Discover components in the API Explorer.',
|
|
||||||
title: 'Components',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'https://discord.vuetifyjs.com',
|
|
||||||
icon: 'mdi-account-group-outline',
|
|
||||||
subtitle: 'Connect with Vuetify developers.',
|
|
||||||
title: 'Community',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<v-sheet class="bg-surface" v-bind="$attrs">
|
||||||
|
<!-- Top Stats Cards -->
|
||||||
|
<v-row class="mb-4">
|
||||||
|
<v-col v-for="(stat, index) in props.stats" :key="index" cols="12" md="3" sm="6">
|
||||||
|
<AnalysisStatsCard
|
||||||
|
:color="stat.color"
|
||||||
|
:icon="stat.icon"
|
||||||
|
:label="stat.label"
|
||||||
|
:title="stat.title"
|
||||||
|
:total="stat.total"
|
||||||
|
:value="stat.value"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<!-- Main Trend Chart (Sparkline Area) -->
|
||||||
|
<v-row class="mb-4">
|
||||||
|
<v-col cols="12">
|
||||||
|
<AnalysisTrendChart
|
||||||
|
:active-filter="activeFilter"
|
||||||
|
:data="props.trendData"
|
||||||
|
:filters="props.trendFilters"
|
||||||
|
:title="props.trendTitle"
|
||||||
|
@filter-change="activeFilter = $event"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<!-- Bottom Charts Grid -->
|
||||||
|
<v-row>
|
||||||
|
<!-- Chart 1: Bar Chart (Proxy for Radar/Distribution) -->
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<AnalysisBarChart :data="props.barData" :title="props.chart1Title" />
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<!-- Chart 2: Donut Chart (Source) -->
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<AnalysisPieChart :data="props.pie1Data" :title="props.chart2Title" />
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<!-- Chart 3: Donut Chart (Distribution) -->
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<AnalysisDonutChart :data="props.pie2Data" :title="props.chart3Title" />
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-sheet>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import AnalysisBarChart from './base/analysis/AnalysisBarChart.vue'
|
||||||
|
import AnalysisDonutChart from './base/analysis/AnalysisDonutChart.vue'
|
||||||
|
import AnalysisPieChart from './base/analysis/AnalysisPieChart.vue'
|
||||||
|
import AnalysisStatsCard from './base/analysis/AnalysisStatsCard.vue'
|
||||||
|
import AnalysisTrendChart from './base/analysis/AnalysisTrendChart.vue'
|
||||||
|
|
||||||
|
interface StatsItem {
|
||||||
|
title: string
|
||||||
|
value: string | number
|
||||||
|
label: string
|
||||||
|
total: string | number
|
||||||
|
icon: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BarDataItem {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PieData {
|
||||||
|
value: number
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DonutData extends PieData {
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// Stats Cards Data
|
||||||
|
stats: { type: Array as () => StatsItem[], default: () => [] },
|
||||||
|
|
||||||
|
// Trend Chart
|
||||||
|
trendTitle: { type: String, default: '流量趨勢' },
|
||||||
|
trendData: { type: Array as () => number[], default: () => [] },
|
||||||
|
trendFilters: { type: Array as () => string[], default: () => ['流量', '訪問量'] },
|
||||||
|
|
||||||
|
// Chart Titles
|
||||||
|
chart1Title: { type: String, default: '核心素養' },
|
||||||
|
chart2Title: { type: String, default: '訪問來源' },
|
||||||
|
chart3Title: { type: String, default: '成績分佈' },
|
||||||
|
|
||||||
|
// Data for Charts
|
||||||
|
barData: { type: Array as () => BarDataItem[], default: () => [] },
|
||||||
|
pie1Data: {
|
||||||
|
type: Object as () => PieData,
|
||||||
|
default: () => ({ value: 75, label: '直接訪問', color: 'primary' }),
|
||||||
|
},
|
||||||
|
pie2Data: {
|
||||||
|
type: Object as () => DonutData,
|
||||||
|
default: () => ({ value: 65, label: '及格率', color: 'success', icon: 'mdi-school' }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeFilter = ref(props.trendFilters[0] || '')
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<v-sheet class="bg-surface" v-bind="$attrs">
|
||||||
|
<!-- Header Section -->
|
||||||
|
<DashboardHeader
|
||||||
|
class="mb-4"
|
||||||
|
:greeting-title="props.greetingTitle"
|
||||||
|
:projects="props.statProjects"
|
||||||
|
:team="props.statTeam"
|
||||||
|
:todo="props.statTodo"
|
||||||
|
:user-avatar="props.userAvatar"
|
||||||
|
:weather-info="props.weatherInfo"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<!-- Left Column (Main) -->
|
||||||
|
<v-col cols="12" md="8">
|
||||||
|
<!-- Applications Card -->
|
||||||
|
<DashboardApps
|
||||||
|
:apps="props.applications"
|
||||||
|
class="mb-4"
|
||||||
|
:title="props.appsTitle"
|
||||||
|
@app-click="$emit('app-click', $event)"
|
||||||
|
@view-all="$emit('view-all-apps')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- School Announcements (Dynamic) -->
|
||||||
|
<DashboardAnnouncements
|
||||||
|
:announcements="props.announcements"
|
||||||
|
:title="props.announcementsTitle"
|
||||||
|
@item-click="$emit('announcement-click', $event)"
|
||||||
|
@view-more="$emit('view-more-announcements')"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<!-- Right Column (Side) -->
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<!-- Quick Nav -->
|
||||||
|
<DashboardQuickNav
|
||||||
|
class="mb-4"
|
||||||
|
:navs="props.quickNavs"
|
||||||
|
:title="props.quickNavTitle"
|
||||||
|
@nav-click="$emit('nav-click', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- To-Do List -->
|
||||||
|
<DashboardTodoList
|
||||||
|
class="mb-4"
|
||||||
|
:title="props.todoTitle"
|
||||||
|
:todos="props.todos"
|
||||||
|
@toggle-todo="$emit('toggle-todo', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Visit Source Chart -->
|
||||||
|
<DashboardChart :title="props.chartTitle" :value="props.chartValue" />
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-sheet>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import DashboardAnnouncements from './base/dashboard/DashboardAnnouncements.vue'
|
||||||
|
import DashboardApps from './base/dashboard/DashboardApps.vue'
|
||||||
|
import DashboardChart from './base/dashboard/DashboardChart.vue'
|
||||||
|
import DashboardHeader from './base/dashboard/DashboardHeader.vue'
|
||||||
|
import DashboardQuickNav from './base/dashboard/DashboardQuickNav.vue'
|
||||||
|
import DashboardTodoList from './base/dashboard/DashboardTodoList.vue'
|
||||||
|
|
||||||
|
defineEmits([
|
||||||
|
'view-all-apps',
|
||||||
|
'app-click',
|
||||||
|
'view-more-announcements',
|
||||||
|
'announcement-click',
|
||||||
|
'nav-click',
|
||||||
|
'toggle-todo',
|
||||||
|
])
|
||||||
|
|
||||||
|
interface DashboardApp {
|
||||||
|
name: string
|
||||||
|
desc: string
|
||||||
|
icon: string
|
||||||
|
color: string
|
||||||
|
group: string
|
||||||
|
date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Announcement {
|
||||||
|
title: string
|
||||||
|
author: string
|
||||||
|
time: string
|
||||||
|
avatarSrc?: string | null
|
||||||
|
avatarColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickNav {
|
||||||
|
icon: string
|
||||||
|
title: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Todo {
|
||||||
|
title: string
|
||||||
|
due: string
|
||||||
|
done: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// Header
|
||||||
|
userAvatar: {
|
||||||
|
type: String,
|
||||||
|
default:
|
||||||
|
'https://avataaars.io/?avatarStyle=Circle&topType=ShortHairShortFlat&accessoriesType=Sunglasses&hairColor=Blonde&facialHairType=Blank&clotheType=Hoodie&clotheColor=Red&eyeType=Happy&eyebrowType=Default&mouthType=Smile&skinColor=Light',
|
||||||
|
},
|
||||||
|
greetingTitle: { type: String, default: '早安,校長!開始您一天的工作吧!' },
|
||||||
|
weatherInfo: { type: String, default: '今日晴,20℃ - 32℃!' },
|
||||||
|
statTodo: { type: String, default: '2/10' },
|
||||||
|
statProjects: { type: String, default: '8' },
|
||||||
|
statTeam: { type: String, default: '300' },
|
||||||
|
|
||||||
|
// Apps
|
||||||
|
appsTitle: { type: String, default: '應用程式' },
|
||||||
|
applications: { type: Array as () => DashboardApp[], default: () => [] },
|
||||||
|
|
||||||
|
// Announcements
|
||||||
|
announcementsTitle: { type: String, default: '學校公告' },
|
||||||
|
announcements: { type: Array as () => Announcement[], default: () => [] },
|
||||||
|
|
||||||
|
// Right Side
|
||||||
|
quickNavTitle: { type: String, default: '快速導航' },
|
||||||
|
quickNavs: { type: Array as () => QuickNav[], default: () => [] },
|
||||||
|
|
||||||
|
todoTitle: { type: String, default: '待辦事項' },
|
||||||
|
todos: { type: Array as () => Todo[], default: () => [] },
|
||||||
|
|
||||||
|
chartTitle: { type: String, default: '訪問來源' },
|
||||||
|
chartValue: { type: Number, default: 75 },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
<template>
|
||||||
|
<v-sheet class="bg-surface" v-bind="$attrs">
|
||||||
|
<v-card border elevation="0">
|
||||||
|
<!-- Top Action Bar -->
|
||||||
|
<SKTableActionBar
|
||||||
|
:create-btn-text="props.createBtnText"
|
||||||
|
:title="props.listTitle"
|
||||||
|
@create="emit('create')"
|
||||||
|
@refresh="emit('refresh')"
|
||||||
|
@settings="emit('settings')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SKFormEditDialog
|
||||||
|
v-model="addSubDialogOpen"
|
||||||
|
:cancel-text="props.dialogCancelText"
|
||||||
|
:confirm-text="props.dialogConfirmText"
|
||||||
|
:item="addSubDraftItem"
|
||||||
|
:loading="props.dialogLoading"
|
||||||
|
:show-permission="false"
|
||||||
|
:status-label-text="props.dialogStatusLabelText"
|
||||||
|
:status-options="props.statusOptions"
|
||||||
|
:title-text="props.addSubDialogTitleText"
|
||||||
|
@submit="onAddSubSubmit"
|
||||||
|
>
|
||||||
|
<template #fields="{ form }">
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.name"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details
|
||||||
|
:label="props.dialogNameLabelText"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
<v-textarea
|
||||||
|
v-model="form.note"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details
|
||||||
|
:label="props.dialogNoteLabelText"
|
||||||
|
rows="3"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SKFormEditDialog>
|
||||||
|
|
||||||
|
<SKFormEditDialog
|
||||||
|
v-model="editDialogOpen"
|
||||||
|
:cancel-text="props.dialogCancelText"
|
||||||
|
:confirm-text="props.dialogConfirmText"
|
||||||
|
:item="selectedItem"
|
||||||
|
:loading="props.dialogLoading"
|
||||||
|
:show-permission="false"
|
||||||
|
:status-label-text="props.dialogStatusLabelText"
|
||||||
|
:status-options="props.statusOptions"
|
||||||
|
:title-text="props.editDialogTitleText"
|
||||||
|
@submit="onEditSubmit"
|
||||||
|
>
|
||||||
|
<template #fields="{ form }">
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.name"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details
|
||||||
|
:label="props.dialogNameLabelText"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
<v-textarea
|
||||||
|
v-model="form.note"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details
|
||||||
|
:label="props.dialogNoteLabelText"
|
||||||
|
rows="3"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SKFormEditDialog>
|
||||||
|
|
||||||
|
<!-- Tree Table -->
|
||||||
|
<SKTreeTable
|
||||||
|
:headers="formattedHeaders"
|
||||||
|
:items="props.items"
|
||||||
|
:loading="props.loading"
|
||||||
|
table-class="dept-table"
|
||||||
|
:tree-column-keys="['name']"
|
||||||
|
@toggle-expand="emit('toggle-expand', $event)"
|
||||||
|
>
|
||||||
|
<!-- Status Column -->
|
||||||
|
<template #[`item.status`]="{ item }">
|
||||||
|
<v-chip
|
||||||
|
:color="isEnabledStatus(item.status) ? 'success' : 'error'"
|
||||||
|
label
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ isEnabledStatus(item.status) ? props.statusEnabledText : props.statusDisabledText }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Actions Column -->
|
||||||
|
<template #[`item.actions`]="{ item }">
|
||||||
|
<v-btn class="px-1" color="primary" size="small" variant="text" @click="openAddSub(item)">
|
||||||
|
{{ props.addSubActionText }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn class="px-1" color="primary" size="small" variant="text" @click="openEdit(item)">
|
||||||
|
{{ props.editActionText }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
class="px-1"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="emit('delete', item)"
|
||||||
|
>
|
||||||
|
{{ props.deleteActionText }}
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</SKTreeTable>
|
||||||
|
</v-card>
|
||||||
|
</v-sheet>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PropType } from 'vue'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import SKFormEditDialog from './base/SKFormEditDialog.vue'
|
||||||
|
import SKTableActionBar from './base/SKTableActionBar.vue'
|
||||||
|
import SKTreeTable from './base/SKTreeTable.vue'
|
||||||
|
|
||||||
|
export interface DeptItem {
|
||||||
|
id: string | number
|
||||||
|
name: string
|
||||||
|
status: string | number
|
||||||
|
createTime: string
|
||||||
|
note: string
|
||||||
|
children?: DeptItem[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
items: { type: Array as () => DeptItem[], default: () => [] },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
|
||||||
|
// Text Props
|
||||||
|
listTitle: { type: String, default: '部門列表' },
|
||||||
|
createBtnText: { type: String, default: '新增部門' },
|
||||||
|
addSubActionText: { type: String, default: '新增下級' },
|
||||||
|
editActionText: { type: String, default: '修改' },
|
||||||
|
deleteActionText: { type: String, default: '刪除' },
|
||||||
|
statusEnabledText: { type: String, default: '已啟用' },
|
||||||
|
statusDisabledText: { type: String, default: '已禁用' },
|
||||||
|
|
||||||
|
// Dialog Props
|
||||||
|
addSubDialogTitleText: { type: String, default: '新增下級' },
|
||||||
|
editDialogTitleText: { type: String, default: '編輯' },
|
||||||
|
dialogStatusLabelText: { type: String, default: '狀態' },
|
||||||
|
dialogNameLabelText: { type: String, default: '部門名稱' },
|
||||||
|
dialogNoteLabelText: { type: String, default: '備註' },
|
||||||
|
dialogCancelText: { type: String, default: '取消' },
|
||||||
|
dialogConfirmText: { type: String, default: '確認' },
|
||||||
|
dialogLoading: { type: Boolean, default: false },
|
||||||
|
statusEnabledValue: { type: [String, Number] as PropType<string | number>, default: undefined },
|
||||||
|
statusOptions: {
|
||||||
|
type: Array as () => Array<string | number | { title: string; value: string | number }>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Header Texts
|
||||||
|
nameHeader: { type: String, default: '部門名稱' },
|
||||||
|
statusHeader: { type: String, default: '狀態' },
|
||||||
|
createTimeHeader: { type: String, default: '創建時間' },
|
||||||
|
noteHeader: { type: String, default: '備註' },
|
||||||
|
actionsHeader: { type: String, default: '操作' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'create',
|
||||||
|
'add-sub',
|
||||||
|
'edit',
|
||||||
|
'delete',
|
||||||
|
'refresh',
|
||||||
|
'settings',
|
||||||
|
'toggle-expand',
|
||||||
|
])
|
||||||
|
|
||||||
|
function normalizeOptions (options: Array<string | number | { title: string; value: string | number }>) {
|
||||||
|
return options.map((o) => {
|
||||||
|
if (typeof o === 'string' || typeof o === 'number') {
|
||||||
|
return { title: String(o), value: o }
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedStatusOptions = computed(() => normalizeOptions(props.statusOptions))
|
||||||
|
|
||||||
|
const resolvedStatusEnabledValue = computed(() => {
|
||||||
|
if (props.statusEnabledValue !== undefined) return props.statusEnabledValue
|
||||||
|
return normalizedStatusOptions.value[0]?.value ?? 'enable'
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEnabledStatus = (status: unknown) => status === resolvedStatusEnabledValue.value
|
||||||
|
|
||||||
|
const addSubDialogOpen = ref(false)
|
||||||
|
const editDialogOpen = ref(false)
|
||||||
|
const addSubParentItem = ref<DeptItem | null>(null)
|
||||||
|
const addSubDraftItem = ref<Record<string, unknown> | null>(null)
|
||||||
|
const selectedItem = ref<DeptItem | null>(null)
|
||||||
|
|
||||||
|
function openAddSub (item: DeptItem) {
|
||||||
|
addSubParentItem.value = item
|
||||||
|
addSubDraftItem.value = {
|
||||||
|
name: '',
|
||||||
|
note: '',
|
||||||
|
status: resolvedStatusEnabledValue.value,
|
||||||
|
}
|
||||||
|
addSubDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit (item: DeptItem) {
|
||||||
|
selectedItem.value = item
|
||||||
|
editDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAddSubSubmit (payload: Record<string, unknown>) {
|
||||||
|
if (!addSubParentItem.value) return
|
||||||
|
const newItem: DeptItem = {
|
||||||
|
id: Date.now(),
|
||||||
|
name: String(payload.name ?? ''),
|
||||||
|
note: String(payload.note ?? ''),
|
||||||
|
status: (payload.status as string | number | undefined) ?? resolvedStatusEnabledValue.value,
|
||||||
|
createTime: new Date().toISOString(),
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
|
emit('add-sub', addSubParentItem.value, newItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEditSubmit (updated: Record<string, unknown>) {
|
||||||
|
emit('edit', updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Table Config ---
|
||||||
|
|
||||||
|
const formattedHeaders = computed(() => [
|
||||||
|
{ title: props.nameHeader, key: 'name', align: 'start' as const, minWidth: '250px' },
|
||||||
|
{ title: props.statusHeader, key: 'status', align: 'center' as const, width: '100px' },
|
||||||
|
{ title: props.createTimeHeader, key: 'createTime', align: 'start' as const },
|
||||||
|
{ title: props.noteHeader, key: 'note', align: 'start' as const },
|
||||||
|
{
|
||||||
|
title: props.actionsHeader,
|
||||||
|
key: 'actions',
|
||||||
|
align: 'center' as const,
|
||||||
|
width: '250px',
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
<template>
|
||||||
|
<v-sheet class="bg-surface" :class="layoutClass" height="100%">
|
||||||
|
<!-- Side Layouts -->
|
||||||
|
<v-row
|
||||||
|
v-if="props.layout !== 'card'" class="fill-height" :class="{ 'flex-row-reverse': props.layout === 'side-right' }"
|
||||||
|
no-gutters>
|
||||||
|
<!-- Illustration Column -->
|
||||||
|
<v-col
|
||||||
|
class="illustration-panel d-none d-sm-flex align-center justify-center position-relative flex-grow-1" cols="12" lg="8"
|
||||||
|
sm="6">
|
||||||
|
<div class="brand-header position-absolute top-0 left-0 px-2 pt-4 pa-lg-4">
|
||||||
|
<LoginBrand :title="props.branding.title" />
|
||||||
|
</div>
|
||||||
|
<v-sheet
|
||||||
|
class="board-wrapper pa-2 pa-lg-0" color="rgba(var(--v-theme-surface), 0.8)" elevation="0" max-width="680"
|
||||||
|
rounded="lg" width="100%">
|
||||||
|
<LoginAnnouncementBoard
|
||||||
|
:all-tab-label="props.announcementBoard.allTabLabel" :date-header="props.announcementBoard.dateHeader"
|
||||||
|
:empty-text="props.announcementBoard.emptyText" :items="props.announcementBoard.items"
|
||||||
|
:items-per-page="props.announcementBoard.itemsPerPage"
|
||||||
|
:pagination-label="props.announcementBoard.paginationLabel" :school-header="props.announcementBoard.schoolHeader"
|
||||||
|
:system-announcements="props.announcementBoard.systemAnnouncements" :tabs="props.announcementBoard.tabs"
|
||||||
|
:title="props.announcementBoard.title" :title-header="props.announcementBoard.titleHeader"
|
||||||
|
@select-announcement="handleSelectAnnouncement" />
|
||||||
|
</v-sheet>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col class="bg-background d-flex flex-column flex-grow-1 px-4 py-2 py-md-0" cols="12" lg="4" sm="6">
|
||||||
|
<div v-if="showMobileAnnouncementBanner" class="banner-wrapper px-3">
|
||||||
|
<v-banner class=" d-sm-none mb-2" density="comfortable" lines="one" :mobile="false" :stacked="false">
|
||||||
|
<template #prepend>
|
||||||
|
<v-slide-x-transition appear>
|
||||||
|
<div class="mobile-banner-icon-wrap d-flex align-center">
|
||||||
|
<v-icon class="mobile-banner-icon" color="primary" size="small">mdi-bullhorn-variant-outline</v-icon>
|
||||||
|
</div>
|
||||||
|
</v-slide-x-transition>
|
||||||
|
</template>
|
||||||
|
<v-banner-text>{{ mobileAnnouncementBannerText }}</v-banner-text>
|
||||||
|
<template #actions>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
class="text-none" color="primary" size="small" variant="text"
|
||||||
|
@click="mobileAnnouncementSheetVisible = true">
|
||||||
|
{{ props.mobileAnnouncement.viewAllText }}
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
</v-banner>
|
||||||
|
</div>
|
||||||
|
<LoginToolBar
|
||||||
|
v-if="props.toolbar.show" :locale="props.toolbar.locale" :locales="props.toolbar.locales"
|
||||||
|
@change-locale="handleChangeLocale" @toggle-layout="handleToggleLayout" />
|
||||||
|
<div
|
||||||
|
class="login-form-wrapper d-flex flex-column justify-center py-0 py-sm-4 py-lg-8 px-4 px-sm-6 px-lg-12 flex-grow-1">
|
||||||
|
<div class="login-header-height d-flex d-sm-none justify-center align-start">
|
||||||
|
<LoginBrand :title="props.branding.title" />
|
||||||
|
</div>
|
||||||
|
<LoginHeader
|
||||||
|
class="d-none d-sm-block" :welcome-description="props.header.welcomeDescription"
|
||||||
|
:welcome-text="props.header.welcomeText" />
|
||||||
|
<LoginForm
|
||||||
|
:acc-placeholder="props.form.accPlaceholder" :forgot-password-href="props.form.forgotPassword.href"
|
||||||
|
:forgot-password-target="props.form.forgotPassword.target" :forgot-password-text="props.form.forgotPassword.text"
|
||||||
|
:passw-placeholder="props.form.passwPlaceholder"
|
||||||
|
:remember-me-label="props.form.rememberMeLabel"
|
||||||
|
:remember-storage-key="props.form.rememberStorageKey" :submit-text="props.form.submitText"
|
||||||
|
@forgot-password="handleForgotPassword" @submit="handleLogin">
|
||||||
|
<template v-if="props.form.withCaptcha" #verify>
|
||||||
|
<LoginVerify
|
||||||
|
:captcha="props.form.captcha" :captcha-placeholder="props.form.captchaPlaceholder"
|
||||||
|
:error-message="props.form.captchaErrorMessage" :loading="props.form.captchaLoading"
|
||||||
|
:model-value="props.form.captchaValue" :refresh-title="props.form.refreshTitle"
|
||||||
|
:verified="props.form.captchaVerified" :verify-text="props.form.verifyText"
|
||||||
|
@refresh="handleCaptchaRefresh" @update:model-value="handleCaptchaChange" />
|
||||||
|
</template>
|
||||||
|
</LoginForm>
|
||||||
|
<div class="mt-auto py-8 text-center text-caption text-grey-darken-1">
|
||||||
|
Copyright © {{ new Date().getFullYear() }} {{ props.branding.organization }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<!-- Card Layout (Centered) -->
|
||||||
|
<v-row v-else class="fill-height align-center justify-center bg-background pa-4 pa-md-0" no-gutters>
|
||||||
|
<v-card
|
||||||
|
class="rounded-lg" :class="props.toolbar.show ? 'px-8 pt-0 pb-4' : 'pa-8'" elevation="10" max-width="450"
|
||||||
|
width="100%">
|
||||||
|
<LoginToolBar
|
||||||
|
v-if="props.toolbar.show" :locale="props.toolbar.locale" :locales="props.toolbar.locales"
|
||||||
|
@change-locale="handleChangeLocale" @toggle-layout="handleToggleLayout" />
|
||||||
|
<div class="d-flex justify-center mb-6 mb-md-4">
|
||||||
|
<LoginBrand :title="props.branding.title" />
|
||||||
|
</div>
|
||||||
|
<LoginHeader
|
||||||
|
class="d-none d-md-block" :welcome-description="props.header.welcomeDescription"
|
||||||
|
:welcome-text="props.header.welcomeText" />
|
||||||
|
<LoginForm
|
||||||
|
:acc-placeholder="props.form.accPlaceholder" :forgot-password-href="props.form.forgotPassword.href"
|
||||||
|
:forgot-password-target="props.form.forgotPassword.target" :forgot-password-text="props.form.forgotPassword.text"
|
||||||
|
:passw-placeholder="props.form.passwPlaceholder"
|
||||||
|
:remember-me-label="props.form.rememberMeLabel"
|
||||||
|
:remember-storage-key="props.form.rememberStorageKey" :submit-text="props.form.submitText"
|
||||||
|
@forgot-password="handleForgotPassword" @submit="handleLogin">
|
||||||
|
<template v-if="props.form.withCaptcha" #verify>
|
||||||
|
<LoginVerify
|
||||||
|
:captcha="props.form.captcha" :captcha-placeholder="props.form.captchaPlaceholder"
|
||||||
|
:error-message="props.form.captchaErrorMessage" :loading="props.form.captchaLoading"
|
||||||
|
:model-value="props.form.captchaValue" :refresh-title="props.form.refreshTitle"
|
||||||
|
:verified="props.form.captchaVerified" :verify-text="props.form.verifyText"
|
||||||
|
@refresh="handleCaptchaRefresh" @update:model-value="handleCaptchaChange" />
|
||||||
|
</template>
|
||||||
|
</LoginForm>
|
||||||
|
<div class="mt-8 text-center text-caption text-grey-darken-1">
|
||||||
|
Copyright © {{ new Date().getFullYear() }} {{ props.branding.organization }}
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-bottom-sheet v-model="mobileAnnouncementSheetVisible" class="d-sm-none">
|
||||||
|
<v-card rounded="t-xl">
|
||||||
|
<v-card-title class="text-subtitle-1 font-weight-bold">
|
||||||
|
{{ props.mobileAnnouncement.listTitle }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-list lines="two">
|
||||||
|
<v-list-item v-for="item in mobileAnnouncementItems" :key="item.id">
|
||||||
|
<v-list-item-title>{{ item.content }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle v-if="item.title || item.createdAt">
|
||||||
|
{{ item.title }}<span v-if="item.title && item.createdAt"> ・ </span>{{ item.createdAt }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item v-if="mobileAnnouncementItems.length === 0">
|
||||||
|
<v-list-item-title>{{ props.mobileAnnouncement.emptyText }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
<v-card-actions class="justify-end">
|
||||||
|
<v-btn color="primary" variant="text" @click="mobileAnnouncementSheetVisible = false">
|
||||||
|
{{ props.mobileAnnouncement.closeText }}
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-bottom-sheet>
|
||||||
|
</v-sheet>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import LoginAnnouncementBoard from './base/login/LoginAnnouncementBoard.vue'
|
||||||
|
import LoginBrand from './base/login/LoginBrand.vue'
|
||||||
|
import LoginForm from './base/login/LoginForm.vue'
|
||||||
|
import LoginHeader from './base/login/LoginHeader.vue'
|
||||||
|
import LoginToolBar from './base/login/LoginToolBar.vue'
|
||||||
|
import LoginVerify from './base/login/LoginVerify.vue'
|
||||||
|
|
||||||
|
interface BrandingConfig {
|
||||||
|
title?: string
|
||||||
|
organization?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IllustrationConfig {
|
||||||
|
image?: string | null
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeaderConfig {
|
||||||
|
welcomeText?: string
|
||||||
|
welcomeDescription?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnnouncementTabConfig {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnnouncementItemConfig {
|
||||||
|
id: string | number
|
||||||
|
date: string
|
||||||
|
school: string
|
||||||
|
title: string
|
||||||
|
tab?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnnouncementBoardConfig {
|
||||||
|
title?: string
|
||||||
|
tabs?: AnnouncementTabConfig[]
|
||||||
|
items?: AnnouncementItemConfig[]
|
||||||
|
systemAnnouncements?: {
|
||||||
|
id: string | number
|
||||||
|
content: string
|
||||||
|
title?: string
|
||||||
|
createdAt?: string
|
||||||
|
}[]
|
||||||
|
allTabLabel?: string
|
||||||
|
itemsPerPage?: number
|
||||||
|
dateHeader?: string
|
||||||
|
schoolHeader?: string
|
||||||
|
titleHeader?: string
|
||||||
|
emptyText?: string
|
||||||
|
paginationLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MobileAnnouncementConfig {
|
||||||
|
items?: {
|
||||||
|
id: string | number
|
||||||
|
content: string
|
||||||
|
title?: string
|
||||||
|
createdAt?: string
|
||||||
|
}[]
|
||||||
|
show?: boolean
|
||||||
|
viewAllText?: string
|
||||||
|
listTitle?: string
|
||||||
|
closeText?: string
|
||||||
|
emptyText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForgotPasswordConfig {
|
||||||
|
text?: string
|
||||||
|
href?: string
|
||||||
|
target?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormConfig {
|
||||||
|
accPlaceholder?: string
|
||||||
|
passwPlaceholder?: string
|
||||||
|
rememberMeLabel?: string
|
||||||
|
submitText?: string
|
||||||
|
rememberStorageKey?: string
|
||||||
|
withCaptcha?: boolean
|
||||||
|
captcha?: {
|
||||||
|
imgUrl?: string
|
||||||
|
id?: string
|
||||||
|
tokenValue?: string
|
||||||
|
}
|
||||||
|
captchaValue?: string
|
||||||
|
captchaLoading?: boolean
|
||||||
|
captchaErrorMessage?: string
|
||||||
|
captchaVerified?: boolean
|
||||||
|
verifyText?: string
|
||||||
|
captchaPlaceholder?: string
|
||||||
|
refreshTitle?: string
|
||||||
|
forgotPassword: ForgotPasswordConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolBarConfig {
|
||||||
|
show?: boolean
|
||||||
|
locale?: string
|
||||||
|
locales?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
layout: 'side-left' | 'side-right' | 'card'
|
||||||
|
branding: BrandingConfig
|
||||||
|
illustration: IllustrationConfig
|
||||||
|
announcementBoard: AnnouncementBoardConfig
|
||||||
|
mobileAnnouncement: MobileAnnouncementConfig
|
||||||
|
header: HeaderConfig
|
||||||
|
form: FormConfig
|
||||||
|
toolbar: ToolBarConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
layout: 'side-left',
|
||||||
|
branding: () => ({
|
||||||
|
title: 'Skyteck Login',
|
||||||
|
organization: 'school',
|
||||||
|
}),
|
||||||
|
illustration: () => ({
|
||||||
|
image: null,
|
||||||
|
title: 'Login',
|
||||||
|
description: 'Login to your account',
|
||||||
|
}),
|
||||||
|
announcementBoard: () => ({
|
||||||
|
title: '學校公告區',
|
||||||
|
tabs: [
|
||||||
|
{ label: '全部', value: '__all__' },
|
||||||
|
{ label: '國中', value: 'junior' },
|
||||||
|
{ label: '高中', value: 'senior' },
|
||||||
|
],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'announcement-1',
|
||||||
|
date: '2024-03-19',
|
||||||
|
school: '市立實踐國中',
|
||||||
|
title: '臺北市立實踐國中徵求113學年度教學支援工作人員',
|
||||||
|
tab: 'junior',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
systemAnnouncements: [],
|
||||||
|
allTabLabel: '全部',
|
||||||
|
itemsPerPage: 5,
|
||||||
|
dateHeader: '公告時間',
|
||||||
|
schoolHeader: '公告學校',
|
||||||
|
titleHeader: '公告標題',
|
||||||
|
emptyText: '目前沒有公告資料',
|
||||||
|
paginationLabel: '總筆數:',
|
||||||
|
}),
|
||||||
|
mobileAnnouncement: () => ({
|
||||||
|
items: [],
|
||||||
|
show: false,
|
||||||
|
viewAllText: '查看全部',
|
||||||
|
listTitle: '系統公告',
|
||||||
|
closeText: '關閉',
|
||||||
|
emptyText: '目前沒有公告',
|
||||||
|
}),
|
||||||
|
header: () => ({
|
||||||
|
welcomeText: 'Welcome back 👋🏻',
|
||||||
|
welcomeDescription: 'Please enter your account password to login',
|
||||||
|
}),
|
||||||
|
form: () => ({
|
||||||
|
accPlaceholder: '請輸入帳號',
|
||||||
|
passwPlaceholder: '請輸入密碼',
|
||||||
|
rememberMeLabel: '記住帳號',
|
||||||
|
submitText: '登入',
|
||||||
|
rememberStorageKey: 'sklogin.remember.username',
|
||||||
|
withCaptcha: true,
|
||||||
|
captcha: undefined,
|
||||||
|
captchaValue: '',
|
||||||
|
captchaLoading: false,
|
||||||
|
captchaErrorMessage: '',
|
||||||
|
captchaVerified: false,
|
||||||
|
verifyText: '驗證',
|
||||||
|
captchaPlaceholder: '驗證碼',
|
||||||
|
refreshTitle: '點擊刷新驗證碼',
|
||||||
|
forgotPassword: {
|
||||||
|
text: '忘記密碼?',
|
||||||
|
href: '',
|
||||||
|
target: undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
toolbar: () => ({
|
||||||
|
show: true,
|
||||||
|
locale: 'zh-TW',
|
||||||
|
locales: ['zh-TW', 'en-US'],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'submit',
|
||||||
|
'change-locale',
|
||||||
|
'forgot-password',
|
||||||
|
'captcha-refresh',
|
||||||
|
'captcha-change',
|
||||||
|
'toggle-layout',
|
||||||
|
'select-announcement',
|
||||||
|
])
|
||||||
|
|
||||||
|
const mobileAnnouncementSheetVisible = ref(false)
|
||||||
|
|
||||||
|
const mobileAnnouncementItems = computed(() => props.mobileAnnouncement.items ?? [])
|
||||||
|
|
||||||
|
const showMobileAnnouncementBanner = computed(() => {
|
||||||
|
if (props.mobileAnnouncement.show === false) return false
|
||||||
|
return mobileAnnouncementItems.value.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const mobileAnnouncementBannerText = computed(() => {
|
||||||
|
return mobileAnnouncementItems.value[0]?.content ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const layoutClass = computed(() => {
|
||||||
|
return `layout-${props.layout}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleLogin (formData: Record<string, unknown>) {
|
||||||
|
emit('submit', formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCaptchaRefresh () {
|
||||||
|
emit('captcha-refresh')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCaptchaChange (value: string) {
|
||||||
|
emit('captcha-change', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChangeLocale (nextLocale: string) {
|
||||||
|
emit('change-locale', nextLocale)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleLayout () {
|
||||||
|
emit('toggle-layout')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleForgotPassword (e: MouseEvent) {
|
||||||
|
emit('forgot-password', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectAnnouncement (item: AnnouncementItemConfig) {
|
||||||
|
emit('select-announcement', item)
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.v-banner__prepend) {
|
||||||
|
align-self: center;
|
||||||
|
margin-inline-end: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.v-banner-actions) {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-banner-icon {
|
||||||
|
animation: mobile-banner-breathe 2.8s ease-in-out infinite;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mobile-banner-breathe {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.mobile-banner-icon {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.illustration-panel {
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
rgb(var(--v-theme-background)) 0%,
|
||||||
|
rgb(var(--v-theme-surface)) 100%);
|
||||||
|
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form-wrapper {
|
||||||
|
max-width: 450px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header-height {
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific styles for side-right to flip border */
|
||||||
|
.layout-side-right .illustration-panel {
|
||||||
|
border-right: none;
|
||||||
|
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<template>
|
||||||
|
<v-sheet class="bg-surface" v-bind="$attrs">
|
||||||
|
<v-card border elevation="0">
|
||||||
|
<!-- Top Action Bar -->
|
||||||
|
<SKTableActionBar
|
||||||
|
:show-create="false"
|
||||||
|
@refresh="emit('refresh')"
|
||||||
|
@settings="emit('settings')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SKFormEditDialog
|
||||||
|
v-model="editDialogOpen"
|
||||||
|
:cancel-text="props.editDialogCancelText"
|
||||||
|
:confirm-text="props.editDialogConfirmText"
|
||||||
|
:item="selectedItem"
|
||||||
|
:loading="props.editDialogLoading"
|
||||||
|
:permission-label-text="props.editDialogPermissionLabelText"
|
||||||
|
:permission-options="props.permissionOptions"
|
||||||
|
:status-label-text="props.editDialogStatusLabelText"
|
||||||
|
:status-options="props.statusOptions"
|
||||||
|
:title-text="props.editDialogTitleText"
|
||||||
|
@submit="onEditSubmit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Tree Table -->
|
||||||
|
<SKTreeTable
|
||||||
|
:headers="formattedHeaders"
|
||||||
|
:items="props.items"
|
||||||
|
:loading="props.loading"
|
||||||
|
table-class="menu-table"
|
||||||
|
:tree-column-keys="['title']"
|
||||||
|
@toggle-expand="emit('toggle-expand', $event)"
|
||||||
|
>
|
||||||
|
<!-- Title Column (Tree Indentation) -->
|
||||||
|
<template #[`tree-title`]="{ item }">
|
||||||
|
<v-chip v-if="item.isNew" class="px-1" color="primary" label size="x-small">new</v-chip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Icon Column -->
|
||||||
|
<template #[`item.icon`]="{ item }">
|
||||||
|
<v-icon v-if="item.icon" size="small">{{ item.icon }}</v-icon>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Permission Column -->
|
||||||
|
<template #[`item.permission`]="{ item }">
|
||||||
|
<v-chip
|
||||||
|
:color="getPermissionColor(item.permission)"
|
||||||
|
label
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
{{ item.permission }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Status Column -->
|
||||||
|
<template #[`item.status`]="{ item }">
|
||||||
|
<v-chip
|
||||||
|
:color="isEnabledStatus(item.status) ? 'success' : 'error'"
|
||||||
|
label
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ isEnabledStatus(item.status) ? props.statusEnabledText : props.statusDisabledText }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Actions Column -->
|
||||||
|
<template #[`item.actions`]="{ item }">
|
||||||
|
<v-btn class="px-1" color="primary" size="small" variant="text" @click="openEdit(item)">
|
||||||
|
{{ props.editActionText }}
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</SKTreeTable>
|
||||||
|
</v-card>
|
||||||
|
</v-sheet>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PropType } from 'vue'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import SKFormEditDialog from './base/SKFormEditDialog.vue'
|
||||||
|
import SKTableActionBar from './base/SKTableActionBar.vue'
|
||||||
|
import SKTreeTable from './base/SKTreeTable.vue'
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
id: string | number
|
||||||
|
title: string
|
||||||
|
icon?: string
|
||||||
|
permission: string
|
||||||
|
path?: string
|
||||||
|
component?: string
|
||||||
|
status: string | number
|
||||||
|
isNew?: boolean
|
||||||
|
children?: MenuItem[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
items: { type: Array as () => MenuItem[], default: () => [] },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
|
||||||
|
// Text Props
|
||||||
|
editActionText: { type: String, default: '修改' },
|
||||||
|
statusEnabledText: { type: String, default: '已啟用' },
|
||||||
|
statusDisabledText: { type: String, default: '已禁用' },
|
||||||
|
|
||||||
|
// Edit Dialog Props
|
||||||
|
editDialogTitleText: { type: String, default: '編輯' },
|
||||||
|
editDialogStatusLabelText: { type: String, default: '狀態' },
|
||||||
|
editDialogPermissionLabelText: { type: String, default: '權限' },
|
||||||
|
editDialogCancelText: { type: String, default: '取消' },
|
||||||
|
editDialogConfirmText: { type: String, default: '確認' },
|
||||||
|
editDialogLoading: { type: Boolean, default: false },
|
||||||
|
statusEnabledValue: {
|
||||||
|
type: [String, Number] as PropType<string | number>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
statusDisabledValue: {
|
||||||
|
type: [String, Number] as PropType<string | number>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
statusOptions: {
|
||||||
|
type: Array as () => Array<string | number | { title: string; value: string | number }>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
permissionOptions: {
|
||||||
|
type: Array as () => Array<string | number | { title: string; value: string | number }>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Header Texts
|
||||||
|
titleHeader: { type: String, default: '標題' },
|
||||||
|
permissionHeader: { type: String, default: '權限' },
|
||||||
|
pathHeader: { type: String, default: '路由路徑' },
|
||||||
|
componentHeader: { type: String, default: '組件路徑' },
|
||||||
|
statusHeader: { type: String, default: '狀態' },
|
||||||
|
actionsHeader: { type: String, default: '操作' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['edit', 'refresh', 'settings', 'toggle-expand'])
|
||||||
|
|
||||||
|
const editDialogOpen = ref(false)
|
||||||
|
const selectedItem = ref<MenuItem | null>(null)
|
||||||
|
|
||||||
|
function normalizeOptions (options: Array<string | number | { title: string; value: string | number }>) {
|
||||||
|
return options.map((o) => {
|
||||||
|
if (typeof o === 'string' || typeof o === 'number') {
|
||||||
|
return { title: String(o), value: o }
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedStatusOptions = computed(() => normalizeOptions(props.statusOptions))
|
||||||
|
|
||||||
|
const resolvedStatusEnabledValue = computed(() => {
|
||||||
|
if (props.statusEnabledValue !== undefined) return props.statusEnabledValue
|
||||||
|
return normalizedStatusOptions.value[0]?.value ?? 'enable'
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEnabledStatus = (status: unknown) => status === resolvedStatusEnabledValue.value
|
||||||
|
|
||||||
|
function openEdit (item: MenuItem) {
|
||||||
|
selectedItem.value = item
|
||||||
|
editDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEditSubmit (updated: Record<string, unknown>) {
|
||||||
|
emit('edit', updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Table Config ---
|
||||||
|
|
||||||
|
const formattedHeaders = computed(() => [
|
||||||
|
{ title: props.titleHeader, key: 'title', align: 'start' as const, minWidth: '250px' },
|
||||||
|
{ title: props.permissionHeader, key: 'permission', align: 'center' as const, width: '120px' },
|
||||||
|
{ title: props.pathHeader, key: 'path', align: 'start' as const },
|
||||||
|
{ title: props.componentHeader, key: 'component', align: 'start' as const },
|
||||||
|
{ title: props.statusHeader, key: 'status', align: 'center' as const, width: '100px' },
|
||||||
|
{
|
||||||
|
title: props.actionsHeader,
|
||||||
|
key: 'actions',
|
||||||
|
align: 'center' as const,
|
||||||
|
width: '250px',
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
function getPermissionColor (permission: string) {
|
||||||
|
switch (permission) {
|
||||||
|
case '管理員': {
|
||||||
|
return 'primary'
|
||||||
|
}
|
||||||
|
case '一級主管': {
|
||||||
|
return 'success'
|
||||||
|
}
|
||||||
|
case '二級主管': {
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
case '使用者': {
|
||||||
|
return 'warning'
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return 'grey'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
<template>
|
||||||
|
<v-sheet class="bg-background" v-bind="$attrs">
|
||||||
|
<!-- Search Filter Section -->
|
||||||
|
<SKSearchFilter
|
||||||
|
v-if="showSearchFilter"
|
||||||
|
:collapse-btn-text="props.collapseBtnText"
|
||||||
|
:expand-btn-text="props.expandBtnText"
|
||||||
|
:fields="searchFields"
|
||||||
|
:reset-btn-text="props.resetBtnText"
|
||||||
|
:search-btn-text="props.searchBtnText"
|
||||||
|
:show-expand="true"
|
||||||
|
:visible-when-collapsed="['roleName', 'roleId']"
|
||||||
|
@reset="$emit('reset')"
|
||||||
|
@search="$emit('search', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Data Table Section -->
|
||||||
|
<v-card class="bg-surface">
|
||||||
|
<SKTableActionBar
|
||||||
|
v-model:settings-selected-keys="visibleHeaderKeys"
|
||||||
|
:create-btn-text="props.createBtnText"
|
||||||
|
:search-visible="showSearchFilter"
|
||||||
|
:settings-items="headerSettingsItems"
|
||||||
|
:show-search-toggle="true"
|
||||||
|
:title="props.listTitle"
|
||||||
|
@create="emit('create')"
|
||||||
|
@refresh="emit('refresh')"
|
||||||
|
@settings="emit('settings')"
|
||||||
|
@toggle-search="showSearchFilter = !showSearchFilter"
|
||||||
|
/>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-data-table
|
||||||
|
class="role-table"
|
||||||
|
:headers="filteredHeaders"
|
||||||
|
hover
|
||||||
|
:items="props.roles"
|
||||||
|
:items-per-page="10"
|
||||||
|
:items-per-page-options="itemsPerPageOptions"
|
||||||
|
:items-per-page-text="props.itemsPerPageText"
|
||||||
|
:loading="props.loading"
|
||||||
|
>
|
||||||
|
<!-- Status Slot -->
|
||||||
|
<template #[`item.status`]="{ item }">
|
||||||
|
<v-switch
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:model-value="item.status"
|
||||||
|
@update:model-value="emit('update:status', item, $event)"
|
||||||
|
></v-switch>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Actions Slot -->
|
||||||
|
<template #[`item.actions`]="{ item }">
|
||||||
|
<v-btn
|
||||||
|
class="px-1"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="emit('edit', item)"
|
||||||
|
>
|
||||||
|
{{ props.editActionText }}
|
||||||
|
</v-btn>
|
||||||
|
<v-divider
|
||||||
|
class="mx-1 d-inline-block"
|
||||||
|
style="height: 12px; vertical-align: middle"
|
||||||
|
vertical
|
||||||
|
></v-divider>
|
||||||
|
<v-btn
|
||||||
|
class="px-1"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="emit('delete', item)"
|
||||||
|
>
|
||||||
|
{{ props.deleteActionText }}
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card>
|
||||||
|
</v-sheet>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watchEffect } from 'vue'
|
||||||
|
import SKSearchFilter from './base/SKSearchFilter.vue'
|
||||||
|
import SKTableActionBar from './base/SKTableActionBar.vue'
|
||||||
|
|
||||||
|
export interface RoleItem {
|
||||||
|
name: string
|
||||||
|
id: string
|
||||||
|
status: boolean
|
||||||
|
note: string
|
||||||
|
createTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const showSearchFilter = ref(true)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// Filter Labels & Placeholders
|
||||||
|
roleNameLabel: { type: String, default: '角色名稱' },
|
||||||
|
roleNamePlaceholder: { type: String, default: '請輸入' },
|
||||||
|
roleIdLabel: { type: String, default: '角色ID' },
|
||||||
|
roleIdPlaceholder: { type: String, default: '請輸入' },
|
||||||
|
statusLabel: { type: String, default: '狀態' },
|
||||||
|
statusPlaceholder: { type: String, default: '請選擇' },
|
||||||
|
statusOptions: { type: Array, default: () => ['已啟用', '已禁用'] },
|
||||||
|
noteLabel: { type: String, default: '備註' },
|
||||||
|
notePlaceholder: { type: String, default: '請輸入' },
|
||||||
|
createTimeLabel: { type: String, default: '建立時間' },
|
||||||
|
startDateLabel: { type: String, default: '開始日期' },
|
||||||
|
endDateLabel: { type: String, default: '結束日期' },
|
||||||
|
datePlaceholder: { type: String, default: '請選擇' },
|
||||||
|
|
||||||
|
// Button Texts
|
||||||
|
searchBtnText: { type: String, default: '搜尋' },
|
||||||
|
resetBtnText: { type: String, default: '重置' },
|
||||||
|
expandBtnText: { type: String, default: '展開' },
|
||||||
|
collapseBtnText: { type: String, default: '收起' },
|
||||||
|
createBtnText: { type: String, default: '新增角色' },
|
||||||
|
|
||||||
|
// Table Texts
|
||||||
|
listTitle: { type: String, default: '權限列表' },
|
||||||
|
roleNameHeader: { type: String, default: '角色名稱' },
|
||||||
|
roleIdHeader: { type: String, default: '角色ID' },
|
||||||
|
statusHeader: { type: String, default: '狀態' },
|
||||||
|
noteHeader: { type: String, default: '備註' },
|
||||||
|
createTimeHeader: { type: String, default: '建立時間' },
|
||||||
|
actionsHeader: { type: String, default: '操作' },
|
||||||
|
editActionText: { type: String, default: '修改' },
|
||||||
|
deleteActionText: { type: String, default: '刪除' },
|
||||||
|
|
||||||
|
// Data Table Footer Texts
|
||||||
|
itemsPerPageText: { type: String, default: '每頁筆數:' },
|
||||||
|
itemsPerPageAllText: { type: String, default: '全部' },
|
||||||
|
|
||||||
|
// Data
|
||||||
|
roles: {
|
||||||
|
type: Array as () => RoleItem[],
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'search',
|
||||||
|
'reset',
|
||||||
|
'create',
|
||||||
|
'edit',
|
||||||
|
'delete',
|
||||||
|
'update:status',
|
||||||
|
'refresh',
|
||||||
|
'settings',
|
||||||
|
])
|
||||||
|
|
||||||
|
// --- Search Fields Configuration ---
|
||||||
|
const searchFields = computed(() => [
|
||||||
|
{
|
||||||
|
key: 'roleName',
|
||||||
|
type: 'text' as const,
|
||||||
|
label: props.roleNameLabel,
|
||||||
|
placeholder: props.roleNamePlaceholder,
|
||||||
|
meta: {
|
||||||
|
cols: 12,
|
||||||
|
md: 4,
|
||||||
|
lg: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'roleId',
|
||||||
|
type: 'text' as const,
|
||||||
|
label: props.roleIdLabel,
|
||||||
|
placeholder: props.roleIdPlaceholder,
|
||||||
|
meta: {
|
||||||
|
cols: 12,
|
||||||
|
md: 4,
|
||||||
|
lg: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
type: 'select' as const,
|
||||||
|
label: props.statusLabel,
|
||||||
|
placeholder: props.statusPlaceholder,
|
||||||
|
items: props.statusOptions,
|
||||||
|
meta: {
|
||||||
|
cols: 12,
|
||||||
|
md: 4,
|
||||||
|
lg: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'note',
|
||||||
|
type: 'text' as const,
|
||||||
|
label: props.noteLabel,
|
||||||
|
placeholder: props.notePlaceholder,
|
||||||
|
meta: {
|
||||||
|
cols: 12,
|
||||||
|
md: 4,
|
||||||
|
lg: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'date' as const,
|
||||||
|
key: 'startDate',
|
||||||
|
label: props.startDateLabel,
|
||||||
|
placeholder: props.datePlaceholder,
|
||||||
|
meta: {
|
||||||
|
cols: 12,
|
||||||
|
md: 4,
|
||||||
|
lg: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'date' as const,
|
||||||
|
key: 'endDate',
|
||||||
|
label: props.endDateLabel,
|
||||||
|
placeholder: props.datePlaceholder,
|
||||||
|
meta: {
|
||||||
|
cols: 12,
|
||||||
|
md: 4,
|
||||||
|
lg: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
// --- Table Config ---
|
||||||
|
|
||||||
|
const formattedHeaders = computed(() => [
|
||||||
|
{ title: props.roleNameHeader, key: 'name', align: 'start' as const },
|
||||||
|
{ title: props.roleIdHeader, key: 'id', align: 'start' as const },
|
||||||
|
{ title: props.statusHeader, key: 'status', align: 'start' as const },
|
||||||
|
{ title: props.noteHeader, key: 'note', align: 'start' as const },
|
||||||
|
{ title: props.createTimeHeader, key: 'createTime', align: 'start' as const },
|
||||||
|
{ title: props.actionsHeader, key: 'actions', align: 'end' as const, sortable: false },
|
||||||
|
])
|
||||||
|
|
||||||
|
const visibleHeaderKeys = ref<string[]>([])
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (visibleHeaderKeys.value.length > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
visibleHeaderKeys.value = formattedHeaders.value.map((h) => String(h.key))
|
||||||
|
})
|
||||||
|
|
||||||
|
const headerSettingsItems = computed(() =>
|
||||||
|
formattedHeaders.value
|
||||||
|
.filter((h) => h.key !== 'actions')
|
||||||
|
.map((h) => ({ key: String(h.key), label: String(h.title) }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredHeaders = computed(() =>
|
||||||
|
formattedHeaders.value.filter((h) => visibleHeaderKeys.value.includes(String(h.key)))
|
||||||
|
)
|
||||||
|
|
||||||
|
const itemsPerPageOptions = computed(() =>
|
||||||
|
[10, 25, 50, 100, -1].map((value) => ({
|
||||||
|
value,
|
||||||
|
title: value === -1 ? props.itemsPerPageAllText : String(value),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog v-model="dialogModel" max-width="480" v-bind="$attrs">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-subtitle-1 font-weight-medium">
|
||||||
|
<slot name="title">
|
||||||
|
{{ props.titleText }}
|
||||||
|
</slot>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text class="pt-2">
|
||||||
|
<slot :form="form" name="content" :permission="formPermission" :status="formStatus">
|
||||||
|
<div class="d-flex flex-column ga-4">
|
||||||
|
<v-select
|
||||||
|
v-if="props.showStatus"
|
||||||
|
v-model="formStatus"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
:items="normalizedStatusOptions"
|
||||||
|
:label="props.statusLabelText"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-select
|
||||||
|
v-if="props.showPermission"
|
||||||
|
v-model="formPermission"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
:items="normalizedPermissionOptions"
|
||||||
|
:label="props.permissionLabelText"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<slot :form="form" name="fields"></slot>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions class="px-4 pb-4">
|
||||||
|
<slot :cancel="handleCancel" name="actions" :submit="handleSubmit">
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn :disabled="props.loading" variant="text" @click="handleCancel">
|
||||||
|
{{ props.cancelText }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn color="primary" :loading="props.loading" @click="handleSubmit">
|
||||||
|
{{ props.confirmText }}
|
||||||
|
</v-btn>
|
||||||
|
</slot>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue'
|
||||||
|
|
||||||
|
type OptionValue = string | number
|
||||||
|
type Option = { title: string; value: OptionValue }
|
||||||
|
|
||||||
|
type GenericRecord = Record<string, unknown>
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: boolean
|
||||||
|
item: GenericRecord | null
|
||||||
|
statusKey?: string
|
||||||
|
permissionKey?: string
|
||||||
|
showStatus?: boolean
|
||||||
|
showPermission?: boolean
|
||||||
|
statusOptions?: Array<Option | string | number>
|
||||||
|
permissionOptions?: Array<Option | string | number>
|
||||||
|
titleText?: string
|
||||||
|
statusLabelText?: string
|
||||||
|
permissionLabelText?: string
|
||||||
|
cancelText?: string
|
||||||
|
confirmText?: string
|
||||||
|
loading?: boolean
|
||||||
|
closeOnSubmit?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
statusKey: 'status',
|
||||||
|
permissionKey: 'permission',
|
||||||
|
showStatus: true,
|
||||||
|
showPermission: true,
|
||||||
|
statusOptions: () => [],
|
||||||
|
permissionOptions: () => [],
|
||||||
|
titleText: '編輯',
|
||||||
|
statusLabelText: '狀態',
|
||||||
|
permissionLabelText: '權限',
|
||||||
|
cancelText: '取消',
|
||||||
|
confirmText: '確認',
|
||||||
|
loading: false,
|
||||||
|
closeOnSubmit: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'submit', value: GenericRecord): void
|
||||||
|
(e: 'cancel'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const dialogModel = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v: boolean) => emit('update:modelValue', v),
|
||||||
|
})
|
||||||
|
|
||||||
|
function normalizeOptions (options: Array<Option | string | number>) {
|
||||||
|
return options.map((o) => {
|
||||||
|
if (typeof o === 'string' || typeof o === 'number') {
|
||||||
|
return { title: String(o), value: o }
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedStatusOptions = computed(() => normalizeOptions(props.statusOptions))
|
||||||
|
const normalizedPermissionOptions = computed(() => normalizeOptions(props.permissionOptions))
|
||||||
|
|
||||||
|
const form = reactive<GenericRecord>({})
|
||||||
|
|
||||||
|
function resetForm (next: GenericRecord) {
|
||||||
|
for (const key of Object.keys(form)) {
|
||||||
|
delete form[key]
|
||||||
|
}
|
||||||
|
Object.assign(form, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDefaultStatus = (): OptionValue | '' => normalizedStatusOptions.value[0]?.value ?? ''
|
||||||
|
function getDefaultPermission (): OptionValue | '' {
|
||||||
|
return normalizedPermissionOptions.value[0]?.value ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const formStatus = computed<OptionValue | ''>({
|
||||||
|
get: () => {
|
||||||
|
const current = form[props.statusKey] as OptionValue | undefined
|
||||||
|
return current ?? getDefaultStatus()
|
||||||
|
},
|
||||||
|
set: (v) => {
|
||||||
|
form[props.statusKey] = v
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const formPermission = computed<OptionValue | ''>({
|
||||||
|
get: () => {
|
||||||
|
const current = form[props.permissionKey] as OptionValue | undefined
|
||||||
|
return current ?? getDefaultPermission()
|
||||||
|
},
|
||||||
|
set: (v) => {
|
||||||
|
form[props.permissionKey] = v
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function syncFromItem () {
|
||||||
|
const item = props.item ?? {}
|
||||||
|
resetForm({ ...item })
|
||||||
|
|
||||||
|
if (props.showStatus) {
|
||||||
|
const status = item[props.statusKey] as OptionValue | undefined
|
||||||
|
form[props.statusKey] = status ?? getDefaultStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.showPermission) {
|
||||||
|
const permission = item[props.permissionKey] as OptionValue | undefined
|
||||||
|
form[props.permissionKey] = permission ?? getDefaultPermission()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (open) syncFromItem()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.item,
|
||||||
|
() => {
|
||||||
|
if (props.modelValue) syncFromItem()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleCancel () {
|
||||||
|
emit('cancel')
|
||||||
|
dialogModel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit () {
|
||||||
|
emit('submit', { ...form })
|
||||||
|
|
||||||
|
if (props.closeOnSubmit) {
|
||||||
|
dialogModel.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="bg-surface mb-4" v-bind="$attrs">
|
||||||
|
<v-card-text>
|
||||||
|
<v-row dense>
|
||||||
|
<!-- Dynamic Search Fields -->
|
||||||
|
<v-col
|
||||||
|
v-for="field in visibleFields"
|
||||||
|
:key="field.key"
|
||||||
|
:cols="field.meta?.cols || field.cols || 12"
|
||||||
|
:lg="field.meta?.lg || field.lg"
|
||||||
|
:md="field.meta?.md || field.md"
|
||||||
|
>
|
||||||
|
<v-row class="ma-0" dense>
|
||||||
|
<v-col class="d-flex align-center justify-start justify-md-end" cols="12" md="4">
|
||||||
|
<span class="text-body-1">{{ field.label }}</span>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col class="py-0" cols="12" md="8">
|
||||||
|
<SKTextField
|
||||||
|
v-if="field.type === 'text'"
|
||||||
|
:model-value="searchState[field.key]"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
@update:model-value="searchState[field.key] = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SKSelectField
|
||||||
|
v-else-if="field.type === 'select'"
|
||||||
|
:items="field.items"
|
||||||
|
:model-value="searchState[field.key]"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
@update:model-value="searchState[field.key] = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SKDatePicker
|
||||||
|
v-else-if="field.type === 'date'"
|
||||||
|
:model-value="searchState[field.key]"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
@update:model-value="searchState[field.key] = $event"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<v-col class="d-flex justify-end align-center flex-md-grow-1" cols="12" md="auto">
|
||||||
|
<v-btn class="mr-2" variant="outlined" @click="handleReset">
|
||||||
|
{{ resetBtnText }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn color="primary" @click="handleSearch">
|
||||||
|
{{ searchBtnText }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="showExpand"
|
||||||
|
class="ml-2"
|
||||||
|
color="primary"
|
||||||
|
variant="text"
|
||||||
|
@click="expand = !expand"
|
||||||
|
>
|
||||||
|
{{ expand ? collapseBtnText : expandBtnText }}
|
||||||
|
<v-icon end :icon="expand ? 'mdi-chevron-up' : 'mdi-chevron-down'"></v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import SKDatePicker from './input_field/SKDatePicker.vue'
|
||||||
|
import SKSelectField from './input_field/SKSelectField.vue'
|
||||||
|
import SKTextField from './input_field/SKTextField.vue'
|
||||||
|
|
||||||
|
interface Field {
|
||||||
|
key: string
|
||||||
|
type: 'text' | 'select' | 'date'
|
||||||
|
label: string
|
||||||
|
placeholder?: string
|
||||||
|
meta?: {
|
||||||
|
cols?: number
|
||||||
|
md?: number
|
||||||
|
lg?: number
|
||||||
|
}
|
||||||
|
cols?: number
|
||||||
|
md?: number
|
||||||
|
lg?: number
|
||||||
|
items?: unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
fields: Field[]
|
||||||
|
visibleWhenCollapsed?: string[]
|
||||||
|
searchBtnText?: string
|
||||||
|
resetBtnText?: string
|
||||||
|
expandBtnText?: string
|
||||||
|
collapseBtnText?: string
|
||||||
|
showExpand?: boolean
|
||||||
|
actionCols?: number
|
||||||
|
actionMd?: number
|
||||||
|
actionLg?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
searchBtnText: '搜尋',
|
||||||
|
resetBtnText: '重置',
|
||||||
|
expandBtnText: '展開',
|
||||||
|
collapseBtnText: '收起',
|
||||||
|
showExpand: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['search', 'reset'])
|
||||||
|
|
||||||
|
const expand = ref(false)
|
||||||
|
|
||||||
|
// Compute visible fields based on expand state
|
||||||
|
const visibleFields = computed(() => {
|
||||||
|
if (expand.value) {
|
||||||
|
return props.fields
|
||||||
|
}
|
||||||
|
if (props.visibleWhenCollapsed && props.visibleWhenCollapsed.length > 0) {
|
||||||
|
return props.fields.filter((field) => props.visibleWhenCollapsed?.includes(field.key))
|
||||||
|
}
|
||||||
|
return props.fields
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize search state
|
||||||
|
const searchState = reactive<Record<string, unknown>>({})
|
||||||
|
|
||||||
|
// Initialize search state based on fields
|
||||||
|
for (const field of props.fields) {
|
||||||
|
searchState[field.key] = field.type === 'select' ? null : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch () {
|
||||||
|
emit('search', { ...searchState })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset () {
|
||||||
|
// Reset all fields
|
||||||
|
for (const field of props.fields) {
|
||||||
|
searchState[field.key] = field.type === 'select' ? null : ''
|
||||||
|
}
|
||||||
|
emit('reset')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<template>
|
||||||
|
<v-row align="center" class="pa-4" no-gutters v-bind="$attrs">
|
||||||
|
<span v-if="title">{{ title }}</span>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
v-if="showCreate"
|
||||||
|
class="mr-4"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
@click="$emit('create')"
|
||||||
|
>
|
||||||
|
{{ createBtnText }}
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-tooltip :disabled="!searchToggleTooltipText" location="top" :text="searchToggleTooltipText">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
v-if="showSearchToggle"
|
||||||
|
v-bind="props"
|
||||||
|
density="comfortable"
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('toggle-search')"
|
||||||
|
>
|
||||||
|
<v-icon :color="searchVisible ? 'primary-variant' : undefined"> mdi-magnify </v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
|
||||||
|
<v-tooltip :disabled="!refreshTooltipText" location="top" :text="refreshTooltipText">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn v-bind="props" density="comfortable" icon variant="text" @click="$emit('refresh')">
|
||||||
|
<v-icon>mdi-refresh</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
|
||||||
|
<v-menu v-if="settingsItems && settingsItems.length > 0">
|
||||||
|
<template #activator="{ props: menuProps }">
|
||||||
|
<v-tooltip :disabled="!settingsTooltipText" location="top" :text="settingsTooltipText">
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<v-btn
|
||||||
|
v-bind="{ ...menuProps, ...tooltipProps }"
|
||||||
|
density="comfortable"
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('settings')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-cog</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item class="py-0">
|
||||||
|
<v-checkbox
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
:disabled="selectAllChecked"
|
||||||
|
hide-details
|
||||||
|
:indeterminate="selectAllIndeterminate"
|
||||||
|
label="全選"
|
||||||
|
:model-value="selectAllChecked"
|
||||||
|
@update:model-value="toggleSelectAll"
|
||||||
|
/>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item v-for="item in settingsItems" :key="item.key" class="py-0">
|
||||||
|
<v-checkbox
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:label="item.label"
|
||||||
|
:model-value="settingsSelectedKeys"
|
||||||
|
:value="item.key"
|
||||||
|
@update:model-value="updateSettingsSelectedKeys"
|
||||||
|
/>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, toRefs } from 'vue'
|
||||||
|
|
||||||
|
interface SettingsItem {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string
|
||||||
|
createBtnText?: string
|
||||||
|
showCreate?: boolean
|
||||||
|
showSearchToggle?: boolean
|
||||||
|
searchVisible?: boolean
|
||||||
|
searchToggleTooltipText?: string
|
||||||
|
refreshTooltipText?: string
|
||||||
|
settingsTooltipText?: string
|
||||||
|
settingsItems?: SettingsItem[]
|
||||||
|
settingsSelectedKeys?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
createBtnText: '新增',
|
||||||
|
showCreate: true,
|
||||||
|
showSearchToggle: false,
|
||||||
|
searchVisible: true,
|
||||||
|
searchToggleTooltipText: '顯示/隱藏搜尋條件',
|
||||||
|
refreshTooltipText: '更新',
|
||||||
|
settingsTooltipText: '欄位設定',
|
||||||
|
settingsItems: () => [],
|
||||||
|
settingsSelectedKeys: () => [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const { settingsItems, settingsSelectedKeys } = toRefs(props)
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'create',
|
||||||
|
'refresh',
|
||||||
|
'settings',
|
||||||
|
'toggle-search',
|
||||||
|
'update:settingsSelectedKeys',
|
||||||
|
])
|
||||||
|
|
||||||
|
const allSettingsKeys = computed(() => settingsItems.value.map((i) => i.key))
|
||||||
|
|
||||||
|
const selectAllChecked = computed(() => {
|
||||||
|
if (allSettingsKeys.value.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return allSettingsKeys.value.every((k) => settingsSelectedKeys.value.includes(k))
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectAllIndeterminate = computed(() => {
|
||||||
|
if (allSettingsKeys.value.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const selectedCount = allSettingsKeys.value.filter((k) =>
|
||||||
|
settingsSelectedKeys.value.includes(k)
|
||||||
|
).length
|
||||||
|
return selectedCount > 0 && selectedCount < allSettingsKeys.value.length
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleSelectAll (checked: unknown) {
|
||||||
|
const current = Array.isArray(settingsSelectedKeys.value) ? settingsSelectedKeys.value : []
|
||||||
|
const nonSettingsKeys = current.filter((k) => !allSettingsKeys.value.includes(k))
|
||||||
|
|
||||||
|
emit(
|
||||||
|
'update:settingsSelectedKeys',
|
||||||
|
checked ? [...nonSettingsKeys, ...allSettingsKeys.value] : nonSettingsKeys
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSettingsSelectedKeys (value: unknown) {
|
||||||
|
emit('update:settingsSelectedKeys', Array.isArray(value) ? value : [])
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<v-data-table
|
||||||
|
:class="`${tableClass} tree-table`"
|
||||||
|
:headers="formattedHeaders"
|
||||||
|
hide-default-footer
|
||||||
|
hover
|
||||||
|
:items="flattenedItems"
|
||||||
|
:items-per-page="-1"
|
||||||
|
:loading="loading"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<!-- Tree Column Slot -->
|
||||||
|
<template v-for="header in treeHeaders" :key="header.key" #[`item.${header.key}`]="{ item }">
|
||||||
|
<div class="d-flex align-center" :style="{ paddingLeft: `${(item.level as number) * 16}px` }">
|
||||||
|
<!-- Expand Toggle -->
|
||||||
|
<v-btn
|
||||||
|
v-if="item.hasChildren"
|
||||||
|
class="mr-1"
|
||||||
|
density="compact"
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="toggleExpand(item.id)"
|
||||||
|
>
|
||||||
|
<v-icon>{{ isExpanded(item.id) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<div v-else style="width: 20px"></div>
|
||||||
|
|
||||||
|
<span class="mr-2 text-body-2">{{ item[header.key] }}</span>
|
||||||
|
<slot :item="item" :name="`tree-${header.key}`"></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Custom Slots -->
|
||||||
|
<template v-for="(_, name) in $slots" #[name]="slotData">
|
||||||
|
<slot :name="name" v-bind="slotData"></slot>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
headers: TableHeader[]
|
||||||
|
items: TreeNode[]
|
||||||
|
loading?: boolean
|
||||||
|
treeColumnKeys?: string[]
|
||||||
|
tableClass?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableHeader {
|
||||||
|
title: string
|
||||||
|
key: string
|
||||||
|
align?: 'start' | 'end' | 'center'
|
||||||
|
width?: string
|
||||||
|
minWidth?: string
|
||||||
|
sortable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeNode {
|
||||||
|
id: string | number
|
||||||
|
children?: TreeNode[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
loading: false,
|
||||||
|
treeColumnKeys: () => ['name', 'title'],
|
||||||
|
tableClass: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['toggle-expand'])
|
||||||
|
|
||||||
|
const expandedIds = ref<Set<string | number>>(new Set())
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.items,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal && newVal.length > 0) {
|
||||||
|
for (const item of newVal) expandedIds.value.add(item.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleExpand (id: string | number) {
|
||||||
|
if (expandedIds.value.has(id)) {
|
||||||
|
expandedIds.value.delete(id)
|
||||||
|
} else {
|
||||||
|
expandedIds.value.add(id)
|
||||||
|
}
|
||||||
|
emit('toggle-expand', id, expandedIds.value.has(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpanded = (id: string | number) => expandedIds.value.has(id)
|
||||||
|
|
||||||
|
const treeHeaders = computed(() =>
|
||||||
|
props.headers.filter((h: TableHeader) => props.treeColumnKeys.includes(h.key))
|
||||||
|
)
|
||||||
|
|
||||||
|
const flattenedItems = computed(() => {
|
||||||
|
const result: TreeNode[] = []
|
||||||
|
|
||||||
|
const traverse = (nodes: TreeNode[], level = 0) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
const hasChildren = node.children && node.children.length > 0
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
...node,
|
||||||
|
level,
|
||||||
|
hasChildren,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hasChildren && expandedIds.value.has(node.id)) {
|
||||||
|
traverse(node.children as TreeNode[], level + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
traverse(props.items)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedHeaders = computed(() => props.headers)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tree-table :deep(th) {
|
||||||
|
font-weight: 600 !important;
|
||||||
|
color: #666;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
.tree-table :deep(td) {
|
||||||
|
height: 54px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="rounded-lg h-100" elevation="2" v-bind="$attrs">
|
||||||
|
<v-card-title class="text-subtitle-1 font-weight-bold py-4">
|
||||||
|
{{ title }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-card-text class="d-flex align-center justify-center pt-8">
|
||||||
|
<div class="w-100">
|
||||||
|
<div v-for="(item, i) in data" :key="i" class="mb-4">
|
||||||
|
<div class="d-flex justify-space-between text-caption mb-1">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<span>{{ item.value }}%</span>
|
||||||
|
</div>
|
||||||
|
<v-progress-linear
|
||||||
|
:color="item.color"
|
||||||
|
height="8"
|
||||||
|
:model-value="item.value"
|
||||||
|
rounded
|
||||||
|
striped
|
||||||
|
></v-progress-linear>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
data: Array<{
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
color: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="rounded-lg h-100" elevation="2" v-bind="$attrs">
|
||||||
|
<v-card-title class="text-subtitle-1 font-weight-bold py-4">
|
||||||
|
{{ title }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-card-text class="d-flex flex-column align-center justify-center pt-8">
|
||||||
|
<div
|
||||||
|
class="d-flex align-center justify-center"
|
||||||
|
style="position: relative; width: 200px; height: 200px"
|
||||||
|
>
|
||||||
|
<v-progress-circular
|
||||||
|
bg-color="grey-lighten-4"
|
||||||
|
:color="data.color"
|
||||||
|
:model-value="data.value"
|
||||||
|
:size="180"
|
||||||
|
:width="25"
|
||||||
|
>
|
||||||
|
<v-icon :color="data.color" size="40">{{ data.icon }}</v-icon>
|
||||||
|
</v-progress-circular>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<div class="text-h6">{{ data.label }}</div>
|
||||||
|
<div class="text-body-2 text-grey">佔比 {{ data.value }}%</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
data: {
|
||||||
|
value: number
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="rounded-lg h-100" elevation="2" v-bind="$attrs">
|
||||||
|
<v-card-title class="text-subtitle-1 font-weight-bold py-4">
|
||||||
|
{{ title }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-card-text class="d-flex flex-column align-center justify-center pt-8">
|
||||||
|
<div
|
||||||
|
class="d-flex align-center justify-center"
|
||||||
|
style="position: relative; width: 200px; height: 200px"
|
||||||
|
>
|
||||||
|
<v-progress-circular
|
||||||
|
class="position-absolute"
|
||||||
|
color="grey-lighten-3"
|
||||||
|
:model-value="100"
|
||||||
|
:size="180"
|
||||||
|
:width="25"
|
||||||
|
></v-progress-circular>
|
||||||
|
|
||||||
|
<v-progress-circular
|
||||||
|
class="position-absolute"
|
||||||
|
:color="data.color"
|
||||||
|
:model-value="data.value"
|
||||||
|
rotate="270"
|
||||||
|
:size="180"
|
||||||
|
:width="25"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-h5 font-weight-bold">{{ data.value }}%</div>
|
||||||
|
<div class="text-caption text-grey">{{ data.label }}</div>
|
||||||
|
</div>
|
||||||
|
</v-progress-circular>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 d-flex flex-wrap justify-center gap-2">
|
||||||
|
<v-chip class="mr-2" :color="data.color" label size="small" variant="flat">
|
||||||
|
{{ data.label }}
|
||||||
|
</v-chip>
|
||||||
|
<v-chip color="grey-lighten-3" label size="small" variant="flat"> 其他 </v-chip>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
data: {
|
||||||
|
value: number
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="rounded-lg h-100" elevation="2" v-bind="$attrs">
|
||||||
|
<v-card-text class="d-flex flex-column justify-space-between h-100">
|
||||||
|
<div class="d-flex justify-space-between align-start mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-subtitle-1 font-weight-bold text-grey-darken-1 mb-1">
|
||||||
|
{{ title }}
|
||||||
|
</div>
|
||||||
|
<div class="text-h4 font-weight-bold">{{ value }}</div>
|
||||||
|
</div>
|
||||||
|
<v-icon class="opacity-80" :color="color" size="x-large">
|
||||||
|
{{ icon }}
|
||||||
|
</v-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-space-between align-center border-t pt-3">
|
||||||
|
<span class="text-body-2 text-grey">{{ label }}</span>
|
||||||
|
<span class="text-body-2 font-weight-medium">{{ total }}</span>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
value: string | number
|
||||||
|
label: string
|
||||||
|
total: string | number
|
||||||
|
icon: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="rounded-lg" elevation="2" v-bind="$attrs">
|
||||||
|
<v-card-title class="d-flex align-center py-4 px-4">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon class="mr-2" color="primary" icon="mdi-chart-timeline-variant"></v-icon>
|
||||||
|
<span>{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<div class="d-flex">
|
||||||
|
<v-btn
|
||||||
|
v-for="filter in filters"
|
||||||
|
:key="filter"
|
||||||
|
:color="activeFilter === filter ? 'primary' : 'grey'"
|
||||||
|
density="compact"
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('filter-change', filter)"
|
||||||
|
>
|
||||||
|
{{ filter }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text class="pt-6 pb-2">
|
||||||
|
<div class="chart-container" style="height: 300px; position: relative">
|
||||||
|
<v-sparkline
|
||||||
|
auto-draw
|
||||||
|
fill
|
||||||
|
:gradient="['#1890ff', '#e6f7ff']"
|
||||||
|
gradient-direction="top"
|
||||||
|
height="100"
|
||||||
|
:line-width="2"
|
||||||
|
:model-value="data"
|
||||||
|
:padding="8"
|
||||||
|
:smooth="10"
|
||||||
|
stroke-linecap="round"
|
||||||
|
>
|
||||||
|
<template #label="item">
|
||||||
|
{{ item.value }}
|
||||||
|
</template>
|
||||||
|
</v-sparkline>
|
||||||
|
<slot name="x-axis">
|
||||||
|
<div class="d-flex justify-space-between mt-2 px-2 text-caption text-grey">
|
||||||
|
<span v-for="i in 12" :key="i">{{ 6 + i }}:00</span>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
data: number[]
|
||||||
|
filters: string[]
|
||||||
|
activeFilter: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
defineEmits(['filter-change'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-container {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="rounded-lg" elevation="2" v-bind="$attrs">
|
||||||
|
<v-card-title class="d-flex align-center py-4 px-4 border-b">
|
||||||
|
<span class="font-weight-bold">{{ title }}</span>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn color="primary" density="compact" variant="text" @click="$emit('view-more')">
|
||||||
|
{{ viewMoreText }}
|
||||||
|
</v-btn>
|
||||||
|
</v-card-title>
|
||||||
|
<v-list class="pa-0" lines="two">
|
||||||
|
<v-list-item
|
||||||
|
v-for="(item, index) in announcements"
|
||||||
|
:key="index"
|
||||||
|
class="border-b"
|
||||||
|
@click="$emit('item-click', item)"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-avatar :color="item.avatarColor || 'primary'" size="40" variant="tonal">
|
||||||
|
<span v-if="!item.avatarSrc" class="text-h6">{{ item.author[0] }}</span>
|
||||||
|
<v-img v-else :src="item.avatarSrc"></v-img>
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list-item-title class="font-weight-medium mb-1">
|
||||||
|
{{ item.title }}
|
||||||
|
</v-list-item-title>
|
||||||
|
|
||||||
|
<v-list-item-subtitle>
|
||||||
|
<span class="text-caption text-grey mr-2">{{ item.author }}</span>
|
||||||
|
<span class="text-caption text-grey">{{ item.time }}</span>
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
announcements: Array<{
|
||||||
|
title: string
|
||||||
|
author: string
|
||||||
|
time: string
|
||||||
|
avatarSrc?: string | null
|
||||||
|
avatarColor?: string
|
||||||
|
}>
|
||||||
|
viewMoreText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
viewMoreText: '更多',
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['view-more', 'item-click'])
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="rounded-lg" elevation="2" v-bind="$attrs">
|
||||||
|
<v-card-title class="d-flex align-center py-4 px-4 border-b">
|
||||||
|
<span class="font-weight-bold">{{ title }}</span>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn color="primary" density="compact" variant="text" @click="$emit('view-all')">
|
||||||
|
{{ viewAllText }}
|
||||||
|
</v-btn>
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text class="pa-0">
|
||||||
|
<v-row no-gutters>
|
||||||
|
<v-col
|
||||||
|
v-for="(app, index) in apps"
|
||||||
|
:key="index"
|
||||||
|
class="border-e border-b app-item"
|
||||||
|
cols="12"
|
||||||
|
sm="4"
|
||||||
|
>
|
||||||
|
<div class="pa-4 h-100 hover-bg" @click="$emit('app-click', app)">
|
||||||
|
<div class="d-flex align-center mb-3">
|
||||||
|
<v-icon class="mr-3" :color="app.color" size="large">{{ app.icon }}</v-icon>
|
||||||
|
<span class="text-subtitle-1 font-weight-medium">{{ app.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="text-body-2 text-grey mb-4"
|
||||||
|
style="
|
||||||
|
height: 40px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ app.desc }}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-space-between text-caption text-grey-lighten-1">
|
||||||
|
<span>{{ app.group }}</span>
|
||||||
|
<span>{{ app.date }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
apps: Array<{
|
||||||
|
name: string
|
||||||
|
desc: string
|
||||||
|
icon: string
|
||||||
|
color: string
|
||||||
|
group: string
|
||||||
|
date: string
|
||||||
|
}>
|
||||||
|
viewAllText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
viewAllText: '全部',
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['view-all', 'app-click'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hover-bg {
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.hover-bg:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.app-item:last-child {
|
||||||
|
border-right: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="rounded-lg" elevation="2" v-bind="$attrs">
|
||||||
|
<v-card-title class="text-subtitle-1 font-weight-bold py-4 border-b">
|
||||||
|
{{ title }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text class="d-flex flex-column align-center justify-center pt-6 pb-6">
|
||||||
|
<div
|
||||||
|
class="d-flex align-center justify-center"
|
||||||
|
style="position: relative; width: 180px; height: 180px"
|
||||||
|
>
|
||||||
|
<v-progress-circular
|
||||||
|
bg-color="grey-lighten-3"
|
||||||
|
color="primary"
|
||||||
|
:model-value="value"
|
||||||
|
:size="160"
|
||||||
|
:width="20"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-h6 font-weight-bold">{{ value }}%</div>
|
||||||
|
<div class="text-caption text-grey">{{ subtitle }}</div>
|
||||||
|
</div>
|
||||||
|
</v-progress-circular>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 d-flex justify-center gap-4 w-100">
|
||||||
|
<div class="d-flex align-center mr-4">
|
||||||
|
<v-icon class="mr-1" color="primary" size="small">mdi-circle</v-icon>
|
||||||
|
<span class="text-caption">{{ primaryLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon class="mr-1" color="grey-lighten-3" size="small">mdi-circle</v-icon>
|
||||||
|
<span class="text-caption">{{ secondaryLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
value: number
|
||||||
|
subtitle?: string
|
||||||
|
primaryLabel?: string
|
||||||
|
secondaryLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
subtitle: '來源佔比',
|
||||||
|
primaryLabel: '校內',
|
||||||
|
secondaryLabel: '校外',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="rounded-lg pa-4 white-bg" elevation="2" v-bind="$attrs">
|
||||||
|
<div class="d-flex flex-column flex-md-row align-center">
|
||||||
|
<!-- Avatar -->
|
||||||
|
<v-avatar class="mr-md-6 mb-4 mb-md-0" size="72">
|
||||||
|
<v-img alt="Avatar" cover :src="userAvatar"></v-img>
|
||||||
|
</v-avatar>
|
||||||
|
|
||||||
|
<!-- Greeting -->
|
||||||
|
<div class="flex-grow-1 text-center text-md-left mb-4 mb-md-0">
|
||||||
|
<h2 class="text-h5 font-weight-bold text-grey-darken-3 mb-2">
|
||||||
|
{{ greetingTitle }}
|
||||||
|
</h2>
|
||||||
|
<div class="text-body-1 text-grey">
|
||||||
|
{{ weatherInfo }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header Stats -->
|
||||||
|
<div class="d-flex justify-center justify-md-end gap-6 px-4" style="gap: 24px">
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-caption text-grey mb-1">{{ todoLabel }}</div>
|
||||||
|
<div class="text-h5 font-weight-bold">{{ todo }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-caption text-grey mb-1">{{ projectsLabel }}</div>
|
||||||
|
<div class="text-h5 font-weight-bold">{{ projects }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-caption text-grey mb-1">{{ teamLabel }}</div>
|
||||||
|
<div class="text-h5 font-weight-bold">{{ team }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
userAvatar: string
|
||||||
|
greetingTitle: string
|
||||||
|
weatherInfo: string
|
||||||
|
todo: string
|
||||||
|
projects: string
|
||||||
|
team: string
|
||||||
|
todoLabel?: string
|
||||||
|
projectsLabel?: string
|
||||||
|
teamLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
todoLabel: '代辦事項',
|
||||||
|
projectsLabel: '專案項目',
|
||||||
|
teamLabel: '團隊成員',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="rounded-lg" elevation="2" v-bind="$attrs">
|
||||||
|
<v-card-title class="text-subtitle-1 font-weight-bold py-4 border-b">
|
||||||
|
{{ title }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text class="pa-4">
|
||||||
|
<v-row dense>
|
||||||
|
<v-col v-for="(nav, i) in navs" :key="i" class="text-center mb-2" cols="4">
|
||||||
|
<v-btn
|
||||||
|
class="mb-1"
|
||||||
|
:color="nav.color"
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('nav-click', nav)"
|
||||||
|
>
|
||||||
|
<v-icon size="24">{{ nav.icon }}</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<div class="text-caption text-grey-darken-1">{{ nav.title }}</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
navs: Array<{
|
||||||
|
icon: string
|
||||||
|
title: string
|
||||||
|
color: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
defineEmits(['nav-click'])
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="rounded-lg" elevation="2" v-bind="$attrs">
|
||||||
|
<v-card-title class="d-flex justify-space-between align-center py-4 border-b">
|
||||||
|
<span class="text-subtitle-1 font-weight-bold">{{ title }}</span>
|
||||||
|
</v-card-title>
|
||||||
|
<v-list class="pa-0" density="compact">
|
||||||
|
<v-list-item v-for="(todo, i) in todos" :key="i" class="py-2">
|
||||||
|
<template #prepend>
|
||||||
|
<v-checkbox-btn
|
||||||
|
v-model="todo.done"
|
||||||
|
class="mr-2"
|
||||||
|
density="compact"
|
||||||
|
@update:model-value="$emit('toggle-todo', todo, $event)"
|
||||||
|
></v-checkbox-btn>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title :class="{ 'text-decoration-line-through text-grey': todo.done }">
|
||||||
|
{{ todo.title }}
|
||||||
|
</v-list-item-title>
|
||||||
|
<template #append>
|
||||||
|
<span class="text-caption text-grey">{{ todo.due }}</span>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
todos: Array<{
|
||||||
|
title: string
|
||||||
|
due: string
|
||||||
|
done: boolean
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
defineEmits(['toggle-todo'])
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<v-text-field
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:label="undefined"
|
||||||
|
:model-value="modelValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
modelValue: unknown
|
||||||
|
placeholder?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: unknown]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<v-select
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:items="items"
|
||||||
|
:label="undefined"
|
||||||
|
:model-value="modelValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
modelValue: unknown
|
||||||
|
placeholder?: string
|
||||||
|
items?: unknown[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: unknown]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<v-text-field
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:label="undefined"
|
||||||
|
:model-value="modelValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
modelValue: unknown
|
||||||
|
placeholder?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: unknown]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-center w-100">
|
||||||
|
<div class="text-body-2">
|
||||||
|
{{ props.promptText }}
|
||||||
|
<a
|
||||||
|
class="text-primary text-decoration-none font-weight-bold ml-1"
|
||||||
|
:href="props.href || '#'"
|
||||||
|
:target="props.target"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
{{ props.linkText }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
promptText: {
|
||||||
|
type: String,
|
||||||
|
default: '還沒有帳號?',
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
type: String,
|
||||||
|
default: '註冊帳號',
|
||||||
|
},
|
||||||
|
href: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
type: String,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['click'])
|
||||||
|
|
||||||
|
function handleClick (e: MouseEvent) {
|
||||||
|
emit('click', e)
|
||||||
|
if (!props.href) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="w-100 h-100 d-flex flex-column bg-transparent pa-2 pa-lg-4" elevation="3">
|
||||||
|
<v-card-title class="text-h6 text-lg-h5 font-weight-bold text-accent mb-4">{{ title }}</v-card-title>
|
||||||
|
|
||||||
|
<v-tabs v-model="activeTab" class="mb-3" color="primary" density="comfortable">
|
||||||
|
<v-tab v-for="tab in normalizedTabs" :key="tab.value" :value="tab.value">
|
||||||
|
{{ tab.label }}
|
||||||
|
</v-tab>
|
||||||
|
</v-tabs>
|
||||||
|
|
||||||
|
<div class="announcement-content mb-3">
|
||||||
|
<v-table v-if="!isSystemTab" density="comfortable" fixed-header height="300">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-left">{{ dateHeader }}</th>
|
||||||
|
<th class="text-left">{{ schoolHeader }}</th>
|
||||||
|
<th class="text-left">{{ titleHeader }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in pageItems" :key="item.id">
|
||||||
|
<td class="text-no-wrap">{{ item.date }}</td>
|
||||||
|
<td class="text-no-wrap">{{ item.school }}</td>
|
||||||
|
<td>
|
||||||
|
<v-btn
|
||||||
|
class="px-0 text-none justify-start" color="primary" variant="text"
|
||||||
|
@click="emit('select-announcement', item)">
|
||||||
|
{{ item.title }}
|
||||||
|
</v-btn>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="pageItems.length === 0">
|
||||||
|
<td class="text-center text-medium-emphasis py-6" :colspan="3">{{ emptyText }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
|
||||||
|
<v-list v-else class="rounded border overflow-y-auto h-100" density="comfortable" lines="two">
|
||||||
|
<v-list-item v-for="item in systemPageItems" :key="item.id" border="b">
|
||||||
|
<v-list-item-title class="text-h6 mb-2">
|
||||||
|
{{ item.content }}
|
||||||
|
</v-list-item-title>
|
||||||
|
<v-list-item-subtitle v-if="item.title || item.createdAt">
|
||||||
|
{{ item.title }}<span v-if="item.title && item.createdAt"> ・ </span>{{ item.createdAt }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item v-if="systemPageItems.length === 0" class="h-100">
|
||||||
|
<v-list-item-title class="text-center text-medium-emphasis">{{ emptyText }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="d-flex justify-space-between align-center mt-auto pt-3">
|
||||||
|
<span class="text-caption text-medium-emphasis">
|
||||||
|
{{ paginationLabel }} {{ totalItems }}
|
||||||
|
</span>
|
||||||
|
<v-pagination v-model="page" density="comfortable" :length="pageCount" rounded="circle" />
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
interface AnnouncementTab {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnnouncementItem {
|
||||||
|
id: string | number
|
||||||
|
date: string
|
||||||
|
school: string
|
||||||
|
title: string
|
||||||
|
tab?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SystemAnnouncementItem {
|
||||||
|
id: string | number
|
||||||
|
content: string
|
||||||
|
title?: string
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string
|
||||||
|
tabs?: AnnouncementTab[]
|
||||||
|
items?: AnnouncementItem[]
|
||||||
|
systemAnnouncements?: SystemAnnouncementItem[]
|
||||||
|
allTabLabel?: string
|
||||||
|
itemsPerPage?: number
|
||||||
|
dateHeader?: string
|
||||||
|
schoolHeader?: string
|
||||||
|
titleHeader?: string
|
||||||
|
emptyText?: string
|
||||||
|
paginationLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
title: '學校甄選簡章公告區',
|
||||||
|
tabs: () => [{ label: '全部', value: '__all__' }],
|
||||||
|
items: () => [],
|
||||||
|
systemAnnouncements: () => [],
|
||||||
|
allTabLabel: '全部',
|
||||||
|
itemsPerPage: 5,
|
||||||
|
dateHeader: '公告時間',
|
||||||
|
schoolHeader: '公告學校',
|
||||||
|
titleHeader: '公告標題',
|
||||||
|
emptyText: '目前沒有公告資料',
|
||||||
|
paginationLabel: '總筆數:',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'select-announcement', item: AnnouncementItem): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const allTabValue = '__all__'
|
||||||
|
const systemTabValue = '__system__'
|
||||||
|
|
||||||
|
const systemTab = computed<AnnouncementTab>(() => ({
|
||||||
|
label: '系統公告',
|
||||||
|
value: systemTabValue,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const normalizedTabs = computed<AnnouncementTab[]>(() => {
|
||||||
|
const baseTabs = props.tabs.length > 0 ? props.tabs : [{ label: props.allTabLabel, value: allTabValue }]
|
||||||
|
if (baseTabs.some((tab) => tab.value === systemTabValue)) return baseTabs
|
||||||
|
return [...baseTabs, systemTab.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeTab = ref(normalizedTabs.value[0]?.value ?? allTabValue)
|
||||||
|
const page = ref(1)
|
||||||
|
const isSystemTab = computed(() => activeTab.value === systemTabValue)
|
||||||
|
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
if (activeTab.value === allTabValue) return props.items
|
||||||
|
return props.items.filter((item) => item.tab === activeTab.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalItems = computed(() => {
|
||||||
|
if (isSystemTab.value) return props.systemAnnouncements.length
|
||||||
|
return filteredItems.value.length
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageCount = computed(() => {
|
||||||
|
const size = Math.max(1, props.itemsPerPage)
|
||||||
|
return Math.max(1, Math.ceil(totalItems.value / size))
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageItems = computed(() => {
|
||||||
|
const size = Math.max(1, props.itemsPerPage)
|
||||||
|
const start = (page.value - 1) * size
|
||||||
|
return filteredItems.value.slice(start, start + size)
|
||||||
|
})
|
||||||
|
|
||||||
|
const systemPageItems = computed<SystemAnnouncementItem[]>(() => {
|
||||||
|
const size = Math.max(1, props.itemsPerPage)
|
||||||
|
const start = (page.value - 1) * size
|
||||||
|
return props.systemAnnouncements.slice(start, start + size)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
normalizedTabs,
|
||||||
|
(tabs) => {
|
||||||
|
if (tabs.some((tab) => tab.value === activeTab.value)) return
|
||||||
|
activeTab.value = tabs[0]?.value ?? allTabValue
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(activeTab, () => {
|
||||||
|
page.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(pageCount, (count) => {
|
||||||
|
if (page.value <= count) return
|
||||||
|
page.value = count
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.announcement-content {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<template>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<span class="text-h5 font-weight-bold text-primary">{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: 'Login PageS',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<template>
|
||||||
|
<v-form @submit.prevent="$emit('submit', { username, password, rememberMe })">
|
||||||
|
<v-text-field
|
||||||
|
v-model="username" bg-color="surface" class="mb-6 mb-md-4" color="primary"
|
||||||
|
density="comfortable" hide-details :placeholder="props.accPlaceholder" variant="outlined"></v-text-field>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="password" :append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'" bg-color="surface"
|
||||||
|
class="mb-6 mb-md-4" color="primary" density="comfortable" hide-details :placeholder="props.passwPlaceholder" :type="showPassword ? 'text' : 'password'"
|
||||||
|
variant="outlined"
|
||||||
|
@click:append-inner="showPassword = !showPassword"></v-text-field>
|
||||||
|
|
||||||
|
<slot name="verify"></slot>
|
||||||
|
|
||||||
|
<div class="d-flex align-center justify-space-between mb-6 mb-md-4">
|
||||||
|
<v-checkbox
|
||||||
|
v-model="rememberMe" color="primary" density="compact" hide-details
|
||||||
|
:label="props.rememberMeLabel"></v-checkbox>
|
||||||
|
<a
|
||||||
|
class="text-body-2 text-primary text-decoration-none" :href="props.forgotPasswordHref || '#'"
|
||||||
|
:target="props.forgotPasswordTarget" @click="handleForgotPasswordClick">
|
||||||
|
{{ props.forgotPasswordText }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-btn block class="mb-6 font-weight-bold" color="primary" elevation="0" height="48" size="large" type="submit">
|
||||||
|
{{ props.submitText }}
|
||||||
|
</v-btn>
|
||||||
|
</v-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const showPassword = ref(false)
|
||||||
|
const rememberMe = ref(false)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
passwPlaceholder: {
|
||||||
|
type: String,
|
||||||
|
default: '請輸入6位數密碼',
|
||||||
|
},
|
||||||
|
accPlaceholder: {
|
||||||
|
type: String,
|
||||||
|
default: '請輸入帳號',
|
||||||
|
},
|
||||||
|
rememberMeLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '記住帳號',
|
||||||
|
},
|
||||||
|
forgotPasswordText: {
|
||||||
|
type: String,
|
||||||
|
default: '忘記密碼?',
|
||||||
|
},
|
||||||
|
forgotPasswordHref: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
forgotPasswordTarget: {
|
||||||
|
type: String,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
submitText: {
|
||||||
|
type: String,
|
||||||
|
default: '登入',
|
||||||
|
},
|
||||||
|
rememberStorageKey: {
|
||||||
|
type: String,
|
||||||
|
default: 'sklogin.remember.username',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit', 'forgot-password'])
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const saved = localStorage.getItem(props.rememberStorageKey)
|
||||||
|
if (saved) {
|
||||||
|
username.value = saved
|
||||||
|
rememberMe.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([rememberMe, username], ([nextRemember, nextUsername]) => {
|
||||||
|
if (!nextRemember) {
|
||||||
|
localStorage.removeItem(props.rememberStorageKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextUsername) {
|
||||||
|
localStorage.removeItem(props.rememberStorageKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(props.rememberStorageKey, nextUsername)
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleForgotPasswordClick (e: MouseEvent) {
|
||||||
|
emit('forgot-password', e)
|
||||||
|
if (!props.forgotPasswordHref) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.v-field--variant-outlined) {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.v-btn) {
|
||||||
|
text-transform: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.v-checkbox .v-label) {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-header-wrapper">
|
||||||
|
<h2 class="text-h5 text-primary font-weight-bold mb-2">{{ props.welcomeText }}</h2>
|
||||||
|
<p class="text-subtitle-1 text-secondary">{{ props.welcomeDescription }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
welcomeText: {
|
||||||
|
type: String,
|
||||||
|
default: '歡迎回來 👋🏻',
|
||||||
|
},
|
||||||
|
welcomeDescription: {
|
||||||
|
type: String,
|
||||||
|
default: '請輸入您的帳號密碼進行登入',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.login-header-wrapper {
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="illustration-container d-flex flex-column align-center justify-center fill-height px-8"
|
||||||
|
>
|
||||||
|
<div class="illustration-wrapper mb-8 w-100 d-flex justify-center">
|
||||||
|
<v-img
|
||||||
|
v-if="image"
|
||||||
|
aspect-ratio="16/9"
|
||||||
|
contain
|
||||||
|
max-width="600"
|
||||||
|
:src="image"
|
||||||
|
width="100%"
|
||||||
|
></v-img>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-h4 font-weight-bold text-secondary mb-4">{{ title }}</h1>
|
||||||
|
<p class="text-body-1 text-secondary">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps({
|
||||||
|
image: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '這是一個標題',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: '這是一個副標題',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.illustration-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<div class="d-flex justify-end py-0 py-sm-2">
|
||||||
|
<v-btn
|
||||||
|
class="d-none d-md-block" color="grey-darken-1" icon="mdi-palette-outline" size="small" variant="text"
|
||||||
|
@click="toggleTheme"></v-btn>
|
||||||
|
<!-- <v-btn icon="mdi-dock-window" variant="text" size="small" color="grey-darken-1" @click="handleToggleLayout"></v-btn> -->
|
||||||
|
<v-menu location="bottom end">
|
||||||
|
<template #activator="{ props: menuActivatorProps }">
|
||||||
|
<v-btn
|
||||||
|
v-bind="menuActivatorProps" color="grey-darken-1" icon="mdi-translate" size="small"
|
||||||
|
variant="text"></v-btn>
|
||||||
|
</template>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item
|
||||||
|
v-for="locale in localeOptions" :key="locale" :active="locale === props.locale"
|
||||||
|
@click="handleSelectLocale(locale)">
|
||||||
|
<v-list-item-title>{{ localeLabels[locale] ?? locale }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
<!-- <v-btn icon="mdi-weather-night" variant="text" size="small" color="grey-darken-1"></v-btn> -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useTheme } from 'vuetify'
|
||||||
|
import { getNextThemeName } from '@/utils/theme'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
locale?: string
|
||||||
|
locales?: string[]
|
||||||
|
localeLabels?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
locale: 'zh-TW',
|
||||||
|
locales: () => ['zh-TW', 'en-US'],
|
||||||
|
localeLabels: () => ({
|
||||||
|
'en-US': 'English',
|
||||||
|
'zh-TW': '中文',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['change-locale', 'toggle-layout'])
|
||||||
|
|
||||||
|
const theme = useTheme()
|
||||||
|
|
||||||
|
const availableThemeNames = computed(() =>
|
||||||
|
Object.keys(theme.themes.value ?? {}).filter((name) => name.startsWith('theme'))
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleTheme () {
|
||||||
|
const names = availableThemeNames.value
|
||||||
|
if (names.length === 0) return
|
||||||
|
|
||||||
|
const current = theme.global.name.value
|
||||||
|
const next = getNextThemeName(names, current)
|
||||||
|
if (!next) return
|
||||||
|
theme.change(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const localeOptions = computed(() =>
|
||||||
|
props.locales.length > 0 ? props.locales : ['zh-TW', 'en-US']
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleSelectLocale (locale: string) {
|
||||||
|
if (locale === props.locale) return
|
||||||
|
emit('change-locale', locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<template>
|
||||||
|
<v-sheet v-bind="$attrs" class="verify-container mb-6 mb-md-4" color="transparent">
|
||||||
|
<div v-if="loading && !captchaImage" class="d-flex justify-center align-center py-4">
|
||||||
|
<v-progress-circular color="primary" indeterminate></v-progress-circular>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="d-flex align-center gap-2">
|
||||||
|
<!-- Captcha Image and Refresh -->
|
||||||
|
<div
|
||||||
|
class="captcha-wrapper d-flex align-center cursor-pointer me-1 me-md-2" :title="props.refreshTitle"
|
||||||
|
@click="handleRefresh">
|
||||||
|
<img v-if="captchaImage" alt="Captcha" class="captcha-img" :src="captchaImage" />
|
||||||
|
<v-icon class="ms-2" color="grey" icon="mdi-refresh"></v-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input and Verify -->
|
||||||
|
<v-text-field
|
||||||
|
v-model="inputCode" :append-inner-icon="props.verified ? 'mdi-check-circle' : ''" bg-color="surface" class="flex-grow-1"
|
||||||
|
color="primary" density="compact" :disabled="props.verified" :error="!!errorMsg" hide-details
|
||||||
|
:placeholder="props.captchaPlaceholder" variant="outlined">
|
||||||
|
<template v-if="props.verified" #append-inner>
|
||||||
|
<v-icon color="success">mdi-check-circle</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-text-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMsg" class="text-caption text-error mt-1">
|
||||||
|
{{ errorMsg }}
|
||||||
|
</div>
|
||||||
|
</v-sheet>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
interface CaptchaPayload {
|
||||||
|
imgUrl?: string
|
||||||
|
id?: string
|
||||||
|
tokenValue?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
captcha?: CaptchaPayload
|
||||||
|
modelValue?: string
|
||||||
|
loading?: boolean
|
||||||
|
errorMessage?: string
|
||||||
|
verified?: boolean
|
||||||
|
verifyText?: string
|
||||||
|
captchaPlaceholder?: string
|
||||||
|
refreshTitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
captcha: undefined,
|
||||||
|
modelValue: '',
|
||||||
|
loading: false,
|
||||||
|
errorMessage: '',
|
||||||
|
verified: false,
|
||||||
|
verifyText: '驗證',
|
||||||
|
captchaPlaceholder: '驗證碼',
|
||||||
|
refreshTitle: '點擊刷新驗證碼',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: string): void
|
||||||
|
(event: 'refresh'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const captchaImage = computed(() => props.captcha?.imgUrl ?? '')
|
||||||
|
|
||||||
|
const inputCode = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val: string) => emit('update:modelValue', val),
|
||||||
|
})
|
||||||
|
|
||||||
|
const errorMsg = computed(() => props.errorMessage)
|
||||||
|
|
||||||
|
const loading = computed(() => props.loading)
|
||||||
|
|
||||||
|
function handleRefresh () {
|
||||||
|
if (props.verified) return
|
||||||
|
emit('refresh')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.verify-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-wrapper {
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 40px;
|
||||||
|
background: rgb(var(--v-theme-surface));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-img {
|
||||||
|
height: 100%;
|
||||||
|
width: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,667 @@
|
|||||||
|
<template>
|
||||||
|
<v-app v-bind="$attrs" class="sk-admin-layout">
|
||||||
|
<v-navigation-drawer
|
||||||
|
v-model="drawer" class="sk-admin-drawer" color="surface" :rail="isRail"
|
||||||
|
:rail-width="railWidth" :temporary="isMobile" :width="drawerWidth">
|
||||||
|
<template #prepend>
|
||||||
|
|
||||||
|
<!-- Sidebar Title -->
|
||||||
|
<v-card class="sidebar-header d-flex align-center pa-0 pl-3 py-2" flat>
|
||||||
|
<v-btn
|
||||||
|
:aria-label="sidebarToggleLabel" color="grey" :icon="isRail ? 'mdi-menu-open' : 'mdi-menu'" size="32"
|
||||||
|
variant="text" @click="toggleSidebar" />
|
||||||
|
<v-card-text v-if="!isRail" class="sidebar-title flex-grow-1 py-0">
|
||||||
|
<slot name="title">
|
||||||
|
<div class="text-subtitle-1 font-weight-bold text-on-surface">
|
||||||
|
{{ branding.title }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-medium-emphasis">
|
||||||
|
{{ branding.subtitle }}
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<!-- User Info -->
|
||||||
|
<v-card v-if="features.showUserInfo" class="user-info d-flex align-center pa-0 pl-3 py-2" flat>
|
||||||
|
<v-avatar class="user-avatar" color="primary" size="32" variant="tonal">
|
||||||
|
<span class="text-subtitle-2 font-weight-bold">{{ userProfile.avatarText }}</span>
|
||||||
|
</v-avatar>
|
||||||
|
<v-card-text v-if="!isRail" class="user-details flex-grow-1 py-0">
|
||||||
|
<div class="user-name text-body-2 font-weight-medium">
|
||||||
|
{{ userProfile.name }}
|
||||||
|
</div>
|
||||||
|
<div class="user-role text-caption text-medium-emphasis">
|
||||||
|
{{ userProfile.role }}
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-sheet v-if="isMobile" class="mobile-menu-subheader d-flex flex-column align-stretch ga-2 px-3 py-2">
|
||||||
|
<v-btn
|
||||||
|
v-if="features.showFavorites" class="justify-start text-none"
|
||||||
|
color="primary" rounded="pill" size="small" :variant="mobileFavoritesPanel ? 'flat' : 'outlined'"
|
||||||
|
@click="openMobileFavoritesPanel">
|
||||||
|
<span class="text-on-secondary text-caption font-weight-medium">{{ favoritesConfig.label }}</span>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-for="step in mobileMenuLevels" :key="`mobile-level-${step.level}`"
|
||||||
|
block class="justify-start text-none" :color="getMobileMenuBtnColor(step.level)"
|
||||||
|
rounded="pill" size="small" :variant="getMobileMenuBtnVariant(step.level)" @click="goToMobileLevel(step.level)">
|
||||||
|
{{ step.title }}
|
||||||
|
</v-btn>
|
||||||
|
</v-sheet>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 桌面板選單 -->
|
||||||
|
<template v-if="!isMobile">
|
||||||
|
<SkAdminDrawerDesktopMenu
|
||||||
|
v-model:opened="opened" :is-shrink="isRail" :menu-items="menuItems"
|
||||||
|
@select="handleSelect" @unshrink="handleUnshrink" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 行動版選單 -->
|
||||||
|
<template v-if="isMobile">
|
||||||
|
<SkAdminDrawerMobileFavoritesPanel
|
||||||
|
v-if="features.showFavorites && mobileFavoritesPanel"
|
||||||
|
:favorite-items="favoriteItems" @select="handleSelectFavorite" />
|
||||||
|
<SkAdminDrawerMobileMenuPanel
|
||||||
|
v-else :mobile-current-items="mobileCurrentItems"
|
||||||
|
@item-click="handleMobileMenuClick" />
|
||||||
|
</template>
|
||||||
|
</v-navigation-drawer>
|
||||||
|
|
||||||
|
<v-app-bar ref="appBarRef" class="" height="auto">
|
||||||
|
<v-row class="flex-column" no-gutters>
|
||||||
|
|
||||||
|
<SkAdminAppBarTopCol
|
||||||
|
:features="features" :is-mobile="isMobile" :logout-label="logoutLabel"
|
||||||
|
:search-config="searchConfig" :search-value="searchValue" :show-breadcrumb-bar="showBreadcrumbBar"
|
||||||
|
:show-favorites-bar="showFavoritesBar" :theme-toggle-label="themeToggleLabel" :toolbar-actions="toolbarActions"
|
||||||
|
:toolbar-counts="toolbarCounts" @action="handleAction"
|
||||||
|
@logout="emitLogout" @search="triggerSearch" @toggle-drawer="drawer = !drawer"
|
||||||
|
@toggle-theme="toggleTheme" @update:search-value="searchValue = $event" @update:show-breadcrumb-bar="showBreadcrumbBar = $event"
|
||||||
|
@update:show-favorites-bar="showFavoritesBar = $event">
|
||||||
|
<template v-if="$slots.actions" #actions>
|
||||||
|
<slot name="actions"></slot>
|
||||||
|
</template>
|
||||||
|
</SkAdminAppBarTopCol>
|
||||||
|
|
||||||
|
<SkAdminAppBarFavoritesCol
|
||||||
|
:favorite-items="favoriteItems" :favorites-config="favoritesConfig" :features="features"
|
||||||
|
:is-mobile="isMobile" :show-favorites-bar="showFavoritesBar" @add-favorite="emitAddFavorite"
|
||||||
|
@remove-favorite="emitRemoveFavorite" @select="handleSelect"
|
||||||
|
@toggle-favorites-bar="toggleFavoritesBar" />
|
||||||
|
|
||||||
|
<SkAdminAppBarBreadcrumbCol
|
||||||
|
:breadcrumb-items="breadcrumbItems" :features="features" :is-mobile="isMobile"
|
||||||
|
:show-breadcrumb-bar="showBreadcrumbBar" :show-favorites-bar="showFavoritesBar"
|
||||||
|
@toggle-favorites-bar="toggleFavoritesBar">
|
||||||
|
<template v-if="$slots['breadcrumb-actions']" #breadcrumb-actions>
|
||||||
|
<slot name="breadcrumb-actions"></slot>
|
||||||
|
</template>
|
||||||
|
</SkAdminAppBarBreadcrumbCol>
|
||||||
|
</v-row>
|
||||||
|
</v-app-bar>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
動態 paddingTop:避免可變高度的 v-app-bar 遮住內容
|
||||||
|
同時固定 v-main 的總高度,避免整頁滾動,改由內容區域自行滾動。
|
||||||
|
-->
|
||||||
|
<v-main class="d-flex flex-column overflow-hidden" :style="mainStyle">
|
||||||
|
<v-container class="content-area" fluid>
|
||||||
|
<slot></slot>
|
||||||
|
</v-container>
|
||||||
|
</v-main>
|
||||||
|
|
||||||
|
<v-slide-y-reverse-transition>
|
||||||
|
<v-card v-if="helpWidgetVisible" class="help-widget" rounded="lg">
|
||||||
|
<v-card-item class="py-2">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon color="primary">mdi-help-circle-outline</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-card-title class="text-subtitle-2">操作說明</v-card-title>
|
||||||
|
<template #append>
|
||||||
|
<v-btn
|
||||||
|
aria-label="關閉說明" icon="mdi-close" size="small" variant="text"
|
||||||
|
@click="helpWidgetVisible = false" />
|
||||||
|
</template>
|
||||||
|
</v-card-item>
|
||||||
|
<v-divider />
|
||||||
|
<v-card-text class="text-body-2">
|
||||||
|
這裡先放暫時說明內容。你可以保持此視窗開啟,並繼續操作頁面上的其他功能。
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions class="justify-end pt-0">
|
||||||
|
<v-btn color="primary" size="small" variant="text" @click="helpWidgetVisible = false">
|
||||||
|
了解
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-slide-y-reverse-transition>
|
||||||
|
|
||||||
|
<!-- <v-btn v-if="isMobile" class="mobile-menu-btn" color="primary" icon="mdi-menu" @click="drawer = true" /> -->
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useDisplay, useTheme } from 'vuetify'
|
||||||
|
import { getNextThemeName } from '@/utils/theme'
|
||||||
|
import SkAdminAppBarBreadcrumbCol from './sk-admin-layout/SkAdminAppBarBreadcrumbCol.vue'
|
||||||
|
import SkAdminAppBarFavoritesCol from './sk-admin-layout/SkAdminAppBarFavoritesCol.vue'
|
||||||
|
import SkAdminAppBarTopCol from './sk-admin-layout/SkAdminAppBarTopCol.vue'
|
||||||
|
import SkAdminDrawerDesktopMenu from './sk-admin-layout/SkAdminDrawerDesktopMenu.vue'
|
||||||
|
import SkAdminDrawerMobileFavoritesPanel from './sk-admin-layout/SkAdminDrawerMobileFavoritesPanel.vue'
|
||||||
|
import SkAdminDrawerMobileMenuPanel from './sk-admin-layout/SkAdminDrawerMobileMenuPanel.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'logout',
|
||||||
|
'select',
|
||||||
|
'search',
|
||||||
|
'action',
|
||||||
|
'toggle-sidebar',
|
||||||
|
'toggle-theme',
|
||||||
|
'add-favorite',
|
||||||
|
'remove-favorite',
|
||||||
|
'update:isRail',
|
||||||
|
'update:favoritesBarVisible',
|
||||||
|
'update:breadcrumbBarVisible',
|
||||||
|
])
|
||||||
|
|
||||||
|
const defaultFeatures = {
|
||||||
|
showThemeToggle: false,
|
||||||
|
showFavorites: true,
|
||||||
|
showBreadcrumb: true,
|
||||||
|
showSearch: true,
|
||||||
|
showToolbarActions: true,
|
||||||
|
showUserInfo: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultBreadcrumbConfig = {
|
||||||
|
homeLabel: '首頁',
|
||||||
|
homeDisabled: true,
|
||||||
|
homeIcon: 'mdi-home',
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
systemTitle: { type: String, default: '管理系統' },
|
||||||
|
systemSubtitle: { type: String, default: 'Campus System' },
|
||||||
|
themeToggleLabel: { type: String, default: '切換主題' },
|
||||||
|
logoutLabel: { type: String, default: '登出' },
|
||||||
|
sidebarToggleLabel: { type: String, default: '切換側欄' },
|
||||||
|
favoriteHeaderLabel: { type: String, default: '我的最愛' },
|
||||||
|
favoriteItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
menuItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [
|
||||||
|
{ title: '首頁', path: '/' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
userProfile: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
name: '王小明',
|
||||||
|
role: '資訊工程系 - 學生',
|
||||||
|
avatarText: '王',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
searchConfig: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
placeholder: '搜尋功能名稱... (試試「成績」、「選課」、「請假」)',
|
||||||
|
label: '搜尋',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
toolbarActions: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
notificationsLabel: '通知',
|
||||||
|
messagesLabel: '訊息',
|
||||||
|
helpLabel: '說明',
|
||||||
|
settingsLabel: '設定',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
toolbarCounts: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
notifications: 0,
|
||||||
|
messages: 0,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
favoritesConfig: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
label: '常用',
|
||||||
|
addLabel: '新增常用',
|
||||||
|
showAdd: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
breadcrumbConfig: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
homeLabel: '首頁',
|
||||||
|
homeDisabled: true,
|
||||||
|
homeIcon: 'mdi-home',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
breadcrumbItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
favoritesBarVisible: {
|
||||||
|
type: [Boolean, null],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
breadcrumbBarVisible: {
|
||||||
|
type: [Boolean, null],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
isRail: {
|
||||||
|
type: [Boolean, null],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
showThemeToggle: false,
|
||||||
|
showFavorites: true,
|
||||||
|
showBreadcrumb: true,
|
||||||
|
showSearch: true,
|
||||||
|
showToolbarActions: true,
|
||||||
|
showUserInfo: true,
|
||||||
|
showMenuHeader: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
drawerConfig: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
width: 280,
|
||||||
|
railWidth: 56,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Feature toggle: UI 區塊顯示
|
||||||
|
const features = computed(() => ({ ...defaultFeatures, ...props.features }))
|
||||||
|
|
||||||
|
// i18n / constants
|
||||||
|
const branding = computed(() => ({
|
||||||
|
title: props.systemTitle,
|
||||||
|
subtitle: props.systemSubtitle,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// feature toggles & layout
|
||||||
|
const display = useDisplay()
|
||||||
|
const isMobile = computed(() => display.mdAndDown.value)
|
||||||
|
const drawer = ref(true)
|
||||||
|
const mobileFavoritesPanel = ref(false)
|
||||||
|
const localIsRail = ref(false)
|
||||||
|
const isRail = computed({
|
||||||
|
get: () => (props.isRail ?? localIsRail.value),
|
||||||
|
set: (value) => {
|
||||||
|
if (props.isRail === null) {
|
||||||
|
localIsRail.value = value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:isRail', value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const opened = ref([])
|
||||||
|
const appBarRef = ref(null)
|
||||||
|
const appBarHeight = ref(0)
|
||||||
|
const helpWidgetVisible = ref(false)
|
||||||
|
const drawerWidth = computed(() => props.drawerConfig?.width)
|
||||||
|
const railWidth = computed(() => props.drawerConfig?.railWidth)
|
||||||
|
|
||||||
|
// i18n computed text
|
||||||
|
const searchValue = ref('')
|
||||||
|
|
||||||
|
// links/settings refs
|
||||||
|
const theme = useTheme()
|
||||||
|
const availableThemeNames = computed(() =>
|
||||||
|
Object.keys(theme.themes.value ?? {}).filter((name) => name.startsWith('theme'))
|
||||||
|
)
|
||||||
|
|
||||||
|
// composed computed objects
|
||||||
|
const breadcrumbConfig = computed(() => ({ ...defaultBreadcrumbConfig, ...props.breadcrumbConfig }))
|
||||||
|
|
||||||
|
const breadcrumbItems = computed(() => {
|
||||||
|
if (props.breadcrumbItems?.length) return props.breadcrumbItems
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: breadcrumbConfig.value.homeLabel,
|
||||||
|
disabled: breadcrumbConfig.value.homeDisabled,
|
||||||
|
icon: breadcrumbConfig.value.homeIcon,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// event handlers / API
|
||||||
|
function toggleTheme () {
|
||||||
|
const names = availableThemeNames.value
|
||||||
|
if (names.length === 0) return
|
||||||
|
|
||||||
|
const current = theme.global.name.value
|
||||||
|
const next = getNextThemeName(names, current)
|
||||||
|
if (!next) return
|
||||||
|
theme.change(next)
|
||||||
|
emit('toggle-theme', next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar () {
|
||||||
|
if (isMobile.value) {
|
||||||
|
drawer.value = !drawer.value
|
||||||
|
} else {
|
||||||
|
isRail.value = !isRail.value
|
||||||
|
}
|
||||||
|
emit('toggle-sidebar', { drawer: drawer.value, rail: isRail.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
const emitLogout = () => emit('logout')
|
||||||
|
|
||||||
|
// 將工具列按鈕行為轉成統一的 action 事件,交由外層應用處理
|
||||||
|
function handleAction (type) {
|
||||||
|
if (type === 'help') {
|
||||||
|
helpWidgetVisible.value = true
|
||||||
|
}
|
||||||
|
emit('action', type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 以按鈕或 Enter 觸發搜尋,避免每個字都觸發
|
||||||
|
function triggerSearch () {
|
||||||
|
const keyword = searchValue.value
|
||||||
|
emit('search', keyword)
|
||||||
|
// 觸發後清空欄位,避免彈窗出現仍保留文字
|
||||||
|
searchValue.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitAddFavorite () {
|
||||||
|
emit('add-favorite')
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitRemoveFavorite (item) {
|
||||||
|
emit('remove-favorite', item)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectFavorite (item) {
|
||||||
|
handleSelect(item)
|
||||||
|
mobileFavoritesPanel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const mobileMenuPath = ref([])
|
||||||
|
const mobileCurrentItems = computed(() =>
|
||||||
|
mobileMenuPath.value.reduce((items, currentItem) => currentItem?.subItems ?? [], props.menuItems || [])
|
||||||
|
)
|
||||||
|
const mobileCurrentLevel = computed(() => mobileMenuPath.value.length + 1)
|
||||||
|
const mobileMenuLevels = computed(() =>
|
||||||
|
Array.from({ length: mobileCurrentLevel.value }, (_, index) => ({
|
||||||
|
level: index + 1,
|
||||||
|
title: index === 0 ? '主選單' : (mobileMenuPath.value[index - 1]?.title ?? `第${index + 1}層`),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
function goToMobileLevel (level) {
|
||||||
|
mobileFavoritesPanel.value = false
|
||||||
|
mobileMenuPath.value = mobileMenuPath.value.slice(0, Math.max(0, level - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMobileFavoritesPanel () {
|
||||||
|
mobileMenuPath.value = []
|
||||||
|
mobileFavoritesPanel.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMobileMenuClick (item) {
|
||||||
|
if (item?.subItems?.length) {
|
||||||
|
mobileMenuPath.value = [...mobileMenuPath.value, item]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleSelect(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const localFavoritesBarVisible = ref(true)
|
||||||
|
const localBreadcrumbBarVisible = ref(true)
|
||||||
|
const showFavoritesBar = computed({
|
||||||
|
get: () => (props.favoritesBarVisible ?? localFavoritesBarVisible.value),
|
||||||
|
set: (value) => {
|
||||||
|
if (props.favoritesBarVisible === null) {
|
||||||
|
localFavoritesBarVisible.value = value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:favoritesBarVisible', value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const showBreadcrumbBar = computed({
|
||||||
|
get: () => (props.breadcrumbBarVisible ?? localBreadcrumbBarVisible.value),
|
||||||
|
set: (value) => {
|
||||||
|
if (props.breadcrumbBarVisible === null) {
|
||||||
|
localBreadcrumbBarVisible.value = value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:breadcrumbBarVisible', value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleFavoritesBar (nextValue) {
|
||||||
|
showFavoritesBar.value = typeof nextValue === 'boolean' ? nextValue : !showFavoritesBar.value
|
||||||
|
}
|
||||||
|
|
||||||
|
let appBarObserver
|
||||||
|
// 量測 v-app-bar 實際高度(常用功能/麵包屑顯示時會改變)
|
||||||
|
function updateAppBarHeight () {
|
||||||
|
const el = appBarRef.value?.$el ?? appBarRef.value
|
||||||
|
if (!el) return
|
||||||
|
appBarHeight.value = Math.round(el.getBoundingClientRect().height || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 初次量測高度
|
||||||
|
updateAppBarHeight()
|
||||||
|
if (typeof ResizeObserver === 'undefined') return
|
||||||
|
const el = appBarRef.value?.$el ?? appBarRef.value
|
||||||
|
if (!el) return
|
||||||
|
// 監聽高度變化,讓 v-main paddingTop 同步更新
|
||||||
|
appBarObserver = new ResizeObserver(() => updateAppBarHeight())
|
||||||
|
appBarObserver.observe(el)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (!appBarObserver) return
|
||||||
|
appBarObserver.disconnect()
|
||||||
|
appBarObserver = null
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleUnshrink () {
|
||||||
|
isRail.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect (item) {
|
||||||
|
emit('select', item)
|
||||||
|
if (isMobile.value) drawer.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainStyle = computed(() => {
|
||||||
|
const appBarHeightValue = appBarHeight.value
|
||||||
|
return {
|
||||||
|
// 以 paddingTop 騰出 appBar 空間,避免內容被遮擋
|
||||||
|
paddingTop: appBarHeightValue ? `${appBarHeightValue}px` : undefined,
|
||||||
|
// 固定 v-main 高度,讓內容區塊能在固定高度內滾動
|
||||||
|
height: '100vh',
|
||||||
|
minHeight: 0,
|
||||||
|
flex: '1 1 0',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// watch(isMobile, (value) => {
|
||||||
|
// if (value) {
|
||||||
|
// isRail.value = false
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
watch(isMobile, (value) => {
|
||||||
|
if (!value) {
|
||||||
|
mobileFavoritesPanel.value = false
|
||||||
|
mobileMenuPath.value = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
watch(drawer, (value) => {
|
||||||
|
if (!value) {
|
||||||
|
mobileFavoritesPanel.value = false
|
||||||
|
mobileMenuPath.value = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function getMobileMenuBtnVariant (level) {
|
||||||
|
return !mobileFavoritesPanel.value && level === mobileCurrentLevel.value
|
||||||
|
? 'flat'
|
||||||
|
: 'outlined'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMobileMenuBtnColor (level) {
|
||||||
|
return level === mobileCurrentLevel.value ? 'primary' : 'secondary'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sk-admin-layout {
|
||||||
|
background: rgb(var(--v-theme-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-admin-drawer {
|
||||||
|
border-right: 1px solid rgb(var(--v-theme-surface-variant));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 第二層選單Padding */
|
||||||
|
:deep(.sk-admin-drawer .v-list-group__items) {
|
||||||
|
--indent-padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 第三層選單Padding */
|
||||||
|
:deep(.sk-admin-drawer .v-list-group__items .v-list-group__items) {
|
||||||
|
--indent-padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-count {
|
||||||
|
min-width: 28px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 8px;
|
||||||
|
padding-top: 4px;
|
||||||
|
background: rgb(var(--v-theme-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.search-input-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.search-input-wrapper .v-field--appended) {
|
||||||
|
padding-inline-end: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-label {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-favorites-panel,
|
||||||
|
.mobile-menu-panel {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-subheader {
|
||||||
|
border-bottom: 1px solid rgb(var(--v-theme-surface-variant));
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-item,
|
||||||
|
.favorite-add {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-btn {
|
||||||
|
position: fixed;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 20px;
|
||||||
|
z-index: 200;
|
||||||
|
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-widget {
|
||||||
|
position: fixed;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 220;
|
||||||
|
width: min(360px, calc(100vw - 32px));
|
||||||
|
border: 1px solid rgb(var(--v-theme-surface-variant));
|
||||||
|
box-shadow: 0 12px 24px rgba(var(--v-theme-on-surface), 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-text-overflow {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 確保 v-list-group 的 activator 也有 hover 效果 */
|
||||||
|
:deep(.v-list-group > .v-list-item) {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.v-list-group > .v-list-item:hover) {
|
||||||
|
background: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 為所有 v-list-item 加上 transition */
|
||||||
|
:deep(.v-list-item) {
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 確保滾軸邊距 */
|
||||||
|
:deep(.v-navigation-drawer__content) {
|
||||||
|
/* scrollbar-gutter: stable; */
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports not (scrollbar-gutter: stable) {
|
||||||
|
:deep(.v-navigation-drawer__content) {
|
||||||
|
/* overflow-y: scroll; */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<v-app>
|
||||||
|
<v-main>
|
||||||
|
<v-container class="pa-0" fluid height="100%">
|
||||||
|
<slot></slot>
|
||||||
|
</v-container>
|
||||||
|
</v-main>
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// 完全空白的佈局,僅提供 Vuetify 必要的容器結構
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<template>
|
||||||
|
<v-app>
|
||||||
|
<v-navigation-drawer v-model="drawer">
|
||||||
|
<v-list v-model:opened="opened" color="primary" density="compact" prepend-gap="8">
|
||||||
|
<!-- 收藏項目區塊 -->
|
||||||
|
<template v-if="favoriteItems?.length">
|
||||||
|
<v-list-subheader class="bg-primary-variant" color="on-primary">
|
||||||
|
<div class="d-flex align-center w-100">
|
||||||
|
<span class="flex-grow-1">{{ favoriteHeaderLabel }}</span>
|
||||||
|
<v-btn
|
||||||
|
density="compact" icon="mdi-unfold-less-horizontal" :ripple="false" variant="text"
|
||||||
|
@click.stop="collapseFavoriteGroups" />
|
||||||
|
</div>
|
||||||
|
</v-list-subheader>
|
||||||
|
<template v-for="item in favoriteItems" :key="item.path ?? item.title">
|
||||||
|
<!-- 第一層:有子項目的群組 -->
|
||||||
|
<v-list-group v-if="item.subItems?.length" :value="`fav:${item.path ?? item.title}`">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-list-item v-bind="props" :link="isNavigable(item)" :to="isNavigable(item) ? item.path : undefined">
|
||||||
|
<!-- 第一層 title(父層) -->
|
||||||
|
<template #title>
|
||||||
|
<span class="text-body-2 nav-text-overflow">{{ item.title }}</span>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
<template v-for="subItem in item.subItems" :key="subItem.path ?? subItem.title">
|
||||||
|
<!-- 第二層:無子項目的單一項目 -->
|
||||||
|
<v-list-item :link="!!subItem.path" :to="subItem.path">
|
||||||
|
<!-- 第二層 title(葉節點) -->
|
||||||
|
<template #title>
|
||||||
|
<v-tooltip location="end" :text="subItem.title">
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
</v-list-group>
|
||||||
|
<!-- 第一層:無子項目的單一項目 -->
|
||||||
|
<v-list-item v-else :link="!!item.path" :to="item.path">
|
||||||
|
<!-- 第一層 title(葉節點) -->
|
||||||
|
<template #title>
|
||||||
|
<v-tooltip location="end" :text="item.title">
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ item.title }}</span>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list-subheader class="bg-primary" color="on-primary">
|
||||||
|
<div class="d-flex align-center w-100">
|
||||||
|
<span class="flex-grow-1">{{ menuHeaderLabel }}</span>
|
||||||
|
<v-btn
|
||||||
|
density="compact" icon="mdi-unfold-less-horizontal" :ripple="false" variant="text"
|
||||||
|
@click.stop="collapseMenuGroups" />
|
||||||
|
</div>
|
||||||
|
</v-list-subheader>
|
||||||
|
<!-- 主選單區塊 -->
|
||||||
|
<template v-for="item in menuItems" :key="item.path ?? item.title">
|
||||||
|
<!-- 第一層:有子項目的群組 -->
|
||||||
|
<v-list-group v-if="item.subItems?.length" :value="`menu:${item.path ?? item.title}`">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-list-item
|
||||||
|
v-bind="props" :link="isNavigable(item) && !!item.path"
|
||||||
|
:to="isNavigable(item) ? item.path : undefined">
|
||||||
|
<!-- 第一層 title(父層) -->
|
||||||
|
<template #title>
|
||||||
|
<span class="text-body-2 nav-text-overflow">{{ item.title }}</span>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
<template v-for="subItem in item.subItems" :key="subItem.path ?? subItem.title">
|
||||||
|
<!-- 第二層:有子項目的群組 -->
|
||||||
|
<v-list-group
|
||||||
|
v-if="subItem.subItems?.length"
|
||||||
|
:value="`menu:${item.path ?? item.title}::${subItem.path ?? subItem.title}`">
|
||||||
|
<template #activator="{ props: subProps }">
|
||||||
|
<v-list-item
|
||||||
|
v-bind="subProps" :link="isNavigable(subItem)"
|
||||||
|
:to="isNavigable(subItem) ? subItem.path : undefined">
|
||||||
|
<!-- 第二層 title(父層) -->
|
||||||
|
<template #title>
|
||||||
|
<span class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 第三層:最底層項目 -->
|
||||||
|
<v-list-item
|
||||||
|
v-for="subSubItem in subItem.subItems" :key="subSubItem.path ?? subSubItem.title"
|
||||||
|
:link="!!subSubItem.path" :to="subSubItem.path">
|
||||||
|
<!-- 第三層 title(葉節點) -->
|
||||||
|
<template #title>
|
||||||
|
<v-tooltip location="end" :text="subSubItem.title">
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ subSubItem.title }}</span>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list-group>
|
||||||
|
|
||||||
|
<!-- 第二層:無子項目的單一項目 -->
|
||||||
|
<v-list-item v-else :link="!!subItem.path" :to="subItem.path">
|
||||||
|
<!-- 第二層 title(葉節點) -->
|
||||||
|
<template #title>
|
||||||
|
<v-tooltip location="end" :text="subItem.title">
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
</v-list-group>
|
||||||
|
<!-- 第一層:無子項目的單一項目 -->
|
||||||
|
<v-list-item v-else :link="!!item.path" :to="item.path">
|
||||||
|
<!-- 第一層 title(葉節點) -->
|
||||||
|
<template #title>
|
||||||
|
<v-tooltip location="end" :text="item.title">
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ item.title }}</span>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
</v-list>
|
||||||
|
</v-navigation-drawer>
|
||||||
|
|
||||||
|
<v-app-bar density="compact">
|
||||||
|
<v-app-bar-nav-icon @click="drawer = !drawer" />
|
||||||
|
<v-toolbar-title>{{ systemTitle }}</v-toolbar-title>
|
||||||
|
<v-spacer />
|
||||||
|
<v-tooltip location="bottom" :text="themeToggleLabel">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn v-bind="props" :aria-label="themeToggleLabel" icon="mdi-palette" variant="text" @click="toggleTheme" />
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
|
||||||
|
<v-tooltip location="bottom" :text="logoutLabel">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn v-bind="props" :aria-label="logoutLabel" icon="mdi-logout" variant="text" @click="$emit('logout')" />
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</v-app-bar>
|
||||||
|
|
||||||
|
<v-main>
|
||||||
|
<v-container class="pa-2" fluid height="100%">
|
||||||
|
<slot></slot>
|
||||||
|
</v-container>
|
||||||
|
</v-main>
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useTheme } from 'vuetify'
|
||||||
|
import { getNextThemeName } from '@/utils/theme'
|
||||||
|
|
||||||
|
defineEmits(['logout'])
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
systemTitle: { type: String, default: '管理系統' },
|
||||||
|
themeToggleLabel: { type: String, default: '切換主題' },
|
||||||
|
logoutLabel: { type: String, default: '登出' },
|
||||||
|
favoriteHeaderLabel: { type: String, default: '我的最愛' },
|
||||||
|
menuHeaderLabel: { type: String, default: '選單' },
|
||||||
|
favoriteItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
menuItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [
|
||||||
|
{ title: '首頁', path: '/' },
|
||||||
|
{ title: '設定', path: '/settings' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const drawer = ref(true)
|
||||||
|
|
||||||
|
const opened = ref([])
|
||||||
|
|
||||||
|
const theme = useTheme()
|
||||||
|
|
||||||
|
const availableThemeNames = computed(() =>
|
||||||
|
Object.keys(theme.themes.value ?? {}).filter((name) => name.startsWith('theme'))
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleTheme () {
|
||||||
|
const names = availableThemeNames.value
|
||||||
|
if (names.length === 0) return
|
||||||
|
|
||||||
|
const current = theme.global.name.value
|
||||||
|
const next = getNextThemeName(names, current)
|
||||||
|
if (!next) return
|
||||||
|
theme.change(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseFavoriteGroups () {
|
||||||
|
opened.value = opened.value.filter((key) => !String(key).startsWith('fav:'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseMenuGroups () {
|
||||||
|
opened.value = opened.value.filter((key) => !String(key).startsWith('menu:'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNavigable = (item) => item?.navigable !== false
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nav-text-overflow {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 確保 v-list-group 的 activator 也有 hover 效果 */
|
||||||
|
:deep(.v-list-group > .v-list-item) {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.v-list-group > .v-list-item:hover) {
|
||||||
|
background: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 為所有 v-list-item 加上 transition */
|
||||||
|
:deep(.v-list-item) {
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.v-navigation-drawer__content) {
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports not (scrollbar-gutter: stable) {
|
||||||
|
:deep(.v-navigation-drawer__content) {
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<v-app>
|
||||||
|
<v-app-bar color="surface">
|
||||||
|
<v-toolbar-title>{{ systemTitle }}</v-toolbar-title>
|
||||||
|
<v-spacer />
|
||||||
|
<v-menu
|
||||||
|
v-model="menuOpen"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
:location="menuLocation"
|
||||||
|
:max-height="menuMaxHeight"
|
||||||
|
offset="8"
|
||||||
|
scroll-strategy="reposition"
|
||||||
|
:width="menuWidth"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn v-bind="props" icon="mdi-dots-vertical" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list :density="menuDensity">
|
||||||
|
<template v-for="(item, index) in menuItems" :key="item?.key ?? item?.path ?? index">
|
||||||
|
<v-list-group
|
||||||
|
v-if="smAndDown && item?.subItems?.length"
|
||||||
|
:value="item?.key ?? item?.path ?? index"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-list-item v-bind="props" :prepend-icon="item.icon" :title="item.title" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
v-for="(subItem, subIndex) in item.subItems"
|
||||||
|
:key="subItem?.key ?? subItem?.path ?? subIndex"
|
||||||
|
class="pl-6"
|
||||||
|
:prepend-icon="subItem.icon"
|
||||||
|
:title="subItem.title"
|
||||||
|
@click="handleSelect(subItem)"
|
||||||
|
/>
|
||||||
|
</v-list-group>
|
||||||
|
|
||||||
|
<v-menu
|
||||||
|
v-else-if="item?.subItems?.length"
|
||||||
|
close-delay="120"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
location="end top"
|
||||||
|
offset="0"
|
||||||
|
open-delay="80"
|
||||||
|
:open-on-hover="submenuOpenOnHover"
|
||||||
|
origin="start top"
|
||||||
|
scroll-strategy="reposition"
|
||||||
|
submenu
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-list-item
|
||||||
|
v-bind="props"
|
||||||
|
append-icon="mdi-chevron-right"
|
||||||
|
:prepend-icon="item.icon"
|
||||||
|
:title="item.title"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list :density="menuDensity">
|
||||||
|
<v-list-item
|
||||||
|
v-for="(subItem, subIndex) in item.subItems"
|
||||||
|
:key="subItem?.key ?? subItem?.path ?? subIndex"
|
||||||
|
:prepend-icon="subItem.icon"
|
||||||
|
:title="subItem.title"
|
||||||
|
@click="handleSelect(subItem)"
|
||||||
|
/>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
v-else
|
||||||
|
:prepend-icon="item.icon"
|
||||||
|
:title="item.title"
|
||||||
|
@click="handleSelect(item)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</v-app-bar>
|
||||||
|
|
||||||
|
<v-main>
|
||||||
|
<v-container fluid height="100%">
|
||||||
|
<slot></slot>
|
||||||
|
</v-container>
|
||||||
|
</v-main>
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
|
const emit = defineEmits(['select'])
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
systemTitle: { type: String, default: '簡潔模式' },
|
||||||
|
menuItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const menuOpen = ref(false)
|
||||||
|
|
||||||
|
const { smAndDown } = useDisplay()
|
||||||
|
|
||||||
|
const menuDensity = computed(() => (smAndDown.value ? 'default' : 'compact'))
|
||||||
|
const menuWidth = computed(() => (smAndDown.value ? 280 : 240))
|
||||||
|
const menuMaxHeight = computed(() => (smAndDown.value ? 420 : 360))
|
||||||
|
const menuLocation = computed(() => (smAndDown.value ? 'bottom end' : 'bottom end'))
|
||||||
|
const submenuOpenOnHover = computed(() => !smAndDown.value)
|
||||||
|
|
||||||
|
function handleSelect (item) {
|
||||||
|
menuOpen.value = false
|
||||||
|
emit('select', item)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<v-col
|
||||||
|
v-if="features.showBreadcrumb && showBreadcrumbBar && !isMobile"
|
||||||
|
class="d-flex align-center justify-space-between pr-2 pl-3 py-1 bg-surface">
|
||||||
|
<v-breadcrumbs class="pa-0" density="compact" :items="breadcrumbItems">
|
||||||
|
<template #prepend>
|
||||||
|
<v-btn
|
||||||
|
v-if="features.showFavorites && !showFavoritesBar" class="mr-2" color="primary" size="small"
|
||||||
|
variant="outlined" @click="emit('toggle-favorites-bar', true)">
|
||||||
|
常用
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<template #item="{ item }">
|
||||||
|
<div class="d-flex align-center ga-1">
|
||||||
|
<v-icon v-if="item.icon" class="mr-1" size="14">{{ item.icon }}</v-icon>
|
||||||
|
<span class="text-caption text-no-wrap">{{ item.title }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #divider>
|
||||||
|
<v-icon color="primary-variant" size="12">mdi-chevron-right</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-breadcrumbs>
|
||||||
|
<div class="page-actions">
|
||||||
|
<slot name="breadcrumb-actions"></slot>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
features: { type: Object, default: () => ({}) },
|
||||||
|
showBreadcrumbBar: { type: Boolean, default: true },
|
||||||
|
isMobile: { type: Boolean, default: false },
|
||||||
|
breadcrumbItems: { type: Array, default: () => [] },
|
||||||
|
showFavoritesBar: { type: Boolean, default: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['toggle-favorites-bar'])
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<v-col
|
||||||
|
v-if="features.showFavorites && showFavoritesBar && !isMobile"
|
||||||
|
class="d-flex align-center pr-2 pl-3 py-1 bg-surface">
|
||||||
|
<div class="favorites-label text-body-2 text-no-wrap pe-2">
|
||||||
|
{{ favoritesConfig.label }}
|
||||||
|
</div>
|
||||||
|
<div class="favorites-list flex-grow-1 d-flex flex-wrap ga-2">
|
||||||
|
<transition-group class="d-flex flex-wrap ga-2" name="favorite-list" tag="div">
|
||||||
|
<v-chip
|
||||||
|
v-for="item in favoriteItems" :key="item.path ?? item.title" class="favorite-item" closable
|
||||||
|
color="secondary" size="small" variant="outlined" @click="emit('select', item)"
|
||||||
|
@click:close="emit('remove-favorite', item)">
|
||||||
|
<v-icon v-if="item.icon" class="me-1" size="16">{{ item.icon }}</v-icon>
|
||||||
|
<span class="text-caption">{{ item.title }}</span>
|
||||||
|
</v-chip>
|
||||||
|
</transition-group>
|
||||||
|
<v-btn
|
||||||
|
v-if="favoritesConfig.showAdd" class="favorite-add" color="primary" size="small" variant="outlined"
|
||||||
|
@click="emit('add-favorite')">
|
||||||
|
<v-icon class="mr-1" size="16">mdi-plus</v-icon>
|
||||||
|
<span class="text-caption">{{ favoritesConfig.addLabel }}</span>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
<v-btn color="grey" size="small" variant="text" @click="emit('toggle-favorites-bar', false)">
|
||||||
|
<v-icon>mdi-eye-off</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
features: { type: Object, default: () => ({}) },
|
||||||
|
showFavoritesBar: { type: Boolean, default: true },
|
||||||
|
isMobile: { type: Boolean, default: false },
|
||||||
|
favoritesConfig: { type: Object, default: () => ({}) },
|
||||||
|
favoriteItems: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['select', 'add-favorite', 'remove-favorite', 'toggle-favorites-bar'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.favorite-item {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-list-enter-active,
|
||||||
|
.favorite-list-leave-active,
|
||||||
|
.favorite-list-move {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-list-enter-from,
|
||||||
|
.favorite-list-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<template>
|
||||||
|
<v-col class="d-flex align-center bg-surface pr-2 pl-2 pl-m-3 py-1">
|
||||||
|
<v-btn v-if="isMobile" icon="mdi-menu" size="small" variant="text" @click="emit('toggle-drawer')"></v-btn>
|
||||||
|
|
||||||
|
<div v-if="features.showSearch" class="search-input-wrapper">
|
||||||
|
<v-text-field
|
||||||
|
v-model="searchValueModel" :aria-label="searchConfig.label" class="search-input" density="compact"
|
||||||
|
hide-details :placeholder="searchConfig.placeholder" variant="outlined"
|
||||||
|
@keyup.enter="triggerSearch">
|
||||||
|
<template v-if="!isMobile" #prepend-inner>
|
||||||
|
<v-icon size="small">mdi-magnify</v-icon>
|
||||||
|
</template>
|
||||||
|
<template #append-inner>
|
||||||
|
<v-btn :aria-label="searchConfig.label" color="primary" size="small" variant="text" @click="triggerSearch">
|
||||||
|
開始搜尋
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-text-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="features.showToolbarActions" class="top-actions">
|
||||||
|
<slot name="actions">
|
||||||
|
|
||||||
|
<!-- 通知 -->
|
||||||
|
<v-tooltip location="bottom" :text="toolbarActions.notificationsLabel">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
v-bind="props" :aria-label="toolbarActions.notificationsLabel" icon size="small" variant="text"
|
||||||
|
@click="emit('action', 'notifications')">
|
||||||
|
<v-badge
|
||||||
|
v-if="toolbarCounts.notifications" color="error" :content="toolbarCounts.notifications"
|
||||||
|
offset-x="4" offset-y="-2">
|
||||||
|
<v-icon>mdi-bell-outline</v-icon>
|
||||||
|
</v-badge>
|
||||||
|
<v-icon v-else>mdi-bell-outline</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
|
||||||
|
<!-- 訊息 -->
|
||||||
|
<v-tooltip location="bottom" :text="toolbarActions.messagesLabel">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
v-bind="props" :aria-label="toolbarActions.messagesLabel" icon size="small" variant="text"
|
||||||
|
@click="emit('action', 'messages')">
|
||||||
|
<v-badge
|
||||||
|
v-if="toolbarCounts.messages" color="warning" :content="toolbarCounts.messages" offset-x="4"
|
||||||
|
offset-y="-2">
|
||||||
|
<v-icon>mdi-message-text-outline</v-icon>
|
||||||
|
</v-badge>
|
||||||
|
<v-icon v-else>mdi-message-text-outline</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
|
||||||
|
<!-- 說明 -->
|
||||||
|
<v-tooltip v-if="false" location="bottom" :text="toolbarActions.helpLabel">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
v-bind="props" :aria-label="toolbarActions.helpLabel" icon size="small" variant="text"
|
||||||
|
@click="emit('action', 'help')">
|
||||||
|
<v-icon>mdi-help</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
|
||||||
|
<!-- 設定 -->
|
||||||
|
<v-menu :close-on-content-click="false" location="bottom end">
|
||||||
|
<template #activator="{ props: menuProps }">
|
||||||
|
<v-tooltip location="bottom" :text="toolbarActions.settingsLabel">
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<v-btn
|
||||||
|
v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsLabel" icon size="small"
|
||||||
|
variant="text">
|
||||||
|
<v-icon>mdi-cog-outline</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</template>
|
||||||
|
<v-list density="compact" width="180">
|
||||||
|
<v-list-subheader class="text-subtitle-1 py-2">顯示設定</v-list-subheader>
|
||||||
|
<v-list-item>
|
||||||
|
<v-switch v-model="showFavoritesBarModel" color="primary" density="comfortable" hide-details>
|
||||||
|
<template #label>
|
||||||
|
<span class="text-body-2" style="width: 8ch;">常用功能</span>
|
||||||
|
</template>
|
||||||
|
</v-switch>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item>
|
||||||
|
<v-switch v-model="showBreadcrumbBarModel" color="primary" density="comfortable" hide-details>
|
||||||
|
<template #label>
|
||||||
|
<span class="text-body-2" style="width: 8ch;">路徑</span>
|
||||||
|
</template>
|
||||||
|
</v-switch>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
|
||||||
|
<!-- 登出 -->
|
||||||
|
<v-tooltip location="bottom" :text="logoutLabel">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn v-bind="props" :aria-label="logoutLabel" icon size="small" variant="text" @click="emit('logout')">
|
||||||
|
<v-icon>mdi-logout</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
|
||||||
|
<v-tooltip v-if="features.showThemeToggle" location="bottom" :text="themeToggleLabel">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn v-bind="props" :aria-label="themeToggleLabel" icon variant="text" @click="emit('toggle-theme')">
|
||||||
|
<v-icon>mdi-palette-outline</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isMobile: { type: Boolean, default: false },
|
||||||
|
features: { type: Object, default: () => ({}) },
|
||||||
|
searchValue: { type: String, default: '' },
|
||||||
|
searchConfig: { type: Object, default: () => ({}) },
|
||||||
|
toolbarActions: { type: Object, default: () => ({}) },
|
||||||
|
toolbarCounts: { type: Object, default: () => ({}) },
|
||||||
|
logoutLabel: { type: String, default: '' },
|
||||||
|
themeToggleLabel: { type: String, default: '' },
|
||||||
|
showFavoritesBar: { type: Boolean, default: true },
|
||||||
|
showBreadcrumbBar: { type: Boolean, default: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'toggle-drawer',
|
||||||
|
'update:searchValue',
|
||||||
|
'search',
|
||||||
|
'action',
|
||||||
|
'logout',
|
||||||
|
'toggle-theme',
|
||||||
|
'update:showFavoritesBar',
|
||||||
|
'update:showBreadcrumbBar',
|
||||||
|
])
|
||||||
|
|
||||||
|
const searchValueModel = computed({
|
||||||
|
get: () => props.searchValue,
|
||||||
|
set: (value) => emit('update:searchValue', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const showFavoritesBarModel = computed({
|
||||||
|
get: () => props.showFavoritesBar,
|
||||||
|
set: (value) => emit('update:showFavoritesBar', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const showBreadcrumbBarModel = computed({
|
||||||
|
get: () => props.showBreadcrumbBar,
|
||||||
|
set: (value) => emit('update:showBreadcrumbBar', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
function triggerSearch () {
|
||||||
|
emit('search')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.search-input-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.search-input-wrapper .v-field--appended) {
|
||||||
|
padding-inline-end: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<template>
|
||||||
|
<v-list v-model:opened="openedModel" color="primary" density="compact" prepend-gap="8">
|
||||||
|
<template v-for="item in menuItems" :key="item.path ?? item.title">
|
||||||
|
<v-list-group v-if="item.subItems?.length" class="menu-group" :value="`menu:${item.path ?? item.title}`">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-list-item
|
||||||
|
v-bind="isShrink ? undefined : props" :class="{ 'px-0': isShrink }"
|
||||||
|
:link="isNavigable(item) && !!item.path" :to="isNavigable(item) ? item.path : undefined" @click="emitSelect(item)">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon v-if="item.icon" size="20">{{ item.icon }}</v-icon>
|
||||||
|
<v-btn v-if="isShrink && !item.icon" class="" rounded size="36" spaced="start" variant="text">{{
|
||||||
|
item.title?.charAt(0) }}</v-btn>
|
||||||
|
</template>
|
||||||
|
<template #title>
|
||||||
|
<span v-if="!isShrink" class="text-body-2 nav-text-overflow">{{ item.title }}</span>
|
||||||
|
</template>
|
||||||
|
<template #append>
|
||||||
|
<v-chip
|
||||||
|
v-if="!isShrink && getItemCount(item) > 0" class="menu-count" color="secondary" size="x-small"
|
||||||
|
variant="tonal">
|
||||||
|
{{ getItemCount(item) }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-for="subItem in item.subItems" :key="subItem.path ?? subItem.title">
|
||||||
|
<v-list-group
|
||||||
|
v-if="subItem.subItems?.length"
|
||||||
|
class="menu-group" :value="`menu:${item.path ?? item.title}::${subItem.path ?? subItem.title}`">
|
||||||
|
<template #activator="{ props: subProps }">
|
||||||
|
<v-list-item
|
||||||
|
v-bind="subProps" :link="isNavigable(subItem)"
|
||||||
|
:prepend-icon="subItem.icon || 'mdi-menu-right'" :to="isNavigable(subItem) ? subItem.path : undefined"
|
||||||
|
@click="emitSelect(subItem)">
|
||||||
|
<template #title>
|
||||||
|
<span class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
|
||||||
|
</template>
|
||||||
|
<template #append>
|
||||||
|
<v-chip
|
||||||
|
v-if="getItemCount(subItem) > 0" class="menu-count" color="secondary" size="x-small"
|
||||||
|
variant="tonal">
|
||||||
|
{{ getItemCount(subItem) }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
v-for="subSubItem in subItem.subItems" :key="subSubItem.path ?? subSubItem.title"
|
||||||
|
:link="!!subSubItem.path" prepend-icon="mdi-circle-small" :to="subSubItem.path"
|
||||||
|
@click="emitSelect(subSubItem)">
|
||||||
|
<template #title>
|
||||||
|
<v-tooltip location="end" :text="subSubItem.title">
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ subSubItem.title }}</span>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list-group>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
v-else :link="!!subItem.path" :prepend-icon="subItem.icon || 'mdi-menu-right'" :to="subItem.path"
|
||||||
|
@click="emitSelect(subItem)">
|
||||||
|
<template #title>
|
||||||
|
<v-tooltip location="end" :text="subItem.title">
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
</v-list-group>
|
||||||
|
|
||||||
|
<v-list-item v-else :class="{ 'px-0': isShrink }" :link="!!item.path" :to="item.path" @click="emitSelect(item)">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon v-if="item.icon" size="20">{{ item.icon }}</v-icon>
|
||||||
|
<v-btn v-if="isShrink && !item.icon" class="" rounded size="36" spaced="start" variant="text">{{
|
||||||
|
item.title?.charAt(0) }}</v-btn>
|
||||||
|
</template>
|
||||||
|
<template #title>
|
||||||
|
<v-tooltip v-if="!isShrink" location="end" :text="item.title">
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ item.title }}</span>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
</v-list>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
title?: string
|
||||||
|
path?: string
|
||||||
|
icon?: string
|
||||||
|
navigable?: boolean
|
||||||
|
subItems?: MenuItem[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
opened?: string[]
|
||||||
|
menuItems?: MenuItem[]
|
||||||
|
isShrink?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
opened: () => [],
|
||||||
|
menuItems: () => [],
|
||||||
|
isShrink: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:opened': [value: string[]]
|
||||||
|
select: [item: MenuItem]
|
||||||
|
unshrink: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const openedModel = computed({
|
||||||
|
get: () => (props.isShrink ? [] : props.opened),
|
||||||
|
set: (value) => {
|
||||||
|
if (!props.isShrink) {
|
||||||
|
emit('update:opened', value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 當側邊欄收縮時,自動收起所有展開的子選單
|
||||||
|
watch(() => props.isShrink, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
openedModel.value = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isNavigable = (item: MenuItem) => item?.navigable !== false
|
||||||
|
|
||||||
|
function emitSelect (item: MenuItem) {
|
||||||
|
// 收縮狀態下點擊選單項目時,先解除收縮再進行選擇
|
||||||
|
// 這樣可以讓使用者看到完整的選單結構和導航結果
|
||||||
|
if (props.isShrink) {
|
||||||
|
emit('unshrink')
|
||||||
|
}
|
||||||
|
emit('select', item)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemCount (item: MenuItem) {
|
||||||
|
if (!item?.subItems?.length) return 0
|
||||||
|
const countLeaf = (list: MenuItem[]): number =>
|
||||||
|
(list || []).reduce((total: number, current: MenuItem) => {
|
||||||
|
if (current?.subItems?.length) return total + countLeaf(current.subItems)
|
||||||
|
return total + 1
|
||||||
|
}, 0)
|
||||||
|
return countLeaf(item.subItems)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.menu-count {
|
||||||
|
min-width: 28px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-text-overflow {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<template>
|
||||||
|
<v-sheet class="mobile-favorites-panel d-flex flex-column" color="surface">
|
||||||
|
<v-list class="px-2 py-2 flex-grow-1 overflow-auto" density="comfortable">
|
||||||
|
<v-list-item
|
||||||
|
v-for="item in favoriteItems" :key="item.path ?? item.title" class="mb-1" rounded="lg"
|
||||||
|
@click="emit('select', item)">
|
||||||
|
<v-list-item-title class="text-body-2">{{ item.title }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-sheet>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
favoriteItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['select'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mobile-favorites-panel {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<v-sheet class="mobile-menu-panel d-flex flex-column" color="surface">
|
||||||
|
<v-list class="px-2 py-2 flex-grow-1 overflow-auto" density="comfortable">
|
||||||
|
<v-list-item
|
||||||
|
v-for="item in mobileCurrentItems" :key="item.path ?? item.title" class="mb-1" rounded="lg"
|
||||||
|
@click="emit('item-click', item)">
|
||||||
|
<v-list-item-title class="text-body-2">{{ item.title }}</v-list-item-title>
|
||||||
|
<template #append>
|
||||||
|
<v-icon size="18">{{ item.subItems?.length ? 'mdi-chevron-right' : 'mdi-arrow-top-right' }}</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-sheet>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
mobileCurrentItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['item-click'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mobile-menu-panel {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
:max-width="maxWidth" :model-value="modelValue" :persistent="persistent"
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-h6">{{ title }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<slot>{{ message }}</slot>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions class="justify-end">
|
||||||
|
<v-btn variant="text" @click="$emit('update:modelValue', false)">取消</v-btn>
|
||||||
|
<v-btn :color="confirmColor" :loading="confirmLoading" :variant="confirmVariant" @click="$emit('confirm')">
|
||||||
|
{{ confirmText }}
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PropType } from 'vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
confirmText: {
|
||||||
|
type: String,
|
||||||
|
default: '確認',
|
||||||
|
},
|
||||||
|
confirmColor: {
|
||||||
|
type: String,
|
||||||
|
default: 'primary',
|
||||||
|
},
|
||||||
|
confirmVariant: {
|
||||||
|
type: String as PropType<'text' | 'flat' | 'outlined' | 'plain' | 'elevated' | 'tonal'>,
|
||||||
|
default: 'flat',
|
||||||
|
},
|
||||||
|
confirmLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
maxWidth: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: 420,
|
||||||
|
},
|
||||||
|
persistent: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: boolean): void
|
||||||
|
(event: 'confirm'): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<v-card border class="d-flex flex-column h-100 rounded-0" :class="cardClass" flat :width="width">
|
||||||
|
<v-card-title class="dialog-title d-flex align-center ga-2">
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ dialogTitle }}</div>
|
||||||
|
<div v-if="dialogSubtitle" class="text-body-2 text-medium-emphasis">{{ dialogSubtitle }}</div>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-chip v-if="isViewMode" color="info" size="small" variant="tonal">檢視中</v-chip>
|
||||||
|
<v-chip v-else-if="isEditMode" color="primary" size="small" variant="tonal">編輯中</v-chip>
|
||||||
|
<v-chip v-else color="secondary" size="small" variant="tonal">新增中</v-chip>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-subtitle class="dialog-toolbar d-flex align-center py-2 ga-2">
|
||||||
|
<slot name="toolbar" />
|
||||||
|
</v-card-subtitle>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-text :class="contentClass">
|
||||||
|
<slot name="content" />
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-actions class="dialog-actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps({
|
||||||
|
dialogTitle: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
dialogSubtitle: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
isViewMode: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isEditMode: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: '100%',
|
||||||
|
},
|
||||||
|
cardClass: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
contentClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'pa-2 flex-grow-1 overflow-y-auto',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<v-sheet class="d-flex flex-column ga-2 h-100">
|
||||||
|
<v-card border class="flex-shrink-0" variant="flat">
|
||||||
|
<v-card-title class="d-flex align-center ga-3">
|
||||||
|
<span class="text-h6">{{ title }}</span>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
:icon="mdAndUp ? false : 'mdi-magnify'" :prepend-icon="mdAndUp ? 'mdi-magnify' : undefined" size="small"
|
||||||
|
:text="mdAndUp ? '搜尋條件' : false" variant="text" @click="$emit('toggle-search')">
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="primary" :icon="mdAndUp ? false : 'mdi-plus'" :prepend-icon="mdAndUp ? 'mdi-plus' : undefined"
|
||||||
|
size="small" :text="mdAndUp ? createLabel : false" @click="$emit('create')">
|
||||||
|
</v-btn>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<!-- Desktop:inline 展開 -->
|
||||||
|
<template v-if="mdAndUp">
|
||||||
|
<v-divider />
|
||||||
|
<v-card-text v-show="searchPanelOpen" class="px-2 py-1">
|
||||||
|
<v-row dense>
|
||||||
|
<slot name="search-fields" />
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
</template>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<slot name="table" />
|
||||||
|
</v-sheet>
|
||||||
|
|
||||||
|
<!-- 手機 / Tablet:Bottom Sheet -->
|
||||||
|
<v-bottom-sheet v-if="!mdAndUp" :model-value="searchPanelOpen" @update:model-value="$emit('toggle-search')">
|
||||||
|
<v-card rounded="t-xl">
|
||||||
|
<v-card-title class="d-flex align-center py-3 px-4">
|
||||||
|
<span class="text-subtitle-1 font-weight-medium">搜尋條件</span>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn icon="mdi-close" size="small" variant="text" @click="$emit('toggle-search')" />
|
||||||
|
</v-card-title>
|
||||||
|
<v-divider />
|
||||||
|
<v-card-text class="px-2 py-1">
|
||||||
|
<v-row dense>
|
||||||
|
<slot name="search-fields" />
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-bottom-sheet>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
|
const { mdAndUp } = useDisplay()
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '學生資料維護',
|
||||||
|
},
|
||||||
|
createLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '新增資料',
|
||||||
|
},
|
||||||
|
searchPanelOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(event: 'toggle-search'): void
|
||||||
|
(event: 'create'): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="mobile" class="d-flex align-center flex-wrap ga-2 w-100">
|
||||||
|
<div class="d-flex align-center ga-1">
|
||||||
|
<v-btn
|
||||||
|
v-if="isViewMode || isEditMode" :disabled="!hasPrevRecord" icon="mdi-chevron-left" size="small" variant="text"
|
||||||
|
@click="$emit('prev')" />
|
||||||
|
<v-btn
|
||||||
|
v-if="isViewMode || isEditMode" :disabled="!hasNextRecord" icon="mdi-chevron-right" size="small" variant="text"
|
||||||
|
@click="$emit('next')" />
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
v-if="isViewMode" color="primary" prepend-icon="mdi-pencil" size="small" variant="tonal"
|
||||||
|
@click="$emit('switch-to-edit')">
|
||||||
|
{{ editLabel }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="isEditMode" color="primary" prepend-icon="mdi-eye" size="small" variant="tonal"
|
||||||
|
@click="$emit('switch-to-view')">
|
||||||
|
{{ viewLabel }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<v-btn
|
||||||
|
v-if="isViewMode || isEditMode" :disabled="!hasPrevRecord" prepend-icon="mdi-skip-previous" size="small"
|
||||||
|
variant="text" @click="$emit('first')">
|
||||||
|
{{ firstLabel }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="isViewMode || isEditMode" :disabled="!hasPrevRecord" prepend-icon="mdi-chevron-left" size="small"
|
||||||
|
variant="text" @click="$emit('prev')">
|
||||||
|
{{ prevLabel }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="isViewMode || isEditMode" append-icon="mdi-chevron-right" :disabled="!hasNextRecord" size="small"
|
||||||
|
variant="text" @click="$emit('next')">
|
||||||
|
{{ nextLabel }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="isViewMode || isEditMode" append-icon="mdi-skip-next" :disabled="!hasNextRecord" size="small"
|
||||||
|
variant="text" @click="$emit('last')">
|
||||||
|
{{ lastLabel }}
|
||||||
|
</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
v-if="isViewMode" color="primary" prepend-icon="mdi-pencil" size="small" variant="tonal"
|
||||||
|
@click="$emit('switch-to-edit')">
|
||||||
|
{{ editLabel }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="isEditMode" color="primary" prepend-icon="mdi-eye" size="small" variant="tonal"
|
||||||
|
@click="$emit('switch-to-view')">
|
||||||
|
{{ viewLabel }}
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps({
|
||||||
|
isViewMode: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isEditMode: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
hasPrevRecord: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
hasNextRecord: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
firstLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '首筆',
|
||||||
|
},
|
||||||
|
prevLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '上一筆',
|
||||||
|
},
|
||||||
|
nextLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '下一筆',
|
||||||
|
},
|
||||||
|
lastLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '末筆',
|
||||||
|
},
|
||||||
|
editLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '編輯',
|
||||||
|
},
|
||||||
|
viewLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '檢視',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(event: 'first'): void
|
||||||
|
(event: 'prev'): void
|
||||||
|
(event: 'next'): void
|
||||||
|
(event: 'last'): void
|
||||||
|
(event: 'switch-to-edit'): void
|
||||||
|
(event: 'switch-to-view'): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<template>
|
||||||
|
<v-card border class="d-flex flex-column h-100 rounded-0" flat width="100%">
|
||||||
|
<v-toolbar color="transparent" density="compact" flat>
|
||||||
|
<v-btn icon="mdi-arrow-left" size="small" variant="text" @click="$emit('close')" />
|
||||||
|
<v-toolbar-title class="text-subtitle-1 font-weight-bold">
|
||||||
|
{{ semester?.semesterName || '學期明細' }}
|
||||||
|
</v-toolbar-title>
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-text class="pa-0 flex-grow-1 overflow-y-auto bg-grey-lighten-5">
|
||||||
|
<div v-if="semester" class="pa-4 d-flex flex-column ga-4">
|
||||||
|
<div class="d-flex flex-column ga-3">
|
||||||
|
<v-card class="py-2 px-3" variant="outlined">
|
||||||
|
<div class="text-caption text-medium-emphasis">學期平均</div>
|
||||||
|
<div class="text-h6 font-weight-bold text-primary">{{ semester.average }}</div>
|
||||||
|
</v-card>
|
||||||
|
<v-card class="py-2 px-3" variant="outlined">
|
||||||
|
<div class="text-caption text-medium-emphasis">班級排名</div>
|
||||||
|
<div class="text-h6 font-weight-bold">{{ semester.rank }}</div>
|
||||||
|
</v-card>
|
||||||
|
<v-card class="py-2 px-3" variant="outlined">
|
||||||
|
<div class="text-caption text-medium-emphasis">總學分</div>
|
||||||
|
<div class="text-h6 font-weight-bold">{{ totalCredits }}</div>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="isViewMode">
|
||||||
|
<v-card v-for="course in semester.courses" :key="course.code" class="pa-3" variant="outlined">
|
||||||
|
<div class="d-flex align-start justify-space-between ga-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-body-1 font-weight-medium">{{ course.name }}</div>
|
||||||
|
<div class="text-caption text-medium-emphasis">{{ course.code }}</div>
|
||||||
|
</div>
|
||||||
|
<v-chip :color="course.score < 60 ? 'error' : 'success'" size="small" variant="tonal">
|
||||||
|
{{ course.score }} 分
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex ga-4 mt-3 text-body-2">
|
||||||
|
<div>學分 {{ course.credits }}</div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<div v-if="semester.courses.length === 0" class="text-center text-medium-emphasis py-6 border border-dashed rounded">
|
||||||
|
尚無課程資料
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<v-text-field
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
hide-details="auto"
|
||||||
|
label="學期名稱"
|
||||||
|
:model-value="semester.semesterName"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="(value) => $emit('update-semester', semester.id, { semesterName: String(value) })"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
hide-details="auto"
|
||||||
|
label="班級排名"
|
||||||
|
:model-value="semester.rank"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="(value) => $emit('update-semester', semester.id, { rank: Number(value) || 0 })"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
density="comfortable"
|
||||||
|
hide-details="auto"
|
||||||
|
label="平均分數"
|
||||||
|
:model-value="semester.average"
|
||||||
|
readonly
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<div class="text-subtitle-2 font-weight-bold">課程列表</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('add-course', semester.id)"
|
||||||
|
>
|
||||||
|
加入課程
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-card v-for="(course, idx) in semester.courses" :key="`${course.code}-${idx}`" class="pa-3" variant="outlined">
|
||||||
|
<div class="d-flex align-center mb-3">
|
||||||
|
<div class="text-subtitle-2 font-weight-bold">課程 {{ idx + 1 }}</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
color="error"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
icon="mdi-delete"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('delete-course', semester.id, idx, course.name)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column ga-3">
|
||||||
|
<v-text-field
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
hide-details
|
||||||
|
label="課程名稱"
|
||||||
|
:model-value="course.name"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="(value) => $emit('update-course', semester.id, idx, { name: String(value) })"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
hide-details
|
||||||
|
label="代碼"
|
||||||
|
:model-value="course.code"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="(value) => $emit('update-course', semester.id, idx, { code: String(value) })"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
hide-details
|
||||||
|
label="學分"
|
||||||
|
:model-value="course.credits"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="(value) => $emit('update-course', semester.id, idx, { credits: Number(value) || 0 })"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
hide-details
|
||||||
|
label="分數"
|
||||||
|
:model-value="course.score"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="(value) => $emit('update-course', semester.id, idx, { score: Number(value) || 0 })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<div v-if="semester.courses.length === 0" class="text-center text-medium-emphasis py-6 border border-dashed rounded">
|
||||||
|
尚無課程資料
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { CourseRecord, SemesterRecord } from '@/stores/semesters'
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
semester: SemesterRecord | null
|
||||||
|
isViewMode: boolean
|
||||||
|
isFormLocked: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(event: 'close'): void
|
||||||
|
(event: 'add-course', semesterId: number): void
|
||||||
|
(event: 'update-semester', semesterId: number, payload: Partial<SemesterRecord>): void
|
||||||
|
(event: 'update-course', semesterId: number, courseIndex: number, payload: Partial<CourseRecord>): void
|
||||||
|
(event: 'delete-course', semesterId: number, courseIndex: number, courseName: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const totalCredits = computed(() =>
|
||||||
|
props.semester?.courses.reduce((sum, course) => sum + course.credits, 0) ?? 0,
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-subtitle-1 font-weight-bold my-3 d-flex align-center">
|
||||||
|
<v-icon start>mdi-school</v-icon>
|
||||||
|
子檔資料示範
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isMobile" class="d-flex flex-column ga-3">
|
||||||
|
<v-card
|
||||||
|
v-for="semester in semesters"
|
||||||
|
:key="semester.id"
|
||||||
|
class="cursor-pointer"
|
||||||
|
:class="{ 'border-opacity-100': selectedSemesterId === semester.id }"
|
||||||
|
:color="selectedSemesterId === semester.id ? 'primary' : undefined"
|
||||||
|
variant="outlined"
|
||||||
|
@click="$emit('select', semester.id)"
|
||||||
|
>
|
||||||
|
<v-card-text class="pa-4">
|
||||||
|
<div class="d-flex align-start justify-space-between ga-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-body-1 font-weight-bold">{{ semester.semesterName }}</div>
|
||||||
|
<div class="text-caption text-medium-emphasis">點擊查看課程與成績</div>
|
||||||
|
</div>
|
||||||
|
<v-icon size="small">mdi-chevron-right</v-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap ga-2 mt-3">
|
||||||
|
<v-chip color="primary" size="small" variant="tonal">平均 {{ semester.average }}</v-chip>
|
||||||
|
<v-chip size="small" variant="tonal">排名 {{ semester.rank }}</v-chip>
|
||||||
|
<v-chip size="small" variant="tonal">課程 {{ semester.courses.length }}</v-chip>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex-grow-1" style="min-height: 0">
|
||||||
|
<div class="d-flex flex-column ga-3 overflow-y-auto pr-1 h-100">
|
||||||
|
<v-expansion-panels>
|
||||||
|
<v-expansion-panel v-for="semester in semesters" :key="semester.id">
|
||||||
|
<v-expansion-panel-title color="background">
|
||||||
|
<div class="d-flex align-center ga-3 w-100 pr-2">
|
||||||
|
<span class="font-weight-medium">{{ semester.semesterName }}</span>
|
||||||
|
</div>
|
||||||
|
</v-expansion-panel-title>
|
||||||
|
<v-expansion-panel-text>
|
||||||
|
<div class="d-flex align-center mb-2">
|
||||||
|
<span class="text-subtitle-1 font-weight-bold text-medium-emphasis">課程列表</span>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
v-if="!isFormReadonly"
|
||||||
|
color="primary"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
@click="$emit('add-course', semester.id)"
|
||||||
|
>
|
||||||
|
加入課程
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-table class="border rounded" density="compact" fixed-header>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="cursor-pointer" width="50%" @click="toggleSort(semester.id, 'name')">
|
||||||
|
<div class="d-flex align-center ga-1">
|
||||||
|
<span>課程名稱</span>
|
||||||
|
<v-icon size="small">{{ getSortIcon(semester.id, 'name') }}</v-icon>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="cursor-pointer" @click="toggleSort(semester.id, 'credits')">
|
||||||
|
<div class="d-flex align-center ga-1">
|
||||||
|
<span>學分</span>
|
||||||
|
<v-icon size="small">{{ getSortIcon(semester.id, 'credits') }}</v-icon>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="cursor-pointer" @click="toggleSort(semester.id, 'score')">
|
||||||
|
<div class="d-flex align-center ga-1">
|
||||||
|
<span>分數</span>
|
||||||
|
<v-icon size="small">{{ getSortIcon(semester.id, 'score') }}</v-icon>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th v-if="!isFormReadonly" width="52"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="{ course, originalIndex } in getSortedCourses(semester)"
|
||||||
|
:key="`${semester.id}-${originalIndex}`"
|
||||||
|
>
|
||||||
|
<td class="py-0">{{ course.name }}</td>
|
||||||
|
<td class="align-top py-0">
|
||||||
|
<span v-if="isFormReadonly">{{ course.credits }}</span>
|
||||||
|
<v-text-field
|
||||||
|
v-else
|
||||||
|
density="compact"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
hide-details
|
||||||
|
hide-spin-buttons
|
||||||
|
:model-value="course.credits"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="
|
||||||
|
(value) =>
|
||||||
|
$emit('update-course', semester.id, originalIndex, {
|
||||||
|
credits: Number(value) || 0,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="align-top py-0">
|
||||||
|
<span v-if="isFormReadonly">{{ course.score }}</span>
|
||||||
|
<v-text-field
|
||||||
|
v-else
|
||||||
|
density="compact"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
hide-details
|
||||||
|
hide-spin-buttons
|
||||||
|
:model-value="course.score"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="
|
||||||
|
(value) =>
|
||||||
|
$emit('update-course', semester.id, originalIndex, {
|
||||||
|
score: Number(value) || 0,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td v-if="!isFormReadonly" class="px-1 text-center">
|
||||||
|
<v-btn
|
||||||
|
color="error"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
icon="mdi-delete-outline"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('delete-course', semester.id, originalIndex, course.name)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="semester.courses.length === 0">
|
||||||
|
<td
|
||||||
|
class="text-center text-medium-emphasis py-6"
|
||||||
|
:colspan="isFormReadonly ? 3 : 4"
|
||||||
|
>
|
||||||
|
<div class="d-flex flex-column align-center ga-2">
|
||||||
|
<v-icon color="medium-emphasis" size="24">mdi-book-open-outline</v-icon>
|
||||||
|
<span class="text-caption">尚無課程,點擊「加入課程」新增</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
</v-expansion-panel-text>
|
||||||
|
</v-expansion-panel>
|
||||||
|
</v-expansion-panels>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="semesters.length === 0"
|
||||||
|
class="text-caption text-center py-6 text-medium-emphasis border border-dashed rounded"
|
||||||
|
>
|
||||||
|
尚無學期資料
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { CourseRecord, SemesterRecord } from '@/stores/semesters'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
type CourseSortKey = 'name' | 'credits' | 'score'
|
||||||
|
|
||||||
|
interface CourseSortState {
|
||||||
|
key: CourseSortKey
|
||||||
|
order: 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortedCourseRow {
|
||||||
|
course: CourseRecord
|
||||||
|
originalIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
semesters: SemesterRecord[]
|
||||||
|
isMobile: boolean
|
||||||
|
isFormReadonly: boolean
|
||||||
|
isFormLocked: boolean
|
||||||
|
selectedSemesterId: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(event: 'select', semesterId: number): void
|
||||||
|
(event: 'add-course', semesterId: number): void
|
||||||
|
(
|
||||||
|
event: 'update-course',
|
||||||
|
semesterId: number,
|
||||||
|
courseIndex: number,
|
||||||
|
payload: Partial<CourseRecord>
|
||||||
|
): void
|
||||||
|
(event: 'delete-course', semesterId: number, courseIndex: number, courseName: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const semesterSortStates = ref<Record<number, CourseSortState>>({})
|
||||||
|
|
||||||
|
const getSortState = (semesterId: number) => semesterSortStates.value[semesterId]
|
||||||
|
|
||||||
|
function toggleSort (semesterId: number, key: CourseSortKey) {
|
||||||
|
const current = getSortState(semesterId)
|
||||||
|
|
||||||
|
semesterSortStates.value[semesterId] =
|
||||||
|
current?.key === key
|
||||||
|
? {
|
||||||
|
key,
|
||||||
|
order: current.order === 'asc' ? 'desc' : 'asc',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
key,
|
||||||
|
order: 'asc',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortIcon (semesterId: number, key: CourseSortKey) {
|
||||||
|
const current = getSortState(semesterId)
|
||||||
|
|
||||||
|
if (current?.key !== key) return 'mdi-swap-vertical'
|
||||||
|
return current.order === 'asc' ? 'mdi-arrow-up' : 'mdi-arrow-down'
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareCourseValue (left: CourseRecord, right: CourseRecord, key: CourseSortKey) {
|
||||||
|
if (key === 'name') return left.name.localeCompare(right.name, 'zh-Hant')
|
||||||
|
return left[key] - right[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortedCourses (semester: SemesterRecord): SortedCourseRow[] {
|
||||||
|
const rows = semester.courses.map((course, originalIndex) => ({
|
||||||
|
course,
|
||||||
|
originalIndex,
|
||||||
|
}))
|
||||||
|
const sortState = getSortState(semester.id)
|
||||||
|
|
||||||
|
if (!sortState) return rows
|
||||||
|
|
||||||
|
return [...rows].sort((left, right) => {
|
||||||
|
const result = compareCourseValue(left.course, right.course, sortState.key)
|
||||||
|
|
||||||
|
if (result === 0) return left.originalIndex - right.originalIndex
|
||||||
|
return sortState.order === 'asc' ? result : -result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<template>
|
||||||
|
<v-card border class="d-flex flex-column h-100 rounded-0" flat width="100%">
|
||||||
|
<v-toolbar color="transparent" density="compact" flat>
|
||||||
|
<v-btn icon="mdi-arrow-left" size="small" variant="text" @click="$emit('close')" />
|
||||||
|
<v-toolbar-title class="text-subtitle-1 font-weight-bold">
|
||||||
|
{{ semester?.semesterName || '課程明細' }}
|
||||||
|
</v-toolbar-title>
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-text class="pa-0 flex-grow-1 overflow-y-auto bg-grey-lighten-5">
|
||||||
|
<div v-if="semester" class="pa-4 d-flex flex-column ga-4">
|
||||||
|
<div class="d-flex flex-column ga-3">
|
||||||
|
<v-card class="py-2 px-3" variant="outlined">
|
||||||
|
<div class="text-caption text-medium-emphasis">學期平均</div>
|
||||||
|
<div class="text-h6 font-weight-bold text-primary">{{ semester.average }}</div>
|
||||||
|
</v-card>
|
||||||
|
<v-card class="py-2 px-3" variant="outlined">
|
||||||
|
<div class="text-caption text-medium-emphasis">班級排名</div>
|
||||||
|
<div class="text-h6 font-weight-bold">{{ semester.rank }}</div>
|
||||||
|
</v-card>
|
||||||
|
<v-card class="py-2 px-3" variant="outlined">
|
||||||
|
<div class="text-caption text-medium-emphasis">總學分</div>
|
||||||
|
<div class="text-h6 font-weight-bold">{{ totalCredits }}</div>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<div>
|
||||||
|
<div class="text-subtitle-2 font-weight-bold">課程列表</div>
|
||||||
|
<div class="text-caption text-medium-emphasis">手機版改用卡片式維護,不使用扁平表格</div>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
v-if="!isViewMode"
|
||||||
|
color="primary"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('add-course', semester.id)"
|
||||||
|
>
|
||||||
|
加入課程
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="semester.courses.length > 0">
|
||||||
|
<v-card v-for="(course, idx) in semester.courses" :key="`${course.code}-${idx}`" class="pa-3" variant="outlined">
|
||||||
|
<template v-if="isViewMode">
|
||||||
|
<div class="d-flex align-start justify-space-between ga-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-body-1 font-weight-medium">{{ course.name }}</div>
|
||||||
|
<div class="text-caption text-medium-emphasis">{{ course.code }}</div>
|
||||||
|
</div>
|
||||||
|
<v-chip :color="course.score < 60 ? 'error' : 'success'" size="small" variant="tonal">
|
||||||
|
{{ course.score }} 分
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex ga-4 mt-3 text-body-2">
|
||||||
|
<div>學分 {{ course.credits }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="d-flex align-center mb-3">
|
||||||
|
<div class="text-subtitle-2 font-weight-bold">課程 {{ idx + 1 }}</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
color="error"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
icon="mdi-delete"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('delete-course', semester.id, idx)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column ga-3">
|
||||||
|
<v-text-field
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
hide-details
|
||||||
|
label="課程名稱"
|
||||||
|
:model-value="course.name"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="(value) => $emit('update-course', semester.id, idx, { name: String(value) })"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
hide-details
|
||||||
|
label="代碼"
|
||||||
|
:model-value="course.code"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="(value) => $emit('update-course', semester.id, idx, { code: String(value) })"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
hide-details
|
||||||
|
label="學分"
|
||||||
|
:model-value="course.credits"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="(value) => $emit('update-course', semester.id, idx, { credits: Number(value) || 0 })"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
hide-details
|
||||||
|
label="分數"
|
||||||
|
:model-value="course.score"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="(value) => $emit('update-course', semester.id, idx, { score: Number(value) || 0 })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="text-center text-medium-emphasis py-6 border border-dashed rounded">
|
||||||
|
尚無課程資料
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { CourseRecord, SemesterRecord } from '@/stores/semesters'
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
semester: SemesterRecord | null
|
||||||
|
isViewMode: boolean
|
||||||
|
isFormLocked: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(event: 'close'): void
|
||||||
|
(event: 'add-course', semesterId: number): void
|
||||||
|
(event: 'update-course', semesterId: number, courseIndex: number, payload: Partial<CourseRecord>): void
|
||||||
|
(event: 'delete-course', semesterId: number, courseIndex: number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const totalCredits = computed(() =>
|
||||||
|
props.semester?.courses.reduce((sum, course) => sum + course.credits, 0) ?? 0,
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-subtitle-1 font-weight-bold my-3 d-flex align-center">
|
||||||
|
<v-icon start>mdi-school</v-icon>
|
||||||
|
子檔資料示範
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
v-if="!isMobile && !isFormReadonly && !isFormLocked" color="primary" prepend-icon="mdi-plus" size="small"
|
||||||
|
variant="tonal" @click="$emit('add-course')">
|
||||||
|
新增成績
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isMobile" class="d-flex flex-column ga-3">
|
||||||
|
<v-card
|
||||||
|
v-for="semester in semesters" :key="semester.id" class="cursor-pointer" :class="{ 'border-opacity-100': selectedSemesterId === semester.id }"
|
||||||
|
:color="selectedSemesterId === semester.id ? 'primary' : undefined"
|
||||||
|
variant="outlined"
|
||||||
|
@click="$emit('select-semester', semester.id)">
|
||||||
|
<v-card-text class="pa-4">
|
||||||
|
<div class="d-flex align-start justify-space-between ga-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-body-1 font-weight-bold">{{ semester.semesterName }}</div>
|
||||||
|
<div class="text-caption text-medium-emphasis">點擊查看課程與成績</div>
|
||||||
|
</div>
|
||||||
|
<v-icon size="small">mdi-chevron-right</v-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap ga-2 mt-3">
|
||||||
|
<v-chip color="primary" size="small" variant="tonal">平均 {{ semester.average }}</v-chip>
|
||||||
|
<v-chip size="small" variant="tonal">排名 {{ semester.rank }}</v-chip>
|
||||||
|
<v-chip size="small" variant="tonal">課程 {{ semester.courses.length }}</v-chip>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex-grow-1" style="min-height: 0;">
|
||||||
|
<div class="d-flex flex-column ga-3 overflow-y-auto pr-1 h-100">
|
||||||
|
<v-card class="flex-shrink-0" variant="flat">
|
||||||
|
<v-card-text class="pa-2">
|
||||||
|
<v-data-table
|
||||||
|
class="border rounded" density="compact" :headers="headers" hide-default-footer
|
||||||
|
:items="flattenedCourses" :items-per-page="-1">
|
||||||
|
<template #[`item.semesterName`]="slotProps">
|
||||||
|
{{ slotProps.item.semesterName }}
|
||||||
|
</template>
|
||||||
|
<template #[`item.name`]="slotProps">
|
||||||
|
{{ slotProps.item.name }}
|
||||||
|
</template>
|
||||||
|
<template #[`item.credits`]="slotProps">
|
||||||
|
<span v-if="isFormReadonly">{{ slotProps.item.credits }}</span>
|
||||||
|
<v-text-field
|
||||||
|
v-else density="compact" :disabled="isFormLocked" hide-details
|
||||||
|
hide-spin-buttons :model-value="slotProps.item.credits" type="number" variant="outlined" @update:model-value="
|
||||||
|
(value) => $emit('update-course', slotProps.item.semesterId, slotProps.item.courseIndex, { credits: Number(value) || 0 })
|
||||||
|
" />
|
||||||
|
</template>
|
||||||
|
<template #[`item.score`]="slotProps">
|
||||||
|
<span v-if="isFormReadonly">{{ slotProps.item.score }}</span>
|
||||||
|
<v-text-field
|
||||||
|
v-else density="compact" :disabled="isFormLocked" hide-details
|
||||||
|
hide-spin-buttons :model-value="slotProps.item.score" type="number" variant="outlined" @update:model-value="
|
||||||
|
(value) => $emit('update-course', slotProps.item.semesterId, slotProps.item.courseIndex, { score: Number(value) || 0 })
|
||||||
|
" />
|
||||||
|
</template>
|
||||||
|
<template #[`item.actions`]="slotProps">
|
||||||
|
<v-btn
|
||||||
|
color="error" :disabled="isFormLocked" icon="mdi-delete" size="small" variant="text"
|
||||||
|
@click="$emit('delete-course', slotProps.item.semesterId, slotProps.item.courseIndex)" />
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="semesters.length === 0" class="text-caption text-center py-6 text-medium-emphasis border border-dashed rounded">
|
||||||
|
尚無成績資料
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { CourseRecord, SemesterRecord } from '@/stores/semesters'
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
semesters: SemesterRecord[]
|
||||||
|
isMobile: boolean
|
||||||
|
isFormReadonly: boolean
|
||||||
|
isFormLocked: boolean
|
||||||
|
selectedSemesterId: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(event: 'select-semester', semesterId: number): void
|
||||||
|
(event: 'add-course'): void
|
||||||
|
(event: 'update-course', semesterId: number, courseIndex: number, payload: Partial<CourseRecord>): void
|
||||||
|
(event: 'delete-course', semesterId: number, courseIndex: number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const headers = computed(() => {
|
||||||
|
const baseHeaders: Array<{ title: string; key: string; sortable: boolean; width?: string }> = [
|
||||||
|
{ title: '學年學期', key: 'semesterName', sortable: true },
|
||||||
|
{ title: '課程名稱', key: 'name', sortable: true },
|
||||||
|
{ title: '學分', key: 'credits', sortable: true, width: '100' },
|
||||||
|
{ title: '分數', key: 'score', sortable: true, width: '100' },
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!props.isFormReadonly) {
|
||||||
|
baseHeaders.push({ title: '操作', key: 'actions', sortable: false, width: '60' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseHeaders
|
||||||
|
})
|
||||||
|
|
||||||
|
const flattenedCourses = computed(() => {
|
||||||
|
const result: Array<{
|
||||||
|
semesterId: number
|
||||||
|
courseIndex: number
|
||||||
|
semesterName: string
|
||||||
|
name: string
|
||||||
|
credits: number
|
||||||
|
score: number
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
for (const semester of props.semesters) {
|
||||||
|
for (const [courseIndex, course] of semester.courses.entries()) {
|
||||||
|
result.push({
|
||||||
|
semesterId: semester.id,
|
||||||
|
courseIndex,
|
||||||
|
semesterName: semester.semesterName,
|
||||||
|
name: course.name,
|
||||||
|
credits: course.credits,
|
||||||
|
score: course.score,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-subtitle-1 font-weight-bold mb-3 d-flex align-center">
|
||||||
|
<v-icon start>mdi-school</v-icon>
|
||||||
|
子檔資料示範 ({{ semesters.length }})
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn v-if="!isViewMode" color="primary" prepend-icon="mdi-plus" size="small" variant="text" @click="$emit('add')">
|
||||||
|
新增學期
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-row dense>
|
||||||
|
<v-col v-for="semester in semesters" :key="semester.id" cols="12" :md="isMobile ? 12 : 6">
|
||||||
|
<v-card
|
||||||
|
class="cursor-pointer mb-2"
|
||||||
|
:class="{ 'border-opacity-100': selectedSemesterId === semester.id }"
|
||||||
|
:color="selectedSemesterId === semester.id ? 'primary' : undefined"
|
||||||
|
variant="outlined"
|
||||||
|
@click="$emit('select', semester.id)"
|
||||||
|
>
|
||||||
|
<v-list-item density="compact">
|
||||||
|
<v-list-item-title class="text-body-2 font-weight-bold">{{ semester.semesterName }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle class="text-caption">
|
||||||
|
平均: {{ semester.average }} ・ 排名: {{ semester.rank }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
<template #append>
|
||||||
|
<v-icon size="small">mdi-chevron-right</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<div v-if="semesters.length === 0" class="text-caption text-center py-6 text-medium-emphasis border border-dashed rounded">
|
||||||
|
尚無學期資料
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { SemesterRecord } from '@/stores/semesters'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
semesters: SemesterRecord[]
|
||||||
|
selectedSemesterId: number | null
|
||||||
|
isViewMode: boolean
|
||||||
|
isMobile: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(event: 'select', id: number): void
|
||||||
|
(event: 'add'): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
<template>
|
||||||
|
<v-card border class="d-flex flex-column h-100 rounded-0" flat width="100%">
|
||||||
|
<v-toolbar v-if="!isDetailEditing" color="transparent" density="compact" flat>
|
||||||
|
<v-btn :icon="isMobile ? 'mdi-arrow-left' : 'mdi-close'" size="small" variant="text" @click="$emit('close')" />
|
||||||
|
<v-toolbar-title class="text-subtitle-1 font-weight-bold">
|
||||||
|
{{ selectedSemester ? selectedSemester.semesterName : '學期明細' }}
|
||||||
|
</v-toolbar-title>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
v-if="selectedSemester && !isViewMode"
|
||||||
|
color="error"
|
||||||
|
prepend-icon="mdi-delete"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('delete', selectedSemester.id)"
|
||||||
|
>
|
||||||
|
刪除
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="selectedSemester && !isViewMode"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-pencil"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('start-edit')"
|
||||||
|
>
|
||||||
|
編輯
|
||||||
|
</v-btn>
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<v-toolbar v-else density="compact" flat>
|
||||||
|
<v-btn :icon="isMobile ? 'mdi-arrow-left' : 'mdi-close'" size="small" variant="text" @click="$emit('cancel-edit')" />
|
||||||
|
<v-toolbar-title class="text-subtitle-1 font-weight-bold">
|
||||||
|
編輯學期
|
||||||
|
</v-toolbar-title>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="$emit('save-edit')">儲存</v-btn>
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-text v-if="!isDetailEditing" class="pa-0 flex-grow-1 overflow-y-auto bg-grey-lighten-5">
|
||||||
|
<div v-if="selectedSemester" class="h-100 d-flex flex-column">
|
||||||
|
<div :class="statsClass">
|
||||||
|
<v-card class="py-2 px-3" :class="statCardClass" variant="outlined">
|
||||||
|
<div class="text-caption text-medium-emphasis">學期平均</div>
|
||||||
|
<div class="text-h6 font-weight-bold text-primary">{{ selectedSemester.average }}</div>
|
||||||
|
</v-card>
|
||||||
|
<v-card class="py-2 px-3" :class="statCardClass" variant="outlined">
|
||||||
|
<div class="text-caption text-medium-emphasis">班級排名</div>
|
||||||
|
<div class="text-h6 font-weight-bold">{{ selectedSemester.rank }}</div>
|
||||||
|
</v-card>
|
||||||
|
<v-card class="py-2 px-3" :class="statCardClass" variant="outlined">
|
||||||
|
<div class="text-caption text-medium-emphasis">總學分</div>
|
||||||
|
<div class="text-h6 font-weight-bold">{{ totalCredits }}</div>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<div v-if="isMobile" class="pa-3 d-flex flex-column ga-3">
|
||||||
|
<v-card v-for="course in selectedSemester.courses" :key="course.code" class="pa-3" variant="outlined">
|
||||||
|
<div class="d-flex align-start justify-space-between ga-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-body-1 font-weight-medium">{{ course.name }}</div>
|
||||||
|
<div class="text-caption text-medium-emphasis">{{ course.code }}</div>
|
||||||
|
</div>
|
||||||
|
<v-chip :color="course.score < 60 ? 'error' : 'success'" size="small" variant="tonal">
|
||||||
|
{{ course.score }} 分
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex ga-4 mt-3 text-body-2">
|
||||||
|
<div>學分 {{ course.credits }}</div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
<div v-if="selectedSemester.courses.length === 0" class="text-center text-medium-emphasis py-6">
|
||||||
|
尚無課程資料
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-table v-else class="flex-grow-1">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-left bg-grey-lighten-4">課程名稱</th>
|
||||||
|
<th class="text-center bg-grey-lighten-4" width="80">學分</th>
|
||||||
|
<th class="text-right bg-grey-lighten-4" width="80">分數</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="course in selectedSemester.courses" :key="course.code">
|
||||||
|
<td>
|
||||||
|
<div class="text-body-2 font-weight-medium">{{ course.name }}</div>
|
||||||
|
<div class="text-caption text-medium-emphasis">{{ course.code }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">{{ course.credits }}</td>
|
||||||
|
<td class="text-right font-weight-bold" :class="course.score < 60 ? 'text-error' : 'text-success'">
|
||||||
|
{{ course.score }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-text v-else class="pa-0 flex-grow-1 overflow-y-auto bg-surface">
|
||||||
|
<div v-if="detailForm" class="pa-4 d-flex flex-column ga-4">
|
||||||
|
<v-row dense>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-text-field
|
||||||
|
v-model="detailForm.semesterName"
|
||||||
|
density="compact"
|
||||||
|
hide-details="auto"
|
||||||
|
label="學期名稱"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col :cols="isMobile ? 12 : 6">
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="detailForm.rank"
|
||||||
|
density="compact"
|
||||||
|
hide-details="auto"
|
||||||
|
label="班級排名"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col :cols="isMobile ? 12 : 6">
|
||||||
|
<v-text-field
|
||||||
|
density="compact"
|
||||||
|
hide-details="auto"
|
||||||
|
label="平均分數 (自動計算)"
|
||||||
|
:model-value="detailForm.average"
|
||||||
|
readonly
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<div class="d-flex align-center mt-2 mb-1">
|
||||||
|
<div class="text-subtitle-2 font-weight-bold">課程列表</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-plus" size="small" variant="text" @click="$emit('add-course')">
|
||||||
|
加入課程
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isMobile" class="d-flex flex-column ga-3">
|
||||||
|
<v-card v-for="(course, idx) in detailForm.courses" :key="idx" class="pa-3" variant="outlined">
|
||||||
|
<div class="d-flex align-center mb-3">
|
||||||
|
<div class="text-subtitle-2 font-weight-bold">課程 {{ idx + 1 }}</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="error" icon="mdi-delete" size="small" variant="text" @click="$emit('remove-course', idx)" />
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column ga-3">
|
||||||
|
<v-text-field v-model="course.name" density="compact" hide-details label="課程名稱" variant="outlined" />
|
||||||
|
<v-text-field v-model="course.code" density="compact" hide-details label="代碼" variant="outlined" />
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="course.credits"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
label="學分"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="course.score"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
label="分數"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
<div v-if="detailForm.courses.length === 0" class="text-center text-medium-emphasis py-4 border border-dashed rounded">
|
||||||
|
暫無課程,請點擊上方按鈕新增
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-table v-else class="border rounded">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th width="40"></th>
|
||||||
|
<th>課程資訊</th>
|
||||||
|
<th width="90">學分</th>
|
||||||
|
<th width="90">分數</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(course, idx) in detailForm.courses" :key="idx">
|
||||||
|
<td class="px-0 text-center">
|
||||||
|
<v-btn color="error" icon="mdi-delete" size="small" variant="text" @click="$emit('remove-course', idx)" />
|
||||||
|
</td>
|
||||||
|
<td class="py-2">
|
||||||
|
<v-text-field v-model="course.name" class="mb-1" density="compact" hide-details label="課程名稱" variant="underlined" />
|
||||||
|
<v-text-field v-model="course.code" density="compact" hide-details label="代碼" style="font-size: 0.85em" variant="underlined" />
|
||||||
|
</td>
|
||||||
|
<td class="align-top py-2">
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="course.credits"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="align-top py-2">
|
||||||
|
<v-text-field v-model.number="course.score" density="compact" hide-details type="number" variant="outlined" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="detailForm.courses.length === 0">
|
||||||
|
<td class="text-center text-medium-emphasis py-4" colspan="4">暫無課程,請點擊上方按鈕新增</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { SemesterRecord } from '@/stores/semesters'
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
selectedSemester: SemesterRecord | null
|
||||||
|
detailForm: SemesterRecord | null
|
||||||
|
isViewMode: boolean
|
||||||
|
isDetailEditing: boolean
|
||||||
|
isMobile: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(event: 'close'): void
|
||||||
|
(event: 'start-edit'): void
|
||||||
|
(event: 'delete', id: number): void
|
||||||
|
(event: 'cancel-edit'): void
|
||||||
|
(event: 'save-edit'): void
|
||||||
|
(event: 'add-course'): void
|
||||||
|
(event: 'remove-course', index: number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const totalCredits = computed(() =>
|
||||||
|
props.selectedSemester?.courses.reduce((sum, course) => sum + course.credits, 0) ?? 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const statsClass = computed(() =>
|
||||||
|
props.isMobile ? 'pa-3 d-flex flex-column ga-3 bg-surface' : 'pa-3 d-flex ga-3 bg-surface',
|
||||||
|
)
|
||||||
|
|
||||||
|
const statCardClass = computed(() => (props.isMobile ? '' : 'flex-grow-1'))
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { type ApiRequestError, normalizeError } from '@/services/error'
|
||||||
|
import { useSnackbarStore } from '@/stores/snackbar'
|
||||||
|
|
||||||
|
type ToastLevel = 'info' | 'warning' | 'error'
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
showErrorToast?: boolean
|
||||||
|
errorToastLevel?: (error: ApiRequestError) => ToastLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultToastLevel (error: ApiRequestError): ToastLevel {
|
||||||
|
if (typeof error.status === 'number' && error.status >= 500) return 'error'
|
||||||
|
return 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelToColor (level: ToastLevel): string {
|
||||||
|
if (level === 'error') return 'error'
|
||||||
|
if (level === 'warning') return 'warning'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApiCall <TResult, TArgs extends unknown[]>(action: (...args: TArgs) => Promise<TResult>,
|
||||||
|
options?: Options) {
|
||||||
|
const loading = ref(false)
|
||||||
|
const data = ref<TResult | null>(null)
|
||||||
|
const error = ref<ApiRequestError | null>(null)
|
||||||
|
|
||||||
|
const snackbar = useSnackbarStore()
|
||||||
|
|
||||||
|
const execute = async (...args: TArgs): Promise<TResult> => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const result = await action(...args)
|
||||||
|
data.value = result
|
||||||
|
return result
|
||||||
|
} catch (error_) {
|
||||||
|
const normalized = normalizeError(error_)
|
||||||
|
error.value = normalized
|
||||||
|
|
||||||
|
const showErrorToast = options?.showErrorToast ?? true
|
||||||
|
if (showErrorToast && normalized.name !== 'CanceledRequestError') {
|
||||||
|
const level = (options?.errorToastLevel ?? getDefaultToastLevel)(normalized)
|
||||||
|
snackbar.show({
|
||||||
|
message: normalized.message,
|
||||||
|
color: levelToColor(level),
|
||||||
|
timeout: 3000,
|
||||||
|
location: 'top right',
|
||||||
|
variant: 'flat',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw normalized
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeSafe = async (...args: TArgs): Promise<TResult | null> => {
|
||||||
|
try {
|
||||||
|
return await execute(...args)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
loading.value = false
|
||||||
|
data.value = null
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
execute,
|
||||||
|
executeSafe,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as SKAdminLayout } from '@/components/layouts/SKAdminLayout.vue'
|
||||||
|
export { default as SKEmptyLayout } from '@/components/layouts/SKEmptyLayout.vue'
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// src/router/guards.ts
|
||||||
|
import type { Router } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
function hasAll(required: string[], owned: string[]) {
|
||||||
|
return required.every((x) => owned.includes(x))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerGuards(router: Router) {
|
||||||
|
router.beforeEach(async (to) => {
|
||||||
|
const skipLogin = import.meta.env.VITE_SKIP_LOGIN === 'true'
|
||||||
|
if (skipLogin) {
|
||||||
|
if (to.name === 'login' || to.meta.guestOnly) {
|
||||||
|
return { name: 'home' }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
// Requires auth:未登入導去 login,附 redirect
|
||||||
|
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||||
|
if (to.name === 'login') return true
|
||||||
|
return { name: 'login', query: { redirect: to.fullPath } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role-based access control (RBAC)
|
||||||
|
if (to.meta.roles?.length && !hasAll(to.meta.roles, auth.roles)) {
|
||||||
|
return { name: 'forbidden' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeResolve(() => {
|
||||||
|
// 適合放:進頁前最後一步的「輕量」工作(例如開始進度條)
|
||||||
|
// NProgress.start() 之類
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
router.afterEach((to) => {
|
||||||
|
// 1) Document title
|
||||||
|
const base = 'Demo App'
|
||||||
|
document.title = to.meta.title ? `${to.meta.title} - ${base}` : base
|
||||||
|
|
||||||
|
// 2) 追蹤(Analytics)可放這裡:pageview
|
||||||
|
// 3) NProgress.done()
|
||||||
|
})
|
||||||
|
|
||||||
|
router.onError((err) => {
|
||||||
|
// Chunk load 失敗、動態 import 失敗等
|
||||||
|
// 可考慮:router.replace(current) 或導到錯誤頁
|
||||||
|
console.error('Router error:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
+44
-6
@@ -6,16 +6,54 @@
|
|||||||
|
|
||||||
// Composables
|
// Composables
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import Index from '@/pages/index.vue'
|
import { HTTP_ERROR_EVENT, type HttpErrorDetail } from '@/services/http-error'
|
||||||
|
import { registerGuards } from './guards'
|
||||||
|
import { routes } from './routes'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes,
|
||||||
{
|
scrollBehavior(to, _from, savedPosition) {
|
||||||
path: '/',
|
// Back/Forward 恢復滾動位置(Restore scroll position)
|
||||||
component: Index,
|
if (savedPosition) return savedPosition
|
||||||
|
// hash anchor
|
||||||
|
if (to.hash) return { el: to.hash, behavior: 'smooth' }
|
||||||
|
return { top: 0 }
|
||||||
},
|
},
|
||||||
],
|
})
|
||||||
|
|
||||||
|
registerGuards(router)
|
||||||
|
|
||||||
|
function getErrorRouteName (status?: number) {
|
||||||
|
switch (status) {
|
||||||
|
case 403: {
|
||||||
|
return 'forbidden'
|
||||||
|
}
|
||||||
|
case 404: {
|
||||||
|
return 'not-found'
|
||||||
|
}
|
||||||
|
case 500: {
|
||||||
|
return 'server-error'
|
||||||
|
}
|
||||||
|
case 503: {
|
||||||
|
return 'maintenance'
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return 'network-error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(HTTP_ERROR_EVENT, (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<HttpErrorDetail>).detail
|
||||||
|
const name = getErrorRouteName(detail?.status)
|
||||||
|
if (router.currentRoute.value.name === name) return
|
||||||
|
|
||||||
|
const message = detail?.message?.trim()
|
||||||
|
void router.replace({
|
||||||
|
name,
|
||||||
|
query: message ? { message } : undefined,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
export const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
component: () => import('@/views/Home.vue'),
|
||||||
|
meta: { layout: 'default', requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: () => import('@/views/Settings.vue'),
|
||||||
|
meta: { layout: 'default' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: () => import('@/views/Login.vue'),
|
||||||
|
meta: { layout: 'none', guestOnly: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/role-management',
|
||||||
|
name: 'role-management',
|
||||||
|
component: () => import('@/views/RoleManagement.vue'),
|
||||||
|
meta: { layout: 'default' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/menu-management',
|
||||||
|
name: 'menu-management',
|
||||||
|
component: () => import('@/views/MenuManagement.vue'),
|
||||||
|
meta: { layout: 'default' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/dept-management',
|
||||||
|
name: 'dept-management',
|
||||||
|
component: () => import('@/views/DeptManagement.vue'),
|
||||||
|
meta: { layout: 'default' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/analysis',
|
||||||
|
name: 'analysis',
|
||||||
|
component: () => import('@/views/Analysis.vue'),
|
||||||
|
meta: { layout: 'default' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/single-record-maintenance',
|
||||||
|
name: 'single-record-maintenance',
|
||||||
|
component: () => import('@/views/maint/SingleRecordMnt.vue'),
|
||||||
|
meta: { layout: 'default' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/master-detail-maintenance',
|
||||||
|
name: 'master-detail-maintenance-a',
|
||||||
|
component: () => import('@/views/maint/MasterDetailMnt.vue'),
|
||||||
|
meta: { layout: 'default' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/master-detail-maintenance-b',
|
||||||
|
name: 'master-detail-maintenance-b',
|
||||||
|
component: () => import('@/views/maint/MasterDetailMntB.vue'),
|
||||||
|
meta: { layout: 'default' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/master-detail-maintenance-c',
|
||||||
|
name: 'master-detail-maintenance-c',
|
||||||
|
component: () => import('@/views/maint/MasterDetailMntC.vue'),
|
||||||
|
meta: { layout: 'default' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/editable-grid-maintenance',
|
||||||
|
name: 'editable-grid-maintenance',
|
||||||
|
component: () => import('@/views/maint/EditableGridMnt.vue'),
|
||||||
|
meta: { layout: 'default' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
name: 'dashboard',
|
||||||
|
component: () => import('@/views/Dashboard.vue'),
|
||||||
|
meta: { layout: 'default' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:fncId([0-9A-Z]{5,6})',
|
||||||
|
name: 'fnc-page',
|
||||||
|
component: () => import('@/views/FncPage.vue'),
|
||||||
|
meta: { layout: 'default' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/403',
|
||||||
|
name: 'forbidden',
|
||||||
|
component: () => import('@/views/errors/Forbidden.vue'),
|
||||||
|
meta: { title: 'Forbidden', layout: 'none' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/500',
|
||||||
|
name: 'server-error',
|
||||||
|
component: () => import('@/views/errors/ServerError.vue'),
|
||||||
|
meta: { title: 'Server Error', layout: 'none' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/503',
|
||||||
|
name: 'service-unavailable',
|
||||||
|
component: () => import('@/views/errors/ServiceUnavailable.vue'),
|
||||||
|
meta: { title: 'Service Unavailable', layout: 'none' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/network',
|
||||||
|
name: 'network-error',
|
||||||
|
component: () => import('@/views/errors/NetworkError.vue'),
|
||||||
|
meta: { title: 'Network Error', layout: 'none' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/maintenance',
|
||||||
|
name: 'maintenance',
|
||||||
|
component: () => import('@/views/errors/Maintenance.vue'),
|
||||||
|
meta: { title: 'Maintenance', layout: 'none' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
name: 'not-found',
|
||||||
|
component: () => import('@/views/errors/NotFound.vue'),
|
||||||
|
meta: { title: 'Not Found', layout: 'none' },
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import axios, { type AxiosInstance } from 'axios'
|
||||||
|
import { setupInterceptors } from './interceptors'
|
||||||
|
|
||||||
|
// HTTP Client(Axios instance)
|
||||||
|
//
|
||||||
|
// 設計重點:
|
||||||
|
// - 透過單一 axios instance 統一管理 baseURL、timeout、headers 與攔截器
|
||||||
|
// - 預設 baseURL 使用 `/api`:搭配 Vite proxy 轉送到後端 dev server
|
||||||
|
// - 攔截器集中在 `interceptors.ts`,避免 client.ts 變得難維護
|
||||||
|
function createClient (): AxiosInstance {
|
||||||
|
const baseURL = import.meta.env.VITE_API_BASE_URL || '/service/api'
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL,
|
||||||
|
timeout: 10_000,
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
setupInterceptors(client)
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
export const httpClient = createClient()
|
||||||
|
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { ApiError } from '@/types/api'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
|
||||||
|
function isRecord (value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstString (value: unknown): string | undefined {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
return trimmed ? trimmed : undefined
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
const found = firstString(item)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isRecord(value)) {
|
||||||
|
const message = firstString(value.message)
|
||||||
|
if (message) return message
|
||||||
|
|
||||||
|
// 常見錯誤格式:{ error: '...' } / { error: { message: '...' } }
|
||||||
|
const errorValue = value.error
|
||||||
|
const errorMessage = firstString(errorValue)
|
||||||
|
if (errorMessage) return errorMessage
|
||||||
|
|
||||||
|
// RFC 7807 (problem+json): { title, detail }
|
||||||
|
const detail = firstString(value.detail)
|
||||||
|
if (detail) return detail
|
||||||
|
const title = firstString(value.title)
|
||||||
|
if (title) return title
|
||||||
|
|
||||||
|
// 有些後端用 msg
|
||||||
|
const msg = firstString(value.msg)
|
||||||
|
if (msg) return msg
|
||||||
|
|
||||||
|
// errors: Record<string, string[]>
|
||||||
|
const errors = value.errors
|
||||||
|
if (isRecord(errors)) {
|
||||||
|
for (const key of Object.keys(errors)) {
|
||||||
|
const found = firstString(errors[key])
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractErrorMessage (data: unknown): string | undefined {
|
||||||
|
return firstString(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 統一錯誤格式
|
||||||
|
//
|
||||||
|
// 設計重點:
|
||||||
|
// - 將 AxiosError 與非預期錯誤統一轉成 ApiRequestError
|
||||||
|
// - Store 只需要處理 message/code/status,不需理解 Axios 結構
|
||||||
|
// - 取消請求(AbortController)會轉成 CanceledRequestError
|
||||||
|
export class ApiRequestError extends Error {
|
||||||
|
code?: number
|
||||||
|
status?: number
|
||||||
|
errors?: ApiError['errors']
|
||||||
|
raw?: unknown
|
||||||
|
|
||||||
|
constructor(params: {
|
||||||
|
message: string
|
||||||
|
code?: number
|
||||||
|
status?: number
|
||||||
|
errors?: ApiError['errors']
|
||||||
|
raw?: unknown
|
||||||
|
}) {
|
||||||
|
super(params.message)
|
||||||
|
this.name = 'ApiRequestError'
|
||||||
|
this.code = params.code
|
||||||
|
this.status = params.status
|
||||||
|
this.errors = params.errors
|
||||||
|
this.raw = params.raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CanceledRequestError extends ApiRequestError {
|
||||||
|
constructor() {
|
||||||
|
super({ message: '請求已取消' })
|
||||||
|
this.name = 'CanceledRequestError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRequestCanceled (error: unknown): boolean {
|
||||||
|
if (isAxiosError(error)) {
|
||||||
|
return error.code === 'ERR_CANCELED'
|
||||||
|
}
|
||||||
|
return error instanceof DOMException && error.name === 'AbortError'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeError (error: unknown): ApiRequestError {
|
||||||
|
if (error instanceof ApiRequestError) {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRequestCanceled(error)) {
|
||||||
|
return new CanceledRequestError()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAxiosError(error)) {
|
||||||
|
const status = error.response?.status
|
||||||
|
const data = error.response?.data as unknown
|
||||||
|
const message = extractErrorMessage(data) || error.message || '請求失敗'
|
||||||
|
const apiError = isRecord(data) ? (data as Partial<ApiError>) : undefined
|
||||||
|
const code = apiError?.code ?? status
|
||||||
|
return new ApiRequestError({
|
||||||
|
message,
|
||||||
|
code,
|
||||||
|
status,
|
||||||
|
errors: apiError?.errors,
|
||||||
|
raw: error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return new ApiRequestError({ message: error.message, raw: error })
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ApiRequestError({ message: '未知錯誤', raw: error })
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// 全域 HTTP 錯誤事件(Playground 專用)
|
||||||
|
//
|
||||||
|
// 目的:
|
||||||
|
// - 避免 axios interceptor 直接 import router 造成耦合
|
||||||
|
// - 由 router 層或 App 層決定要導到哪個錯誤頁與顯示哪些訊息
|
||||||
|
|
||||||
|
export const HTTP_ERROR_EVENT = 'sk-playground:http-error'
|
||||||
|
|
||||||
|
export type HttpErrorDetail = {
|
||||||
|
status?: number
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let httpErrorEmitted = false
|
||||||
|
|
||||||
|
export function emitHttpError (detail: HttpErrorDetail) {
|
||||||
|
// 避免同一波大量錯誤觸發多次導頁
|
||||||
|
if (httpErrorEmitted) return
|
||||||
|
httpErrorEmitted = true
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent<HttpErrorDetail>(HTTP_ERROR_EVENT, { detail }))
|
||||||
|
|
||||||
|
// 下一個 event loop 再允許觸發(避免把 guard 永久鎖住)
|
||||||
|
setTimeout(() => {
|
||||||
|
httpErrorEmitted = false
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// 全域 HTTP Toast 事件(Playground 專用)
|
||||||
|
//
|
||||||
|
// 目的:
|
||||||
|
// - 讓 axios interceptor 能用「事件」通知 UI 顯示錯誤提示,不需直接依賴 Pinia
|
||||||
|
// - 預設只用於「非阻斷」錯誤(例如 500 / 網路中斷),避免導頁打斷使用者
|
||||||
|
|
||||||
|
export const HTTP_TOAST_EVENT = 'sk-playground:http-toast'
|
||||||
|
|
||||||
|
export type HttpToastLevel = 'info' | 'warning' | 'error'
|
||||||
|
|
||||||
|
export type HttpToastDetail = {
|
||||||
|
level: HttpToastLevel
|
||||||
|
message: string
|
||||||
|
dedupeKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastKey = ''
|
||||||
|
let lastAt = 0
|
||||||
|
|
||||||
|
export function emitHttpToast (detail: HttpToastDetail) {
|
||||||
|
const now = Date.now()
|
||||||
|
const key = detail.dedupeKey ?? `${detail.level}:${detail.message}`
|
||||||
|
|
||||||
|
// 500ms 內同樣訊息不重複噴(避免同頁多支 API 一起爆)
|
||||||
|
if (key === lastKey && now - lastAt < 500) return
|
||||||
|
lastKey = key
|
||||||
|
lastAt = now
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent<HttpToastDetail>(HTTP_TOAST_EVENT, { detail }))
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { type AxiosError, AxiosHeaders, type AxiosInstance } from 'axios'
|
||||||
|
import { extractErrorMessage, normalizeError } from './error'
|
||||||
|
import { emitHttpError } from './http-error'
|
||||||
|
import { emitHttpToast } from './http-toast'
|
||||||
|
import { emitForceLogout } from './session'
|
||||||
|
import { tokenService } from './token'
|
||||||
|
|
||||||
|
// Axios 攔截器
|
||||||
|
//
|
||||||
|
// 設計重點:
|
||||||
|
// - Request:自動注入 token(從 localStorage 讀取)
|
||||||
|
// - 使用 tokenService 作為單一來源,避免 interceptor 直接 import Pinia store 造成循環依賴
|
||||||
|
// store(auth) -> services(userApi) -> httpClient -> interceptors -> store(auth)
|
||||||
|
// - Response:統一處理 HTTP 錯誤(目前示範 401/403/500)
|
||||||
|
// - 使用 normalizeError 將錯誤轉成 ApiRequestError
|
||||||
|
//
|
||||||
|
// 注意:
|
||||||
|
// - Store 仍然是唯一負責「寫入/清除 token」的地方(login/logout)
|
||||||
|
// - Interceptor 只負責「讀取 token 並附加到 request」
|
||||||
|
export function setupInterceptors (client: AxiosInstance) {
|
||||||
|
// Request: 自動注入 token
|
||||||
|
client.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = tokenService.getToken()
|
||||||
|
const url = config.url ?? ''
|
||||||
|
const shouldAttachToken = !url.includes('/Auth/login')
|
||||||
|
|
||||||
|
if (token && shouldAttachToken) {
|
||||||
|
const headers = AxiosHeaders.from(config.headers ?? {})
|
||||||
|
headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
config.headers = headers
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Response: 統一錯誤處理
|
||||||
|
client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error: AxiosError) => {
|
||||||
|
const normalized = normalizeError(error)
|
||||||
|
|
||||||
|
// 取消請求不做全域錯誤導頁
|
||||||
|
if (error.code === 'ERR_CANCELED') {
|
||||||
|
return Promise.reject(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestUrl = error.config?.url ?? ''
|
||||||
|
const isLoginRequest = requestUrl.includes('/Auth/login')
|
||||||
|
const silentToast = Boolean(error.config?.meta?.silentToast)
|
||||||
|
|
||||||
|
// 統一處理 HTTP 狀態碼錯誤
|
||||||
|
const status = error.response?.status
|
||||||
|
switch (status) {
|
||||||
|
// 401 Unauthorized
|
||||||
|
case 401: {
|
||||||
|
// 不是所有 401 都代表「token 過期」:
|
||||||
|
// - 登入失敗通常也會是 401,但不應觸發全域登出流程
|
||||||
|
// 這裡以「是否帶 Authorization header」作為判斷依據
|
||||||
|
{
|
||||||
|
const url = error.config?.url ?? ''
|
||||||
|
if (url.includes('/Auth/login')) break
|
||||||
|
|
||||||
|
const requestHeaders = AxiosHeaders.from(error.config?.headers ?? {})
|
||||||
|
const hasAuthHeader = Boolean(requestHeaders.get('Authorization'))
|
||||||
|
if (hasAuthHeader) {
|
||||||
|
tokenService.clearToken()
|
||||||
|
const backendMessage = extractErrorMessage(error.response?.data)
|
||||||
|
emitForceLogout({ message: backendMessage })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// 403 Forbidden
|
||||||
|
case 403: {
|
||||||
|
emitHttpError({ status, message: normalized.message })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// 404 Not Found(API 端點不存在/被移除)
|
||||||
|
case 404: {
|
||||||
|
emitHttpError({ status, message: normalized.message })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// 500 Internal Server Error
|
||||||
|
case 500: {
|
||||||
|
// 500 通常是「單一 API 失敗」:交由呼叫端決定 UI(snackbar/區塊錯誤/重試)
|
||||||
|
// 避免同一頁多支 API 時,其中一支 500 就把整個頁面導走
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// 503 Service Unavailable
|
||||||
|
case 503: {
|
||||||
|
// 503 通常對使用者來說就是「系統維護/暫時無法使用」
|
||||||
|
emitHttpError({ status, message: normalized.message })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// 無 response status 時,多半是網路/跨網域/連線問題:
|
||||||
|
// 交由呼叫端決定 UI(snackbar/區塊錯誤/重試),避免全域導頁打斷使用者操作
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldToast =
|
||||||
|
!silentToast &&
|
||||||
|
!isLoginRequest &&
|
||||||
|
status !== 401 &&
|
||||||
|
status !== 403 &&
|
||||||
|
status !== 404 &&
|
||||||
|
status !== 503 &&
|
||||||
|
(status === 500 || !status || (typeof status === 'number' && status >= 500))
|
||||||
|
|
||||||
|
if (shouldToast) {
|
||||||
|
emitHttpToast({
|
||||||
|
level: status ? 'error' : 'warning',
|
||||||
|
message: normalized.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.reject(normalized)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { CaptchaResponse } from '@/types/api'
|
||||||
|
import { httpClient } from '../client'
|
||||||
|
|
||||||
|
export interface RequestOptions {
|
||||||
|
// 供 AbortController 取消請求使用
|
||||||
|
signal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
getCaptcha: () => httpClient.get<CaptchaResponse>('/Auth/get-captcha'),
|
||||||
|
login: (payload: FormData, options?: RequestOptions) =>
|
||||||
|
httpClient.post<unknown>('/Auth/login', payload, {
|
||||||
|
signal: options?.signal,
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { httpClient } from '../client'
|
||||||
|
|
||||||
|
export interface RequestOptions {
|
||||||
|
signal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuPayload {
|
||||||
|
userID: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuNode {
|
||||||
|
children?: MenuNode[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuOuterResponse {
|
||||||
|
data: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export const menuApi = {
|
||||||
|
getMenu: (payload: MenuPayload, options?: RequestOptions) =>
|
||||||
|
httpClient.post<MenuOuterResponse>('/Menu/GetMenu', payload, {
|
||||||
|
signal: options?.signal,
|
||||||
|
}),
|
||||||
|
getFavorite: (payload: MenuPayload, options?: RequestOptions) =>
|
||||||
|
httpClient.post<MenuOuterResponse>('/Menu/GetFavorite', payload, {
|
||||||
|
signal: options?.signal,
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// 全域 Session 事件(Playground 專用)
|
||||||
|
//
|
||||||
|
// 目的:
|
||||||
|
// - 避免 axios interceptor 直接 import Pinia store / router 造成循環依賴
|
||||||
|
// - 由 App.vue 在 UI 層統一處理登出流程(清狀態、導頁、提示訊息)
|
||||||
|
|
||||||
|
export const SESSION_FORCE_LOGOUT_EVENT = 'sk-playground:session-force-logout'
|
||||||
|
|
||||||
|
type ForceLogoutDetail = {
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let forceLogoutEmitted = false
|
||||||
|
|
||||||
|
export function emitForceLogout (detail: ForceLogoutDetail) {
|
||||||
|
// 避免同一波大量 401 觸發多次登出流程
|
||||||
|
if (forceLogoutEmitted) return
|
||||||
|
forceLogoutEmitted = true
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent(SESSION_FORCE_LOGOUT_EVENT, { detail }))
|
||||||
|
|
||||||
|
// 下一個 event loop 再允許觸發(避免把 guard 永久鎖住)
|
||||||
|
setTimeout(() => {
|
||||||
|
forceLogoutEmitted = false
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
// Token Service
|
||||||
|
//
|
||||||
|
// 設計重點:
|
||||||
|
// - 單一來源(Single Source of Truth):用 ref 維護 token,並同步 localStorage
|
||||||
|
// - Store 與 Interceptor 只透過此 service 讀寫 token,避免來源分裂
|
||||||
|
|
||||||
|
const storageKey = 'token'
|
||||||
|
const tokenRef = ref<string | null>(localStorage.getItem(storageKey))
|
||||||
|
|
||||||
|
export const tokenService = {
|
||||||
|
token: tokenRef,
|
||||||
|
getToken() {
|
||||||
|
return tokenRef.value
|
||||||
|
},
|
||||||
|
setToken(nextToken: string) {
|
||||||
|
tokenRef.value = nextToken
|
||||||
|
localStorage.setItem(storageKey, nextToken)
|
||||||
|
},
|
||||||
|
clearToken() {
|
||||||
|
tokenRef.value = null
|
||||||
|
localStorage.removeItem(storageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './stores/auth'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './stores/breadcrumbs'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './stores/favorites'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './stores/loginAnnouncements'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './stores/menu'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './stores/messages'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './stores/semesters'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './stores/snackbar'
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import type { CaptchaResponse, LoginPayload, User } from '@/types/api'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { normalizeError } from '@/services/error'
|
||||||
|
import { authApi } from '@/services/modules/auth'
|
||||||
|
import { tokenService } from '@/services/token'
|
||||||
|
import { useMenuStore } from '@/stores/menu'
|
||||||
|
|
||||||
|
// - 只在 store 管理登入狀態:user/token/loading/error
|
||||||
|
// - Component 不直接呼叫 API,避免狀態散落
|
||||||
|
// - token 單一來源:透過 tokenService 同步 ref + localStorage
|
||||||
|
// - store 負責寫入/清除 token(login/logout)
|
||||||
|
// - axios interceptor 只讀 tokenService
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
// State
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const token = tokenService.token
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const captcha = ref<CaptchaResponse | null>(null)
|
||||||
|
const captchaLoading = ref(false)
|
||||||
|
const captchaErrorMessage = ref<string | null>(null)
|
||||||
|
// 只針對 login 取消重複請求,避免競態與重複提交
|
||||||
|
const loginController = ref<AbortController | null>(null)
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
|
const roles = computed(() => (user.value?.role ? [user.value.role] : []))
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const getCaptcha = async () => {
|
||||||
|
captchaLoading.value = true
|
||||||
|
captchaErrorMessage.value = null
|
||||||
|
try {
|
||||||
|
const { data } = await authApi.getCaptcha()
|
||||||
|
captcha.value = data
|
||||||
|
return data
|
||||||
|
} catch (error_) {
|
||||||
|
const normalizedError = normalizeError(error_)
|
||||||
|
captcha.value = null
|
||||||
|
captchaErrorMessage.value = normalizedError.message
|
||||||
|
throw normalizedError
|
||||||
|
} finally {
|
||||||
|
captchaLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (payload: LoginPayload) => {
|
||||||
|
loginController.value?.abort()
|
||||||
|
loginController.value = new AbortController()
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!captcha.value?.dntCaptchaTextValue || !captcha.value?.dntCaptchaTokenValue) {
|
||||||
|
throw new Error('驗證碼資料缺失,請先刷新驗證碼')
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestPayload = {
|
||||||
|
UserID: payload.UserID,
|
||||||
|
Password: payload.Password,
|
||||||
|
DNTCaptchaInputText: payload.DNTCaptchaInputText,
|
||||||
|
DNTCaptchaText: captcha.value.dntCaptchaTextValue,
|
||||||
|
DNTCaptchaToken: captcha.value.dntCaptchaTokenValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('UserID', requestPayload.UserID)
|
||||||
|
formData.append('Password', requestPayload.Password)
|
||||||
|
formData.append('DNTCaptchaInputText', requestPayload.DNTCaptchaInputText)
|
||||||
|
formData.append('DNTCaptchaText', requestPayload.DNTCaptchaText)
|
||||||
|
formData.append('DNTCaptchaToken', requestPayload.DNTCaptchaToken)
|
||||||
|
|
||||||
|
const { data } = await authApi.login(formData, {
|
||||||
|
signal: loginController.value.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
const parseUser = (val: unknown): User | undefined => {
|
||||||
|
if (!val || typeof val !== 'object') return
|
||||||
|
const obj = val as Record<string, unknown>
|
||||||
|
const id = obj.id
|
||||||
|
const name = obj.name
|
||||||
|
const role = obj.role
|
||||||
|
if (typeof id !== 'string' || typeof name !== 'string' || typeof role !== 'string') return
|
||||||
|
return { id, name, role }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseLoginResult = (
|
||||||
|
raw: unknown
|
||||||
|
): {
|
||||||
|
accessToken?: string
|
||||||
|
tokenType?: string
|
||||||
|
expiresIn?: number
|
||||||
|
user?: User
|
||||||
|
message?: string
|
||||||
|
} => {
|
||||||
|
if (!raw || typeof raw !== 'object') return {}
|
||||||
|
|
||||||
|
const obj = raw as Record<string, unknown>
|
||||||
|
const accessToken = typeof obj.access_token === 'string' ? obj.access_token : undefined
|
||||||
|
const tokenType = typeof obj.token_type === 'string' ? obj.token_type : undefined
|
||||||
|
const expiresIn = typeof obj.expires_in === 'number' ? obj.expires_in : undefined
|
||||||
|
const user = parseUser(obj.user)
|
||||||
|
const message = typeof obj.message === 'string' ? obj.message : undefined
|
||||||
|
|
||||||
|
return { accessToken, tokenType, expiresIn, user, message }
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = parseLoginResult(data)
|
||||||
|
|
||||||
|
if (!result.accessToken) {
|
||||||
|
throw new Error(result.message || '登入回傳缺少 access_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
user.value = result.user ?? null
|
||||||
|
tokenService.setToken(result.accessToken)
|
||||||
|
} catch (error_) {
|
||||||
|
const normalizedError = normalizeError(error_)
|
||||||
|
if (normalizedError.name !== 'CanceledRequestError') {
|
||||||
|
error.value = normalizedError.message
|
||||||
|
}
|
||||||
|
throw normalizedError
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
loginController.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
user.value = null
|
||||||
|
tokenService.clearToken()
|
||||||
|
useMenuStore().clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCaptcha,
|
||||||
|
captcha,
|
||||||
|
captchaLoading,
|
||||||
|
captchaErrorMessage,
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
isAuthenticated,
|
||||||
|
roles,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type { LayoutMenuItem } from './menu'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
title: string
|
||||||
|
to?: string
|
||||||
|
disabled?: boolean
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BreadcrumbPayload {
|
||||||
|
path: string
|
||||||
|
menuItems: LayoutMenuItem[]
|
||||||
|
favoriteItems?: LayoutMenuItem[]
|
||||||
|
fallbackTitle?: string | null
|
||||||
|
homeLabel?: string
|
||||||
|
homeIcon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTrail (items: LayoutMenuItem[], targetPath: string): LayoutMenuItem[] | null {
|
||||||
|
const walk = (nodes: LayoutMenuItem[], trail: LayoutMenuItem[]): LayoutMenuItem[] | null => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
const nextTrail = [...trail, node]
|
||||||
|
if (node.path && node.path === targetPath) return nextTrail
|
||||||
|
if (node.subItems?.length) {
|
||||||
|
const found = walk(node.subItems, nextTrail)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return walk(items || [], [])
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBreadcrumbItems (trail: LayoutMenuItem[],
|
||||||
|
homeLabel: string,
|
||||||
|
homeIcon: string): BreadcrumbItem[] {
|
||||||
|
const isHomePath = (path?: string) => path === '/' || path === ''
|
||||||
|
const startsWithHome = trail.length > 0 && isHomePath(trail[0]?.path)
|
||||||
|
const crumbs: BreadcrumbItem[] = []
|
||||||
|
|
||||||
|
if (!startsWithHome) {
|
||||||
|
crumbs.push({
|
||||||
|
title: homeLabel,
|
||||||
|
to: '/',
|
||||||
|
icon: homeIcon,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, node] of trail.entries()) {
|
||||||
|
const isLast = index === trail.length - 1
|
||||||
|
crumbs.push({
|
||||||
|
title: node.title,
|
||||||
|
to: isLast ? undefined : node.path,
|
||||||
|
icon: startsWithHome && index === 0 ? homeIcon : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return crumbs
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBreadcrumbStore = defineStore('breadcrumbs', () => {
|
||||||
|
const items = ref<BreadcrumbItem[]>([])
|
||||||
|
const homeLabel = ref('首頁')
|
||||||
|
const homeIcon = ref('mdi-home')
|
||||||
|
|
||||||
|
const setBreadcrumbs = (payload: BreadcrumbPayload) => {
|
||||||
|
if (!payload?.path) return
|
||||||
|
|
||||||
|
homeLabel.value = payload.homeLabel ?? homeLabel.value
|
||||||
|
homeIcon.value = payload.homeIcon ?? homeIcon.value
|
||||||
|
|
||||||
|
const trailFromMenu = buildTrail(payload.menuItems || [], payload.path)
|
||||||
|
const trailFromFavorite = payload.favoriteItems?.length
|
||||||
|
? buildTrail(payload.favoriteItems, payload.path)
|
||||||
|
: null
|
||||||
|
const trail = trailFromMenu || trailFromFavorite
|
||||||
|
|
||||||
|
if (trail?.length) {
|
||||||
|
items.value = toBreadcrumbItems(trail, homeLabel.value, homeIcon.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.fallbackTitle && payload.fallbackTitle !== homeLabel.value) {
|
||||||
|
items.value = [
|
||||||
|
{
|
||||||
|
title: homeLabel.value,
|
||||||
|
to: '/',
|
||||||
|
icon: homeIcon.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: payload.fallbackTitle,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items.value = [
|
||||||
|
{
|
||||||
|
title: homeLabel.value,
|
||||||
|
to: '/',
|
||||||
|
icon: homeIcon.value,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
items.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const breadcrumbItems = computed(() => items.value)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
breadcrumbItems,
|
||||||
|
setBreadcrumbs,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type { LayoutMenuItem } from './menu'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
export interface FavoriteItem {
|
||||||
|
title: string
|
||||||
|
path: string
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageKey = 'sk_playground_user_favorites'
|
||||||
|
const favoritesBarStorageKey = 'sk_admin_favorites_bar_visible'
|
||||||
|
const breadcrumbBarStorageKey = 'sk_admin_breadcrumb_bar_visible'
|
||||||
|
|
||||||
|
function readFavorites (): FavoriteItem[] {
|
||||||
|
if (typeof window === 'undefined') return []
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(storageKey)
|
||||||
|
if (!raw) return []
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return Array.isArray(parsed) ? (parsed as FavoriteItem[]) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFavorites (items: FavoriteItem[]) {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey, JSON.stringify(items))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFavoritesStore = defineStore('favorites', () => {
|
||||||
|
const items = ref<FavoriteItem[]>(readFavorites())
|
||||||
|
const favoritesBarVisible = ref(true)
|
||||||
|
const breadcrumbBarVisible = ref(true)
|
||||||
|
|
||||||
|
const loadFavoritesBarVisible = () => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const stored = window.localStorage.getItem(favoritesBarStorageKey)
|
||||||
|
if (stored === null) return
|
||||||
|
favoritesBarVisible.value = stored === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistFavoritesBarVisible = () => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.localStorage.setItem(favoritesBarStorageKey, favoritesBarVisible.value ? '1' : '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadBreadcrumbBarVisible = () => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const stored = window.localStorage.getItem(breadcrumbBarStorageKey)
|
||||||
|
if (stored === null) return
|
||||||
|
breadcrumbBarVisible.value = stored === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistBreadcrumbBarVisible = () => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.localStorage.setItem(breadcrumbBarStorageKey, breadcrumbBarVisible.value ? '1' : '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
const add = (item: FavoriteItem) => {
|
||||||
|
if (!item?.path) return
|
||||||
|
if (items.value.some((x) => x.path === item.path)) return
|
||||||
|
items.value = [...items.value, item]
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = (path: string) => {
|
||||||
|
if (!path) return
|
||||||
|
items.value = items.value.filter((x) => x.path !== path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = (item: FavoriteItem) => {
|
||||||
|
if (!item?.path) return
|
||||||
|
const exists = items.value.some((x) => x.path === item.path)
|
||||||
|
if (exists) remove(item.path)
|
||||||
|
else add(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFavorite = (path: string) => {
|
||||||
|
if (!path) return false
|
||||||
|
return items.value.some((x) => x.path === path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutItems = computed<LayoutMenuItem[]>(() =>
|
||||||
|
items.value.map((item) => ({
|
||||||
|
title: item.title,
|
||||||
|
path: item.path,
|
||||||
|
icon: item.icon,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
items,
|
||||||
|
(val) => {
|
||||||
|
writeFavorites(val)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const setFavoritesBarVisible = (value: boolean) => {
|
||||||
|
favoritesBarVisible.value = value
|
||||||
|
persistFavoritesBarVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleFavoritesBarVisible = (nextValue?: boolean) => {
|
||||||
|
if (typeof nextValue === 'boolean') {
|
||||||
|
setFavoritesBarVisible(nextValue)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFavoritesBarVisible(!favoritesBarVisible.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFavoritesBarVisible()
|
||||||
|
loadBreadcrumbBarVisible()
|
||||||
|
|
||||||
|
const setBreadcrumbBarVisible = (value: boolean) => {
|
||||||
|
breadcrumbBarVisible.value = value
|
||||||
|
persistBreadcrumbBarVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleBreadcrumbBarVisible = (nextValue?: boolean) => {
|
||||||
|
if (typeof nextValue === 'boolean') {
|
||||||
|
setBreadcrumbBarVisible(nextValue)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBreadcrumbBarVisible(!breadcrumbBarVisible.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
layoutItems,
|
||||||
|
add,
|
||||||
|
remove,
|
||||||
|
toggle,
|
||||||
|
isFavorite,
|
||||||
|
favoritesBarVisible,
|
||||||
|
setFavoritesBarVisible,
|
||||||
|
toggleFavoritesBarVisible,
|
||||||
|
breadcrumbBarVisible,
|
||||||
|
setBreadcrumbBarVisible,
|
||||||
|
toggleBreadcrumbBarVisible,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
export interface LoginAnnouncementItem {
|
||||||
|
id: string | number
|
||||||
|
date: string
|
||||||
|
school: string
|
||||||
|
title: string
|
||||||
|
tab?: string
|
||||||
|
detail: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginAnnouncementListItem {
|
||||||
|
id: string | number
|
||||||
|
date: string
|
||||||
|
school: string
|
||||||
|
title: string
|
||||||
|
tab?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginMobileAnnouncementItem {
|
||||||
|
id: string | number
|
||||||
|
content: string
|
||||||
|
title?: string
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageKey = 'sk_playground_login_announcements'
|
||||||
|
|
||||||
|
const defaultItems: LoginAnnouncementItem[] = [
|
||||||
|
{
|
||||||
|
id: 'announcement-1',
|
||||||
|
date: '2024-03-19',
|
||||||
|
school: '市立實踐國中',
|
||||||
|
title: '臺北市立實踐國中徵求113學年度教學支援工作人員',
|
||||||
|
tab: 'junior',
|
||||||
|
detail: '公告內容:本校辦理本土語教學支援工作人員甄選,請於期限內完成報名與資料繳交。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'announcement-2',
|
||||||
|
date: '2023-12-12',
|
||||||
|
school: '市立華江高中',
|
||||||
|
title: '臺北市立華江高級中學112學年度第二學期本土語教學支援人員甄選',
|
||||||
|
tab: 'senior',
|
||||||
|
detail: '公告內容:甄選包含書面審查與面試,相關時間地點請參閱簡章附件。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'announcement-3',
|
||||||
|
date: '2023-12-05',
|
||||||
|
school: '市立麗山高中',
|
||||||
|
title: '內湖區麗山高中誠徵閩南語教支人員數名',
|
||||||
|
tab: 'senior',
|
||||||
|
detail: '公告內容:需具備相關教學經驗,錄取後依課務需求排課。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'announcement-4',
|
||||||
|
date: '2023-11-28',
|
||||||
|
school: '市立永吉國中',
|
||||||
|
title: '公告本市學校本土語教學支援人員報名資訊',
|
||||||
|
tab: 'junior',
|
||||||
|
detail: '公告內容:統一受理報名,請依公告流程檢附文件並完成線上登錄。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'announcement-5',
|
||||||
|
date: '2023-11-21',
|
||||||
|
school: '市立百齡高中',
|
||||||
|
title: '112學年度本土語文教學支援工作人員甄選簡章',
|
||||||
|
tab: 'senior',
|
||||||
|
detail: '公告內容:簡章含資格條件、甄選方式、成績計算與錄取標準。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'announcement-6',
|
||||||
|
date: '2023-11-10',
|
||||||
|
school: '市立成德國中',
|
||||||
|
title: '本土語教學支援工作人員甄選(第二次)',
|
||||||
|
tab: 'junior',
|
||||||
|
detail: '公告內容:第二次甄選開放補件,報名截止日以公告為準。',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function readItems (): LoginAnnouncementItem[] {
|
||||||
|
if (typeof window === 'undefined') return defaultItems
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(storageKey)
|
||||||
|
if (!raw) return defaultItems
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return Array.isArray(parsed) ? (parsed as LoginAnnouncementItem[]) : defaultItems
|
||||||
|
} catch {
|
||||||
|
return defaultItems
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeItems (items: LoginAnnouncementItem[]) {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey, JSON.stringify(items))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockFetchMobileAnnouncementsApi (): Promise<LoginMobileAnnouncementItem[]> {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'mobile-announcement-1',
|
||||||
|
content: '系統正常運行中',
|
||||||
|
title: '系統公告',
|
||||||
|
createdAt: '2026-02-11',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLoginAnnouncementsStore = defineStore('loginAnnouncements', () => {
|
||||||
|
const items = ref<LoginAnnouncementItem[]>(readItems())
|
||||||
|
const selectedId = ref<string | number | null>(null)
|
||||||
|
const mobileAnnouncements = ref<LoginMobileAnnouncementItem[]>([])
|
||||||
|
|
||||||
|
const listItems = computed<LoginAnnouncementListItem[]>(() =>
|
||||||
|
items.value.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
date: item.date,
|
||||||
|
school: item.school,
|
||||||
|
title: item.title,
|
||||||
|
tab: item.tab,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const boardConfig = computed(() => ({
|
||||||
|
title: '學校公告區',
|
||||||
|
tabs: [
|
||||||
|
{ label: '全部', value: '__all__' },
|
||||||
|
{ label: '國中', value: 'junior' },
|
||||||
|
{ label: '高中', value: 'senior' },
|
||||||
|
],
|
||||||
|
items: listItems.value,
|
||||||
|
systemAnnouncements: mobileAnnouncements.value,
|
||||||
|
itemsPerPage: 5,
|
||||||
|
dateHeader: '公告時間',
|
||||||
|
schoolHeader: '公告學校',
|
||||||
|
titleHeader: '公告標題',
|
||||||
|
paginationLabel: '總筆數:',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const selectedAnnouncement = computed(() => {
|
||||||
|
if (selectedId.value === null) return null
|
||||||
|
return items.value.find((item) => item.id === selectedId.value) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedAnnouncementDetail = computed(() => {
|
||||||
|
return selectedAnnouncement.value?.detail ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const mobileAnnouncementConfig = computed(() => ({
|
||||||
|
items: mobileAnnouncements.value,
|
||||||
|
show: mobileAnnouncements.value.length > 0,
|
||||||
|
viewAllText: '查看全部',
|
||||||
|
listTitle: '系統公告',
|
||||||
|
closeText: '關閉',
|
||||||
|
emptyText: '目前沒有公告',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const hydrate = () => {
|
||||||
|
items.value = readItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaceAll = (nextItems: LoginAnnouncementItem[]) => {
|
||||||
|
items.value = Array.isArray(nextItems) ? nextItems : []
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectById = (id: string | number) => {
|
||||||
|
selectedId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
selectedId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMobileAnnouncements = async () => {
|
||||||
|
const result = await mockFetchMobileAnnouncementsApi()
|
||||||
|
mobileAnnouncements.value = Array.isArray(result) ? result : []
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMobileAnnouncement = async () => {
|
||||||
|
await fetchMobileAnnouncements()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
items,
|
||||||
|
(val) => {
|
||||||
|
writeItems(val)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
listItems,
|
||||||
|
boardConfig,
|
||||||
|
mobileAnnouncementConfig,
|
||||||
|
selectedAnnouncement,
|
||||||
|
selectedAnnouncementDetail,
|
||||||
|
hydrate,
|
||||||
|
replaceAll,
|
||||||
|
selectById,
|
||||||
|
clearSelection,
|
||||||
|
fetchMobileAnnouncements,
|
||||||
|
fetchMobileAnnouncement,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { normalizeError } from '@/services/error'
|
||||||
|
import { menuApi, type MenuNode } from '@/services/modules/menu'
|
||||||
|
|
||||||
|
export interface LayoutMenuItem {
|
||||||
|
title: string
|
||||||
|
path?: string
|
||||||
|
navigable?: boolean
|
||||||
|
subItems?: LayoutMenuItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMenuStore = defineStore('menu', () => {
|
||||||
|
const menu = ref<MenuNode[]>([])
|
||||||
|
const favorite = ref<MenuNode[]>([])
|
||||||
|
const isRail = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const menuStorageKey = 'sk_playground_menu'
|
||||||
|
const favoriteStorageKey = 'sk_playground_favorite'
|
||||||
|
const isRailStorageKey = 'sk_playground_is_rail'
|
||||||
|
|
||||||
|
const readNodes = (key: string): MenuNode[] => {
|
||||||
|
if (typeof window === 'undefined') return []
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key)
|
||||||
|
if (!raw) return []
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return Array.isArray(parsed) ? (parsed as MenuNode[]) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readBoolean = (key: string, defaultValue = false): boolean => {
|
||||||
|
if (typeof window === 'undefined') return defaultValue
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key)
|
||||||
|
return raw === null ? defaultValue : raw === 'true'
|
||||||
|
} catch {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeValue = (key: string, value: any) => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, String(value))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeNodes = (key: string, nodes: MenuNode[]) => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(nodes))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeValue = (key: string) => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(key)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydrate = () => {
|
||||||
|
menu.value = readNodes(menuStorageKey)
|
||||||
|
favorite.value = readNodes(favoriteStorageKey)
|
||||||
|
isRail.value = readBoolean(isRailStorageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrate()
|
||||||
|
|
||||||
|
const toLayoutMenuItems = (nodes: MenuNode[]): LayoutMenuItem[] => {
|
||||||
|
const getString = (node: MenuNode, key: string): string | undefined => {
|
||||||
|
const v = node?.[key]
|
||||||
|
return typeof v === 'string' ? v : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const getChildren = (node: MenuNode): MenuNode[] => {
|
||||||
|
return Array.isArray(node?.children) ? (node.children as MenuNode[]) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
.map((mdl) => {
|
||||||
|
const mdlTitle = getString(mdl, 'mdl_name') ?? ''
|
||||||
|
const untItems = getChildren(mdl)
|
||||||
|
.map((unt) => {
|
||||||
|
const untTitle = getString(unt, 'unt_name') ?? ''
|
||||||
|
const fncItems = getChildren(unt)
|
||||||
|
.map((fnc) => {
|
||||||
|
const fncTitle = getString(fnc, 'fnc_name') ?? ''
|
||||||
|
const fncId = getString(fnc, 'fnc_id')
|
||||||
|
return {
|
||||||
|
title: fncTitle,
|
||||||
|
path: fncId ? `/${fncId}` : undefined,
|
||||||
|
} satisfies LayoutMenuItem
|
||||||
|
})
|
||||||
|
.filter((x) => x.title)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: untTitle,
|
||||||
|
navigable: false,
|
||||||
|
subItems: fncItems,
|
||||||
|
} satisfies LayoutMenuItem
|
||||||
|
})
|
||||||
|
.filter((x) => x.title)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: mdlTitle,
|
||||||
|
navigable: false,
|
||||||
|
subItems: untItems,
|
||||||
|
} satisfies LayoutMenuItem
|
||||||
|
})
|
||||||
|
.filter((x) => x.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toFavoriteLayoutMenuItems = (nodes: MenuNode[]): LayoutMenuItem[] => {
|
||||||
|
const getString = (node: MenuNode, key: string): string | undefined => {
|
||||||
|
const v = node?.[key]
|
||||||
|
return typeof v === 'string' ? v : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const getChildren = (node: MenuNode): MenuNode[] => {
|
||||||
|
return Array.isArray(node?.children) ? (node.children as MenuNode[]) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
.map((unt) => {
|
||||||
|
const untTitle = getString(unt, 'unt_name') ?? ''
|
||||||
|
const fncItems = getChildren(unt)
|
||||||
|
.map((fnc) => {
|
||||||
|
const fncTitle = getString(fnc, 'fnc_name') ?? ''
|
||||||
|
const fncId = getString(fnc, 'fnc_id')
|
||||||
|
return {
|
||||||
|
title: fncTitle,
|
||||||
|
path: fncId ? `/${fncId}` : undefined,
|
||||||
|
} satisfies LayoutMenuItem
|
||||||
|
})
|
||||||
|
.filter((x) => x.title)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: untTitle,
|
||||||
|
navigable: false,
|
||||||
|
subItems: fncItems,
|
||||||
|
} satisfies LayoutMenuItem
|
||||||
|
})
|
||||||
|
.filter((x) => x.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItems = computed<LayoutMenuItem[]>(() => toLayoutMenuItems(menu.value))
|
||||||
|
const favoriteItems = computed<LayoutMenuItem[]>(() => toFavoriteLayoutMenuItems(favorite.value))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
menu,
|
||||||
|
(val) => {
|
||||||
|
writeNodes(menuStorageKey, val)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
favorite,
|
||||||
|
(val) => {
|
||||||
|
writeNodes(favoriteStorageKey, val)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
isRail,
|
||||||
|
(val) => {
|
||||||
|
writeValue(isRailStorageKey, val)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
menu.value = []
|
||||||
|
favorite.value = []
|
||||||
|
isRail.value = false
|
||||||
|
error.value = null
|
||||||
|
removeValue(menuStorageKey)
|
||||||
|
removeValue(favoriteStorageKey)
|
||||||
|
removeValue(isRailStorageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMenu = async (id: string) => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const res = await menuApi.getMenu({ userID: id })
|
||||||
|
menu.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
|
||||||
|
} catch (error_) {
|
||||||
|
const normalizedError = normalizeError(error_)
|
||||||
|
if (normalizedError.name !== 'CanceledRequestError') {
|
||||||
|
error.value = normalizedError.message
|
||||||
|
}
|
||||||
|
throw normalizedError
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFavorite = async (id: string) => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const res = await menuApi.getFavorite({ userID: id })
|
||||||
|
favorite.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
|
||||||
|
} catch (error_) {
|
||||||
|
const normalizedError = normalizeError(error_)
|
||||||
|
if (normalizedError.name !== 'CanceledRequestError') {
|
||||||
|
error.value = normalizedError.message
|
||||||
|
}
|
||||||
|
throw normalizedError
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
menu,
|
||||||
|
favorite,
|
||||||
|
isRail,
|
||||||
|
menuItems,
|
||||||
|
favoriteItems,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
hydrate,
|
||||||
|
clear,
|
||||||
|
getMenu,
|
||||||
|
getFavorite,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
export const useMessageStore = defineStore('messages', () => {
|
||||||
|
const openState = ref(false)
|
||||||
|
|
||||||
|
// 開啟訊息中心 Dialog
|
||||||
|
const open = () => {
|
||||||
|
openState.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 關閉訊息中心 Dialog
|
||||||
|
const close = () => {
|
||||||
|
openState.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提供 v-model 綁定用的 computed
|
||||||
|
const isOpen = computed({
|
||||||
|
get: () => openState.value,
|
||||||
|
set: (value) => {
|
||||||
|
openState.value = value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export interface CourseRecord {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
credits: number
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SemesterRecord {
|
||||||
|
id: number
|
||||||
|
studentId: number
|
||||||
|
semesterName: string
|
||||||
|
courses: CourseRecord[]
|
||||||
|
rank: number
|
||||||
|
average: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const seedSemesters: SemesterRecord[] = []
|
||||||
|
|
||||||
|
// Helper to generate random score
|
||||||
|
const randomScore = (min = 60, max = 99) => Math.floor(Math.random() * (max - min + 1)) + min
|
||||||
|
|
||||||
|
// Helper to generate mock semesters for a student
|
||||||
|
export function generateMockSemesters (studentId: number) {
|
||||||
|
const semesters = [
|
||||||
|
{ name: '111 學年度第 1 學期', baseId: 1000 },
|
||||||
|
{ name: '111 學年度第 2 學期', baseId: 2000 },
|
||||||
|
{ name: '112 學年度第 1 學期', baseId: 3000 },
|
||||||
|
{ name: '112 學年度第 2 學期', baseId: 4000 },
|
||||||
|
{ name: '113 學年度第 1 學期', baseId: 5000 },
|
||||||
|
{ name: '113 學年度第 2 學期', baseId: 6000 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const subjects = [
|
||||||
|
{ name: '資料結構', credits: 3 },
|
||||||
|
{ name: '演算法', credits: 3 },
|
||||||
|
{ name: '作業系統', credits: 3 },
|
||||||
|
{ name: '計算機組織', credits: 3 },
|
||||||
|
{ name: '線性代數', credits: 3 },
|
||||||
|
{ name: '機率與統計', credits: 3 },
|
||||||
|
{ name: '資料庫系統', credits: 3 },
|
||||||
|
{ name: '人工智慧導論', credits: 3 },
|
||||||
|
{ name: '網頁程式設計', credits: 3 },
|
||||||
|
{ name: '計算機網路', credits: 3 },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Assign 5-6 semesters per student
|
||||||
|
const count = 5 + (studentId % 2)
|
||||||
|
const result: SemesterRecord[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const sem = semesters[i]
|
||||||
|
if (!sem) continue
|
||||||
|
// Pick 8-10 random courses
|
||||||
|
const courseCount = 8 + (studentId % 3)
|
||||||
|
const courses: CourseRecord[] = []
|
||||||
|
const usedSubjects = new Set<number>()
|
||||||
|
|
||||||
|
let totalScore = 0
|
||||||
|
let totalCredits = 0
|
||||||
|
|
||||||
|
while (courses.length < courseCount) {
|
||||||
|
const idx = Math.floor(Math.random() * subjects.length)
|
||||||
|
if (usedSubjects.has(idx)) continue
|
||||||
|
usedSubjects.add(idx)
|
||||||
|
|
||||||
|
const score = randomScore()
|
||||||
|
const subject = subjects[idx]
|
||||||
|
if (!subject) continue
|
||||||
|
|
||||||
|
courses.push({
|
||||||
|
code: `CS${1000 + idx}`,
|
||||||
|
name: subject.name,
|
||||||
|
credits: subject.credits,
|
||||||
|
score
|
||||||
|
})
|
||||||
|
|
||||||
|
totalScore += score * subject.credits
|
||||||
|
totalCredits += subject.credits
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: sem.baseId + studentId,
|
||||||
|
studentId,
|
||||||
|
semesterName: sem.name,
|
||||||
|
courses,
|
||||||
|
rank: Math.floor(Math.random() * 20) + 1,
|
||||||
|
average: Number((totalScore / totalCredits).toFixed(2))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate for initial seed students (assuming IDs 1-20)
|
||||||
|
for (let i = 1; i <= 20; i++) {
|
||||||
|
seedSemesters.push(...generateMockSemesters(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSemesterStore = defineStore('semesters', () => {
|
||||||
|
// State
|
||||||
|
const semesters = ref<SemesterRecord[]>([...seedSemesters])
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const getStudentSemesters = (studentId: number) => {
|
||||||
|
return semesters.value.filter(s => s.studentId === studentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateForStudent = (studentId: number) => {
|
||||||
|
const newSemesters = generateMockSemesters(studentId)
|
||||||
|
semesters.value.push(...newSemesters)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSemester = (studentId: number) => {
|
||||||
|
const newId = Math.max(...semesters.value.map(s => s.id), 0) + 1
|
||||||
|
const newSemester: SemesterRecord = {
|
||||||
|
id: newId,
|
||||||
|
studentId,
|
||||||
|
semesterName: '新學期',
|
||||||
|
courses: [],
|
||||||
|
rank: 0,
|
||||||
|
average: 0
|
||||||
|
}
|
||||||
|
semesters.value.push(newSemester)
|
||||||
|
return newSemester
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSemester = (id: number, payload: Partial<SemesterRecord>) => {
|
||||||
|
const index = semesters.value.findIndex(s => s.id === id)
|
||||||
|
if (index === -1) return
|
||||||
|
const current = semesters.value[index]
|
||||||
|
if (!current) return
|
||||||
|
|
||||||
|
// Recalculate average if courses are updated
|
||||||
|
if (payload.courses) {
|
||||||
|
const totalScore = payload.courses.reduce((sum, c) => sum + (c.score * c.credits), 0)
|
||||||
|
const totalCredits = payload.courses.reduce((sum, c) => sum + c.credits, 0)
|
||||||
|
payload.average = totalCredits > 0 ? Number((totalScore / totalCredits).toFixed(2)) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(current, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeSemester = (id: number) => {
|
||||||
|
const index = semesters.value.findIndex(s => s.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
semesters.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeByStudentId = (studentId: number) => {
|
||||||
|
semesters.value = semesters.value.filter(s => s.studentId !== studentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
semesters,
|
||||||
|
getStudentSemesters,
|
||||||
|
generateForStudent,
|
||||||
|
addSemester,
|
||||||
|
updateSemester,
|
||||||
|
removeSemester,
|
||||||
|
removeByStudentId
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
type SnackbarColor = string
|
||||||
|
type SnackbarVariant = 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'
|
||||||
|
|
||||||
|
type SnackbarLocation = string
|
||||||
|
|
||||||
|
interface ShowOptions {
|
||||||
|
message: string
|
||||||
|
color?: SnackbarColor
|
||||||
|
timeout?: number
|
||||||
|
location?: SnackbarLocation
|
||||||
|
variant?: SnackbarVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSnackbarStore = defineStore('snackbar', () => {
|
||||||
|
const visible = ref(false)
|
||||||
|
const message = ref('')
|
||||||
|
const color = ref<SnackbarColor>('success')
|
||||||
|
const timeout = ref(2000)
|
||||||
|
const location = ref<SnackbarLocation>('top right')
|
||||||
|
const variant = ref<SnackbarVariant>('flat')
|
||||||
|
|
||||||
|
const show = (options: ShowOptions) => {
|
||||||
|
message.value = options.message
|
||||||
|
color.value = options.color ?? 'success'
|
||||||
|
timeout.value = options.timeout ?? 2000
|
||||||
|
location.value = options.location ?? 'top right'
|
||||||
|
variant.value = options.variant ?? 'flat'
|
||||||
|
|
||||||
|
visible.value = false
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
visible.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
visible,
|
||||||
|
message,
|
||||||
|
color,
|
||||||
|
timeout,
|
||||||
|
location,
|
||||||
|
variant,
|
||||||
|
show,
|
||||||
|
hide,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export interface StudentRecord {
|
||||||
|
id: number
|
||||||
|
studentId: string
|
||||||
|
name: string
|
||||||
|
department: string
|
||||||
|
grade: number
|
||||||
|
enrollYear: number
|
||||||
|
credits: number
|
||||||
|
advisor: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const seedStudents: StudentRecord[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
studentId: 'S2024001',
|
||||||
|
name: '王小明',
|
||||||
|
department: '資訊工程',
|
||||||
|
grade: 1,
|
||||||
|
enrollYear: 2024,
|
||||||
|
credits: 18,
|
||||||
|
advisor: '林育成',
|
||||||
|
email: 'ming.wang@school.edu',
|
||||||
|
phone: '02-2345-1001',
|
||||||
|
status: '在學',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
studentId: 'S2023017',
|
||||||
|
name: '陳怡君',
|
||||||
|
department: '企業管理',
|
||||||
|
grade: 2,
|
||||||
|
enrollYear: 2023,
|
||||||
|
credits: 36,
|
||||||
|
advisor: '許雅婷',
|
||||||
|
email: 'yijun.chen@school.edu',
|
||||||
|
phone: '02-2345-1002',
|
||||||
|
status: '在學',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
studentId: 'S2022008',
|
||||||
|
name: '林冠宇',
|
||||||
|
department: '財務金融',
|
||||||
|
grade: 3,
|
||||||
|
enrollYear: 2022,
|
||||||
|
credits: 64,
|
||||||
|
advisor: '張國華',
|
||||||
|
email: 'kuanyu.lin@school.edu',
|
||||||
|
phone: '02-2345-1003',
|
||||||
|
status: '休學',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
studentId: 'S2021022',
|
||||||
|
name: '郭雅婷',
|
||||||
|
department: '視覺設計',
|
||||||
|
grade: 4,
|
||||||
|
enrollYear: 2021,
|
||||||
|
credits: 92,
|
||||||
|
advisor: '蔡怡芳',
|
||||||
|
email: 'yating.kuo@school.edu',
|
||||||
|
phone: '02-2345-1004',
|
||||||
|
status: '在學',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
studentId: 'S2019013',
|
||||||
|
name: '張柏翰',
|
||||||
|
department: '應用外語',
|
||||||
|
grade: 5,
|
||||||
|
enrollYear: 2019,
|
||||||
|
credits: 28,
|
||||||
|
advisor: '吳佳玲',
|
||||||
|
email: 'bohan.chang@school.edu',
|
||||||
|
phone: '02-2345-1005',
|
||||||
|
status: '畢業',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
studentId: 'S2024024',
|
||||||
|
name: '李詩涵',
|
||||||
|
department: '視覺設計',
|
||||||
|
grade: 1,
|
||||||
|
enrollYear: 2024,
|
||||||
|
credits: 16,
|
||||||
|
advisor: '蔡怡芳',
|
||||||
|
email: 'shihan.li@school.edu',
|
||||||
|
phone: '02-2345-1006',
|
||||||
|
status: '在學',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
studentId: 'S2023044',
|
||||||
|
name: '黃俊豪',
|
||||||
|
department: '資訊工程',
|
||||||
|
grade: 2,
|
||||||
|
enrollYear: 2023,
|
||||||
|
credits: 40,
|
||||||
|
advisor: '林育成',
|
||||||
|
email: 'junhao.huang@school.edu',
|
||||||
|
phone: '02-2345-1007',
|
||||||
|
status: '在學',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
studentId: 'S2022066',
|
||||||
|
name: '周佳穎',
|
||||||
|
department: '企業管理',
|
||||||
|
grade: 3,
|
||||||
|
enrollYear: 2022,
|
||||||
|
credits: 58,
|
||||||
|
advisor: '許雅婷',
|
||||||
|
email: 'jiaying.chou@school.edu',
|
||||||
|
phone: '02-2345-1008',
|
||||||
|
status: '在學',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
studentId: 'S2021088',
|
||||||
|
name: '許景皓',
|
||||||
|
department: '財務金融',
|
||||||
|
grade: 4,
|
||||||
|
enrollYear: 2021,
|
||||||
|
credits: 88,
|
||||||
|
advisor: '張國華',
|
||||||
|
email: 'jinghao.hsu@school.edu',
|
||||||
|
phone: '02-2345-1009',
|
||||||
|
status: '在學',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
studentId: 'S2020019',
|
||||||
|
name: '鄭婉如',
|
||||||
|
department: '應用外語',
|
||||||
|
grade: 5,
|
||||||
|
enrollYear: 2020,
|
||||||
|
credits: 22,
|
||||||
|
advisor: '吳佳玲',
|
||||||
|
email: 'wanru.cheng@school.edu',
|
||||||
|
phone: '02-2345-1010',
|
||||||
|
status: '在學',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
studentId: 'S2024031',
|
||||||
|
name: '謝承翰',
|
||||||
|
department: '資訊工程',
|
||||||
|
grade: 1,
|
||||||
|
enrollYear: 2024,
|
||||||
|
credits: 20,
|
||||||
|
advisor: '林育成',
|
||||||
|
email: 'chenghan.hsieh@school.edu',
|
||||||
|
phone: '02-2345-1011',
|
||||||
|
status: '在學',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
studentId: 'S2023055',
|
||||||
|
name: '邱雅雯',
|
||||||
|
department: '視覺設計',
|
||||||
|
grade: 2,
|
||||||
|
enrollYear: 2023,
|
||||||
|
credits: 34,
|
||||||
|
advisor: '蔡怡芳',
|
||||||
|
email: 'yawin.chiu@school.edu',
|
||||||
|
phone: '02-2345-1012',
|
||||||
|
status: '在學',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 13,
|
||||||
|
studentId: 'S2022073',
|
||||||
|
name: '何柏勳',
|
||||||
|
department: '財務金融',
|
||||||
|
grade: 3,
|
||||||
|
enrollYear: 2022,
|
||||||
|
credits: 62,
|
||||||
|
advisor: '張國華',
|
||||||
|
email: 'boxun.he@school.edu',
|
||||||
|
phone: '02-2345-1013',
|
||||||
|
status: '休學',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 14,
|
||||||
|
studentId: 'S2021095',
|
||||||
|
name: '鄒庭安',
|
||||||
|
department: '企業管理',
|
||||||
|
grade: 4,
|
||||||
|
enrollYear: 2021,
|
||||||
|
credits: 96,
|
||||||
|
advisor: '許雅婷',
|
||||||
|
email: 'tingan.tsou@school.edu',
|
||||||
|
phone: '02-2345-1014',
|
||||||
|
status: '在學',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 15,
|
||||||
|
studentId: 'S2020028',
|
||||||
|
name: '潘子涵',
|
||||||
|
department: '應用外語',
|
||||||
|
grade: 5,
|
||||||
|
enrollYear: 2020,
|
||||||
|
credits: 26,
|
||||||
|
advisor: '吳佳玲',
|
||||||
|
email: 'zihan.pan@school.edu',
|
||||||
|
phone: '02-2345-1015',
|
||||||
|
status: '畢業',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 16,
|
||||||
|
studentId: 'S2024042',
|
||||||
|
name: '賴昀潔',
|
||||||
|
department: '視覺設計',
|
||||||
|
grade: 1,
|
||||||
|
enrollYear: 2024,
|
||||||
|
credits: 14,
|
||||||
|
advisor: '蔡怡芳',
|
||||||
|
email: 'yunjie.lai@school.edu',
|
||||||
|
phone: '02-2345-1016',
|
||||||
|
status: '在學',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 17,
|
||||||
|
studentId: 'S2023068',
|
||||||
|
name: '高宇辰',
|
||||||
|
department: '資訊工程',
|
||||||
|
grade: 2,
|
||||||
|
enrollYear: 2023,
|
||||||
|
credits: 38,
|
||||||
|
advisor: '林育成',
|
||||||
|
email: 'yuchen.kao@school.edu',
|
||||||
|
phone: '02-2345-1017',
|
||||||
|
status: '在學',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 18,
|
||||||
|
studentId: 'S2022089',
|
||||||
|
name: '游品妤',
|
||||||
|
department: '企業管理',
|
||||||
|
grade: 3,
|
||||||
|
enrollYear: 2022,
|
||||||
|
credits: 60,
|
||||||
|
advisor: '許雅婷',
|
||||||
|
email: 'pinyu.yu@school.edu',
|
||||||
|
phone: '02-2345-1018',
|
||||||
|
status: '在學',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 19,
|
||||||
|
studentId: 'S2021106',
|
||||||
|
name: '羅子軒',
|
||||||
|
department: '財務金融',
|
||||||
|
grade: 4,
|
||||||
|
enrollYear: 2021,
|
||||||
|
credits: 84,
|
||||||
|
advisor: '張國華',
|
||||||
|
email: 'zixuan.lo@school.edu',
|
||||||
|
phone: '02-2345-1019',
|
||||||
|
status: '在學',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 20,
|
||||||
|
studentId: 'S2020036',
|
||||||
|
name: '謝佳玲',
|
||||||
|
department: '應用外語',
|
||||||
|
grade: 5,
|
||||||
|
enrollYear: 2020,
|
||||||
|
credits: 24,
|
||||||
|
advisor: '吳佳玲',
|
||||||
|
email: 'jialing.hsieh@school.edu',
|
||||||
|
phone: '02-2345-1020',
|
||||||
|
status: '畢業',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const useStudentStore = defineStore('students', () => {
|
||||||
|
// State
|
||||||
|
const students = ref<StudentRecord[]>([...seedStudents])
|
||||||
|
const deletedIds = ref<Set<number>>(new Set())
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const addStudent = (payload: Omit<StudentRecord, 'id'>) => {
|
||||||
|
const nextId = students.value.reduce((max, item) => Math.max(max, item.id), 0) + 1
|
||||||
|
const created = { id: nextId, ...payload }
|
||||||
|
students.value.push(created)
|
||||||
|
return created.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateStudent = (id: number, payload: Omit<StudentRecord, 'id'>) => {
|
||||||
|
const target = students.value.find((item) => item.id === id)
|
||||||
|
if (!target) return false
|
||||||
|
Object.assign(target, payload)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeStudent = (id: number) => {
|
||||||
|
const before = students.value.length
|
||||||
|
students.value = students.value.filter((item) => item.id !== id)
|
||||||
|
return students.value.length !== before
|
||||||
|
}
|
||||||
|
|
||||||
|
// 標記刪除(軟刪除,還原用)
|
||||||
|
const markAsDeleted = (id: number) => {
|
||||||
|
deletedIds.value.add(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除所有標記
|
||||||
|
const clearDeletedIds = () => {
|
||||||
|
deletedIds.value.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交刪除(實際刪除)
|
||||||
|
const commitDeleted = () => {
|
||||||
|
for (const id of deletedIds.value) {
|
||||||
|
removeStudent(id)
|
||||||
|
}
|
||||||
|
deletedIds.value.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 還原標記(取消刪除)
|
||||||
|
const restoreDeleted = () => {
|
||||||
|
deletedIds.value.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 檢查是否已標記刪除
|
||||||
|
const isMarkedAsDeleted = (id: number) => deletedIds.value.has(id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
students,
|
||||||
|
deletedIds,
|
||||||
|
addStudent,
|
||||||
|
updateStudent,
|
||||||
|
removeStudent,
|
||||||
|
markAsDeleted,
|
||||||
|
clearDeletedIds,
|
||||||
|
commitDeleted,
|
||||||
|
restoreDeleted,
|
||||||
|
isMarkedAsDeleted,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './stores/students'
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export interface ApiError {
|
||||||
|
code?: number
|
||||||
|
errors?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaptchaResponse {
|
||||||
|
dntCaptchaImgUrl: string
|
||||||
|
dntCaptchaId: string
|
||||||
|
dntCaptchaTokenValue: string
|
||||||
|
dntCaptchaTextValue: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginPayload {
|
||||||
|
UserID: string
|
||||||
|
Password: string
|
||||||
|
DNTCaptchaInputText: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function getNextThemeName (themeNames: string[], currentName: string): string | undefined {
|
||||||
|
if (themeNames.length === 0) return undefined
|
||||||
|
|
||||||
|
const currentIndex = themeNames.indexOf(currentName)
|
||||||
|
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % themeNames.length
|
||||||
|
return themeNames[nextIndex]
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<SKAnalysis
|
||||||
|
:bar-data="barData"
|
||||||
|
:chart1-title="chart1Title"
|
||||||
|
:chart2-title="chart2Title"
|
||||||
|
:chart3-title="chart3Title"
|
||||||
|
:pie1-data="pie1Data"
|
||||||
|
:pie2-data="pie2Data"
|
||||||
|
:stats="stats"
|
||||||
|
:trend-data="trendData"
|
||||||
|
:trend-title="trendTitle"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import SKAnalysis from '@/components/SKAnalysis.vue'
|
||||||
|
|
||||||
|
// Mock Data
|
||||||
|
const stats = ref([
|
||||||
|
{
|
||||||
|
title: '學生總數',
|
||||||
|
value: '2,580',
|
||||||
|
icon: 'mdi-account-school',
|
||||||
|
color: 'primary',
|
||||||
|
label: '總學籍人數',
|
||||||
|
total: '120,000',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '平台訪問',
|
||||||
|
value: '20,000',
|
||||||
|
icon: 'mdi-chart-pie',
|
||||||
|
color: 'error',
|
||||||
|
label: '今日訪問量',
|
||||||
|
total: '500,000',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '教材下載',
|
||||||
|
value: '8,000',
|
||||||
|
icon: 'mdi-cloud-download',
|
||||||
|
color: 'warning',
|
||||||
|
label: '本月下載次數',
|
||||||
|
total: '120,000',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '圖書館借閱',
|
||||||
|
value: '5,000',
|
||||||
|
icon: 'mdi-book-open-page-variant',
|
||||||
|
color: 'success',
|
||||||
|
label: '總借閱量',
|
||||||
|
total: '50,000',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const trendTitle = ref('數位學習平台流量')
|
||||||
|
const trendData = ref([0, 2, 5, 9, 5, 10, 3, 5, 0, 0, 1, 8, 2, 9, 0])
|
||||||
|
|
||||||
|
const chart1Title = ref('學生核心素養指標 (平均)')
|
||||||
|
const barData = ref([
|
||||||
|
{ label: '道德實踐 (Moral)', value: 85, color: 'info' },
|
||||||
|
{ label: '智力發展 (Intellectual)', value: 72, color: 'success' },
|
||||||
|
{ label: '體育健康 (Physical)', value: 90, color: 'warning' },
|
||||||
|
{ label: '群育合作 (Social)', value: 65, color: 'error' },
|
||||||
|
{ label: '美感教育 (Aesthetic)', value: 80, color: 'primary' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const chart2Title = ref('訪問裝置來源')
|
||||||
|
const pie1Data = ref({
|
||||||
|
value: 75,
|
||||||
|
label: '行動裝置',
|
||||||
|
color: 'purple-accent-2',
|
||||||
|
})
|
||||||
|
|
||||||
|
const chart3Title = ref('本學期及格率')
|
||||||
|
const pie2Data = ref({
|
||||||
|
value: 92,
|
||||||
|
label: '全校平均',
|
||||||
|
color: 'teal-lighten-1',
|
||||||
|
icon: 'mdi-check-decagram',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<SKDashboard
|
||||||
|
:announcements="announcements"
|
||||||
|
:applications="applications"
|
||||||
|
:greeting-title="greetingTitle"
|
||||||
|
:quick-navs="quickNavs"
|
||||||
|
:todos="todos"
|
||||||
|
:user-avatar="userAvatar"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import SKDashboard from '@/components/SKDashboard.vue'
|
||||||
|
|
||||||
|
const userAvatar = ref(
|
||||||
|
'https://avataaars.io/?avatarStyle=Circle&topType=ShortHairShortFlat&accessoriesType=Sunglasses&hairColor=Black&facialHairType=Blank&clotheType=BlazerShirt&clotheColor=Blue01&eyeType=Happy&eyebrowType=Default&mouthType=Smile&skinColor=Light'
|
||||||
|
)
|
||||||
|
const greetingTitle = ref('早安,王校長,開始您一天的工作吧!')
|
||||||
|
|
||||||
|
const applications = ref([
|
||||||
|
{
|
||||||
|
name: '校務行政系統',
|
||||||
|
icon: 'mdi-school',
|
||||||
|
desc: '全校教職員工生學籍資料、人事資料、財產管理等核心系統入口。',
|
||||||
|
group: '行政組',
|
||||||
|
date: '2025-01-05',
|
||||||
|
color: 'primary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '數位學習平台',
|
||||||
|
icon: 'mdi-monitor-shimmer',
|
||||||
|
desc: '提供線上課程、作業繳交、測驗評量與師生互動討論功能。',
|
||||||
|
group: '教學組',
|
||||||
|
date: '2025-01-02',
|
||||||
|
color: 'success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '圖書館系統',
|
||||||
|
icon: 'mdi-book-open-variant',
|
||||||
|
desc: '館藏查詢、圖書借閱、還書預約與電子書資源整合平台。',
|
||||||
|
group: '圖書館',
|
||||||
|
date: '2024-12-28',
|
||||||
|
color: 'warning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '學生請假系統',
|
||||||
|
icon: 'mdi-calendar-check',
|
||||||
|
desc: '學生線上請假申請、導師審核、生輔組備查流程電子化。',
|
||||||
|
group: '學務處',
|
||||||
|
date: '2024-12-25',
|
||||||
|
color: 'error',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '報修系統',
|
||||||
|
icon: 'mdi-hammer-wrench',
|
||||||
|
desc: '校園設施設備故障通報、維修進度查詢與滿意度調查。',
|
||||||
|
group: '總務處',
|
||||||
|
date: '2024-12-20',
|
||||||
|
color: 'purple',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '會議室預約',
|
||||||
|
icon: 'mdi-account-group',
|
||||||
|
desc: '校內各大型會議室、視聽教室場地查詢與線上預約登記。',
|
||||||
|
group: '總務處',
|
||||||
|
date: '2024-12-15',
|
||||||
|
color: 'teal',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const announcements = ref([
|
||||||
|
{
|
||||||
|
title: '發布全校停課通知 (凱米颱風)',
|
||||||
|
author: '教務處',
|
||||||
|
time: '1 小時前',
|
||||||
|
avatarColor: 'error',
|
||||||
|
avatarSrc: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '發布 113 學年度行事曆',
|
||||||
|
author: '王校長',
|
||||||
|
time: '2 天前',
|
||||||
|
avatarColor: 'primary',
|
||||||
|
avatarSrc: userAvatar.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '回覆關於營養午餐的建議',
|
||||||
|
author: '總務主任',
|
||||||
|
time: '3 天前',
|
||||||
|
avatarColor: 'warning',
|
||||||
|
avatarSrc: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '更新校園防疫規定',
|
||||||
|
author: '衛生組長',
|
||||||
|
time: '1 週前',
|
||||||
|
avatarColor: 'success',
|
||||||
|
avatarSrc: null,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const quickNavs = ref([
|
||||||
|
{ title: '首頁', icon: 'mdi-home', color: 'primary' },
|
||||||
|
{ title: '控制台', icon: 'mdi-view-dashboard', color: 'error' },
|
||||||
|
{ title: '組件', icon: 'mdi-layers', color: 'warning' },
|
||||||
|
{ title: '系統管理', icon: 'mdi-cog', color: 'success' },
|
||||||
|
{ title: '權限', icon: 'mdi-lock', color: 'purple' },
|
||||||
|
{ title: '圖表', icon: 'mdi-chart-bar', color: 'info' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const todos = ref([
|
||||||
|
{ title: '審查期末考題', due: '今天 11:00', done: false },
|
||||||
|
{ title: '簽核採購申請單', due: '今天 14:00', done: false },
|
||||||
|
{ title: '校務會議', due: '明天 09:00', done: false },
|
||||||
|
{ title: '教學巡堂', due: '週五 10:00', done: true },
|
||||||
|
])
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<template>
|
||||||
|
<SKDeptManagement
|
||||||
|
:items="deptItems"
|
||||||
|
:loading="loading"
|
||||||
|
:status-options="statusOptions"
|
||||||
|
@add-sub="onAddSub"
|
||||||
|
@create="onCreate"
|
||||||
|
@delete="onDelete"
|
||||||
|
@edit="onEdit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { DeptItem } from '@/components/SKDeptManagement.vue'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import SKDeptManagement from '@/components/SKDeptManagement.vue'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ title: '已啟用', value: 1 },
|
||||||
|
{ title: '已禁用', value: 0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const deptItems = ref([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '校長室',
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023/08/01 09:00:00',
|
||||||
|
note: '負責統籌全校校務發展',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '教務處',
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023/08/01 09:00:00',
|
||||||
|
note: '負責課程教學與學籍管理',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 21,
|
||||||
|
name: '教學組',
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023/08/01 09:00:00',
|
||||||
|
note: '課程編排、教師授課與教學評鑑',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 22,
|
||||||
|
name: '註冊組',
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023/08/01 09:00:00',
|
||||||
|
note: '學生學籍管理、成績處理與升學輔導',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 23,
|
||||||
|
name: '設備組',
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023/08/01 09:00:00',
|
||||||
|
note: '教學設備採購與維護',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: '學務處',
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023/08/01 09:00:00',
|
||||||
|
note: '負責學生生活輔導與活動規劃',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 31,
|
||||||
|
name: '訓育組',
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023/08/01 09:00:00',
|
||||||
|
note: '學生自治活動、社團活動與校慶規劃',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 32,
|
||||||
|
name: '生輔組',
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023/08/01 09:00:00',
|
||||||
|
note: '學生生活常規、出缺席管理與校園安全',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 33,
|
||||||
|
name: '衛生組',
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023/08/01 09:00:00',
|
||||||
|
note: '校園環境衛生、傳染病防治與健康促進',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: '總務處',
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023/08/01 09:00:00',
|
||||||
|
note: '負責校園營繕、財產管理與經費出納',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 41,
|
||||||
|
name: '庶務組',
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023/08/01 09:00:00',
|
||||||
|
note: '校舍修繕、物品採購與工友管理',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 42,
|
||||||
|
name: '出納組',
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023/08/01 09:00:00',
|
||||||
|
note: '現金出納、薪津發放與學費收繳',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: '輔導室',
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023/08/01 09:00:00',
|
||||||
|
note: '負責學生心理輔導與生涯規劃',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
function addChildById (items: DeptItem[], parentId: string | number, child: DeptItem): boolean {
|
||||||
|
for (const current of items) {
|
||||||
|
if (!current) continue
|
||||||
|
|
||||||
|
if (current.id === parentId) {
|
||||||
|
if (!current.children) current.children = []
|
||||||
|
current.children.push(child)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.children && current.children.length > 0) {
|
||||||
|
const found = addChildById(current.children, parentId, child)
|
||||||
|
if (found) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTreeItemById (items: DeptItem[], updated: DeptItem): boolean {
|
||||||
|
for (let i = 0; i < items.length; i += 1) {
|
||||||
|
const current = items[i]
|
||||||
|
if (!current) continue
|
||||||
|
|
||||||
|
if (current.id === updated.id) {
|
||||||
|
items[i] = {
|
||||||
|
...current,
|
||||||
|
name: updated.name,
|
||||||
|
note: updated.note,
|
||||||
|
status: updated.status,
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.children && current.children.length > 0) {
|
||||||
|
const found = updateTreeItemById(current.children, updated)
|
||||||
|
if (found) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCreate = () => alert('Create Dept')
|
||||||
|
function onAddSub (parent: DeptItem, newItem: DeptItem) {
|
||||||
|
addChildById(deptItems.value, parent.id, newItem)
|
||||||
|
}
|
||||||
|
function onEdit (item: DeptItem) {
|
||||||
|
updateTreeItemById(deptItems.value, item)
|
||||||
|
}
|
||||||
|
function onDelete (item: DeptItem) {
|
||||||
|
if (confirm(`Delete ${item.name}?`)) {
|
||||||
|
alert('Deleted')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<template>
|
||||||
|
<v-sheet height="100%" width="100%">
|
||||||
|
{{ fncId }}
|
||||||
|
</v-sheet>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const fncId = computed(() => String(route.params.fncId ?? ''))
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
<template>
|
||||||
|
<v-container class="pa-0" fluid>
|
||||||
|
<div class="d-flex flex-column ga-5 py-4 pr-2 pl-0">
|
||||||
|
<v-sheet class="d-flex flex-column flex-sm-row align-start align-sm-center ga-4 pa-5 elevation-1" color="surface">
|
||||||
|
<v-avatar color="primary" size="52" variant="tonal">
|
||||||
|
<span class="text-h5">👋</span>
|
||||||
|
</v-avatar>
|
||||||
|
<div>
|
||||||
|
<div class="text-h5 font-weight-bold">歡迎使用校務資訊系統</div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis mt-1">
|
||||||
|
使用頂部搜尋框快速找到功能,或從左側選單瀏覽所有系統模組
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-sheet>
|
||||||
|
|
||||||
|
<section class="d-flex flex-column">
|
||||||
|
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">📰 最新消息</div>
|
||||||
|
<!--
|
||||||
|
使用 v-data-iterator 讓消息列表具備一致的資料迭代結構,
|
||||||
|
也方便未來需要加上排序 / 分頁時直接擴充。
|
||||||
|
-->
|
||||||
|
<v-data-iterator class="mt-2" item-key="id" :items="newsItems" :items-per-page="-1">
|
||||||
|
<!--
|
||||||
|
v-data-iterator 的 default slot 會提供包裝後的 items,
|
||||||
|
這裡透過 resolveNewsItem 抽出原始資料,再沿用原本的卡片排版。
|
||||||
|
-->
|
||||||
|
<template #default="{ items }">
|
||||||
|
<v-row dense>
|
||||||
|
<v-col v-for="wrapped in items" :key="resolveNewsItem(wrapped).id" cols="12">
|
||||||
|
<v-card
|
||||||
|
class="news-item d-flex flex-column flex-sm-row ga-4 pa-4 bg-surface" variant="outlined"
|
||||||
|
@click="handleNews(resolveNewsItem(wrapped))">
|
||||||
|
<v-sheet class="news-badge">
|
||||||
|
<div class="news-badge-date">{{ resolveNewsItem(wrapped).date }}</div>
|
||||||
|
<div class="news-badge-month">{{ resolveNewsItem(wrapped).month }}</div>
|
||||||
|
</v-sheet>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="d-flex flex-wrap align-center font-weight-bold">
|
||||||
|
{{ resolveNewsItem(wrapped).title }}
|
||||||
|
<v-chip
|
||||||
|
v-if="resolveNewsItem(wrapped).isNew" class="ml-2" color="primary" size="x-small"
|
||||||
|
variant="flat">
|
||||||
|
NEW
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis mt-2">
|
||||||
|
{{ resolveNewsItem(wrapped).desc }}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex ga-4 mt-3 text-caption text-medium-emphasis">
|
||||||
|
<div class="d-flex align-center ga-1">
|
||||||
|
<v-icon size="14">mdi-folder-outline</v-icon>
|
||||||
|
<span>{{ resolveNewsItem(wrapped).dept }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center ga-1">
|
||||||
|
<v-icon size="14">mdi-eye-outline</v-icon>
|
||||||
|
<span>{{ resolveNewsItem(wrapped).views }} 次瀏覽</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
</v-data-iterator>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<v-card
|
||||||
|
class="d-flex align-center justify-space-between ga-3 px-5 py-4" color="secondary" rounded="xl"
|
||||||
|
variant="tonal" @click="handleMessageCenter">
|
||||||
|
<div class="d-flex align-center ga-4">
|
||||||
|
<v-avatar color="secondary" size="44" variant="flat">
|
||||||
|
<span class="text-h6">✉️</span>
|
||||||
|
</v-avatar>
|
||||||
|
<div>
|
||||||
|
<div class="text-subtitle-1 font-weight-bold">訊息中心</div>
|
||||||
|
<div class="text-body-2 font-weight-bold text-on-secondary">12 筆未讀</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 font-weight-medium">查看全部 →</div>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<section class="d-flex flex-column pb-4">
|
||||||
|
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">🚀 快速存取</div>
|
||||||
|
<v-row class="mt-2" dense>
|
||||||
|
<v-col v-for="item in quickItems" :key="item.title" cols="6" md="2" sm="4">
|
||||||
|
<v-card
|
||||||
|
class="d-flex flex-column align-center ga-2 text-center py-4 px-2 quick-item" variant="outlined"
|
||||||
|
@click="handleQuick(item)">
|
||||||
|
<div class="text-h5">{{ item.icon }}</div>
|
||||||
|
<div class="text-body-2 font-weight-medium">{{ item.title }}</div>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
點擊消息後顯示的對話框。
|
||||||
|
僅負責呈現內容,不做任何延伸操作,確保互動行為單純可預期。
|
||||||
|
-->
|
||||||
|
<v-dialog v-model="isNewsDialogOpen" max-width="640">
|
||||||
|
<v-card v-if="selectedNews">
|
||||||
|
<v-card-title class="text-h6 font-weight-bold bg-primary-variant pa-4">
|
||||||
|
{{ selectedNews.title }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis">
|
||||||
|
{{ selectedNews.month }} {{ selectedNews.date }} · {{ selectedNews.dept }} ·
|
||||||
|
{{ selectedNews.views }} 次瀏覽
|
||||||
|
</v-card-subtitle>
|
||||||
|
<v-card-text class="pt-4">
|
||||||
|
{{ selectedNews.desc }}
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions class="justify-end">
|
||||||
|
<v-btn color="primary" variant="text" @click="isNewsDialogOpen = false">關閉</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useMessageStore } from '@/stores/messages'
|
||||||
|
import { useSnackbarStore } from '@/stores/snackbar'
|
||||||
|
|
||||||
|
const snackbar = useSnackbarStore()
|
||||||
|
const messageStore = useMessageStore()
|
||||||
|
|
||||||
|
const newsItems = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
date: '29',
|
||||||
|
month: '1月',
|
||||||
|
title: '113學年度第2學期加退選開始',
|
||||||
|
desc: '加退選時間為1月29日至2月9日止,請同學把握時間完成選課作業。',
|
||||||
|
dept: '教務處',
|
||||||
|
views: '1,234',
|
||||||
|
isNew: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
date: '27',
|
||||||
|
month: '1月',
|
||||||
|
title: '場地借用系統維護通知',
|
||||||
|
desc: '系統將於本週六凌晨01:00-05:00進行維護,期間暫停服務。',
|
||||||
|
dept: '總務處',
|
||||||
|
views: '856',
|
||||||
|
isNew: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
date: '25',
|
||||||
|
month: '1月',
|
||||||
|
title: '112學年度第1學期期末成績已開放查詢',
|
||||||
|
desc: '同學可至「查詢 > 教務資訊查詢 > 學期成績查詢」查看本學期成績。',
|
||||||
|
dept: '教務處',
|
||||||
|
views: '3,567',
|
||||||
|
isNew: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
type NewsItem = (typeof newsItems)[number]
|
||||||
|
|
||||||
|
const quickItems = [
|
||||||
|
{ icon: '➕', title: '線上加選' },
|
||||||
|
{ icon: '➖', title: '線上退選' },
|
||||||
|
{ icon: '📊', title: '成績查詢' },
|
||||||
|
{ icon: '📅', title: '個人課表' },
|
||||||
|
{ icon: '📝', title: '網路請假' },
|
||||||
|
{ icon: '🏢', title: '場地借用' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const selectedNews = ref<NewsItem | null>(null)
|
||||||
|
const isNewsDialogOpen = ref(false)
|
||||||
|
|
||||||
|
// v-data-iterator 會包裝 items,這個方法用來安全地取回原始資料結構。
|
||||||
|
function resolveNewsItem (wrapped: unknown): NewsItem {
|
||||||
|
if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
|
||||||
|
return (wrapped as { raw: NewsItem }).raw
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapped as NewsItem
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNews (item: NewsItem) {
|
||||||
|
selectedNews.value = item
|
||||||
|
isNewsDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 點擊首頁「訊息中心」卡片,開啟共用的訊息清單 dialog
|
||||||
|
function handleMessageCenter () {
|
||||||
|
messageStore.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuick (item: (typeof quickItems)[number]) {
|
||||||
|
snackbar.show({ message: `前往:${item.title}`, color: 'info' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.news-item {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-item:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-badge {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgb(var(--v-theme-primary));
|
||||||
|
color: rgb(var(--v-theme-on-primary));
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 6px;
|
||||||
|
min-height: 64px;
|
||||||
|
min-width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-badge-date {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-badge-month {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-item {
|
||||||
|
display: flex;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-item:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user