From 069141794e5664e91c4f7646974aac2a6710b825 Mon Sep 17 00:00:00 2001 From: skytek_xinliang Date: Thu, 26 Mar 2026 11:24:37 +0800 Subject: [PATCH] feat: add SingleRecordMnt component for student record maintenance with search, add, edit, view, and delete functionalities --- .npmrc | 16 + eslint.config.js | 14 +- package.json | 1 + pnpm-lock.yaml | 190 +++ public/robot-svgrepo-com.svg | 5 + public/web.config | 18 + src/App.vue | 525 +++++++- src/components/HelloWorld.vue | 95 +- src/components/SKAnalysis.vue | 110 ++ src/components/SKDashboard.vue | 137 ++ src/components/SKDeptManagement.vue | 253 ++++ src/components/SKDraggableDialog.vue | 0 src/components/SKLogin.vue | 452 +++++++ src/components/SKMenuManagement.vue | 209 +++ src/components/SKRoleManagement.vue | 263 ++++ src/components/base/SKFormEditDialog.vue | 197 +++ src/components/base/SKSearchFilter.vue | 145 +++ src/components/base/SKTableActionBar.vue | 161 +++ src/components/base/SKTreeTable.vue | 137 ++ .../base/analysis/AnalysisBarChart.vue | 38 + .../base/analysis/AnalysisDonutChart.vue | 42 + .../base/analysis/AnalysisPieChart.vue | 56 + .../base/analysis/AnalysisStatsCard.vue | 35 + .../base/analysis/AnalysisTrendChart.vue | 68 + .../base/dashboard/DashboardAnnouncements.vue | 55 + .../base/dashboard/DashboardApps.vue | 80 ++ .../base/dashboard/DashboardChart.vue | 52 + .../base/dashboard/DashboardHeader.vue | 56 + .../base/dashboard/DashboardQuickNav.vue | 38 + .../base/dashboard/DashboardTodoList.vue | 40 + .../base/input_field/SKDatePicker.vue | 23 + .../base/input_field/SKSelectField.vue | 25 + .../base/input_field/SKTextField.vue | 23 + .../base/login/CreateAccountLink.vue | 45 + .../base/login/LoginAnnouncementBoard.vue | 187 +++ src/components/base/login/LoginBrand.vue | 14 + src/components/base/login/LoginForm.vue | 121 ++ src/components/base/login/LoginHeader.vue | 24 + .../base/login/LoginIllustration.vue | 44 + src/components/base/login/LoginToolBar.vue | 72 ++ src/components/base/login/LoginVerify.vue | 106 ++ src/components/layouts/SKAdminLayout.vue | 667 ++++++++++ src/components/layouts/SKEmptyLayout.vue | 13 + src/components/layouts/SKMainLayout.vue | 251 ++++ src/components/layouts/SKSimpleLayout.vue | 118 ++ .../SkAdminAppBarBreadcrumbCol.vue | 39 + .../SkAdminAppBarFavoritesCol.vue | 59 + .../sk-admin-layout/SkAdminAppBarTopCol.vue | 184 +++ .../SkAdminDrawerDesktopMenu.vue | 176 +++ .../SkAdminDrawerMobileFavoritesPanel.vue | 29 + .../SkAdminDrawerMobileMenuPanel.vue | 32 + .../maintenance/CommonConfirmDialog.vue | 66 + src/components/maintenance/MntDialogCard.vue | 63 + src/components/maintenance/MntPageCards.vue | 73 ++ .../maintenance/MntRecordNavToolbar.vue | 115 ++ .../MasterDetailBSemesterMobilePanel.vue | 183 +++ .../MasterDetailBSemesterSection.vue | 250 ++++ .../MasterDetailCCourseMobilePanel.vue | 152 +++ .../MasterDetailCCourseSection.vue | 142 ++ .../MasterDetailSemesterList.vue | 52 + .../MasterDetailSemesterPanel.vue | 253 ++++ src/composables/useApiCall.ts | 83 ++ src/index.ts | 2 + src/router/guards.ts | 55 + src/router/index.ts | 52 +- src/router/routes.ts | 124 ++ src/services/client.ts | 22 + src/services/error.ts | 125 ++ src/services/http-error.ts | 28 + src/services/http-toast.ts | 31 + src/services/interceptors.ts | 122 ++ src/services/modules/auth.ts | 15 + src/services/modules/menu.ts | 29 + src/services/session.ts | 26 + src/services/token.ts | 25 + src/stores/auth.ts | 1 + src/stores/breadcrumbs.ts | 1 + src/stores/favorites.ts | 1 + src/stores/loginAnnouncements.ts | 1 + src/stores/menu.ts | 1 + src/stores/messages.ts | 1 + src/stores/semesters.ts | 1 + src/stores/snackbar.ts | 1 + src/stores/stores/auth.ts | 149 +++ src/stores/stores/breadcrumbs.ts | 121 ++ src/stores/stores/favorites.ts | 147 +++ src/stores/stores/loginAnnouncements.ts | 209 +++ src/stores/stores/menu.ts | 239 ++++ src/stores/stores/messages.ts | 30 + src/stores/stores/semesters.ts | 165 +++ src/stores/stores/snackbar.ts | 52 + src/stores/stores/students.ts | 345 +++++ src/stores/students.ts | 1 + src/types/api.ts | 23 + src/utils/theme.ts | 7 + src/views/Analysis.vue | 81 ++ src/views/Dashboard.vue | 118 ++ src/views/DeptManagement.vue | 188 +++ src/views/FncPage.vue | 14 + src/views/Home.vue | 245 ++++ src/views/Login.vue | 259 ++++ src/views/MenuManagement.vue | 144 +++ src/views/RoleManagement.vue | 122 ++ src/views/Settings.vue | 5 + src/views/errors/ErrorShell.vue | 110 ++ src/views/errors/Forbidden.vue | 13 + src/views/errors/Maintenance.vue | 15 + src/views/errors/NetworkError.vue | 14 + src/views/errors/NotFound.vue | 14 + src/views/errors/ServerError.vue | 14 + src/views/errors/ServiceUnavailable.vue | 14 + src/views/maint/EditableGridMnt.vue | 392 ++++++ src/views/maint/MasterDetailMnt.vue | 1108 ++++++++++++++++ src/views/maint/MasterDetailMntB.vue | 1142 +++++++++++++++++ src/views/maint/MasterDetailMntC.vue | 1132 ++++++++++++++++ src/views/maint/SingleRecordMnt.vue | 886 +++++++++++++ 116 files changed, 15247 insertions(+), 107 deletions(-) create mode 100644 .npmrc create mode 100644 public/robot-svgrepo-com.svg create mode 100644 public/web.config create mode 100644 src/components/SKAnalysis.vue create mode 100644 src/components/SKDashboard.vue create mode 100644 src/components/SKDeptManagement.vue create mode 100644 src/components/SKDraggableDialog.vue create mode 100644 src/components/SKLogin.vue create mode 100644 src/components/SKMenuManagement.vue create mode 100644 src/components/SKRoleManagement.vue create mode 100644 src/components/base/SKFormEditDialog.vue create mode 100644 src/components/base/SKSearchFilter.vue create mode 100644 src/components/base/SKTableActionBar.vue create mode 100644 src/components/base/SKTreeTable.vue create mode 100644 src/components/base/analysis/AnalysisBarChart.vue create mode 100644 src/components/base/analysis/AnalysisDonutChart.vue create mode 100644 src/components/base/analysis/AnalysisPieChart.vue create mode 100644 src/components/base/analysis/AnalysisStatsCard.vue create mode 100644 src/components/base/analysis/AnalysisTrendChart.vue create mode 100644 src/components/base/dashboard/DashboardAnnouncements.vue create mode 100644 src/components/base/dashboard/DashboardApps.vue create mode 100644 src/components/base/dashboard/DashboardChart.vue create mode 100644 src/components/base/dashboard/DashboardHeader.vue create mode 100644 src/components/base/dashboard/DashboardQuickNav.vue create mode 100644 src/components/base/dashboard/DashboardTodoList.vue create mode 100644 src/components/base/input_field/SKDatePicker.vue create mode 100644 src/components/base/input_field/SKSelectField.vue create mode 100644 src/components/base/input_field/SKTextField.vue create mode 100644 src/components/base/login/CreateAccountLink.vue create mode 100644 src/components/base/login/LoginAnnouncementBoard.vue create mode 100644 src/components/base/login/LoginBrand.vue create mode 100644 src/components/base/login/LoginForm.vue create mode 100644 src/components/base/login/LoginHeader.vue create mode 100644 src/components/base/login/LoginIllustration.vue create mode 100644 src/components/base/login/LoginToolBar.vue create mode 100644 src/components/base/login/LoginVerify.vue create mode 100644 src/components/layouts/SKAdminLayout.vue create mode 100644 src/components/layouts/SKEmptyLayout.vue create mode 100644 src/components/layouts/SKMainLayout.vue create mode 100644 src/components/layouts/SKSimpleLayout.vue create mode 100644 src/components/layouts/sk-admin-layout/SkAdminAppBarBreadcrumbCol.vue create mode 100644 src/components/layouts/sk-admin-layout/SkAdminAppBarFavoritesCol.vue create mode 100644 src/components/layouts/sk-admin-layout/SkAdminAppBarTopCol.vue create mode 100644 src/components/layouts/sk-admin-layout/SkAdminDrawerDesktopMenu.vue create mode 100644 src/components/layouts/sk-admin-layout/SkAdminDrawerMobileFavoritesPanel.vue create mode 100644 src/components/layouts/sk-admin-layout/SkAdminDrawerMobileMenuPanel.vue create mode 100644 src/components/maintenance/CommonConfirmDialog.vue create mode 100644 src/components/maintenance/MntDialogCard.vue create mode 100644 src/components/maintenance/MntPageCards.vue create mode 100644 src/components/maintenance/MntRecordNavToolbar.vue create mode 100644 src/components/maintenance/master-detail-b/MasterDetailBSemesterMobilePanel.vue create mode 100644 src/components/maintenance/master-detail-b/MasterDetailBSemesterSection.vue create mode 100644 src/components/maintenance/master-detail-c/MasterDetailCCourseMobilePanel.vue create mode 100644 src/components/maintenance/master-detail-c/MasterDetailCCourseSection.vue create mode 100644 src/components/maintenance/master-detail/MasterDetailSemesterList.vue create mode 100644 src/components/maintenance/master-detail/MasterDetailSemesterPanel.vue create mode 100644 src/composables/useApiCall.ts create mode 100644 src/index.ts create mode 100644 src/router/guards.ts create mode 100644 src/router/routes.ts create mode 100644 src/services/client.ts create mode 100644 src/services/error.ts create mode 100644 src/services/http-error.ts create mode 100644 src/services/http-toast.ts create mode 100644 src/services/interceptors.ts create mode 100644 src/services/modules/auth.ts create mode 100644 src/services/modules/menu.ts create mode 100644 src/services/session.ts create mode 100644 src/services/token.ts create mode 100644 src/stores/auth.ts create mode 100644 src/stores/breadcrumbs.ts create mode 100644 src/stores/favorites.ts create mode 100644 src/stores/loginAnnouncements.ts create mode 100644 src/stores/menu.ts create mode 100644 src/stores/messages.ts create mode 100644 src/stores/semesters.ts create mode 100644 src/stores/snackbar.ts create mode 100644 src/stores/stores/auth.ts create mode 100644 src/stores/stores/breadcrumbs.ts create mode 100644 src/stores/stores/favorites.ts create mode 100644 src/stores/stores/loginAnnouncements.ts create mode 100644 src/stores/stores/menu.ts create mode 100644 src/stores/stores/messages.ts create mode 100644 src/stores/stores/semesters.ts create mode 100644 src/stores/stores/snackbar.ts create mode 100644 src/stores/stores/students.ts create mode 100644 src/stores/students.ts create mode 100644 src/types/api.ts create mode 100644 src/utils/theme.ts create mode 100644 src/views/Analysis.vue create mode 100644 src/views/Dashboard.vue create mode 100644 src/views/DeptManagement.vue create mode 100644 src/views/FncPage.vue create mode 100644 src/views/Home.vue create mode 100644 src/views/Login.vue create mode 100644 src/views/MenuManagement.vue create mode 100644 src/views/RoleManagement.vue create mode 100644 src/views/Settings.vue create mode 100644 src/views/errors/ErrorShell.vue create mode 100644 src/views/errors/Forbidden.vue create mode 100644 src/views/errors/Maintenance.vue create mode 100644 src/views/errors/NetworkError.vue create mode 100644 src/views/errors/NotFound.vue create mode 100644 src/views/errors/ServerError.vue create mode 100644 src/views/errors/ServiceUnavailable.vue create mode 100644 src/views/maint/EditableGridMnt.vue create mode 100644 src/views/maint/MasterDetailMnt.vue create mode 100644 src/views/maint/MasterDetailMntB.vue create mode 100644 src/views/maint/MasterDetailMntC.vue create mode 100644 src/views/maint/SingleRecordMnt.vue 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 @@ - 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 @@ - - 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 @@ + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + + + + 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 @@ + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + +