feat(stores): add Pinia domain stores and update docs
Implement concrete Pinia stores for app UI and domain data instead of placeholder re-exports, including seeded student records and snackbar state. Refresh README guidance for components, plugins, and services to document the current project structure, data flow, and usage conventions.feat(stores): add Pinia domain stores and update docs Implement concrete Pinia stores for app UI and domain data instead of placeholder re-exports, including seeded student records and snackbar state. Refresh README guidance for components, plugins, and services to document the current project structure, data flow, and usage conventions.
This commit is contained in:
+17
-27
@@ -1,35 +1,25 @@
|
|||||||
# Components
|
# 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。
|
||||||
<template>
|
- 直接被 route 載入的檔案放在 `src/views`,不要放在 `src/components`。
|
||||||
<div>
|
- 負責完整頁面主畫面組裝的元件使用 `Page` 前綴。
|
||||||
<MyComponent />
|
- 只服務單一功能或 domain 的元件,放在對應資料夾,不要放進 `base`。
|
||||||
</div>
|
- layout 元件只處理 app shell 與框架 UI,不放頁面專屬業務流程。
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
## 資料流
|
||||||
//
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
When your template is rendered, the component's import will automatically be inlined, which renders to this:
|
component 以 props 接收資料,以 emit 回報使用者事件。需要跨頁共享的狀態交給 `src/stores`;可重用流程或較複雜 UI state 放到 `src/composables`。
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<MyComponent />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import MyComponent from '@/components/MyComponent.vue'
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|||||||
+14
-1
@@ -1,3 +1,16 @@
|
|||||||
# Plugins
|
# 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 設定檔。
|
||||||
|
|||||||
+41
-92
@@ -1,115 +1,64 @@
|
|||||||
元件 (Component)
|
# Services
|
||||||
↓ 呼叫
|
|
||||||
Store (Pinia) ← 管理狀態、快取
|
|
||||||
↓ 呼叫
|
|
||||||
API Service ← 封裝業務邏輯
|
|
||||||
↓ 呼叫
|
|
||||||
HTTP Client ← Axios 實例、攔截器
|
|
||||||
|
|
||||||
## 目前的資料流(以登入為例)
|
`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 與資料結構
|
```txt
|
||||||
|
component/view -> store/composable -> service module -> httpClient -> interceptor
|
||||||
選單系統採用 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[]
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 階層關係
|
原則:
|
||||||
|
|
||||||
- **第一層**:模組(mdl)
|
- component 不直接處理底層 HTTP client、token、interceptor 或錯誤正規化。
|
||||||
- **第二層**:單位(unt)
|
- store 或 composable 負責協調 UI 狀態與呼叫 service。
|
||||||
- **第三層**:功能(fnc),作為葉節點使用 `fnc_id` 作為路由路徑
|
- 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/<domain>.ts`。
|
||||||
|
|
||||||
目前 Playground 已將 `api` 資料夾更名為 `services`,避免與 API 前綴 `/api` 衝突。
|
API module 應:
|
||||||
|
|
||||||
在開發模式下:
|
- 使用 `httpClient` 發 request。
|
||||||
|
- 匯出清楚命名的 API 物件,例如 `authApi`、`menuApi`。
|
||||||
- 前端呼叫一律使用 `/service/api/*`
|
- 定義與該 module 相關的 request/response 型別。
|
||||||
- Vite dev server 透過 proxy 將 `/service/*` 轉送到後端(目前指向 `http://192.168.89.54:9002`)
|
- 接收 `AbortSignal` 等 request option,但不管理頁面 loading 或 controller 狀態。
|
||||||
|
|
||||||
## HTTP Client 設定
|
## HTTP Client 設定
|
||||||
|
|
||||||
- `baseURL`:優先使用 `VITE_API_BASE_URL`,否則預設 `/service/api`(搭配 Vite proxy)
|
`client.ts` 的 `baseURL` 優先使用 `VITE_API_BASE_URL`,否則使用 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到後端。
|
||||||
- `Content-Type`:預設 `application/json`
|
|
||||||
|
|
||||||
## 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
|
## Token 與錯誤處理
|
||||||
- Store 與 Interceptor 都只透過 `tokenService` 讀寫 token
|
|
||||||
- 401 時清除 token,可立即同步到 UI
|
|
||||||
|
|
||||||
## Token 注入策略(Interceptor)
|
token 由 `tokenService` 作為單一來源:
|
||||||
|
|
||||||
Interceptor 會從 `tokenService` 讀取 `token` 並注入 `Authorization: Bearer <token>`。
|
- 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」。
|
需要取消請求時,由 store 或 composable 建立 `AbortController`,service module 只接收 `signal`。不要讓 service module 持有 controller 或 UI 狀態。
|
||||||
|
|
||||||
## 錯誤正規化(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<T> | 型別安全、IDE 提示 | 用 any 會失去 TS 優勢 |
|
|
||||||
| API 前綴使用 `/api` | 使用習慣一致、搭配 Vite proxy 容易理解 | 若前端資料夾也叫 `api` 容易造成路徑衝突,因此此專案改名為 `services` |
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { tokenService } from './token'
|
|||||||
// 設計重點:
|
// 設計重點:
|
||||||
// - Request:自動注入 token(從 localStorage 讀取)
|
// - Request:自動注入 token(從 localStorage 讀取)
|
||||||
// - 使用 tokenService 作為單一來源,避免 interceptor 直接 import Pinia store 造成循環依賴
|
// - 使用 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)
|
// - Response:統一處理 HTTP 錯誤(目前示範 401/403/500)
|
||||||
// - 使用 normalizeError 將錯誤轉成 ApiRequestError
|
// - 使用 normalizeError 將錯誤轉成 ApiRequestError
|
||||||
//
|
//
|
||||||
|
|||||||
+146
-1
@@ -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<User | null>(null)
|
||||||
|
const token = tokenService.token
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const captcha = ref<CaptchaResponse | null>(null)
|
||||||
|
const captchaLoading = ref(false)
|
||||||
|
const captchaErrorMessage = ref<string | null>(null)
|
||||||
|
// 只針對 login 取消重複請求,避免競態與重複提交
|
||||||
|
const loginController = ref<AbortController | null>(null)
|
||||||
|
|
||||||
|
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<string, unknown>
|
||||||
|
const id = obj.id
|
||||||
|
const name = obj.name
|
||||||
|
const role = obj.role
|
||||||
|
if (typeof id !== 'string' || typeof name !== 'string' || typeof role !== 'string') return
|
||||||
|
return { id, name, role }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseLoginResult = (
|
||||||
|
raw: unknown
|
||||||
|
): {
|
||||||
|
accessToken?: string
|
||||||
|
tokenType?: string
|
||||||
|
expiresIn?: number
|
||||||
|
user?: User
|
||||||
|
message?: string
|
||||||
|
} => {
|
||||||
|
if (!raw || typeof raw !== 'object') return {}
|
||||||
|
|
||||||
|
const obj = raw as Record<string, unknown>
|
||||||
|
const accessToken = typeof obj.access_token === 'string' ? obj.access_token : undefined
|
||||||
|
const tokenType = typeof obj.token_type === 'string' ? obj.token_type : undefined
|
||||||
|
const expiresIn = typeof obj.expires_in === 'number' ? obj.expires_in : undefined
|
||||||
|
const user = parseUser(obj.user)
|
||||||
|
const message = typeof obj.message === 'string' ? obj.message : undefined
|
||||||
|
|
||||||
|
return { accessToken, tokenType, expiresIn, user, message }
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = parseLoginResult(data)
|
||||||
|
|
||||||
|
if (!result.accessToken) {
|
||||||
|
throw new Error(result.message || '登入回傳缺少 access_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
user.value = result.user ?? null
|
||||||
|
tokenService.setToken(result.accessToken)
|
||||||
|
} catch (error_) {
|
||||||
|
const normalizedError = normalizeError(error_)
|
||||||
|
if (normalizedError.name !== 'CanceledRequestError') {
|
||||||
|
error.value = normalizedError.message
|
||||||
|
}
|
||||||
|
throw normalizedError
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
loginController.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
user.value = null
|
||||||
|
tokenService.clearToken()
|
||||||
|
useMenuStore().clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCaptcha,
|
||||||
|
captcha,
|
||||||
|
captchaLoading,
|
||||||
|
captchaErrorMessage,
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
isAuthenticated,
|
||||||
|
roles,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
+124
-1
@@ -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<BreadcrumbItem[]>([])
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
+147
-1
@@ -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<FavoriteItem[]>(readFavorites())
|
||||||
|
const favoritesBarVisible = ref(true)
|
||||||
|
const breadcrumbBarVisible = ref(true)
|
||||||
|
|
||||||
|
const loadFavoritesBarVisible = () => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const stored = window.localStorage.getItem(favoritesBarStorageKey)
|
||||||
|
if (stored === null) return
|
||||||
|
favoritesBarVisible.value = stored === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistFavoritesBarVisible = () => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.localStorage.setItem(favoritesBarStorageKey, favoritesBarVisible.value ? '1' : '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadBreadcrumbBarVisible = () => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const stored = window.localStorage.getItem(breadcrumbBarStorageKey)
|
||||||
|
if (stored === null) return
|
||||||
|
breadcrumbBarVisible.value = stored === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistBreadcrumbBarVisible = () => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.localStorage.setItem(breadcrumbBarStorageKey, breadcrumbBarVisible.value ? '1' : '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
const add = (item: FavoriteItem) => {
|
||||||
|
if (!item?.path) return
|
||||||
|
if (items.value.some((x) => x.path === item.path)) return
|
||||||
|
items.value = [...items.value, item]
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = (path: string) => {
|
||||||
|
if (!path) return
|
||||||
|
items.value = items.value.filter((x) => x.path !== path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = (item: FavoriteItem) => {
|
||||||
|
if (!item?.path) return
|
||||||
|
const exists = items.value.some((x) => x.path === item.path)
|
||||||
|
if (exists) remove(item.path)
|
||||||
|
else add(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFavorite = (path: string) => {
|
||||||
|
if (!path) return false
|
||||||
|
return items.value.some((x) => x.path === path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutItems = computed<LayoutMenuItem[]>(() =>
|
||||||
|
items.value.map((item) => ({
|
||||||
|
title: item.title,
|
||||||
|
path: item.path,
|
||||||
|
icon: item.icon,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
items,
|
||||||
|
(val) => {
|
||||||
|
writeFavorites(val)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const setFavoritesBarVisible = (value: boolean) => {
|
||||||
|
favoritesBarVisible.value = value
|
||||||
|
persistFavoritesBarVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleFavoritesBarVisible = (nextValue?: boolean) => {
|
||||||
|
if (typeof nextValue === 'boolean') {
|
||||||
|
setFavoritesBarVisible(nextValue)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFavoritesBarVisible(!favoritesBarVisible.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFavoritesBarVisible()
|
||||||
|
loadBreadcrumbBarVisible()
|
||||||
|
|
||||||
|
const setBreadcrumbBarVisible = (value: boolean) => {
|
||||||
|
breadcrumbBarVisible.value = value
|
||||||
|
persistBreadcrumbBarVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleBreadcrumbBarVisible = (nextValue?: boolean) => {
|
||||||
|
if (typeof nextValue === 'boolean') {
|
||||||
|
setBreadcrumbBarVisible(nextValue)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBreadcrumbBarVisible(!breadcrumbBarVisible.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
layoutItems,
|
||||||
|
add,
|
||||||
|
remove,
|
||||||
|
toggle,
|
||||||
|
isFavorite,
|
||||||
|
favoritesBarVisible,
|
||||||
|
setFavoritesBarVisible,
|
||||||
|
toggleFavoritesBarVisible,
|
||||||
|
breadcrumbBarVisible,
|
||||||
|
setBreadcrumbBarVisible,
|
||||||
|
toggleBreadcrumbBarVisible,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -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<LoginMobileAnnouncementItem[]> {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'mobile-announcement-1',
|
||||||
|
content: '系統正常運行中',
|
||||||
|
title: '系統公告',
|
||||||
|
createdAt: '2026-02-11',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLoginAnnouncementsStore = defineStore('loginAnnouncements', () => {
|
||||||
|
const items = ref<LoginAnnouncementItem[]>(readItems())
|
||||||
|
const selectedId = ref<string | number | null>(null)
|
||||||
|
const mobileAnnouncements = ref<LoginMobileAnnouncementItem[]>([])
|
||||||
|
|
||||||
|
const listItems = computed<LoginAnnouncementListItem[]>(() =>
|
||||||
|
items.value.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
date: item.date,
|
||||||
|
school: item.school,
|
||||||
|
title: item.title,
|
||||||
|
tab: item.tab,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const boardConfig = computed(() => ({
|
||||||
|
title: '學校公告區',
|
||||||
|
tabs: [
|
||||||
|
{ label: '全部', value: '__all__' },
|
||||||
|
{ label: '國中', value: 'junior' },
|
||||||
|
{ label: '高中', value: 'senior' },
|
||||||
|
],
|
||||||
|
items: listItems.value,
|
||||||
|
systemAnnouncements: mobileAnnouncements.value,
|
||||||
|
itemsPerPage: 5,
|
||||||
|
dateHeader: '公告時間',
|
||||||
|
schoolHeader: '公告學校',
|
||||||
|
titleHeader: '公告標題',
|
||||||
|
paginationLabel: '總筆數:',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const selectedAnnouncement = computed(() => {
|
||||||
|
if (selectedId.value === null) return null
|
||||||
|
return items.value.find((item) => item.id === selectedId.value) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedAnnouncementDetail = computed(() => {
|
||||||
|
return selectedAnnouncement.value?.detail ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const mobileAnnouncementConfig = computed(() => ({
|
||||||
|
items: mobileAnnouncements.value,
|
||||||
|
show: mobileAnnouncements.value.length > 0,
|
||||||
|
viewAllText: '查看全部',
|
||||||
|
listTitle: '系統公告',
|
||||||
|
closeText: '關閉',
|
||||||
|
emptyText: '目前沒有公告',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const hydrate = () => {
|
||||||
|
items.value = readItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaceAll = (nextItems: LoginAnnouncementItem[]) => {
|
||||||
|
items.value = Array.isArray(nextItems) ? nextItems : []
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectById = (id: string | number) => {
|
||||||
|
selectedId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
selectedId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMobileAnnouncements = async () => {
|
||||||
|
const result = await mockFetchMobileAnnouncementsApi()
|
||||||
|
mobileAnnouncements.value = Array.isArray(result) ? result : []
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMobileAnnouncement = async () => {
|
||||||
|
await fetchMobileAnnouncements()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
items,
|
||||||
|
(val) => {
|
||||||
|
writeItems(val)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
listItems,
|
||||||
|
boardConfig,
|
||||||
|
mobileAnnouncementConfig,
|
||||||
|
selectedAnnouncement,
|
||||||
|
selectedAnnouncementDetail,
|
||||||
|
hydrate,
|
||||||
|
replaceAll,
|
||||||
|
selectById,
|
||||||
|
clearSelection,
|
||||||
|
fetchMobileAnnouncements,
|
||||||
|
fetchMobileAnnouncement,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
+236
-1
@@ -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<MenuNode[]>([])
|
||||||
|
const favorite = ref<MenuNode[]>([])
|
||||||
|
const isRail = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const menuStorageKey = 'sk_playground_menu'
|
||||||
|
const favoriteStorageKey = 'sk_playground_favorite'
|
||||||
|
const isRailStorageKey = 'sk_playground_is_rail'
|
||||||
|
|
||||||
|
const readNodes = (key: string): MenuNode[] => {
|
||||||
|
if (typeof window === 'undefined') return []
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key)
|
||||||
|
if (!raw) return []
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return Array.isArray(parsed) ? (parsed as MenuNode[]) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readBoolean = (key: string, defaultValue = false): boolean => {
|
||||||
|
if (typeof window === 'undefined') return defaultValue
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key)
|
||||||
|
return raw === null ? defaultValue : raw === 'true'
|
||||||
|
} catch {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeValue = (key: string, value: any) => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, String(value))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeNodes = (key: string, nodes: MenuNode[]) => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(nodes))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeValue = (key: string) => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(key)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydrate = () => {
|
||||||
|
menu.value = readNodes(menuStorageKey)
|
||||||
|
favorite.value = readNodes(favoriteStorageKey)
|
||||||
|
isRail.value = readBoolean(isRailStorageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrate()
|
||||||
|
|
||||||
|
const toLayoutMenuItems = (nodes: MenuNode[]): LayoutMenuItem[] => {
|
||||||
|
const getString = (node: MenuNode, key: string): string | undefined => {
|
||||||
|
const v = node?.[key]
|
||||||
|
return typeof v === 'string' ? v : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const getChildren = (node: MenuNode): MenuNode[] => {
|
||||||
|
return Array.isArray(node?.children) ? (node.children as MenuNode[]) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
.map((mdl) => {
|
||||||
|
const mdlTitle = getString(mdl, 'mdl_name') ?? ''
|
||||||
|
const untItems = getChildren(mdl)
|
||||||
|
.map((unt) => {
|
||||||
|
const untTitle = getString(unt, 'unt_name') ?? ''
|
||||||
|
const fncItems = getChildren(unt)
|
||||||
|
.map((fnc) => {
|
||||||
|
const fncTitle = getString(fnc, 'fnc_name') ?? ''
|
||||||
|
const fncId = getString(fnc, 'fnc_id')
|
||||||
|
return {
|
||||||
|
title: fncTitle,
|
||||||
|
path: fncId ? `/${fncId}` : undefined,
|
||||||
|
} satisfies LayoutMenuItem
|
||||||
|
})
|
||||||
|
.filter((x) => x.title)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: untTitle,
|
||||||
|
navigable: false,
|
||||||
|
subItems: fncItems,
|
||||||
|
} satisfies LayoutMenuItem
|
||||||
|
})
|
||||||
|
.filter((x) => x.title)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: mdlTitle,
|
||||||
|
navigable: false,
|
||||||
|
subItems: untItems,
|
||||||
|
} satisfies LayoutMenuItem
|
||||||
|
})
|
||||||
|
.filter((x) => x.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toFavoriteLayoutMenuItems = (nodes: MenuNode[]): LayoutMenuItem[] => {
|
||||||
|
const getString = (node: MenuNode, key: string): string | undefined => {
|
||||||
|
const v = node?.[key]
|
||||||
|
return typeof v === 'string' ? v : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const getChildren = (node: MenuNode): MenuNode[] => {
|
||||||
|
return Array.isArray(node?.children) ? (node.children as MenuNode[]) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
.map((unt) => {
|
||||||
|
const untTitle = getString(unt, 'unt_name') ?? ''
|
||||||
|
const fncItems = getChildren(unt)
|
||||||
|
.map((fnc) => {
|
||||||
|
const fncTitle = getString(fnc, 'fnc_name') ?? ''
|
||||||
|
const fncId = getString(fnc, 'fnc_id')
|
||||||
|
return {
|
||||||
|
title: fncTitle,
|
||||||
|
path: fncId ? `/${fncId}` : undefined,
|
||||||
|
} satisfies LayoutMenuItem
|
||||||
|
})
|
||||||
|
.filter((x) => x.title)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: untTitle,
|
||||||
|
navigable: false,
|
||||||
|
subItems: fncItems,
|
||||||
|
} satisfies LayoutMenuItem
|
||||||
|
})
|
||||||
|
.filter((x) => x.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItems = computed<LayoutMenuItem[]>(() => toLayoutMenuItems(menu.value))
|
||||||
|
const favoriteItems = computed<LayoutMenuItem[]>(() => toFavoriteLayoutMenuItems(favorite.value))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
menu,
|
||||||
|
(val) => {
|
||||||
|
writeNodes(menuStorageKey, val)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
favorite,
|
||||||
|
(val) => {
|
||||||
|
writeNodes(favoriteStorageKey, val)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(isRail, (val) => {
|
||||||
|
writeValue(isRailStorageKey, val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
menu.value = []
|
||||||
|
favorite.value = []
|
||||||
|
isRail.value = false
|
||||||
|
error.value = null
|
||||||
|
removeValue(menuStorageKey)
|
||||||
|
removeValue(favoriteStorageKey)
|
||||||
|
removeValue(isRailStorageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMenu = async (id: string) => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const res = await menuApi.getMenu({ userID: id })
|
||||||
|
menu.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
|
||||||
|
} catch (error_) {
|
||||||
|
const normalizedError = normalizeError(error_)
|
||||||
|
if (normalizedError.name !== 'CanceledRequestError') {
|
||||||
|
error.value = normalizedError.message
|
||||||
|
}
|
||||||
|
throw normalizedError
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFavorite = async (id: string) => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const res = await menuApi.getFavorite({ userID: id })
|
||||||
|
favorite.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
|
||||||
|
} catch (error_) {
|
||||||
|
const normalizedError = normalizeError(error_)
|
||||||
|
if (normalizedError.name !== 'CanceledRequestError') {
|
||||||
|
error.value = normalizedError.message
|
||||||
|
}
|
||||||
|
throw normalizedError
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
menu,
|
||||||
|
favorite,
|
||||||
|
isRail,
|
||||||
|
menuItems,
|
||||||
|
favoriteItems,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
hydrate,
|
||||||
|
clear,
|
||||||
|
getMenu,
|
||||||
|
getFavorite,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
+30
-1
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
+157
-1
@@ -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<number>()
|
||||||
|
|
||||||
|
let totalScore = 0
|
||||||
|
let totalCredits = 0
|
||||||
|
|
||||||
|
while (courses.length < courseCount) {
|
||||||
|
const idx = Math.floor(Math.random() * subjects.length)
|
||||||
|
if (usedSubjects.has(idx)) continue
|
||||||
|
usedSubjects.add(idx)
|
||||||
|
|
||||||
|
const score = randomScore()
|
||||||
|
const subject = subjects[idx]
|
||||||
|
if (!subject) continue
|
||||||
|
|
||||||
|
courses.push({
|
||||||
|
code: `CS${1000 + idx}`,
|
||||||
|
name: subject.name,
|
||||||
|
credits: subject.credits,
|
||||||
|
score,
|
||||||
|
})
|
||||||
|
|
||||||
|
totalScore += score * subject.credits
|
||||||
|
totalCredits += subject.credits
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: sem.baseId + studentId,
|
||||||
|
studentId,
|
||||||
|
semesterName: sem.name,
|
||||||
|
courses,
|
||||||
|
rank: Math.floor(Math.random() * 20) + 1,
|
||||||
|
average: Number((totalScore / totalCredits).toFixed(2)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i <= 20; i++) {
|
||||||
|
seedSemesters.push(...generateMockSemesters(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSemesterStore = defineStore('semesters', () => {
|
||||||
|
const semesters = ref<SemesterRecord[]>([...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<SemesterRecord>) => {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
+52
-1
@@ -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<SnackbarColor>('success')
|
||||||
|
const timeout = ref(2000)
|
||||||
|
const location = ref<SnackbarLocation>('top right')
|
||||||
|
const variant = ref<SnackbarVariant>('flat')
|
||||||
|
|
||||||
|
const show = (options: ShowOptions) => {
|
||||||
|
message.value = options.message
|
||||||
|
color.value = options.color ?? 'success'
|
||||||
|
timeout.value = options.timeout ?? 2000
|
||||||
|
location.value = options.location ?? 'top right'
|
||||||
|
variant.value = options.variant ?? 'flat'
|
||||||
|
|
||||||
|
visible.value = false
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
visible.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
visible,
|
||||||
|
message,
|
||||||
|
color,
|
||||||
|
timeout,
|
||||||
|
location,
|
||||||
|
variant,
|
||||||
|
show,
|
||||||
|
hide,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -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<User | null>(null)
|
|
||||||
const token = tokenService.token
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref<string | null>(null)
|
|
||||||
const captcha = ref<CaptchaResponse | null>(null)
|
|
||||||
const captchaLoading = ref(false)
|
|
||||||
const captchaErrorMessage = ref<string | null>(null)
|
|
||||||
// 只針對 login 取消重複請求,避免競態與重複提交
|
|
||||||
const loginController = ref<AbortController | null>(null)
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
const isAuthenticated = computed(() => !!token.value)
|
|
||||||
const roles = computed(() => (user.value?.role ? [user.value.role] : []))
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
const getCaptcha = async () => {
|
|
||||||
captchaLoading.value = true
|
|
||||||
captchaErrorMessage.value = null
|
|
||||||
try {
|
|
||||||
const { data } = await authApi.getCaptcha()
|
|
||||||
captcha.value = data
|
|
||||||
return data
|
|
||||||
} catch (error_) {
|
|
||||||
const normalizedError = normalizeError(error_)
|
|
||||||
captcha.value = null
|
|
||||||
captchaErrorMessage.value = normalizedError.message
|
|
||||||
throw normalizedError
|
|
||||||
} finally {
|
|
||||||
captchaLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const login = async (payload: LoginPayload) => {
|
|
||||||
loginController.value?.abort()
|
|
||||||
loginController.value = new AbortController()
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!captcha.value?.dntCaptchaTextValue || !captcha.value?.dntCaptchaTokenValue) {
|
|
||||||
throw new Error('驗證碼資料缺失,請先刷新驗證碼')
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestPayload = {
|
|
||||||
UserID: payload.UserID,
|
|
||||||
Password: payload.Password,
|
|
||||||
DNTCaptchaInputText: payload.DNTCaptchaInputText,
|
|
||||||
DNTCaptchaText: captcha.value.dntCaptchaTextValue,
|
|
||||||
DNTCaptchaToken: captcha.value.dntCaptchaTokenValue,
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('UserID', requestPayload.UserID)
|
|
||||||
formData.append('Password', requestPayload.Password)
|
|
||||||
formData.append('DNTCaptchaInputText', requestPayload.DNTCaptchaInputText)
|
|
||||||
formData.append('DNTCaptchaText', requestPayload.DNTCaptchaText)
|
|
||||||
formData.append('DNTCaptchaToken', requestPayload.DNTCaptchaToken)
|
|
||||||
|
|
||||||
const { data } = await authApi.login(formData, {
|
|
||||||
signal: loginController.value.signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
const parseUser = (val: unknown): User | undefined => {
|
|
||||||
if (!val || typeof val !== 'object') return
|
|
||||||
const obj = val as Record<string, unknown>
|
|
||||||
const id = obj.id
|
|
||||||
const name = obj.name
|
|
||||||
const role = obj.role
|
|
||||||
if (typeof id !== 'string' || typeof name !== 'string' || typeof role !== 'string') return
|
|
||||||
return { id, name, role }
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseLoginResult = (
|
|
||||||
raw: unknown
|
|
||||||
): {
|
|
||||||
accessToken?: string
|
|
||||||
tokenType?: string
|
|
||||||
expiresIn?: number
|
|
||||||
user?: User
|
|
||||||
message?: string
|
|
||||||
} => {
|
|
||||||
if (!raw || typeof raw !== 'object') return {}
|
|
||||||
|
|
||||||
const obj = raw as Record<string, unknown>
|
|
||||||
const accessToken = typeof obj.access_token === 'string' ? obj.access_token : undefined
|
|
||||||
const tokenType = typeof obj.token_type === 'string' ? obj.token_type : undefined
|
|
||||||
const expiresIn = typeof obj.expires_in === 'number' ? obj.expires_in : undefined
|
|
||||||
const user = parseUser(obj.user)
|
|
||||||
const message = typeof obj.message === 'string' ? obj.message : undefined
|
|
||||||
|
|
||||||
return { accessToken, tokenType, expiresIn, user, message }
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = parseLoginResult(data)
|
|
||||||
|
|
||||||
if (!result.accessToken) {
|
|
||||||
throw new Error(result.message || '登入回傳缺少 access_token')
|
|
||||||
}
|
|
||||||
|
|
||||||
user.value = result.user ?? null
|
|
||||||
tokenService.setToken(result.accessToken)
|
|
||||||
} catch (error_) {
|
|
||||||
const normalizedError = normalizeError(error_)
|
|
||||||
if (normalizedError.name !== 'CanceledRequestError') {
|
|
||||||
error.value = normalizedError.message
|
|
||||||
}
|
|
||||||
throw normalizedError
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
loginController.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
user.value = null
|
|
||||||
tokenService.clearToken()
|
|
||||||
useMenuStore().clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
getCaptcha,
|
|
||||||
captcha,
|
|
||||||
captchaLoading,
|
|
||||||
captchaErrorMessage,
|
|
||||||
user,
|
|
||||||
token,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
isAuthenticated,
|
|
||||||
roles,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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<BreadcrumbItem[]>([])
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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<FavoriteItem[]>(readFavorites())
|
|
||||||
const favoritesBarVisible = ref(true)
|
|
||||||
const breadcrumbBarVisible = ref(true)
|
|
||||||
|
|
||||||
const loadFavoritesBarVisible = () => {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
const stored = window.localStorage.getItem(favoritesBarStorageKey)
|
|
||||||
if (stored === null) return
|
|
||||||
favoritesBarVisible.value = stored === '1'
|
|
||||||
}
|
|
||||||
|
|
||||||
const persistFavoritesBarVisible = () => {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
window.localStorage.setItem(favoritesBarStorageKey, favoritesBarVisible.value ? '1' : '0')
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadBreadcrumbBarVisible = () => {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
const stored = window.localStorage.getItem(breadcrumbBarStorageKey)
|
|
||||||
if (stored === null) return
|
|
||||||
breadcrumbBarVisible.value = stored === '1'
|
|
||||||
}
|
|
||||||
|
|
||||||
const persistBreadcrumbBarVisible = () => {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
window.localStorage.setItem(breadcrumbBarStorageKey, breadcrumbBarVisible.value ? '1' : '0')
|
|
||||||
}
|
|
||||||
|
|
||||||
const add = (item: FavoriteItem) => {
|
|
||||||
if (!item?.path) return
|
|
||||||
if (items.value.some((x) => x.path === item.path)) return
|
|
||||||
items.value = [...items.value, item]
|
|
||||||
}
|
|
||||||
|
|
||||||
const remove = (path: string) => {
|
|
||||||
if (!path) return
|
|
||||||
items.value = items.value.filter((x) => x.path !== path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggle = (item: FavoriteItem) => {
|
|
||||||
if (!item?.path) return
|
|
||||||
const exists = items.value.some((x) => x.path === item.path)
|
|
||||||
if (exists) remove(item.path)
|
|
||||||
else add(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFavorite = (path: string) => {
|
|
||||||
if (!path) return false
|
|
||||||
return items.value.some((x) => x.path === path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const layoutItems = computed<LayoutMenuItem[]>(() =>
|
|
||||||
items.value.map((item) => ({
|
|
||||||
title: item.title,
|
|
||||||
path: item.path,
|
|
||||||
icon: item.icon,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
items,
|
|
||||||
(val) => {
|
|
||||||
writeFavorites(val)
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
const setFavoritesBarVisible = (value: boolean) => {
|
|
||||||
favoritesBarVisible.value = value
|
|
||||||
persistFavoritesBarVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleFavoritesBarVisible = (nextValue?: boolean) => {
|
|
||||||
if (typeof nextValue === 'boolean') {
|
|
||||||
setFavoritesBarVisible(nextValue)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setFavoritesBarVisible(!favoritesBarVisible.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
loadFavoritesBarVisible()
|
|
||||||
loadBreadcrumbBarVisible()
|
|
||||||
|
|
||||||
const setBreadcrumbBarVisible = (value: boolean) => {
|
|
||||||
breadcrumbBarVisible.value = value
|
|
||||||
persistBreadcrumbBarVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleBreadcrumbBarVisible = (nextValue?: boolean) => {
|
|
||||||
if (typeof nextValue === 'boolean') {
|
|
||||||
setBreadcrumbBarVisible(nextValue)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setBreadcrumbBarVisible(!breadcrumbBarVisible.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
layoutItems,
|
|
||||||
add,
|
|
||||||
remove,
|
|
||||||
toggle,
|
|
||||||
isFavorite,
|
|
||||||
favoritesBarVisible,
|
|
||||||
setFavoritesBarVisible,
|
|
||||||
toggleFavoritesBarVisible,
|
|
||||||
breadcrumbBarVisible,
|
|
||||||
setBreadcrumbBarVisible,
|
|
||||||
toggleBreadcrumbBarVisible,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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<LoginMobileAnnouncementItem[]> {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'mobile-announcement-1',
|
|
||||||
content: '系統正常運行中',
|
|
||||||
title: '系統公告',
|
|
||||||
createdAt: '2026-02-11',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useLoginAnnouncementsStore = defineStore('loginAnnouncements', () => {
|
|
||||||
const items = ref<LoginAnnouncementItem[]>(readItems())
|
|
||||||
const selectedId = ref<string | number | null>(null)
|
|
||||||
const mobileAnnouncements = ref<LoginMobileAnnouncementItem[]>([])
|
|
||||||
|
|
||||||
const listItems = computed<LoginAnnouncementListItem[]>(() =>
|
|
||||||
items.value.map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
date: item.date,
|
|
||||||
school: item.school,
|
|
||||||
title: item.title,
|
|
||||||
tab: item.tab,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
const boardConfig = computed(() => ({
|
|
||||||
title: '學校公告區',
|
|
||||||
tabs: [
|
|
||||||
{ label: '全部', value: '__all__' },
|
|
||||||
{ label: '國中', value: 'junior' },
|
|
||||||
{ label: '高中', value: 'senior' },
|
|
||||||
],
|
|
||||||
items: listItems.value,
|
|
||||||
systemAnnouncements: mobileAnnouncements.value,
|
|
||||||
itemsPerPage: 5,
|
|
||||||
dateHeader: '公告時間',
|
|
||||||
schoolHeader: '公告學校',
|
|
||||||
titleHeader: '公告標題',
|
|
||||||
paginationLabel: '總筆數:',
|
|
||||||
}))
|
|
||||||
|
|
||||||
const selectedAnnouncement = computed(() => {
|
|
||||||
if (selectedId.value === null) return null
|
|
||||||
return items.value.find((item) => item.id === selectedId.value) ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedAnnouncementDetail = computed(() => {
|
|
||||||
return selectedAnnouncement.value?.detail ?? ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const mobileAnnouncementConfig = computed(() => ({
|
|
||||||
items: mobileAnnouncements.value,
|
|
||||||
show: mobileAnnouncements.value.length > 0,
|
|
||||||
viewAllText: '查看全部',
|
|
||||||
listTitle: '系統公告',
|
|
||||||
closeText: '關閉',
|
|
||||||
emptyText: '目前沒有公告',
|
|
||||||
}))
|
|
||||||
|
|
||||||
const hydrate = () => {
|
|
||||||
items.value = readItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
const replaceAll = (nextItems: LoginAnnouncementItem[]) => {
|
|
||||||
items.value = Array.isArray(nextItems) ? nextItems : []
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectById = (id: string | number) => {
|
|
||||||
selectedId.value = id
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearSelection = () => {
|
|
||||||
selectedId.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchMobileAnnouncements = async () => {
|
|
||||||
const result = await mockFetchMobileAnnouncementsApi()
|
|
||||||
mobileAnnouncements.value = Array.isArray(result) ? result : []
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchMobileAnnouncement = async () => {
|
|
||||||
await fetchMobileAnnouncements()
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
items,
|
|
||||||
(val) => {
|
|
||||||
writeItems(val)
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
listItems,
|
|
||||||
boardConfig,
|
|
||||||
mobileAnnouncementConfig,
|
|
||||||
selectedAnnouncement,
|
|
||||||
selectedAnnouncementDetail,
|
|
||||||
hydrate,
|
|
||||||
replaceAll,
|
|
||||||
selectById,
|
|
||||||
clearSelection,
|
|
||||||
fetchMobileAnnouncements,
|
|
||||||
fetchMobileAnnouncement,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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<MenuNode[]>([])
|
|
||||||
const favorite = ref<MenuNode[]>([])
|
|
||||||
const isRail = ref(false)
|
|
||||||
const error = ref<string | null>(null)
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
const menuStorageKey = 'sk_playground_menu'
|
|
||||||
const favoriteStorageKey = 'sk_playground_favorite'
|
|
||||||
const isRailStorageKey = 'sk_playground_is_rail'
|
|
||||||
|
|
||||||
const readNodes = (key: string): MenuNode[] => {
|
|
||||||
if (typeof window === 'undefined') return []
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(key)
|
|
||||||
if (!raw) return []
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
return Array.isArray(parsed) ? (parsed as MenuNode[]) : []
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const readBoolean = (key: string, defaultValue = false): boolean => {
|
|
||||||
if (typeof window === 'undefined') return defaultValue
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(key)
|
|
||||||
return raw === null ? defaultValue : raw === 'true'
|
|
||||||
} catch {
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeValue = (key: string, value: any) => {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(key, String(value))
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeNodes = (key: string, nodes: MenuNode[]) => {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(key, JSON.stringify(nodes))
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeValue = (key: string) => {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
try {
|
|
||||||
window.localStorage.removeItem(key)
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hydrate = () => {
|
|
||||||
menu.value = readNodes(menuStorageKey)
|
|
||||||
favorite.value = readNodes(favoriteStorageKey)
|
|
||||||
isRail.value = readBoolean(isRailStorageKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
hydrate()
|
|
||||||
|
|
||||||
const toLayoutMenuItems = (nodes: MenuNode[]): LayoutMenuItem[] => {
|
|
||||||
const getString = (node: MenuNode, key: string): string | undefined => {
|
|
||||||
const v = node?.[key]
|
|
||||||
return typeof v === 'string' ? v : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const getChildren = (node: MenuNode): MenuNode[] => {
|
|
||||||
return Array.isArray(node?.children) ? (node.children as MenuNode[]) : []
|
|
||||||
}
|
|
||||||
|
|
||||||
return nodes
|
|
||||||
.map((mdl) => {
|
|
||||||
const mdlTitle = getString(mdl, 'mdl_name') ?? ''
|
|
||||||
const untItems = getChildren(mdl)
|
|
||||||
.map((unt) => {
|
|
||||||
const untTitle = getString(unt, 'unt_name') ?? ''
|
|
||||||
const fncItems = getChildren(unt)
|
|
||||||
.map((fnc) => {
|
|
||||||
const fncTitle = getString(fnc, 'fnc_name') ?? ''
|
|
||||||
const fncId = getString(fnc, 'fnc_id')
|
|
||||||
return {
|
|
||||||
title: fncTitle,
|
|
||||||
path: fncId ? `/${fncId}` : undefined,
|
|
||||||
} satisfies LayoutMenuItem
|
|
||||||
})
|
|
||||||
.filter((x) => x.title)
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: untTitle,
|
|
||||||
navigable: false,
|
|
||||||
subItems: fncItems,
|
|
||||||
} satisfies LayoutMenuItem
|
|
||||||
})
|
|
||||||
.filter((x) => x.title)
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: mdlTitle,
|
|
||||||
navigable: false,
|
|
||||||
subItems: untItems,
|
|
||||||
} satisfies LayoutMenuItem
|
|
||||||
})
|
|
||||||
.filter((x) => x.title)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toFavoriteLayoutMenuItems = (nodes: MenuNode[]): LayoutMenuItem[] => {
|
|
||||||
const getString = (node: MenuNode, key: string): string | undefined => {
|
|
||||||
const v = node?.[key]
|
|
||||||
return typeof v === 'string' ? v : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const getChildren = (node: MenuNode): MenuNode[] => {
|
|
||||||
return Array.isArray(node?.children) ? (node.children as MenuNode[]) : []
|
|
||||||
}
|
|
||||||
|
|
||||||
return nodes
|
|
||||||
.map((unt) => {
|
|
||||||
const untTitle = getString(unt, 'unt_name') ?? ''
|
|
||||||
const fncItems = getChildren(unt)
|
|
||||||
.map((fnc) => {
|
|
||||||
const fncTitle = getString(fnc, 'fnc_name') ?? ''
|
|
||||||
const fncId = getString(fnc, 'fnc_id')
|
|
||||||
return {
|
|
||||||
title: fncTitle,
|
|
||||||
path: fncId ? `/${fncId}` : undefined,
|
|
||||||
} satisfies LayoutMenuItem
|
|
||||||
})
|
|
||||||
.filter((x) => x.title)
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: untTitle,
|
|
||||||
navigable: false,
|
|
||||||
subItems: fncItems,
|
|
||||||
} satisfies LayoutMenuItem
|
|
||||||
})
|
|
||||||
.filter((x) => x.title)
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuItems = computed<LayoutMenuItem[]>(() => toLayoutMenuItems(menu.value))
|
|
||||||
const favoriteItems = computed<LayoutMenuItem[]>(() => toFavoriteLayoutMenuItems(favorite.value))
|
|
||||||
|
|
||||||
watch(
|
|
||||||
menu,
|
|
||||||
(val) => {
|
|
||||||
writeNodes(menuStorageKey, val)
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
favorite,
|
|
||||||
(val) => {
|
|
||||||
writeNodes(favoriteStorageKey, val)
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(isRail, (val) => {
|
|
||||||
writeValue(isRailStorageKey, val)
|
|
||||||
})
|
|
||||||
|
|
||||||
const clear = () => {
|
|
||||||
menu.value = []
|
|
||||||
favorite.value = []
|
|
||||||
isRail.value = false
|
|
||||||
error.value = null
|
|
||||||
removeValue(menuStorageKey)
|
|
||||||
removeValue(favoriteStorageKey)
|
|
||||||
removeValue(isRailStorageKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMenu = async (id: string) => {
|
|
||||||
try {
|
|
||||||
loading.value = true
|
|
||||||
const res = await menuApi.getMenu({ userID: id })
|
|
||||||
menu.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
|
|
||||||
} catch (error_) {
|
|
||||||
const normalizedError = normalizeError(error_)
|
|
||||||
if (normalizedError.name !== 'CanceledRequestError') {
|
|
||||||
error.value = normalizedError.message
|
|
||||||
}
|
|
||||||
throw normalizedError
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getFavorite = async (id: string) => {
|
|
||||||
try {
|
|
||||||
loading.value = true
|
|
||||||
const res = await menuApi.getFavorite({ userID: id })
|
|
||||||
favorite.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
|
|
||||||
} catch (error_) {
|
|
||||||
const normalizedError = normalizeError(error_)
|
|
||||||
if (normalizedError.name !== 'CanceledRequestError') {
|
|
||||||
error.value = normalizedError.message
|
|
||||||
}
|
|
||||||
throw normalizedError
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
menu,
|
|
||||||
favorite,
|
|
||||||
isRail,
|
|
||||||
menuItems,
|
|
||||||
favoriteItems,
|
|
||||||
error,
|
|
||||||
loading,
|
|
||||||
hydrate,
|
|
||||||
clear,
|
|
||||||
getMenu,
|
|
||||||
getFavorite,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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<number>()
|
|
||||||
|
|
||||||
let totalScore = 0
|
|
||||||
let totalCredits = 0
|
|
||||||
|
|
||||||
while (courses.length < courseCount) {
|
|
||||||
const idx = Math.floor(Math.random() * subjects.length)
|
|
||||||
if (usedSubjects.has(idx)) continue
|
|
||||||
usedSubjects.add(idx)
|
|
||||||
|
|
||||||
const score = randomScore()
|
|
||||||
const subject = subjects[idx]
|
|
||||||
if (!subject) continue
|
|
||||||
|
|
||||||
courses.push({
|
|
||||||
code: `CS${1000 + idx}`,
|
|
||||||
name: subject.name,
|
|
||||||
credits: subject.credits,
|
|
||||||
score,
|
|
||||||
})
|
|
||||||
|
|
||||||
totalScore += score * subject.credits
|
|
||||||
totalCredits += subject.credits
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push({
|
|
||||||
id: sem.baseId + studentId,
|
|
||||||
studentId,
|
|
||||||
semesterName: sem.name,
|
|
||||||
courses,
|
|
||||||
rank: Math.floor(Math.random() * 20) + 1,
|
|
||||||
average: Number((totalScore / totalCredits).toFixed(2)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate for initial seed students (assuming IDs 1-20)
|
|
||||||
for (let i = 1; i <= 20; i++) {
|
|
||||||
seedSemesters.push(...generateMockSemesters(i))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSemesterStore = defineStore('semesters', () => {
|
|
||||||
// State
|
|
||||||
const semesters = ref<SemesterRecord[]>([...seedSemesters])
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
const getStudentSemesters = (studentId: number) => {
|
|
||||||
return semesters.value.filter((s) => s.studentId === studentId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const generateForStudent = (studentId: number) => {
|
|
||||||
const newSemesters = generateMockSemesters(studentId)
|
|
||||||
semesters.value.push(...newSemesters)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addSemester = (studentId: number) => {
|
|
||||||
const newId = Math.max(...semesters.value.map((s) => s.id), 0) + 1
|
|
||||||
const newSemester: SemesterRecord = {
|
|
||||||
id: newId,
|
|
||||||
studentId,
|
|
||||||
semesterName: '新學期',
|
|
||||||
courses: [],
|
|
||||||
rank: 0,
|
|
||||||
average: 0,
|
|
||||||
}
|
|
||||||
semesters.value.push(newSemester)
|
|
||||||
return newSemester
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateSemester = (id: number, payload: Partial<SemesterRecord>) => {
|
|
||||||
const index = semesters.value.findIndex((s) => s.id === id)
|
|
||||||
if (index === -1) return
|
|
||||||
const current = semesters.value[index]
|
|
||||||
if (!current) return
|
|
||||||
|
|
||||||
// Recalculate average if courses are updated
|
|
||||||
if (payload.courses) {
|
|
||||||
const totalScore = payload.courses.reduce((sum, c) => sum + c.score * c.credits, 0)
|
|
||||||
const totalCredits = payload.courses.reduce((sum, c) => sum + c.credits, 0)
|
|
||||||
payload.average = totalCredits > 0 ? Number((totalScore / totalCredits).toFixed(2)) : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(current, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeSemester = (id: number) => {
|
|
||||||
const index = semesters.value.findIndex((s) => s.id === id)
|
|
||||||
if (index !== -1) {
|
|
||||||
semesters.value.splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeByStudentId = (studentId: number) => {
|
|
||||||
semesters.value = semesters.value.filter((s) => s.studentId !== studentId)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
semesters,
|
|
||||||
getStudentSemesters,
|
|
||||||
generateForStudent,
|
|
||||||
addSemester,
|
|
||||||
updateSemester,
|
|
||||||
removeSemester,
|
|
||||||
removeByStudentId,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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<SnackbarColor>('success')
|
|
||||||
const timeout = ref(2000)
|
|
||||||
const location = ref<SnackbarLocation>('top right')
|
|
||||||
const variant = ref<SnackbarVariant>('flat')
|
|
||||||
|
|
||||||
const show = (options: ShowOptions) => {
|
|
||||||
message.value = options.message
|
|
||||||
color.value = options.color ?? 'success'
|
|
||||||
timeout.value = options.timeout ?? 2000
|
|
||||||
location.value = options.location ?? 'top right'
|
|
||||||
variant.value = options.variant ?? 'flat'
|
|
||||||
|
|
||||||
visible.value = false
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
visible.value = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const hide = () => {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
visible,
|
|
||||||
message,
|
|
||||||
color,
|
|
||||||
timeout,
|
|
||||||
location,
|
|
||||||
variant,
|
|
||||||
show,
|
|
||||||
hide,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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<StudentRecord[]>([...seedStudents])
|
|
||||||
const deletedIds = ref<Set<number>>(new Set())
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
const addStudent = (payload: Omit<StudentRecord, 'id'>) => {
|
|
||||||
const nextId = students.value.reduce((max, item) => Math.max(max, item.id), 0) + 1
|
|
||||||
const created = { id: nextId, ...payload }
|
|
||||||
students.value.push(created)
|
|
||||||
return created.id
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateStudent = (id: number, payload: Omit<StudentRecord, 'id'>) => {
|
|
||||||
const target = students.value.find((item) => item.id === id)
|
|
||||||
if (!target) return false
|
|
||||||
Object.assign(target, payload)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeStudent = (id: number) => {
|
|
||||||
const before = students.value.length
|
|
||||||
students.value = students.value.filter((item) => item.id !== id)
|
|
||||||
return students.value.length !== before
|
|
||||||
}
|
|
||||||
|
|
||||||
// 標記刪除(軟刪除,還原用)
|
|
||||||
const markAsDeleted = (id: number) => {
|
|
||||||
deletedIds.value.add(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除所有標記
|
|
||||||
const clearDeletedIds = () => {
|
|
||||||
deletedIds.value.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提交刪除(實際刪除)
|
|
||||||
const commitDeleted = () => {
|
|
||||||
for (const id of deletedIds.value) {
|
|
||||||
removeStudent(id)
|
|
||||||
}
|
|
||||||
deletedIds.value.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 還原標記(取消刪除)
|
|
||||||
const restoreDeleted = () => {
|
|
||||||
deletedIds.value.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 檢查是否已標記刪除
|
|
||||||
const isMarkedAsDeleted = (id: number) => deletedIds.value.has(id)
|
|
||||||
|
|
||||||
return {
|
|
||||||
students,
|
|
||||||
deletedIds,
|
|
||||||
addStudent,
|
|
||||||
updateStudent,
|
|
||||||
removeStudent,
|
|
||||||
markAsDeleted,
|
|
||||||
clearDeletedIds,
|
|
||||||
commitDeleted,
|
|
||||||
restoreDeleted,
|
|
||||||
isMarkedAsDeleted,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
+343
-1
@@ -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<StudentRecord[]>([...seedStudents])
|
||||||
|
const deletedIds = ref<Set<number>>(new Set())
|
||||||
|
|
||||||
|
const addStudent = (payload: Omit<StudentRecord, 'id'>) => {
|
||||||
|
const nextId = students.value.reduce((max, item) => Math.max(max, item.id), 0) + 1
|
||||||
|
const created = { id: nextId, ...payload }
|
||||||
|
students.value.push(created)
|
||||||
|
return created.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateStudent = (id: number, payload: Omit<StudentRecord, 'id'>) => {
|
||||||
|
const target = students.value.find((item) => item.id === id)
|
||||||
|
if (!target) return false
|
||||||
|
Object.assign(target, payload)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeStudent = (id: number) => {
|
||||||
|
const before = students.value.length
|
||||||
|
students.value = students.value.filter((item) => item.id !== id)
|
||||||
|
return students.value.length !== before
|
||||||
|
}
|
||||||
|
|
||||||
|
// 標記刪除(軟刪除,還原用)
|
||||||
|
const markAsDeleted = (id: number) => {
|
||||||
|
deletedIds.value.add(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除所有標記
|
||||||
|
const clearDeletedIds = () => {
|
||||||
|
deletedIds.value.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交刪除(實際刪除)
|
||||||
|
const commitDeleted = () => {
|
||||||
|
for (const id of deletedIds.value) {
|
||||||
|
removeStudent(id)
|
||||||
|
}
|
||||||
|
deletedIds.value.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 還原標記(取消刪除)
|
||||||
|
const restoreDeleted = () => {
|
||||||
|
deletedIds.value.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 檢查是否已標記刪除
|
||||||
|
const isMarkedAsDeleted = (id: number) => deletedIds.value.has(id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
students,
|
||||||
|
deletedIds,
|
||||||
|
addStudent,
|
||||||
|
updateStudent,
|
||||||
|
removeStudent,
|
||||||
|
markAsDeleted,
|
||||||
|
clearDeletedIds,
|
||||||
|
commitDeleted,
|
||||||
|
restoreDeleted,
|
||||||
|
isMarkedAsDeleted,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
+14
-1
@@ -1,3 +1,16 @@
|
|||||||
# Styles
|
# 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` 檔的 `<style scoped>`。
|
||||||
|
- 不要把單一 component 的樣式放進 `src/styles`。
|
||||||
|
- 不要在 view 或 component 內重複建立 theme 設定。
|
||||||
|
|||||||
Reference in New Issue
Block a user