diff --git a/src/components/README.md b/src/components/README.md
index 0675b96..fd44950 100644
--- a/src/components/README.md
+++ b/src/components/README.md
@@ -1,35 +1,25 @@
# Components
-Vue template files in this folder are automatically imported.
+`src/components` 放 Vue 元件,包含 layout、page component、feature/domain component 與少量跨頁共用元件。
-## 🚀 Usage
+## 目前結構
-Importing is handled by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components). This plugin automatically imports `.vue` files created in the `src/components` directory, and registers them as global components. This means that you can use any component in your application without having to manually import it.
+- `PageLogin.vue`、`PageIndex.vue`、`PageMaint.vue`:頁面型元件,接收 view 組好的資料與事件,負責完整頁面主畫面組裝。
+- `layouts/*`:app shell 與 layout 子元件。`MainLayout.vue` 負責主框架,`PlainLayout.vue` 負責不套主框架的頁面。
+- `layouts/main-layout/*`:`MainLayout.vue` 拆出的 drawer、app bar、breadcrumb、favorites 等骨架子元件。
+- `login/*`:登入頁專用 UI 區塊,服務 `PageLogin.vue`。
+- `maint/*`:maintenance 領域元件,服務 `views/maint/*`。
+- `maint/master-detail/*`:master-detail 維護頁專用子元件。
+- `base/*`:真正跨頁重用且不屬於特定 domain 的基礎元件。
-The following example assumes a component located at `src/components/MyComponent.vue`:
+## 使用規則
-```vue
-
-
-
-
-
+- 不要假設 `src/components` 會自動全域註冊元件;需要使用元件時,依照目前 Vue SFC 慣例明確 import。
+- 直接被 route 載入的檔案放在 `src/views`,不要放在 `src/components`。
+- 負責完整頁面主畫面組裝的元件使用 `Page` 前綴。
+- 只服務單一功能或 domain 的元件,放在對應資料夾,不要放進 `base`。
+- layout 元件只處理 app shell 與框架 UI,不放頁面專屬業務流程。
-
-```
+## 資料流
-When your template is rendered, the component's import will automatically be inlined, which renders to this:
-
-```vue
-
-
-
-
-
-
-
-```
+component 以 props 接收資料,以 emit 回報使用者事件。需要跨頁共享的狀態交給 `src/stores`;可重用流程或較複雜 UI state 放到 `src/composables`。
diff --git a/src/plugins/README.md b/src/plugins/README.md
index 62201c7..c68495e 100644
--- a/src/plugins/README.md
+++ b/src/plugins/README.md
@@ -1,3 +1,16 @@
# Plugins
-Plugins are a way to extend the functionality of your Vue application. Use this folder for registering plugins that you want to use globally.
+`src/plugins` 負責集中註冊全域 Vue plugin。`src/main.ts` 只需要呼叫 `registerPlugins(app)`。
+
+## 目前檔案
+
+- `index.ts`:統一註冊 Vuetify、Pinia、Vue I18n 與 Vue Router。
+- `vuetify.ts`:建立 Vuetify instance,設定 MDI SVG icon set 與預設 theme。
+- `i18n.ts`:建立 Vue I18n instance,載入 `src/language/en-US.json` 與 `src/language/zh-TW.json`。
+
+## 使用規則
+
+- 新增需要 app-wide 安裝的 plugin 時,先建立獨立設定檔,再在 `index.ts` 註冊。
+- 不要在 view 或 component 內重複安裝 plugin。
+- Vuetify theme 設定放在 `src/styles/themes.ts`,不要直接塞在 component。
+- 語系文字放在 `src/language/*.json`,不要散落在 plugin 設定檔。
diff --git a/src/services/README.md b/src/services/README.md
index db4b40e..1197b76 100644
--- a/src/services/README.md
+++ b/src/services/README.md
@@ -1,115 +1,64 @@
-元件 (Component)
-↓ 呼叫
-Store (Pinia) ← 管理狀態、快取
-↓ 呼叫
-API Service ← 封裝業務邏輯
-↓ 呼叫
-HTTP Client ← Axios 實例、攔截器
+# Services
-## 目前的資料流(以登入為例)
+`src/services` 是資料存取與 HTTP 邊界,負責封裝 axios client、interceptor、token/session、錯誤處理與 API 模組。
-1. `views/Login.vue`(Playground 頁面)只負責表單/驗證碼/導頁等 UI 行為
-2. `stores/auth.ts` 統一負責登入狀態(`user`/`token`/`loading`/`error`)
-3. `services/modules/user.ts` 封裝 `login/getProfile/...` 端點
-4. `services/client.ts` 建立 `axios` instance
-5. `services/interceptors.ts` 統一注入 token 與處理 HTTP 錯誤
+## 目前資料流
-## Menu API 與資料結構
-
-選單系統採用 API 驅動設計:
-
-### API 端點
-
-- `GET /service/api/menu`:取得完整選單樹
-- `GET /service/api/menu/favorite`:取得使用者收藏選單
-
-### 資料結構
-
-```ts
-interface MenuNode {
- mdl_id: string // 模組 ID
- mdl_name: string // 模組名稱
- unt_id?: string // 單位 ID
- unt_name?: string // 單位名稱
- fnc_id?: string // 功能 ID
- fnc_name?: string // 功能名稱
- children?: MenuNode[]
-}
+```txt
+component/view -> store/composable -> service module -> httpClient -> interceptor
```
-### 階層關係
+原則:
-- **第一層**:模組(mdl)
-- **第二層**:單位(unt)
-- **第三層**:功能(fnc),作為葉節點使用 `fnc_id` 作為路由路徑
+- component 不直接處理底層 HTTP client、token、interceptor 或錯誤正規化。
+- store 或 composable 負責協調 UI 狀態與呼叫 service。
+- service 回傳資料,不持有 UI 狀態。
+- service 不 import component、view 或 store。
-### Store 持久化
+## 目前檔案
-`stores/menu.ts` 提供:
+- `client.ts`:建立單一 axios instance,設定 `baseURL`、timeout、credentials 與 interceptor。
+- `interceptors.ts`:集中處理 request token 注入與 response 錯誤。
+- `error.ts`:提供 `normalizeError()` 與統一錯誤型別。
+- `http-error.ts`:提供全域 HTTP 錯誤事件。
+- `http-toast.ts`:提供 HTTP 錯誤提示相關流程。
+- `token.ts`:提供 token 單一來源,並同步 localStorage。
+- `session.ts`:提供 session 相關流程。
+- `modules/auth.ts`:封裝登入與驗證碼 API。
+- `modules/menu.ts`:封裝選單與收藏選單 API。
-- 自動 localStorage 持久化選單與收藏
-- 初始化時自動還原資料
-- 登出時清除快取
+## API 模組規則
-## API 前綴:`/api`
+新增 API 時,優先放在 `src/services/modules/.ts`。
-目前 Playground 已將 `api` 資料夾更名為 `services`,避免與 API 前綴 `/api` 衝突。
+API module 應:
-在開發模式下:
-
-- 前端呼叫一律使用 `/service/api/*`
-- Vite dev server 透過 proxy 將 `/service/*` 轉送到後端(目前指向 `http://192.168.89.54:9002`)
+- 使用 `httpClient` 發 request。
+- 匯出清楚命名的 API 物件,例如 `authApi`、`menuApi`。
+- 定義與該 module 相關的 request/response 型別。
+- 接收 `AbortSignal` 等 request option,但不管理頁面 loading 或 controller 狀態。
## HTTP Client 設定
-- `baseURL`:優先使用 `VITE_API_BASE_URL`,否則預設 `/service/api`(搭配 Vite proxy)
-- `Content-Type`:預設 `application/json`
+`client.ts` 的 `baseURL` 優先使用 `VITE_API_BASE_URL`,否則使用 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到後端。
-## Token Service(單一來源)
+目前 API 呼叫範例:
-為避免「Pinia token」與「localStorage token」不同步的問題,這裡採用單一來源:
+- `authApi.getCaptcha()` -> `/Auth/get-captcha`
+- `authApi.login()` -> `/Auth/login`
+- `menuApi.getMenu()` -> `/Menu/GetMenu`
+- `menuApi.getFavorite()` -> `/Menu/GetFavorite`
-- `services/token.ts` 使用 `ref` 保存 token,並同步 localStorage
-- Store 與 Interceptor 都只透過 `tokenService` 讀寫 token
-- 401 時清除 token,可立即同步到 UI
+## Token 與錯誤處理
-## Token 注入策略(Interceptor)
+token 由 `tokenService` 作為單一來源:
-Interceptor 會從 `tokenService` 讀取 `token` 並注入 `Authorization: Bearer `。
+- store 負責登入成功後寫入 token,以及登出時清除 token。
+- interceptor 只讀取 token 並附加到 request。
+- 401 或 HTTP 錯誤由 interceptor 與錯誤事件流程集中處理。
-這樣做的原因是避免循環依賴:
+錯誤透過 `normalizeError()` 轉成 UI 可理解的格式。UI 或 store 不需要直接理解 AxiosError。
-`store(auth) -> services(userApi) -> httpClient -> interceptors -> store(auth)`
+## 請求取消
-Store 仍然是「唯一負責更新 token 的地方」,Interceptor 只負責「讀取 token 並附加到 request」。
-
-## 錯誤正規化(normalizeError)
-
-為了讓 UI 不需要理解 AxiosError,這裡將錯誤統一成 `ApiRequestError`:
-
-- `services/error.ts` 提供 `normalizeError()` 與 `ApiRequestError`
-- Interceptor 在 response error 時呼叫 `normalizeError()`
-- Store 只需要處理 `error.message / error.code / error.status`
-
-最低限度的映射規則:
-
-- 有 `response.data.message` 優先使用
-- 其次使用 `AxiosError.message`
-- 都沒有則顯示 `請求失敗`
-
-## 請求取消(AbortController)
-
-取消策略採「同類型請求互斥」,目前示範在 `login`:
-
-- Store 建立 `AbortController`,每次登入前先取消前一次
-- Service 只接收 `signal`,不管理 controller 狀態
-- `normalizeError` 會將取消行為轉為 `CanceledRequestError`
-- UI 不顯示取消造成的錯誤訊息
-
-| DECISION | WHY | WHY NOT |
-| -------------------------------- | -------------------------------------- | -------------------------------------------------------------------- |
-| Store 呼叫 API,不是元件直接呼叫 | 狀態集中管理、可快取、易測試 | 元件直接呼叫會導致狀態散落 |
-| API 模組化(userApi、orderApi) | 關注點分離、好維護 | 全塞一個檔案會變超大 |
-| Interceptor 獨立檔案 | 單一職責、好測試 | 寫在 client.ts 會雜亂 |
-| 泛型 ApiResponse | 型別安全、IDE 提示 | 用 any 會失去 TS 優勢 |
-| API 前綴使用 `/api` | 使用習慣一致、搭配 Vite proxy 容易理解 | 若前端資料夾也叫 `api` 容易造成路徑衝突,因此此專案改名為 `services` |
+需要取消請求時,由 store 或 composable 建立 `AbortController`,service module 只接收 `signal`。不要讓 service module 持有 controller 或 UI 狀態。
diff --git a/src/services/interceptors.ts b/src/services/interceptors.ts
index 50b1ba0..20c4bdf 100644
--- a/src/services/interceptors.ts
+++ b/src/services/interceptors.ts
@@ -10,7 +10,7 @@ import { tokenService } from './token'
// 設計重點:
// - Request:自動注入 token(從 localStorage 讀取)
// - 使用 tokenService 作為單一來源,避免 interceptor 直接 import Pinia store 造成循環依賴
-// store(auth) -> services(userApi) -> httpClient -> interceptors -> store(auth)
+// store(auth) -> services(authApi/menuApi) -> httpClient -> interceptors -> store(auth)
// - Response:統一處理 HTTP 錯誤(目前示範 401/403/500)
// - 使用 normalizeError 將錯誤轉成 ApiRequestError
//
diff --git a/src/stores/auth.ts b/src/stores/auth.ts
index f7b3d49..6dd279b 100644
--- a/src/stores/auth.ts
+++ b/src/stores/auth.ts
@@ -1 +1,146 @@
-export * from './stores/auth'
+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', () => {
+ 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)
+
+ const isAuthenticated = computed(() => !!token.value)
+ const roles = computed(() => (user.value?.role ? [user.value.role] : []))
+
+ 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/breadcrumbs.ts b/src/stores/breadcrumbs.ts
index c6dcece..4c47ebc 100644
--- a/src/stores/breadcrumbs.ts
+++ b/src/stores/breadcrumbs.ts
@@ -1 +1,124 @@
-export * from './stores/breadcrumbs'
+import type { LayoutMenuItem } from './menu'
+import { mdiHome } from '@mdi/js'
+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(mdiHome)
+
+ 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/favorites.ts b/src/stores/favorites.ts
index 16a8c43..d778c63 100644
--- a/src/stores/favorites.ts
+++ b/src/stores/favorites.ts
@@ -1 +1,147 @@
-export * from './stores/favorites'
+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/loginAnnouncements.ts b/src/stores/loginAnnouncements.ts
index 0e2466d..bf246ec 100644
--- a/src/stores/loginAnnouncements.ts
+++ b/src/stores/loginAnnouncements.ts
@@ -1 +1,209 @@
-export * from './stores/loginAnnouncements'
+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/menu.ts b/src/stores/menu.ts
index a203efd..4b01f08 100644
--- a/src/stores/menu.ts
+++ b/src/stores/menu.ts
@@ -1 +1,236 @@
-export * from './stores/menu'
+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/messages.ts b/src/stores/messages.ts
index 1017b72..b4d8b36 100644
--- a/src/stores/messages.ts
+++ b/src/stores/messages.ts
@@ -1 +1,30 @@
-export * from './stores/messages'
+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/semesters.ts b/src/stores/semesters.ts
index f746495..532ca62 100644
--- a/src/stores/semesters.ts
+++ b/src/stores/semesters.ts
@@ -1 +1,157 @@
-export * from './stores/semesters'
+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[] = []
+
+const randomScore = (min = 60, max = 99) => Math.floor(Math.random() * (max - min + 1)) + min
+
+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 },
+ ]
+
+ const count = 5 + (studentId % 2)
+ const result: SemesterRecord[] = []
+
+ for (let i = 0; i < count; i++) {
+ const sem = semesters[i]
+ if (!sem) continue
+ 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
+}
+
+for (let i = 1; i <= 20; i++) {
+ seedSemesters.push(...generateMockSemesters(i))
+}
+
+export const useSemesterStore = defineStore('semesters', () => {
+ const semesters = ref([...seedSemesters])
+
+ 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
+
+ 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/snackbar.ts b/src/stores/snackbar.ts
index c148762..165e8e9 100644
--- a/src/stores/snackbar.ts
+++ b/src/stores/snackbar.ts
@@ -1 +1,52 @@
-export * from './stores/snackbar'
+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/auth.ts b/src/stores/stores/auth.ts
deleted file mode 100644
index 72b9565..0000000
--- a/src/stores/stores/auth.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-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
deleted file mode 100644
index 4c47ebc..0000000
--- a/src/stores/stores/breadcrumbs.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import type { LayoutMenuItem } from './menu'
-import { mdiHome } from '@mdi/js'
-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(mdiHome)
-
- 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
deleted file mode 100644
index d778c63..0000000
--- a/src/stores/stores/favorites.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-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
deleted file mode 100644
index bf246ec..0000000
--- a/src/stores/stores/loginAnnouncements.ts
+++ /dev/null
@@ -1,209 +0,0 @@
-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
deleted file mode 100644
index 4b01f08..0000000
--- a/src/stores/stores/menu.ts
+++ /dev/null
@@ -1,236 +0,0 @@
-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
deleted file mode 100644
index b4d8b36..0000000
--- a/src/stores/stores/messages.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-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
deleted file mode 100644
index ac71535..0000000
--- a/src/stores/stores/semesters.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-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
deleted file mode 100644
index 165e8e9..0000000
--- a/src/stores/stores/snackbar.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-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
deleted file mode 100644
index 2e6c979..0000000
--- a/src/stores/stores/students.ts
+++ /dev/null
@@ -1,345 +0,0 @@
-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
index c04f405..8dcda7a 100644
--- a/src/stores/students.ts
+++ b/src/stores/students.ts
@@ -1 +1,343 @@
-export * from './stores/students'
+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', () => {
+ const students = ref([...seedStudents])
+ const deletedIds = ref>(new Set())
+
+ 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/styles/README.md b/src/styles/README.md
index ea86179..05065c7 100644
--- a/src/styles/README.md
+++ b/src/styles/README.md
@@ -1,3 +1,16 @@
# Styles
-This directory is for configuring the styles of the application.
+`src/styles` 放 Vuetify 與全域樣式設定。
+
+## 目前檔案
+
+- `settings.scss`:Vuetify SASS 設定,目前主要設定全域字型。
+- `themes.ts`:Vuetify theme definitions,供 `src/plugins/vuetify.ts` 使用。
+
+## 使用規則
+
+- Vuetify SASS 變數與全域字型設定放在 `settings.scss`。
+- 新增或調整 Vuetify theme 時,修改 `themes.ts`,並確認 `src/plugins/vuetify.ts` 有使用對應 theme。
+- component 專屬樣式優先寫在該 `.vue` 檔的 `