diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..34b5602 --- /dev/null +++ b/.npmrc @@ -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 diff --git a/eslint.config.js b/eslint.config.js index 5053bec..9a69220 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,5 +4,17 @@ import vuetify from 'eslint-config-vuetify' export default vuetify({ 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', + } }) diff --git a/package.json b/package.json index 43c6215..a7fb3e4 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@mdi/js": "^7.4.47", + "axios": "^1.13.6", "pinia": "^3.0.4", "vue": "^3.5.31", "vue-i18n": "^11.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75e0f91..8d6fc8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@mdi/js': specifier: ^7.4.47 version: 7.4.47 + axios: + specifier: ^1.13.6 + version: 1.13.6 pinia: specifier: ^3.0.4 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==} 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: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -681,6 +690,10 @@ packages: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} 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: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -728,6 +741,10 @@ packages: colorjs.io@0.5.2: 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: resolution: {integrity: sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==} engines: {node: '>= 12.0.0'} @@ -772,6 +789,10 @@ packages: deep-is@0.1.4: 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: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -780,6 +801,10 @@ packages: resolution: {integrity: sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==} 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: resolution: {integrity: sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==} @@ -794,6 +819,22 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} 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: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1019,11 +1060,27 @@ packages: flatted@3.4.2: 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: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -1032,6 +1089,14 @@ packages: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} 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: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1048,6 +1113,10 @@ packages: resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==} engines: {node: '>=18'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -1055,6 +1124,18 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} 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: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -1248,10 +1329,22 @@ packages: magic-string@0.30.21: 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: resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} 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: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -1394,6 +1487,9 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2456,6 +2552,16 @@ snapshots: '@babel/parser': 7.29.2 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@4.0.4: {} @@ -2485,6 +2591,11 @@ snapshots: 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: {} caniuse-lite@1.0.30001781: {} @@ -2527,6 +2638,10 @@ snapshots: colorjs.io@0.5.2: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comment-parser@1.4.5: {} concat-map@0.0.1: {} @@ -2559,10 +2674,18 @@ snapshots: deep-is@0.1.4: {} + delayed-stream@1.0.0: {} + detect-libc@2.1.2: {} 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: {} emoji-regex@10.6.0: {} @@ -2571,6 +2694,21 @@ snapshots: 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: {} escape-string-regexp@1.0.5: {} @@ -2850,13 +2988,43 @@ snapshots: 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: optional: true + function-bind@1.1.2: {} + get-caller-file@2.0.5: {} 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: dependencies: is-glob: 4.0.3 @@ -2867,10 +3035,22 @@ snapshots: globals@17.4.0: {} + gopd@1.2.0: {} + graphemer@1.4.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: {} ignore@5.3.2: {} @@ -3020,8 +3200,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + memorystream@0.3.1: {} + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + minimatch@10.2.4: dependencies: brace-expansion: 5.0.5 @@ -3158,6 +3346,8 @@ snapshots: prelude-ls@1.2.1: {} + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} quansync@0.2.11: {} diff --git a/public/robot-svgrepo-com.svg b/public/robot-svgrepo-com.svg new file mode 100644 index 0000000..153f36f --- /dev/null +++ b/public/robot-svgrepo-com.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/web.config b/public/web.config new file mode 100644 index 0000000..e9d0204 --- /dev/null +++ b/public/web.config @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/App.vue b/src/App.vue index 6ceb9da..2de2cbb 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,11 +1,522 @@ - - - - - + + + + + {{ favoriteActionIcon }} + {{ favoriteActionLabel }} + + + mdi-home + 返回首頁 + + + + + + + + {{ tab.title }} + + mdi-close + + + + + + + + + + + + + + + + + + + + + 搜尋結果 + 關鍵字:{{ searchKeyword }} + + + 查無結果 + + + + + {{ item.icon }} + + {{ item.title }} + + {{ item.parents.join(' / ') }} + + + + + + + 關閉 + + + + + + + + 訊息清單 + 僅示意資料,不含延伸功能 + + + + + + + + + {{ resolveMessageItem(wrapped).icon }} + + + + {{ resolveMessageItem(wrapped).title }} + + + {{ resolveMessageItem(wrapped).meta }} + + + + + + + + 關閉 + + + + + + {{ snackbar.message }} + - diff --git a/src/components/HelloWorld.vue b/src/components/HelloWorld.vue index c9ef8b0..750eb46 100644 --- a/src/components/HelloWorld.vue +++ b/src/components/HelloWorld.vue @@ -1,94 +1,5 @@ - - - - - - Welcome to - Vuetify - - - - - - - - - - - - - - - Get started - - - - - Change this page by updating {{ `` }} in components/HelloWorld.vue. - - - - - - - - - - - - - - - + + Hello World + - - diff --git a/src/components/SKAnalysis.vue b/src/components/SKAnalysis.vue new file mode 100644 index 0000000..92c7613 --- /dev/null +++ b/src/components/SKAnalysis.vue @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/SKDashboard.vue b/src/components/SKDashboard.vue new file mode 100644 index 0000000..5398632 --- /dev/null +++ b/src/components/SKDashboard.vue @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/SKDeptManagement.vue b/src/components/SKDeptManagement.vue new file mode 100644 index 0000000..686b5a2 --- /dev/null +++ b/src/components/SKDeptManagement.vue @@ -0,0 +1,253 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + {{ isEnabledStatus(item.status) ? props.statusEnabledText : props.statusDisabledText }} + + + + + + + {{ props.addSubActionText }} + + + {{ props.editActionText }} + + + {{ props.deleteActionText }} + + + + + + + + diff --git a/src/components/SKDraggableDialog.vue b/src/components/SKDraggableDialog.vue new file mode 100644 index 0000000..e69de29 diff --git a/src/components/SKLogin.vue b/src/components/SKLogin.vue new file mode 100644 index 0000000..db02057 --- /dev/null +++ b/src/components/SKLogin.vue @@ -0,0 +1,452 @@ + + + + + + + + + + + + + + + + + + + + + mdi-bullhorn-variant-outline + + + + {{ mobileAnnouncementBannerText }} + + + + {{ props.mobileAnnouncement.viewAllText }} + + + + + + + + + + + + + + + + + + Copyright © {{ new Date().getFullYear() }} {{ props.branding.organization }} + + + + + + + + + + + + + + + + + + + + Copyright © {{ new Date().getFullYear() }} {{ props.branding.organization }} + + + + + + + + {{ props.mobileAnnouncement.listTitle }} + + + + {{ item.content }} + + {{ item.title }} ・ {{ item.createdAt }} + + + + {{ props.mobileAnnouncement.emptyText }} + + + + + {{ props.mobileAnnouncement.closeText }} + + + + + + + + + + diff --git a/src/components/SKMenuManagement.vue b/src/components/SKMenuManagement.vue new file mode 100644 index 0000000..b451e25 --- /dev/null +++ b/src/components/SKMenuManagement.vue @@ -0,0 +1,209 @@ + + + + + + + + + + + + + new + + + + + {{ item.icon }} + + + + + + {{ item.permission }} + + + + + + + {{ isEnabledStatus(item.status) ? props.statusEnabledText : props.statusDisabledText }} + + + + + + + {{ props.editActionText }} + + + + + + + + diff --git a/src/components/SKRoleManagement.vue b/src/components/SKRoleManagement.vue new file mode 100644 index 0000000..12109ab --- /dev/null +++ b/src/components/SKRoleManagement.vue @@ -0,0 +1,263 @@ + + + + + + + + + + + + + + + + + + + {{ props.editActionText }} + + + + {{ props.deleteActionText }} + + + + + + + + diff --git a/src/components/base/SKFormEditDialog.vue b/src/components/base/SKFormEditDialog.vue new file mode 100644 index 0000000..26e5249 --- /dev/null +++ b/src/components/base/SKFormEditDialog.vue @@ -0,0 +1,197 @@ + + + + + + {{ props.titleText }} + + + + + + + + + + + + + + + + + + + + {{ props.cancelText }} + + + {{ props.confirmText }} + + + + + + + + diff --git a/src/components/base/SKSearchFilter.vue b/src/components/base/SKSearchFilter.vue new file mode 100644 index 0000000..c35f180 --- /dev/null +++ b/src/components/base/SKSearchFilter.vue @@ -0,0 +1,145 @@ + + + + + + + + + {{ field.label }} + + + + + + + + + + + + + + + + {{ resetBtnText }} + + + {{ searchBtnText }} + + + {{ expand ? collapseBtnText : expandBtnText }} + + + + + + + + + diff --git a/src/components/base/SKTableActionBar.vue b/src/components/base/SKTableActionBar.vue new file mode 100644 index 0000000..4a05b75 --- /dev/null +++ b/src/components/base/SKTableActionBar.vue @@ -0,0 +1,161 @@ + + + {{ title }} + + + + {{ createBtnText }} + + + + + + mdi-magnify + + + + + + + + mdi-refresh + + + + + + + + + + mdi-cog + + + + + + + + + + + + + + + + + + + diff --git a/src/components/base/SKTreeTable.vue b/src/components/base/SKTreeTable.vue new file mode 100644 index 0000000..d8c7d5f --- /dev/null +++ b/src/components/base/SKTreeTable.vue @@ -0,0 +1,137 @@ + + + + + + + + {{ isExpanded(item.id) ? 'mdi-chevron-down' : 'mdi-chevron-right' }} + + + + {{ item[header.key] }} + + + + + + + + + + + + + + diff --git a/src/components/base/analysis/AnalysisBarChart.vue b/src/components/base/analysis/AnalysisBarChart.vue new file mode 100644 index 0000000..0042bea --- /dev/null +++ b/src/components/base/analysis/AnalysisBarChart.vue @@ -0,0 +1,38 @@ + + + + {{ title }} + + + + + + + {{ item.label }} + {{ item.value }}% + + + + + + + + + diff --git a/src/components/base/analysis/AnalysisDonutChart.vue b/src/components/base/analysis/AnalysisDonutChart.vue new file mode 100644 index 0000000..409f25c --- /dev/null +++ b/src/components/base/analysis/AnalysisDonutChart.vue @@ -0,0 +1,42 @@ + + + + {{ title }} + + + + + + {{ data.icon }} + + + + {{ data.label }} + 佔比 {{ data.value }}% + + + + + + diff --git a/src/components/base/analysis/AnalysisPieChart.vue b/src/components/base/analysis/AnalysisPieChart.vue new file mode 100644 index 0000000..50dd404 --- /dev/null +++ b/src/components/base/analysis/AnalysisPieChart.vue @@ -0,0 +1,56 @@ + + + + {{ title }} + + + + + + + + + {{ data.value }}% + {{ data.label }} + + + + + + + {{ data.label }} + + 其他 + + + + + + diff --git a/src/components/base/analysis/AnalysisStatsCard.vue b/src/components/base/analysis/AnalysisStatsCard.vue new file mode 100644 index 0000000..fdb51f2 --- /dev/null +++ b/src/components/base/analysis/AnalysisStatsCard.vue @@ -0,0 +1,35 @@ + + + + + + + {{ title }} + + {{ value }} + + + {{ icon }} + + + + + {{ label }} + {{ total }} + + + + + + diff --git a/src/components/base/analysis/AnalysisTrendChart.vue b/src/components/base/analysis/AnalysisTrendChart.vue new file mode 100644 index 0000000..2bd0780 --- /dev/null +++ b/src/components/base/analysis/AnalysisTrendChart.vue @@ -0,0 +1,68 @@ + + + + + + {{ title }} + + + + + {{ filter }} + + + + + + + + + {{ item.value }} + + + + + {{ 6 + i }}:00 + + + + + + + + + + diff --git a/src/components/base/dashboard/DashboardAnnouncements.vue b/src/components/base/dashboard/DashboardAnnouncements.vue new file mode 100644 index 0000000..a2675e7 --- /dev/null +++ b/src/components/base/dashboard/DashboardAnnouncements.vue @@ -0,0 +1,55 @@ + + + + {{ title }} + + + {{ viewMoreText }} + + + + + + + {{ item.author[0] }} + + + + + + {{ item.title }} + + + + {{ item.author }} + {{ item.time }} + + + + + + + diff --git a/src/components/base/dashboard/DashboardApps.vue b/src/components/base/dashboard/DashboardApps.vue new file mode 100644 index 0000000..7400c1f --- /dev/null +++ b/src/components/base/dashboard/DashboardApps.vue @@ -0,0 +1,80 @@ + + + + {{ title }} + + + {{ viewAllText }} + + + + + + + + {{ app.icon }} + {{ app.name }} + + + {{ app.desc }} + + + {{ app.group }} + {{ app.date }} + + + + + + + + + + + diff --git a/src/components/base/dashboard/DashboardChart.vue b/src/components/base/dashboard/DashboardChart.vue new file mode 100644 index 0000000..1070d65 --- /dev/null +++ b/src/components/base/dashboard/DashboardChart.vue @@ -0,0 +1,52 @@ + + + + {{ title }} + + + + + + {{ value }}% + {{ subtitle }} + + + + + + mdi-circle + {{ primaryLabel }} + + + mdi-circle + {{ secondaryLabel }} + + + + + + + diff --git a/src/components/base/dashboard/DashboardHeader.vue b/src/components/base/dashboard/DashboardHeader.vue new file mode 100644 index 0000000..5c7b4ca --- /dev/null +++ b/src/components/base/dashboard/DashboardHeader.vue @@ -0,0 +1,56 @@ + + + + + + + + + + + + {{ greetingTitle }} + + + {{ weatherInfo }} + + + + + + + {{ todoLabel }} + {{ todo }} + + + {{ projectsLabel }} + {{ projects }} + + + {{ teamLabel }} + {{ team }} + + + + + + + diff --git a/src/components/base/dashboard/DashboardQuickNav.vue b/src/components/base/dashboard/DashboardQuickNav.vue new file mode 100644 index 0000000..3c0d39a --- /dev/null +++ b/src/components/base/dashboard/DashboardQuickNav.vue @@ -0,0 +1,38 @@ + + + + {{ title }} + + + + + + {{ nav.icon }} + + {{ nav.title }} + + + + + + + diff --git a/src/components/base/dashboard/DashboardTodoList.vue b/src/components/base/dashboard/DashboardTodoList.vue new file mode 100644 index 0000000..c451779 --- /dev/null +++ b/src/components/base/dashboard/DashboardTodoList.vue @@ -0,0 +1,40 @@ + + + + {{ title }} + + + + + + + + {{ todo.title }} + + + {{ todo.due }} + + + + + + + diff --git a/src/components/base/input_field/SKDatePicker.vue b/src/components/base/input_field/SKDatePicker.vue new file mode 100644 index 0000000..bd452e8 --- /dev/null +++ b/src/components/base/input_field/SKDatePicker.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/src/components/base/input_field/SKSelectField.vue b/src/components/base/input_field/SKSelectField.vue new file mode 100644 index 0000000..4e7289e --- /dev/null +++ b/src/components/base/input_field/SKSelectField.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/src/components/base/input_field/SKTextField.vue b/src/components/base/input_field/SKTextField.vue new file mode 100644 index 0000000..bd452e8 --- /dev/null +++ b/src/components/base/input_field/SKTextField.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/src/components/base/login/CreateAccountLink.vue b/src/components/base/login/CreateAccountLink.vue new file mode 100644 index 0000000..d0e28e1 --- /dev/null +++ b/src/components/base/login/CreateAccountLink.vue @@ -0,0 +1,45 @@ + + + + {{ props.promptText }} + + {{ props.linkText }} + + + + + + diff --git a/src/components/base/login/LoginAnnouncementBoard.vue b/src/components/base/login/LoginAnnouncementBoard.vue new file mode 100644 index 0000000..380d266 --- /dev/null +++ b/src/components/base/login/LoginAnnouncementBoard.vue @@ -0,0 +1,187 @@ + + + {{ title }} + + + + {{ tab.label }} + + + + + + + + {{ dateHeader }} + {{ schoolHeader }} + {{ titleHeader }} + + + + + {{ item.date }} + {{ item.school }} + + + {{ item.title }} + + + + + {{ emptyText }} + + + + + + + + {{ item.content }} + + + {{ item.title }} ・ {{ item.createdAt }} + + + + {{ emptyText }} + + + + + + + + + {{ paginationLabel }} {{ totalItems }} + + + + + + + + + diff --git a/src/components/base/login/LoginBrand.vue b/src/components/base/login/LoginBrand.vue new file mode 100644 index 0000000..2726950 --- /dev/null +++ b/src/components/base/login/LoginBrand.vue @@ -0,0 +1,14 @@ + + + {{ title }} + + + + diff --git a/src/components/base/login/LoginForm.vue b/src/components/base/login/LoginForm.vue new file mode 100644 index 0000000..a56eee6 --- /dev/null +++ b/src/components/base/login/LoginForm.vue @@ -0,0 +1,121 @@ + + + + + + + + + + + + {{ props.forgotPasswordText }} + + + + + {{ props.submitText }} + + + + + + + diff --git a/src/components/base/login/LoginHeader.vue b/src/components/base/login/LoginHeader.vue new file mode 100644 index 0000000..35571bb --- /dev/null +++ b/src/components/base/login/LoginHeader.vue @@ -0,0 +1,24 @@ + + + {{ props.welcomeText }} + {{ props.welcomeDescription }} + + + + + diff --git a/src/components/base/login/LoginIllustration.vue b/src/components/base/login/LoginIllustration.vue new file mode 100644 index 0000000..0d124f0 --- /dev/null +++ b/src/components/base/login/LoginIllustration.vue @@ -0,0 +1,44 @@ + + + + + + + + {{ title }} + {{ description }} + + + + + + + diff --git a/src/components/base/login/LoginToolBar.vue b/src/components/base/login/LoginToolBar.vue new file mode 100644 index 0000000..8745726 --- /dev/null +++ b/src/components/base/login/LoginToolBar.vue @@ -0,0 +1,72 @@ + + + + + + + + + + + {{ localeLabels[locale] ?? locale }} + + + + + + + + diff --git a/src/components/base/login/LoginVerify.vue b/src/components/base/login/LoginVerify.vue new file mode 100644 index 0000000..5db3259 --- /dev/null +++ b/src/components/base/login/LoginVerify.vue @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + mdi-check-circle + + + + + + {{ errorMsg }} + + + + + + + diff --git a/src/components/layouts/SKAdminLayout.vue b/src/components/layouts/SKAdminLayout.vue new file mode 100644 index 0000000..28d2629 --- /dev/null +++ b/src/components/layouts/SKAdminLayout.vue @@ -0,0 +1,667 @@ + + + + + + + + + + + + {{ branding.title }} + + + {{ branding.subtitle }} + + + + + + + + + + {{ userProfile.avatarText }} + + + + {{ userProfile.name }} + + + {{ userProfile.role }} + + + + + + + + {{ favoritesConfig.label }} + + + {{ step.title }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + mdi-help-circle-outline + + 操作說明 + + + + + + + 這裡先放暫時說明內容。你可以保持此視窗開啟,並繼續操作頁面上的其他功能。 + + + + 了解 + + + + + + + + + + + + diff --git a/src/components/layouts/SKEmptyLayout.vue b/src/components/layouts/SKEmptyLayout.vue new file mode 100644 index 0000000..dbed7ce --- /dev/null +++ b/src/components/layouts/SKEmptyLayout.vue @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/src/components/layouts/SKMainLayout.vue b/src/components/layouts/SKMainLayout.vue new file mode 100644 index 0000000..d2eddaf --- /dev/null +++ b/src/components/layouts/SKMainLayout.vue @@ -0,0 +1,251 @@ + + + + + + + + + {{ favoriteHeaderLabel }} + + + + + + + + + + + {{ item.title }} + + + + + + + + + + + {{ subItem.title }} + + + + + + + + + + + + + {{ item.title }} + + + + + + + + + + {{ menuHeaderLabel }} + + + + + + + + + + + + {{ item.title }} + + + + + + + + + + + {{ subItem.title }} + + + + + + + + + + + {{ subSubItem.title }} + + + + + + + + + + + + + {{ subItem.title }} + + + + + + + + + + + + + {{ item.title }} + + + + + + + + + + + {{ systemTitle }} + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/layouts/SKSimpleLayout.vue b/src/components/layouts/SKSimpleLayout.vue new file mode 100644 index 0000000..9a73535 --- /dev/null +++ b/src/components/layouts/SKSimpleLayout.vue @@ -0,0 +1,118 @@ + + + + {{ systemTitle }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/layouts/sk-admin-layout/SkAdminAppBarBreadcrumbCol.vue b/src/components/layouts/sk-admin-layout/SkAdminAppBarBreadcrumbCol.vue new file mode 100644 index 0000000..3f72bdb --- /dev/null +++ b/src/components/layouts/sk-admin-layout/SkAdminAppBarBreadcrumbCol.vue @@ -0,0 +1,39 @@ + + + + + + 常用 + + + + + {{ item.icon }} + {{ item.title }} + + + + mdi-chevron-right + + + + + + + + + diff --git a/src/components/layouts/sk-admin-layout/SkAdminAppBarFavoritesCol.vue b/src/components/layouts/sk-admin-layout/SkAdminAppBarFavoritesCol.vue new file mode 100644 index 0000000..ca4b8bc --- /dev/null +++ b/src/components/layouts/sk-admin-layout/SkAdminAppBarFavoritesCol.vue @@ -0,0 +1,59 @@ + + + + {{ favoritesConfig.label }} + + + + + {{ item.icon }} + {{ item.title }} + + + + mdi-plus + {{ favoritesConfig.addLabel }} + + + + mdi-eye-off + + + + + + + diff --git a/src/components/layouts/sk-admin-layout/SkAdminAppBarTopCol.vue b/src/components/layouts/sk-admin-layout/SkAdminAppBarTopCol.vue new file mode 100644 index 0000000..8cc4535 --- /dev/null +++ b/src/components/layouts/sk-admin-layout/SkAdminAppBarTopCol.vue @@ -0,0 +1,184 @@ + + + + + + + + mdi-magnify + + + + 開始搜尋 + + + + + + + + + + + + + + mdi-bell-outline + + mdi-bell-outline + + + + + + + + + + mdi-message-text-outline + + mdi-message-text-outline + + + + + + + + + mdi-help + + + + + + + + + + + mdi-cog-outline + + + + + + 顯示設定 + + + + 常用功能 + + + + + + + 路徑 + + + + + + + + + + + mdi-logout + + + + + + + + mdi-palette-outline + + + + + + + + + + + diff --git a/src/components/layouts/sk-admin-layout/SkAdminDrawerDesktopMenu.vue b/src/components/layouts/sk-admin-layout/SkAdminDrawerDesktopMenu.vue new file mode 100644 index 0000000..092bd1b --- /dev/null +++ b/src/components/layouts/sk-admin-layout/SkAdminDrawerDesktopMenu.vue @@ -0,0 +1,176 @@ + + + + + + + + {{ item.icon }} + {{ + item.title?.charAt(0) }} + + + {{ item.title }} + + + 0" class="menu-count" color="secondary" size="x-small" + variant="tonal"> + {{ getItemCount(item) }} + + + + + + + + + + + {{ subItem.title }} + + + 0" class="menu-count" color="secondary" size="x-small" + variant="tonal"> + {{ getItemCount(subItem) }} + + + + + + + + + + {{ subSubItem.title }} + + + + + + + + + + + {{ subItem.title }} + + + + + + + + + + {{ item.icon }} + {{ + item.title?.charAt(0) }} + + + + + {{ item.title }} + + + + + + + + + + + diff --git a/src/components/layouts/sk-admin-layout/SkAdminDrawerMobileFavoritesPanel.vue b/src/components/layouts/sk-admin-layout/SkAdminDrawerMobileFavoritesPanel.vue new file mode 100644 index 0000000..03205ac --- /dev/null +++ b/src/components/layouts/sk-admin-layout/SkAdminDrawerMobileFavoritesPanel.vue @@ -0,0 +1,29 @@ + + + + + {{ item.title }} + + + + + + + + diff --git a/src/components/layouts/sk-admin-layout/SkAdminDrawerMobileMenuPanel.vue b/src/components/layouts/sk-admin-layout/SkAdminDrawerMobileMenuPanel.vue new file mode 100644 index 0000000..13a241e --- /dev/null +++ b/src/components/layouts/sk-admin-layout/SkAdminDrawerMobileMenuPanel.vue @@ -0,0 +1,32 @@ + + + + + {{ item.title }} + + {{ item.subItems?.length ? 'mdi-chevron-right' : 'mdi-arrow-top-right' }} + + + + + + + + + diff --git a/src/components/maintenance/CommonConfirmDialog.vue b/src/components/maintenance/CommonConfirmDialog.vue new file mode 100644 index 0000000..98e382e --- /dev/null +++ b/src/components/maintenance/CommonConfirmDialog.vue @@ -0,0 +1,66 @@ + + + + {{ title }} + + {{ message }} + + + 取消 + + {{ confirmText }} + + + + + + + diff --git a/src/components/maintenance/MntDialogCard.vue b/src/components/maintenance/MntDialogCard.vue new file mode 100644 index 0000000..781183c --- /dev/null +++ b/src/components/maintenance/MntDialogCard.vue @@ -0,0 +1,63 @@ + + + + + {{ dialogTitle }} + {{ dialogSubtitle }} + + + 檢視中 + 編輯中 + 新增中 + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/maintenance/MntPageCards.vue b/src/components/maintenance/MntPageCards.vue new file mode 100644 index 0000000..4a42125 --- /dev/null +++ b/src/components/maintenance/MntPageCards.vue @@ -0,0 +1,73 @@ + + + + + {{ title }} + + + + + + + + + + + + + + + + + + + + + + + + + + 搜尋條件 + + + + + + + + + + + + + + diff --git a/src/components/maintenance/MntRecordNavToolbar.vue b/src/components/maintenance/MntRecordNavToolbar.vue new file mode 100644 index 0000000..0bd1fa9 --- /dev/null +++ b/src/components/maintenance/MntRecordNavToolbar.vue @@ -0,0 +1,115 @@ + + + + + + + + + {{ editLabel }} + + + {{ viewLabel }} + + + + + + {{ firstLabel }} + + + {{ prevLabel }} + + + {{ nextLabel }} + + + {{ lastLabel }} + + + + {{ editLabel }} + + + {{ viewLabel }} + + + + + diff --git a/src/components/maintenance/master-detail-b/MasterDetailBSemesterMobilePanel.vue b/src/components/maintenance/master-detail-b/MasterDetailBSemesterMobilePanel.vue new file mode 100644 index 0000000..27764ed --- /dev/null +++ b/src/components/maintenance/master-detail-b/MasterDetailBSemesterMobilePanel.vue @@ -0,0 +1,183 @@ + + + + + + {{ semester?.semesterName || '學期明細' }} + + + + + + + + + + 學期平均 + {{ semester.average }} + + + 班級排名 + {{ semester.rank }} + + + 總學分 + {{ totalCredits }} + + + + + + + + {{ course.name }} + {{ course.code }} + + + {{ course.score }} 分 + + + + 學分 {{ course.credits }} + + + + + 尚無課程資料 + + + + + $emit('update-semester', semester.id, { semesterName: String(value) })" + /> + + $emit('update-semester', semester.id, { rank: Number(value) || 0 })" + /> + + + + + 課程列表 + + + 加入課程 + + + + + + 課程 {{ idx + 1 }} + + + + + + $emit('update-course', semester.id, idx, { name: String(value) })" + /> + $emit('update-course', semester.id, idx, { code: String(value) })" + /> + $emit('update-course', semester.id, idx, { credits: Number(value) || 0 })" + /> + $emit('update-course', semester.id, idx, { score: Number(value) || 0 })" + /> + + + + + 尚無課程資料 + + + + + + + + diff --git a/src/components/maintenance/master-detail-b/MasterDetailBSemesterSection.vue b/src/components/maintenance/master-detail-b/MasterDetailBSemesterSection.vue new file mode 100644 index 0000000..54fdfe2 --- /dev/null +++ b/src/components/maintenance/master-detail-b/MasterDetailBSemesterSection.vue @@ -0,0 +1,250 @@ + + + mdi-school + 子檔資料示範 + + + + + + + + {{ semester.semesterName }} + 點擊查看課程與成績 + + mdi-chevron-right + + + + 平均 {{ semester.average }} + 排名 {{ semester.rank }} + 課程 {{ semester.courses.length }} + + + + + + + + + + + + {{ semester.semesterName }} + + + + + 課程列表 + + + 加入課程 + + + + + + + + + 課程名稱 + {{ getSortIcon(semester.id, 'name') }} + + + + + 學分 + {{ getSortIcon(semester.id, 'credits') }} + + + + + 分數 + {{ getSortIcon(semester.id, 'score') }} + + + + + + + + {{ course.name }} + + {{ course.credits }} + + $emit('update-course', semester.id, originalIndex, { + credits: Number(value) || 0, + }) + " + /> + + + {{ course.score }} + + $emit('update-course', semester.id, originalIndex, { + score: Number(value) || 0, + }) + " + /> + + + + + + + + + mdi-book-open-outline + 尚無課程,點擊「加入課程」新增 + + + + + + + + + + + + + 尚無學期資料 + + + + diff --git a/src/components/maintenance/master-detail-c/MasterDetailCCourseMobilePanel.vue b/src/components/maintenance/master-detail-c/MasterDetailCCourseMobilePanel.vue new file mode 100644 index 0000000..e5766de --- /dev/null +++ b/src/components/maintenance/master-detail-c/MasterDetailCCourseMobilePanel.vue @@ -0,0 +1,152 @@ + + + + + + {{ semester?.semesterName || '課程明細' }} + + + + + + + + + + 學期平均 + {{ semester.average }} + + + 班級排名 + {{ semester.rank }} + + + 總學分 + {{ totalCredits }} + + + + + + 課程列表 + 手機版改用卡片式維護,不使用扁平表格 + + + + 加入課程 + + + + + + + + + {{ course.name }} + {{ course.code }} + + + {{ course.score }} 分 + + + + 學分 {{ course.credits }} + + + + + + 課程 {{ idx + 1 }} + + + + + + $emit('update-course', semester.id, idx, { name: String(value) })" + /> + $emit('update-course', semester.id, idx, { code: String(value) })" + /> + $emit('update-course', semester.id, idx, { credits: Number(value) || 0 })" + /> + $emit('update-course', semester.id, idx, { score: Number(value) || 0 })" + /> + + + + + + + 尚無課程資料 + + + + + + + diff --git a/src/components/maintenance/master-detail-c/MasterDetailCCourseSection.vue b/src/components/maintenance/master-detail-c/MasterDetailCCourseSection.vue new file mode 100644 index 0000000..dd7d974 --- /dev/null +++ b/src/components/maintenance/master-detail-c/MasterDetailCCourseSection.vue @@ -0,0 +1,142 @@ + + + mdi-school + 子檔資料示範 + + + 新增成績 + + + + + + + + + {{ semester.semesterName }} + 點擊查看課程與成績 + + mdi-chevron-right + + + + 平均 {{ semester.average }} + 排名 {{ semester.rank }} + 課程 {{ semester.courses.length }} + + + + + + + + + + + + {{ slotProps.item.semesterName }} + + + {{ slotProps.item.name }} + + + {{ slotProps.item.credits }} + $emit('update-course', slotProps.item.semesterId, slotProps.item.courseIndex, { credits: Number(value) || 0 }) + " /> + + + {{ slotProps.item.score }} + $emit('update-course', slotProps.item.semesterId, slotProps.item.courseIndex, { score: Number(value) || 0 }) + " /> + + + + + + + + + + + + 尚無成績資料 + + + + diff --git a/src/components/maintenance/master-detail/MasterDetailSemesterList.vue b/src/components/maintenance/master-detail/MasterDetailSemesterList.vue new file mode 100644 index 0000000..16acb38 --- /dev/null +++ b/src/components/maintenance/master-detail/MasterDetailSemesterList.vue @@ -0,0 +1,52 @@ + + + mdi-school + 子檔資料示範 ({{ semesters.length }}) + + + 新增學期 + + + + + + + + {{ semester.semesterName }} + + 平均: {{ semester.average }} ・ 排名: {{ semester.rank }} + + + mdi-chevron-right + + + + + + + + 尚無學期資料 + + + + diff --git a/src/components/maintenance/master-detail/MasterDetailSemesterPanel.vue b/src/components/maintenance/master-detail/MasterDetailSemesterPanel.vue new file mode 100644 index 0000000..ca4d70e --- /dev/null +++ b/src/components/maintenance/master-detail/MasterDetailSemesterPanel.vue @@ -0,0 +1,253 @@ + + + + + + {{ selectedSemester ? selectedSemester.semesterName : '學期明細' }} + + + + 刪除 + + + 編輯 + + + + + + + 編輯學期 + + + 儲存 + + + + + + + + + 學期平均 + {{ selectedSemester.average }} + + + 班級排名 + {{ selectedSemester.rank }} + + + 總學分 + {{ totalCredits }} + + + + + + + + + + {{ course.name }} + {{ course.code }} + + + {{ course.score }} 分 + + + + 學分 {{ course.credits }} + + + + 尚無課程資料 + + + + + + + 課程名稱 + 學分 + 分數 + + + + + + {{ course.name }} + {{ course.code }} + + {{ course.credits }} + + {{ course.score }} + + + + + + + + + + + + + + + + + + + + + + + 課程列表 + + + 加入課程 + + + + + + + 課程 {{ idx + 1 }} + + + + + + + + + + + + 暫無課程,請點擊上方按鈕新增 + + + + + + + + 課程資訊 + 學分 + 分數 + + + + + + + + + + + + + + + + + + + + 暫無課程,請點擊上方按鈕新增 + + + + + + + + + diff --git a/src/composables/useApiCall.ts b/src/composables/useApiCall.ts new file mode 100644 index 0000000..013e478 --- /dev/null +++ b/src/composables/useApiCall.ts @@ -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 (action: (...args: TArgs) => Promise, + options?: Options) { + const loading = ref(false) + const data = ref(null) + const error = ref(null) + + const snackbar = useSnackbarStore() + + const execute = async (...args: TArgs): Promise => { + 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 => { + 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, + } +} + diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a334323 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export { default as SKAdminLayout } from '@/components/layouts/SKAdminLayout.vue' +export { default as SKEmptyLayout } from '@/components/layouts/SKEmptyLayout.vue' diff --git a/src/router/guards.ts b/src/router/guards.ts new file mode 100644 index 0000000..73301cd --- /dev/null +++ b/src/router/guards.ts @@ -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) + }) +} diff --git a/src/router/index.ts b/src/router/index.ts index b66b893..7101059 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -6,16 +6,54 @@ // Composables 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({ history: createWebHistory(import.meta.env.BASE_URL), - routes: [ - { - path: '/', - component: Index, - }, - ], + routes, + scrollBehavior(to, _from, savedPosition) { + // Back/Forward 恢復滾動位置(Restore scroll position) + 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).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 diff --git a/src/router/routes.ts b/src/router/routes.ts new file mode 100644 index 0000000..be21c1c --- /dev/null +++ b/src/router/routes.ts @@ -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' }, + }, +] diff --git a/src/services/client.ts b/src/services/client.ts new file mode 100644 index 0000000..1564114 --- /dev/null +++ b/src/services/client.ts @@ -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() + diff --git a/src/services/error.ts b/src/services/error.ts new file mode 100644 index 0000000..1012c5e --- /dev/null +++ b/src/services/error.ts @@ -0,0 +1,125 @@ +import type { ApiError } from '@/types/api' +import { isAxiosError } from 'axios' + +function isRecord (value: unknown): value is Record { + 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 + 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) : 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 }) +} diff --git a/src/services/http-error.ts b/src/services/http-error.ts new file mode 100644 index 0000000..00a9c4a --- /dev/null +++ b/src/services/http-error.ts @@ -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(HTTP_ERROR_EVENT, { detail })) + + // 下一個 event loop 再允許觸發(避免把 guard 永久鎖住) + setTimeout(() => { + httpErrorEmitted = false + }, 0) +} + diff --git a/src/services/http-toast.ts b/src/services/http-toast.ts new file mode 100644 index 0000000..10d2ea6 --- /dev/null +++ b/src/services/http-toast.ts @@ -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(HTTP_TOAST_EVENT, { detail })) +} + diff --git a/src/services/interceptors.ts b/src/services/interceptors.ts new file mode 100644 index 0000000..fd3762d --- /dev/null +++ b/src/services/interceptors.ts @@ -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) + } + ) +} diff --git a/src/services/modules/auth.ts b/src/services/modules/auth.ts new file mode 100644 index 0000000..74d707e --- /dev/null +++ b/src/services/modules/auth.ts @@ -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('/Auth/get-captcha'), + login: (payload: FormData, options?: RequestOptions) => + httpClient.post('/Auth/login', payload, { + signal: options?.signal, + }), +} diff --git a/src/services/modules/menu.ts b/src/services/modules/menu.ts new file mode 100644 index 0000000..a228804 --- /dev/null +++ b/src/services/modules/menu.ts @@ -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('/Menu/GetMenu', payload, { + signal: options?.signal, + }), + getFavorite: (payload: MenuPayload, options?: RequestOptions) => + httpClient.post('/Menu/GetFavorite', payload, { + signal: options?.signal, + }), +} diff --git a/src/services/session.ts b/src/services/session.ts new file mode 100644 index 0000000..6356877 --- /dev/null +++ b/src/services/session.ts @@ -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) +} diff --git a/src/services/token.ts b/src/services/token.ts new file mode 100644 index 0000000..24fef40 --- /dev/null +++ b/src/services/token.ts @@ -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(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) + } +} diff --git a/src/stores/auth.ts b/src/stores/auth.ts new file mode 100644 index 0000000..f7b3d49 --- /dev/null +++ b/src/stores/auth.ts @@ -0,0 +1 @@ +export * from './stores/auth' diff --git a/src/stores/breadcrumbs.ts b/src/stores/breadcrumbs.ts new file mode 100644 index 0000000..c6dcece --- /dev/null +++ b/src/stores/breadcrumbs.ts @@ -0,0 +1 @@ +export * from './stores/breadcrumbs' diff --git a/src/stores/favorites.ts b/src/stores/favorites.ts new file mode 100644 index 0000000..16a8c43 --- /dev/null +++ b/src/stores/favorites.ts @@ -0,0 +1 @@ +export * from './stores/favorites' diff --git a/src/stores/loginAnnouncements.ts b/src/stores/loginAnnouncements.ts new file mode 100644 index 0000000..0e2466d --- /dev/null +++ b/src/stores/loginAnnouncements.ts @@ -0,0 +1 @@ +export * from './stores/loginAnnouncements' diff --git a/src/stores/menu.ts b/src/stores/menu.ts new file mode 100644 index 0000000..a203efd --- /dev/null +++ b/src/stores/menu.ts @@ -0,0 +1 @@ +export * from './stores/menu' diff --git a/src/stores/messages.ts b/src/stores/messages.ts new file mode 100644 index 0000000..1017b72 --- /dev/null +++ b/src/stores/messages.ts @@ -0,0 +1 @@ +export * from './stores/messages' diff --git a/src/stores/semesters.ts b/src/stores/semesters.ts new file mode 100644 index 0000000..f746495 --- /dev/null +++ b/src/stores/semesters.ts @@ -0,0 +1 @@ +export * from './stores/semesters' diff --git a/src/stores/snackbar.ts b/src/stores/snackbar.ts new file mode 100644 index 0000000..c148762 --- /dev/null +++ b/src/stores/snackbar.ts @@ -0,0 +1 @@ +export * from './stores/snackbar' diff --git a/src/stores/stores/auth.ts b/src/stores/stores/auth.ts new file mode 100644 index 0000000..72b9565 --- /dev/null +++ b/src/stores/stores/auth.ts @@ -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(null) + const token = tokenService.token + const loading = ref(false) + const error = ref(null) + const captcha = ref(null) + const captchaLoading = ref(false) + const captchaErrorMessage = ref(null) + // 只針對 login 取消重複請求,避免競態與重複提交 + const loginController = ref(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 + 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 + 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, + } +}) diff --git a/src/stores/stores/breadcrumbs.ts b/src/stores/stores/breadcrumbs.ts new file mode 100644 index 0000000..73eb5c3 --- /dev/null +++ b/src/stores/stores/breadcrumbs.ts @@ -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([]) + 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, + } +}) diff --git a/src/stores/stores/favorites.ts b/src/stores/stores/favorites.ts new file mode 100644 index 0000000..8bb53f4 --- /dev/null +++ b/src/stores/stores/favorites.ts @@ -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(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(() => + 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, + } +}) diff --git a/src/stores/stores/loginAnnouncements.ts b/src/stores/stores/loginAnnouncements.ts new file mode 100644 index 0000000..1039fcb --- /dev/null +++ b/src/stores/stores/loginAnnouncements.ts @@ -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 { + return [ + { + id: 'mobile-announcement-1', + content: '系統正常運行中', + title: '系統公告', + createdAt: '2026-02-11', + }, + ]; +} + +export const useLoginAnnouncementsStore = defineStore('loginAnnouncements', () => { + const items = ref(readItems()) + const selectedId = ref(null) + const mobileAnnouncements = ref([]) + + const listItems = computed(() => + 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, + } +}) diff --git a/src/stores/stores/menu.ts b/src/stores/stores/menu.ts new file mode 100644 index 0000000..3d42c05 --- /dev/null +++ b/src/stores/stores/menu.ts @@ -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([]) + const favorite = ref([]) + const isRail = ref(false) + const error = ref(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(() => toLayoutMenuItems(menu.value)) + const favoriteItems = computed(() => 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, + } +}) diff --git a/src/stores/stores/messages.ts b/src/stores/stores/messages.ts new file mode 100644 index 0000000..b4d8b36 --- /dev/null +++ b/src/stores/stores/messages.ts @@ -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, + } +}) diff --git a/src/stores/stores/semesters.ts b/src/stores/stores/semesters.ts new file mode 100644 index 0000000..f611f99 --- /dev/null +++ b/src/stores/stores/semesters.ts @@ -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() + + 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([...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) => { + 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 + } +}) diff --git a/src/stores/stores/snackbar.ts b/src/stores/stores/snackbar.ts new file mode 100644 index 0000000..165e8e9 --- /dev/null +++ b/src/stores/stores/snackbar.ts @@ -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('success') + const timeout = ref(2000) + const location = ref('top right') + const variant = ref('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, + } +}) diff --git a/src/stores/stores/students.ts b/src/stores/stores/students.ts new file mode 100644 index 0000000..2e6c979 --- /dev/null +++ b/src/stores/stores/students.ts @@ -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([...seedStudents]) + const deletedIds = ref>(new Set()) + + // Actions + const addStudent = (payload: Omit) => { + 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) => { + 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, + } +}) diff --git a/src/stores/students.ts b/src/stores/students.ts new file mode 100644 index 0000000..c04f405 --- /dev/null +++ b/src/stores/students.ts @@ -0,0 +1 @@ +export * from './stores/students' diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..bdd491a --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,23 @@ +export interface ApiError { + code?: number + errors?: Record +} + +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 +} diff --git a/src/utils/theme.ts b/src/utils/theme.ts new file mode 100644 index 0000000..c2b9307 --- /dev/null +++ b/src/utils/theme.ts @@ -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] +} diff --git a/src/views/Analysis.vue b/src/views/Analysis.vue new file mode 100644 index 0000000..11b86ba --- /dev/null +++ b/src/views/Analysis.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue new file mode 100644 index 0000000..c6bb606 --- /dev/null +++ b/src/views/Dashboard.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/src/views/DeptManagement.vue b/src/views/DeptManagement.vue new file mode 100644 index 0000000..e2b1315 --- /dev/null +++ b/src/views/DeptManagement.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/src/views/FncPage.vue b/src/views/FncPage.vue new file mode 100644 index 0000000..36c909a --- /dev/null +++ b/src/views/FncPage.vue @@ -0,0 +1,14 @@ + + + {{ fncId }} + + + + + diff --git a/src/views/Home.vue b/src/views/Home.vue new file mode 100644 index 0000000..5bff146 --- /dev/null +++ b/src/views/Home.vue @@ -0,0 +1,245 @@ + + + + + + 👋 + + + 歡迎使用校務資訊系統 + + 使用頂部搜尋框快速找到功能,或從左側選單瀏覽所有系統模組 + + + + + + 📰 最新消息 + + + + + + + + + {{ resolveNewsItem(wrapped).date }} + {{ resolveNewsItem(wrapped).month }} + + + + {{ resolveNewsItem(wrapped).title }} + + NEW + + + + {{ resolveNewsItem(wrapped).desc }} + + + + mdi-folder-outline + {{ resolveNewsItem(wrapped).dept }} + + + mdi-eye-outline + {{ resolveNewsItem(wrapped).views }} 次瀏覽 + + + + + + + + + + + + + + ✉️ + + + 訊息中心 + 12 筆未讀 + + + 查看全部 → + + + + 🚀 快速存取 + + + + {{ item.icon }} + {{ item.title }} + + + + + + + + + + + {{ selectedNews.title }} + + + {{ selectedNews.month }} {{ selectedNews.date }} · {{ selectedNews.dept }} · + {{ selectedNews.views }} 次瀏覽 + + + {{ selectedNews.desc }} + + + 關閉 + + + + + + + + + diff --git a/src/views/Login.vue b/src/views/Login.vue new file mode 100644 index 0000000..77decc0 --- /dev/null +++ b/src/views/Login.vue @@ -0,0 +1,259 @@ + + + + + + {{ dialogTitle }} + {{ dialogMessage }} + + + + {{ t('common.ok') }} + + + + + + + + + {{ selectedAnnouncement?.title }} + + + {{ selectedAnnouncement?.date }} ・ {{ selectedAnnouncement?.school }} + + + {{ selectedAnnouncementDetail }} + + + + + 關閉 + + + + + + + diff --git a/src/views/MenuManagement.vue b/src/views/MenuManagement.vue new file mode 100644 index 0000000..370069b --- /dev/null +++ b/src/views/MenuManagement.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/src/views/RoleManagement.vue b/src/views/RoleManagement.vue new file mode 100644 index 0000000..4d421dd --- /dev/null +++ b/src/views/RoleManagement.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/src/views/Settings.vue b/src/views/Settings.vue new file mode 100644 index 0000000..52f1e87 --- /dev/null +++ b/src/views/Settings.vue @@ -0,0 +1,5 @@ + + 設定頁面 + + + diff --git a/src/views/errors/ErrorShell.vue b/src/views/errors/ErrorShell.vue new file mode 100644 index 0000000..928c86d --- /dev/null +++ b/src/views/errors/ErrorShell.vue @@ -0,0 +1,110 @@ + + + + + + + {{ icon }} + {{ title }} + {{ codeLabel }} + + + + + + + {{ description }} + + + {{ backendMessage }} + + + + + 返回上一頁 + + 回到首頁 + + 前往登入 + + + + + + + + + + + + diff --git a/src/views/errors/Forbidden.vue b/src/views/errors/Forbidden.vue new file mode 100644 index 0000000..ae1dcdb --- /dev/null +++ b/src/views/errors/Forbidden.vue @@ -0,0 +1,13 @@ + + + + + diff --git a/src/views/errors/Maintenance.vue b/src/views/errors/Maintenance.vue new file mode 100644 index 0000000..709bae4 --- /dev/null +++ b/src/views/errors/Maintenance.vue @@ -0,0 +1,15 @@ + + + + + + diff --git a/src/views/errors/NetworkError.vue b/src/views/errors/NetworkError.vue new file mode 100644 index 0000000..b5e38b9 --- /dev/null +++ b/src/views/errors/NetworkError.vue @@ -0,0 +1,14 @@ + + + + + + diff --git a/src/views/errors/NotFound.vue b/src/views/errors/NotFound.vue new file mode 100644 index 0000000..ad75b5b --- /dev/null +++ b/src/views/errors/NotFound.vue @@ -0,0 +1,14 @@ + + + + + diff --git a/src/views/errors/ServerError.vue b/src/views/errors/ServerError.vue new file mode 100644 index 0000000..df01e31 --- /dev/null +++ b/src/views/errors/ServerError.vue @@ -0,0 +1,14 @@ + + + + + + diff --git a/src/views/errors/ServiceUnavailable.vue b/src/views/errors/ServiceUnavailable.vue new file mode 100644 index 0000000..5e5cb31 --- /dev/null +++ b/src/views/errors/ServiceUnavailable.vue @@ -0,0 +1,14 @@ + + + + + + diff --git a/src/views/maint/EditableGridMnt.vue b/src/views/maint/EditableGridMnt.vue new file mode 100644 index 0000000..086e672 --- /dev/null +++ b/src/views/maint/EditableGridMnt.vue @@ -0,0 +1,392 @@ + + + + + 可編輯表格維護示範 + + {{ hasAnyChange ? '有未儲存變更' : '已同步' }} + + 筆數 {{ filteredStudents.length }} + + 條件搜尋 + + + + + + + + + + + 學號 + + + + 姓名 + + + + 系所 + + + + + + + + 批次刪除 + + + + 儲存變更 + + + 取消變更 + + + + + + + + + + + toggleSingleRowSelection(item.id, checked)" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 刪除 + + + + + + + + + + + + + diff --git a/src/views/maint/MasterDetailMnt.vue b/src/views/maint/MasterDetailMnt.vue new file mode 100644 index 0000000..74bb395 --- /dev/null +++ b/src/views/maint/MasterDetailMnt.vue @@ -0,0 +1,1108 @@ + + + + + 學號 + + + + 姓名 + + + + 系所 + + + + 年級 + + + + 狀態 + + + + 清除 + 查詢 + + + + + + {{ gradeLabel(item.grade) }} + + + + {{ item.status }} + + + + + + 檢視 + + + 修改 + + + 刪除 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 請先修正以下問題 + + + {{ error.message }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 取消 + + 刪除 + + + 儲存 + + 關閉 + + + + 取消 + + 刪除 + + + 儲存 + + 關閉 + + + + + + + + + + + + + + + {{ item.label }} + + 原: + + {{ item.before }} + + + + 新: + + {{ item.after }} + + + + + 目前沒有可儲存的變更。 + + + + + + + + + + + 儲存成功 + + + + + + diff --git a/src/views/maint/MasterDetailMntB.vue b/src/views/maint/MasterDetailMntB.vue new file mode 100644 index 0000000..9ee91d9 --- /dev/null +++ b/src/views/maint/MasterDetailMntB.vue @@ -0,0 +1,1142 @@ + + + + + 學號 + + + + 姓名 + + + + 系所 + + + + 年級 + + + + 狀態 + + + + 清除 + 查詢 + + + + + + {{ gradeLabel(item.grade) }} + + + + {{ item.status }} + + + + + + 檢視 + + + 修改 + + + 刪除 + + + + + + + + + + + + + + + + + + + + + + + + + 請先修正以下問題 + + + {{ error.message }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 取消 + + 刪除 + + + 儲存 + + 關閉 + + + + 取消 + + 刪除 + + + 儲存 + + 關閉 + + + + + + + + + + + + + {{ item.label }} + + 原: + + {{ item.before }} + + + + 新: + + {{ item.after }} + + + + + 目前沒有可儲存的變更。 + + + + + + + + + + + + + + + + mdi-book-plus + 加入課程 + + + + + + + + + + + + + + + 取消 + 加入 + + + + + + + 儲存成功 + + + + + + diff --git a/src/views/maint/MasterDetailMntC.vue b/src/views/maint/MasterDetailMntC.vue new file mode 100644 index 0000000..eafa2c3 --- /dev/null +++ b/src/views/maint/MasterDetailMntC.vue @@ -0,0 +1,1132 @@ + + + + + 學號 + + + + 姓名 + + + + 系所 + + + + 年級 + + + + 狀態 + + + + 清除 + 查詢 + + + + + + {{ gradeLabel(item.grade) }} + + + + {{ item.status }} + + + + + + 檢視 + + + 修改 + + + 刪除 + + + + + + + + + + + + + + + + + + + + + + + + + 請先修正以下問題 + + + {{ error.message }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 取消 + + 刪除 + + + 儲存 + + 關閉 + + + + 取消 + + 刪除 + + + 儲存 + + 關閉 + + + + + + + + + + + + + {{ item.label }} + + 原: + + {{ item.before }} + + + + 新: + + {{ item.after }} + + + + + 目前沒有可儲存的變更。 + + + + + + + + + + + 儲存成功 + + + + + + + mdi-school + 新增成績 + + + + + + + + + + 取消 + 新增 + + + + + + + + diff --git a/src/views/maint/SingleRecordMnt.vue b/src/views/maint/SingleRecordMnt.vue new file mode 100644 index 0000000..eac52fa --- /dev/null +++ b/src/views/maint/SingleRecordMnt.vue @@ -0,0 +1,886 @@ + + + + + 學號 + + + + 姓名 + + + + 系所 + + + + 年級 + + + + 狀態 + + + + 清除 + 查詢 + + + + + + {{ gradeLabel(item.grade) }} + + + + {{ item.status }} + + + + + + 檢視 + + + 修改 + + + 刪除 + + + + + + + + + + + + + + + + + + + + 請先修正以下問題 + + + {{ error.message }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 取消 + + 刪除 + + + 儲存 + + 關閉 + + + + + + + + + + + + + + {{ item.label }} + + 原: + + {{ item.before }} + + + + 新: + + {{ item.after }} + + + + + 目前沒有可儲存的變更。 + + + + + + + + + + + 儲存成功 + + + + + +
{{ props.welcomeDescription }}
{{ description }}
+ {{ description }} +