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` 檔的 `