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
+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,
}
})