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:
skytek_xinliang
2026-05-05 11:54:19 +08:00
parent 6eab4d9744
commit b37f4363eb
23 changed files with 1531 additions and 1588 deletions
+17 -27
View File
@@ -1,35 +1,25 @@
# Components
Vue template files in this folder are automatically imported.
`src/components` 放 Vue 元件,包含 layout、page component、feature/domain component 與少量跨頁共用元件。
## 🚀 Usage
## 目前結構
Importing is handled by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components). This plugin automatically imports `.vue` files created in the `src/components` directory, and registers them as global components. This means that you can use any component in your application without having to manually import it.
- `PageLogin.vue``PageIndex.vue``PageMaint.vue`:頁面型元件,接收 view 組好的資料與事件,負責完整頁面主畫面組裝。
- `layouts/*`app shell 與 layout 子元件。`MainLayout.vue` 負責主框架,`PlainLayout.vue` 負責不套主框架的頁面。
- `layouts/main-layout/*``MainLayout.vue` 拆出的 drawer、app bar、breadcrumb、favorites 等骨架子元件。
- `login/*`:登入頁專用 UI 區塊,服務 `PageLogin.vue`
- `maint/*`maintenance 領域元件,服務 `views/maint/*`
- `maint/master-detail/*`master-detail 維護頁專用子元件。
- `base/*`:真正跨頁重用且不屬於特定 domain 的基礎元件。
The following example assumes a component located at `src/components/MyComponent.vue`:
## 使用規則
```vue
<template>
<div>
<MyComponent />
</div>
</template>
- 不要假設 `src/components` 會自動全域註冊元件;需要使用元件時,依照目前 Vue SFC 慣例明確 import。
- 直接被 route 載入的檔案放在 `src/views`,不要放在 `src/components`
- 負責完整頁面主畫面組裝的元件使用 `Page` 前綴。
- 只服務單一功能或 domain 的元件,放在對應資料夾,不要放進 `base`
- layout 元件只處理 app shell 與框架 UI,不放頁面專屬業務流程。
<script lang="ts" setup>
//
</script>
```
## 資料流
When your template is rendered, the component's import will automatically be inlined, which renders to this:
```vue
<template>
<div>
<MyComponent />
</div>
</template>
<script lang="ts" setup>
import MyComponent from '@/components/MyComponent.vue'
</script>
```
component 以 props 接收資料,以 emit 回報使用者事件。需要跨頁共享的狀態交給 `src/stores`;可重用流程或較複雜 UI state 放到 `src/composables`
+14 -1
View File
@@ -1,3 +1,16 @@
# Plugins
Plugins are a way to extend the functionality of your Vue application. Use this folder for registering plugins that you want to use globally.
`src/plugins` 負責集中註冊全域 Vue plugin。`src/main.ts` 只需要呼叫 `registerPlugins(app)`
## 目前檔案
- `index.ts`:統一註冊 Vuetify、Pinia、Vue I18n 與 Vue Router。
- `vuetify.ts`:建立 Vuetify instance,設定 MDI SVG icon set 與預設 theme。
- `i18n.ts`:建立 Vue I18n instance,載入 `src/language/en-US.json``src/language/zh-TW.json`
## 使用規則
- 新增需要 app-wide 安裝的 plugin 時,先建立獨立設定檔,再在 `index.ts` 註冊。
- 不要在 view 或 component 內重複安裝 plugin。
- Vuetify theme 設定放在 `src/styles/themes.ts`,不要直接塞在 component。
- 語系文字放在 `src/language/*.json`,不要散落在 plugin 設定檔。
+41 -92
View File
@@ -1,115 +1,64 @@
元件 (Component)
↓ 呼叫
Store (Pinia) ← 管理狀態、快取
↓ 呼叫
API Service ← 封裝業務邏輯
↓ 呼叫
HTTP Client ← Axios 實例、攔截器
# Services
## 目前的資料流(以登入為例)
`src/services` 是資料存取與 HTTP 邊界,負責封裝 axios client、interceptor、token/session、錯誤處理與 API 模組。
1. `views/Login.vue`Playground 頁面)只負責表單/驗證碼/導頁等 UI 行為
2. `stores/auth.ts` 統一負責登入狀態(`user`/`token`/`loading`/`error`
3. `services/modules/user.ts` 封裝 `login/getProfile/...` 端點
4. `services/client.ts` 建立 `axios` instance
5. `services/interceptors.ts` 統一注入 token 與處理 HTTP 錯誤
## 目前資料流
## Menu API 與資料結構
選單系統採用 API 驅動設計:
### API 端點
- `GET /service/api/menu`:取得完整選單樹
- `GET /service/api/menu/favorite`:取得使用者收藏選單
### 資料結構
```ts
interface MenuNode {
mdl_id: string // 模組 ID
mdl_name: string // 模組名稱
unt_id?: string // 單位 ID
unt_name?: string // 單位名稱
fnc_id?: string // 功能 ID
fnc_name?: string // 功能名稱
children?: MenuNode[]
}
```txt
component/view -> store/composable -> service module -> httpClient -> interceptor
```
### 階層關係
原則:
- **第一層**:模組(mdl
- **第二層**:單位(unt
- **第三層**:功能(fnc),作為葉節點使用 `fnc_id` 作為路由路徑
- component 不直接處理底層 HTTP client、token、interceptor 或錯誤正規化。
- store 或 composable 負責協調 UI 狀態與呼叫 service。
- service 回傳資料,不持有 UI 狀態。
- service 不 import component、view 或 store。
### Store 持久化
## 目前檔案
`stores/menu.ts` 提供:
- `client.ts`:建立單一 axios instance,設定 `baseURL`、timeout、credentials 與 interceptor。
- `interceptors.ts`:集中處理 request token 注入與 response 錯誤。
- `error.ts`:提供 `normalizeError()` 與統一錯誤型別。
- `http-error.ts`:提供全域 HTTP 錯誤事件。
- `http-toast.ts`:提供 HTTP 錯誤提示相關流程。
- `token.ts`:提供 token 單一來源,並同步 localStorage。
- `session.ts`:提供 session 相關流程。
- `modules/auth.ts`:封裝登入與驗證碼 API。
- `modules/menu.ts`:封裝選單與收藏選單 API。
- 自動 localStorage 持久化選單與收藏
- 初始化時自動還原資料
- 登出時清除快取
## API 模組規則
## API 前綴:`/api`
新增 API 時,優先放在 `src/services/modules/<domain>.ts`
目前 Playground 已將 `api` 資料夾更名為 `services`,避免與 API 前綴 `/api` 衝突。
API module 應:
在開發模式下:
- 前端呼叫一律使用 `/service/api/*`
- Vite dev server 透過 proxy 將 `/service/*` 轉送到後端(目前指向 `http://192.168.89.54:9002`
- 使用 `httpClient` 發 request。
- 匯出清楚命名的 API 物件,例如 `authApi``menuApi`
- 定義與該 module 相關的 request/response 型別。
- 接收 `AbortSignal` 等 request option,但不管理頁面 loading 或 controller 狀態。
## HTTP Client 設定
- `baseURL`優先使用 `VITE_API_BASE_URL`,否則預設 `/service/api`(搭配 Vite proxy
- `Content-Type`:預設 `application/json`
`client.ts` `baseURL` 優先使用 `VITE_API_BASE_URL`,否則使用 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到後端。
## Token Service(單一來源)
目前 API 呼叫範例:
為避免「Pinia token」與「localStorage token」不同步的問題,這裡採用單一來源:
- `authApi.getCaptcha()` -> `/Auth/get-captcha`
- `authApi.login()` -> `/Auth/login`
- `menuApi.getMenu()` -> `/Menu/GetMenu`
- `menuApi.getFavorite()` -> `/Menu/GetFavorite`
- `services/token.ts` 使用 `ref` 保存 token,並同步 localStorage
- Store 與 Interceptor 都只透過 `tokenService` 讀寫 token
- 401 時清除 token,可立即同步到 UI
## Token 與錯誤處理
## Token 注入策略(Interceptor
token 由 `tokenService` 作為單一來源:
Interceptor 會從 `tokenService` 讀取 `token` 並注入 `Authorization: Bearer <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」
## 錯誤正規化(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` |
需要取消請求時,由 store 或 composable 建立 `AbortController`service module 只接收 `signal`。不要讓 service module 持有 controller 或 UI 狀態
+1 -1
View File
@@ -10,7 +10,7 @@ import { tokenService } from './token'
// 設計重點:
// - Request:自動注入 token(從 localStorage 讀取)
// - 使用 tokenService 作為單一來源,避免 interceptor 直接 import Pinia store 造成循環依賴
// store(auth) -> services(userApi) -> httpClient -> interceptors -> store(auth)
// store(auth) -> services(authApi/menuApi) -> httpClient -> interceptors -> store(auth)
// - Response:統一處理 HTTP 錯誤(目前示範 401/403/500
// - 使用 normalizeError 將錯誤轉成 ApiRequestError
//
+146 -1
View File
@@ -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 負責寫入/清除 tokenlogin/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
View File
@@ -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
View File
@@ -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,
}
})
+209 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}
})
-149
View File
@@ -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 負責寫入/清除 tokenlogin/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,
}
})
-124
View File
@@ -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,
}
})
-147
View File
@@ -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,
}
})
-209
View File
@@ -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,
}
})
-236
View File
@@ -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,
}
})
-30
View File
@@ -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,
}
})
-165
View File
@@ -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,
}
})
-52
View File
@@ -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,
}
})
-345
View File
@@ -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
View File
@@ -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
View File
@@ -1,3 +1,16 @@
# Styles
This directory is for configuring the styles of the application.
`src/styles` 放 Vuetify 與全域樣式設定。
## 目前檔案
- `settings.scss`Vuetify SASS 設定,目前主要設定全域字型。
- `themes.ts`Vuetify theme definitions,供 `src/plugins/vuetify.ts` 使用。
## 使用規則
- Vuetify SASS 變數與全域字型設定放在 `settings.scss`
- 新增或調整 Vuetify theme 時,修改 `themes.ts`,並確認 `src/plugins/vuetify.ts` 有使用對應 theme。
- component 專屬樣式優先寫在該 `.vue` 檔的 `<style scoped>`
- 不要把單一 component 的樣式放進 `src/styles`
- 不要在 view 或 component 內重複建立 theme 設定。