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:
+146
-1
@@ -1 +1,146 @@
|
||||
export * from './stores/auth'
|
||||
import type { CaptchaResponse, LoginPayload, User } from '@/types/api'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { normalizeError } from '@/services/error'
|
||||
import { authApi } from '@/services/modules/auth'
|
||||
import { tokenService } from '@/services/token'
|
||||
import { useMenuStore } from '@/stores/menu'
|
||||
|
||||
// - 只在 store 管理登入狀態:user/token/loading/error
|
||||
// - Component 不直接呼叫 API,避免狀態散落
|
||||
// - token 單一來源:透過 tokenService 同步 ref + localStorage
|
||||
// - store 負責寫入/清除 token(login/logout)
|
||||
// - axios interceptor 只讀 tokenService
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const token = tokenService.token
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const captcha = ref<CaptchaResponse | null>(null)
|
||||
const captchaLoading = ref(false)
|
||||
const captchaErrorMessage = ref<string | null>(null)
|
||||
// 只針對 login 取消重複請求,避免競態與重複提交
|
||||
const loginController = ref<AbortController | null>(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
const roles = computed(() => (user.value?.role ? [user.value.role] : []))
|
||||
|
||||
const getCaptcha = async () => {
|
||||
captchaLoading.value = true
|
||||
captchaErrorMessage.value = null
|
||||
try {
|
||||
const { data } = await authApi.getCaptcha()
|
||||
captcha.value = data
|
||||
return data
|
||||
} catch (error_) {
|
||||
const normalizedError = normalizeError(error_)
|
||||
captcha.value = null
|
||||
captchaErrorMessage.value = normalizedError.message
|
||||
throw normalizedError
|
||||
} finally {
|
||||
captchaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (payload: LoginPayload) => {
|
||||
loginController.value?.abort()
|
||||
loginController.value = new AbortController()
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
if (!captcha.value?.dntCaptchaTextValue || !captcha.value?.dntCaptchaTokenValue) {
|
||||
throw new Error('驗證碼資料缺失,請先刷新驗證碼')
|
||||
}
|
||||
|
||||
const requestPayload = {
|
||||
UserID: payload.UserID,
|
||||
Password: payload.Password,
|
||||
DNTCaptchaInputText: payload.DNTCaptchaInputText,
|
||||
DNTCaptchaText: captcha.value.dntCaptchaTextValue,
|
||||
DNTCaptchaToken: captcha.value.dntCaptchaTokenValue,
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('UserID', requestPayload.UserID)
|
||||
formData.append('Password', requestPayload.Password)
|
||||
formData.append('DNTCaptchaInputText', requestPayload.DNTCaptchaInputText)
|
||||
formData.append('DNTCaptchaText', requestPayload.DNTCaptchaText)
|
||||
formData.append('DNTCaptchaToken', requestPayload.DNTCaptchaToken)
|
||||
|
||||
const { data } = await authApi.login(formData, {
|
||||
signal: loginController.value.signal,
|
||||
})
|
||||
|
||||
const parseUser = (val: unknown): User | undefined => {
|
||||
if (!val || typeof val !== 'object') return
|
||||
const obj = val as Record<string, unknown>
|
||||
const id = obj.id
|
||||
const name = obj.name
|
||||
const role = obj.role
|
||||
if (typeof id !== 'string' || typeof name !== 'string' || typeof role !== 'string') return
|
||||
return { id, name, role }
|
||||
}
|
||||
|
||||
const parseLoginResult = (
|
||||
raw: unknown
|
||||
): {
|
||||
accessToken?: string
|
||||
tokenType?: string
|
||||
expiresIn?: number
|
||||
user?: User
|
||||
message?: string
|
||||
} => {
|
||||
if (!raw || typeof raw !== 'object') return {}
|
||||
|
||||
const obj = raw as Record<string, unknown>
|
||||
const accessToken = typeof obj.access_token === 'string' ? obj.access_token : undefined
|
||||
const tokenType = typeof obj.token_type === 'string' ? obj.token_type : undefined
|
||||
const expiresIn = typeof obj.expires_in === 'number' ? obj.expires_in : undefined
|
||||
const user = parseUser(obj.user)
|
||||
const message = typeof obj.message === 'string' ? obj.message : undefined
|
||||
|
||||
return { accessToken, tokenType, expiresIn, user, message }
|
||||
}
|
||||
|
||||
const result = parseLoginResult(data)
|
||||
|
||||
if (!result.accessToken) {
|
||||
throw new Error(result.message || '登入回傳缺少 access_token')
|
||||
}
|
||||
|
||||
user.value = result.user ?? null
|
||||
tokenService.setToken(result.accessToken)
|
||||
} catch (error_) {
|
||||
const normalizedError = normalizeError(error_)
|
||||
if (normalizedError.name !== 'CanceledRequestError') {
|
||||
error.value = normalizedError.message
|
||||
}
|
||||
throw normalizedError
|
||||
} finally {
|
||||
loading.value = false
|
||||
loginController.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
user.value = null
|
||||
tokenService.clearToken()
|
||||
useMenuStore().clear()
|
||||
}
|
||||
|
||||
return {
|
||||
getCaptcha,
|
||||
captcha,
|
||||
captchaLoading,
|
||||
captchaErrorMessage,
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
error,
|
||||
isAuthenticated,
|
||||
roles,
|
||||
login,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
|
||||
+124
-1
@@ -1 +1,124 @@
|
||||
export * from './stores/breadcrumbs'
|
||||
import type { LayoutMenuItem } from './menu'
|
||||
import { mdiHome } from '@mdi/js'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
title: string
|
||||
to?: string
|
||||
disabled?: boolean
|
||||
icon?: string
|
||||
}
|
||||
|
||||
interface BreadcrumbPayload {
|
||||
path: string
|
||||
menuItems: LayoutMenuItem[]
|
||||
favoriteItems?: LayoutMenuItem[]
|
||||
fallbackTitle?: string | null
|
||||
homeLabel?: string
|
||||
homeIcon?: string
|
||||
}
|
||||
|
||||
function buildTrail(items: LayoutMenuItem[], targetPath: string): LayoutMenuItem[] | null {
|
||||
const walk = (nodes: LayoutMenuItem[], trail: LayoutMenuItem[]): LayoutMenuItem[] | null => {
|
||||
for (const node of nodes) {
|
||||
const nextTrail = [...trail, node]
|
||||
if (node.path && node.path === targetPath) return nextTrail
|
||||
if (node.subItems?.length) {
|
||||
const found = walk(node.subItems, nextTrail)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return walk(items || [], [])
|
||||
}
|
||||
|
||||
function toBreadcrumbItems(
|
||||
trail: LayoutMenuItem[],
|
||||
homeLabel: string,
|
||||
homeIcon: string
|
||||
): BreadcrumbItem[] {
|
||||
const isHomePath = (path?: string) => path === '/' || path === ''
|
||||
const startsWithHome = trail.length > 0 && isHomePath(trail[0]?.path)
|
||||
const crumbs: BreadcrumbItem[] = []
|
||||
|
||||
if (!startsWithHome) {
|
||||
crumbs.push({
|
||||
title: homeLabel,
|
||||
to: '/',
|
||||
icon: homeIcon,
|
||||
})
|
||||
}
|
||||
|
||||
for (const [index, node] of trail.entries()) {
|
||||
const isLast = index === trail.length - 1
|
||||
crumbs.push({
|
||||
title: node.title,
|
||||
to: isLast ? undefined : node.path,
|
||||
icon: startsWithHome && index === 0 ? homeIcon : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return crumbs
|
||||
}
|
||||
|
||||
export const useBreadcrumbStore = defineStore('breadcrumbs', () => {
|
||||
const items = ref<BreadcrumbItem[]>([])
|
||||
const homeLabel = ref('首頁')
|
||||
const homeIcon = ref(mdiHome)
|
||||
|
||||
const setBreadcrumbs = (payload: BreadcrumbPayload) => {
|
||||
if (!payload?.path) return
|
||||
|
||||
homeLabel.value = payload.homeLabel ?? homeLabel.value
|
||||
homeIcon.value = payload.homeIcon ?? homeIcon.value
|
||||
|
||||
const trailFromMenu = buildTrail(payload.menuItems || [], payload.path)
|
||||
const trailFromFavorite = payload.favoriteItems?.length
|
||||
? buildTrail(payload.favoriteItems, payload.path)
|
||||
: null
|
||||
const trail = trailFromMenu || trailFromFavorite
|
||||
|
||||
if (trail?.length) {
|
||||
items.value = toBreadcrumbItems(trail, homeLabel.value, homeIcon.value)
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.fallbackTitle && payload.fallbackTitle !== homeLabel.value) {
|
||||
items.value = [
|
||||
{
|
||||
title: homeLabel.value,
|
||||
to: '/',
|
||||
icon: homeIcon.value,
|
||||
},
|
||||
{
|
||||
title: payload.fallbackTitle,
|
||||
},
|
||||
]
|
||||
return
|
||||
}
|
||||
|
||||
items.value = [
|
||||
{
|
||||
title: homeLabel.value,
|
||||
to: '/',
|
||||
icon: homeIcon.value,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
items.value = []
|
||||
}
|
||||
|
||||
const breadcrumbItems = computed(() => items.value)
|
||||
|
||||
return {
|
||||
items,
|
||||
breadcrumbItems,
|
||||
setBreadcrumbs,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
|
||||
+147
-1
@@ -1 +1,147 @@
|
||||
export * from './stores/favorites'
|
||||
import type { LayoutMenuItem } from './menu'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
export interface FavoriteItem {
|
||||
title: string
|
||||
path: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
const storageKey = 'sk_playground_user_favorites'
|
||||
const favoritesBarStorageKey = 'sk_admin_favorites_bar_visible'
|
||||
const breadcrumbBarStorageKey = 'sk_admin_breadcrumb_bar_visible'
|
||||
|
||||
function readFavorites(): FavoriteItem[] {
|
||||
if (typeof window === 'undefined') return []
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? (parsed as FavoriteItem[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeFavorites(items: FavoriteItem[]) {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(items))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export const useFavoritesStore = defineStore('favorites', () => {
|
||||
const items = ref<FavoriteItem[]>(readFavorites())
|
||||
const favoritesBarVisible = ref(true)
|
||||
const breadcrumbBarVisible = ref(true)
|
||||
|
||||
const loadFavoritesBarVisible = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
const stored = window.localStorage.getItem(favoritesBarStorageKey)
|
||||
if (stored === null) return
|
||||
favoritesBarVisible.value = stored === '1'
|
||||
}
|
||||
|
||||
const persistFavoritesBarVisible = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(favoritesBarStorageKey, favoritesBarVisible.value ? '1' : '0')
|
||||
}
|
||||
|
||||
const loadBreadcrumbBarVisible = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
const stored = window.localStorage.getItem(breadcrumbBarStorageKey)
|
||||
if (stored === null) return
|
||||
breadcrumbBarVisible.value = stored === '1'
|
||||
}
|
||||
|
||||
const persistBreadcrumbBarVisible = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(breadcrumbBarStorageKey, breadcrumbBarVisible.value ? '1' : '0')
|
||||
}
|
||||
|
||||
const add = (item: FavoriteItem) => {
|
||||
if (!item?.path) return
|
||||
if (items.value.some((x) => x.path === item.path)) return
|
||||
items.value = [...items.value, item]
|
||||
}
|
||||
|
||||
const remove = (path: string) => {
|
||||
if (!path) return
|
||||
items.value = items.value.filter((x) => x.path !== path)
|
||||
}
|
||||
|
||||
const toggle = (item: FavoriteItem) => {
|
||||
if (!item?.path) return
|
||||
const exists = items.value.some((x) => x.path === item.path)
|
||||
if (exists) remove(item.path)
|
||||
else add(item)
|
||||
}
|
||||
|
||||
const isFavorite = (path: string) => {
|
||||
if (!path) return false
|
||||
return items.value.some((x) => x.path === path)
|
||||
}
|
||||
|
||||
const layoutItems = computed<LayoutMenuItem[]>(() =>
|
||||
items.value.map((item) => ({
|
||||
title: item.title,
|
||||
path: item.path,
|
||||
icon: item.icon,
|
||||
}))
|
||||
)
|
||||
|
||||
watch(
|
||||
items,
|
||||
(val) => {
|
||||
writeFavorites(val)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const setFavoritesBarVisible = (value: boolean) => {
|
||||
favoritesBarVisible.value = value
|
||||
persistFavoritesBarVisible()
|
||||
}
|
||||
|
||||
const toggleFavoritesBarVisible = (nextValue?: boolean) => {
|
||||
if (typeof nextValue === 'boolean') {
|
||||
setFavoritesBarVisible(nextValue)
|
||||
return
|
||||
}
|
||||
setFavoritesBarVisible(!favoritesBarVisible.value)
|
||||
}
|
||||
|
||||
loadFavoritesBarVisible()
|
||||
loadBreadcrumbBarVisible()
|
||||
|
||||
const setBreadcrumbBarVisible = (value: boolean) => {
|
||||
breadcrumbBarVisible.value = value
|
||||
persistBreadcrumbBarVisible()
|
||||
}
|
||||
|
||||
const toggleBreadcrumbBarVisible = (nextValue?: boolean) => {
|
||||
if (typeof nextValue === 'boolean') {
|
||||
setBreadcrumbBarVisible(nextValue)
|
||||
return
|
||||
}
|
||||
setBreadcrumbBarVisible(!breadcrumbBarVisible.value)
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
layoutItems,
|
||||
add,
|
||||
remove,
|
||||
toggle,
|
||||
isFavorite,
|
||||
favoritesBarVisible,
|
||||
setFavoritesBarVisible,
|
||||
toggleFavoritesBarVisible,
|
||||
breadcrumbBarVisible,
|
||||
setBreadcrumbBarVisible,
|
||||
toggleBreadcrumbBarVisible,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1 +1,209 @@
|
||||
export * from './stores/loginAnnouncements'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
export interface LoginAnnouncementItem {
|
||||
id: string | number
|
||||
date: string
|
||||
school: string
|
||||
title: string
|
||||
tab?: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface LoginAnnouncementListItem {
|
||||
id: string | number
|
||||
date: string
|
||||
school: string
|
||||
title: string
|
||||
tab?: string
|
||||
}
|
||||
|
||||
export interface LoginMobileAnnouncementItem {
|
||||
id: string | number
|
||||
content: string
|
||||
title?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
const storageKey = 'sk_playground_login_announcements'
|
||||
|
||||
const defaultItems: LoginAnnouncementItem[] = [
|
||||
{
|
||||
id: 'announcement-1',
|
||||
date: '2024-03-19',
|
||||
school: '市立實踐國中',
|
||||
title: '臺北市立實踐國中徵求113學年度教學支援工作人員',
|
||||
tab: 'junior',
|
||||
detail: '公告內容:本校辦理本土語教學支援工作人員甄選,請於期限內完成報名與資料繳交。',
|
||||
},
|
||||
{
|
||||
id: 'announcement-2',
|
||||
date: '2023-12-12',
|
||||
school: '市立華江高中',
|
||||
title: '臺北市立華江高級中學112學年度第二學期本土語教學支援人員甄選',
|
||||
tab: 'senior',
|
||||
detail: '公告內容:甄選包含書面審查與面試,相關時間地點請參閱簡章附件。',
|
||||
},
|
||||
{
|
||||
id: 'announcement-3',
|
||||
date: '2023-12-05',
|
||||
school: '市立麗山高中',
|
||||
title: '內湖區麗山高中誠徵閩南語教支人員數名',
|
||||
tab: 'senior',
|
||||
detail: '公告內容:需具備相關教學經驗,錄取後依課務需求排課。',
|
||||
},
|
||||
{
|
||||
id: 'announcement-4',
|
||||
date: '2023-11-28',
|
||||
school: '市立永吉國中',
|
||||
title: '公告本市學校本土語教學支援人員報名資訊',
|
||||
tab: 'junior',
|
||||
detail: '公告內容:統一受理報名,請依公告流程檢附文件並完成線上登錄。',
|
||||
},
|
||||
{
|
||||
id: 'announcement-5',
|
||||
date: '2023-11-21',
|
||||
school: '市立百齡高中',
|
||||
title: '112學年度本土語文教學支援工作人員甄選簡章',
|
||||
tab: 'senior',
|
||||
detail: '公告內容:簡章含資格條件、甄選方式、成績計算與錄取標準。',
|
||||
},
|
||||
{
|
||||
id: 'announcement-6',
|
||||
date: '2023-11-10',
|
||||
school: '市立成德國中',
|
||||
title: '本土語教學支援工作人員甄選(第二次)',
|
||||
tab: 'junior',
|
||||
detail: '公告內容:第二次甄選開放補件,報名截止日以公告為準。',
|
||||
},
|
||||
]
|
||||
|
||||
function readItems(): LoginAnnouncementItem[] {
|
||||
if (typeof window === 'undefined') return defaultItems
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey)
|
||||
if (!raw) return defaultItems
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? (parsed as LoginAnnouncementItem[]) : defaultItems
|
||||
} catch {
|
||||
return defaultItems
|
||||
}
|
||||
}
|
||||
|
||||
function writeItems(items: LoginAnnouncementItem[]) {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(items))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function mockFetchMobileAnnouncementsApi(): Promise<LoginMobileAnnouncementItem[]> {
|
||||
return [
|
||||
{
|
||||
id: 'mobile-announcement-1',
|
||||
content: '系統正常運行中',
|
||||
title: '系統公告',
|
||||
createdAt: '2026-02-11',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export const useLoginAnnouncementsStore = defineStore('loginAnnouncements', () => {
|
||||
const items = ref<LoginAnnouncementItem[]>(readItems())
|
||||
const selectedId = ref<string | number | null>(null)
|
||||
const mobileAnnouncements = ref<LoginMobileAnnouncementItem[]>([])
|
||||
|
||||
const listItems = computed<LoginAnnouncementListItem[]>(() =>
|
||||
items.value.map((item) => ({
|
||||
id: item.id,
|
||||
date: item.date,
|
||||
school: item.school,
|
||||
title: item.title,
|
||||
tab: item.tab,
|
||||
}))
|
||||
)
|
||||
|
||||
const boardConfig = computed(() => ({
|
||||
title: '學校公告區',
|
||||
tabs: [
|
||||
{ label: '全部', value: '__all__' },
|
||||
{ label: '國中', value: 'junior' },
|
||||
{ label: '高中', value: 'senior' },
|
||||
],
|
||||
items: listItems.value,
|
||||
systemAnnouncements: mobileAnnouncements.value,
|
||||
itemsPerPage: 5,
|
||||
dateHeader: '公告時間',
|
||||
schoolHeader: '公告學校',
|
||||
titleHeader: '公告標題',
|
||||
paginationLabel: '總筆數:',
|
||||
}))
|
||||
|
||||
const selectedAnnouncement = computed(() => {
|
||||
if (selectedId.value === null) return null
|
||||
return items.value.find((item) => item.id === selectedId.value) ?? null
|
||||
})
|
||||
|
||||
const selectedAnnouncementDetail = computed(() => {
|
||||
return selectedAnnouncement.value?.detail ?? ''
|
||||
})
|
||||
|
||||
const mobileAnnouncementConfig = computed(() => ({
|
||||
items: mobileAnnouncements.value,
|
||||
show: mobileAnnouncements.value.length > 0,
|
||||
viewAllText: '查看全部',
|
||||
listTitle: '系統公告',
|
||||
closeText: '關閉',
|
||||
emptyText: '目前沒有公告',
|
||||
}))
|
||||
|
||||
const hydrate = () => {
|
||||
items.value = readItems()
|
||||
}
|
||||
|
||||
const replaceAll = (nextItems: LoginAnnouncementItem[]) => {
|
||||
items.value = Array.isArray(nextItems) ? nextItems : []
|
||||
}
|
||||
|
||||
const selectById = (id: string | number) => {
|
||||
selectedId.value = id
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
selectedId.value = null
|
||||
}
|
||||
|
||||
const fetchMobileAnnouncements = async () => {
|
||||
const result = await mockFetchMobileAnnouncementsApi()
|
||||
mobileAnnouncements.value = Array.isArray(result) ? result : []
|
||||
}
|
||||
|
||||
const fetchMobileAnnouncement = async () => {
|
||||
await fetchMobileAnnouncements()
|
||||
}
|
||||
|
||||
watch(
|
||||
items,
|
||||
(val) => {
|
||||
writeItems(val)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
return {
|
||||
items,
|
||||
listItems,
|
||||
boardConfig,
|
||||
mobileAnnouncementConfig,
|
||||
selectedAnnouncement,
|
||||
selectedAnnouncementDetail,
|
||||
hydrate,
|
||||
replaceAll,
|
||||
selectById,
|
||||
clearSelection,
|
||||
fetchMobileAnnouncements,
|
||||
fetchMobileAnnouncement,
|
||||
}
|
||||
})
|
||||
|
||||
+236
-1
@@ -1 +1,236 @@
|
||||
export * from './stores/menu'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { normalizeError } from '@/services/error'
|
||||
import { menuApi, type MenuNode } from '@/services/modules/menu'
|
||||
|
||||
export interface LayoutMenuItem {
|
||||
title: string
|
||||
path?: string
|
||||
navigable?: boolean
|
||||
subItems?: LayoutMenuItem[]
|
||||
}
|
||||
|
||||
export const useMenuStore = defineStore('menu', () => {
|
||||
const menu = ref<MenuNode[]>([])
|
||||
const favorite = ref<MenuNode[]>([])
|
||||
const isRail = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const menuStorageKey = 'sk_playground_menu'
|
||||
const favoriteStorageKey = 'sk_playground_favorite'
|
||||
const isRailStorageKey = 'sk_playground_is_rail'
|
||||
|
||||
const readNodes = (key: string): MenuNode[] => {
|
||||
if (typeof window === 'undefined') return []
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? (parsed as MenuNode[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const readBoolean = (key: string, defaultValue = false): boolean => {
|
||||
if (typeof window === 'undefined') return defaultValue
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key)
|
||||
return raw === null ? defaultValue : raw === 'true'
|
||||
} catch {
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
const writeValue = (key: string, value: any) => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(key, String(value))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const writeNodes = (key: string, nodes: MenuNode[]) => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(nodes))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const removeValue = (key: string) => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.removeItem(key)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const hydrate = () => {
|
||||
menu.value = readNodes(menuStorageKey)
|
||||
favorite.value = readNodes(favoriteStorageKey)
|
||||
isRail.value = readBoolean(isRailStorageKey)
|
||||
}
|
||||
|
||||
hydrate()
|
||||
|
||||
const toLayoutMenuItems = (nodes: MenuNode[]): LayoutMenuItem[] => {
|
||||
const getString = (node: MenuNode, key: string): string | undefined => {
|
||||
const v = node?.[key]
|
||||
return typeof v === 'string' ? v : undefined
|
||||
}
|
||||
|
||||
const getChildren = (node: MenuNode): MenuNode[] => {
|
||||
return Array.isArray(node?.children) ? (node.children as MenuNode[]) : []
|
||||
}
|
||||
|
||||
return nodes
|
||||
.map((mdl) => {
|
||||
const mdlTitle = getString(mdl, 'mdl_name') ?? ''
|
||||
const untItems = getChildren(mdl)
|
||||
.map((unt) => {
|
||||
const untTitle = getString(unt, 'unt_name') ?? ''
|
||||
const fncItems = getChildren(unt)
|
||||
.map((fnc) => {
|
||||
const fncTitle = getString(fnc, 'fnc_name') ?? ''
|
||||
const fncId = getString(fnc, 'fnc_id')
|
||||
return {
|
||||
title: fncTitle,
|
||||
path: fncId ? `/${fncId}` : undefined,
|
||||
} satisfies LayoutMenuItem
|
||||
})
|
||||
.filter((x) => x.title)
|
||||
|
||||
return {
|
||||
title: untTitle,
|
||||
navigable: false,
|
||||
subItems: fncItems,
|
||||
} satisfies LayoutMenuItem
|
||||
})
|
||||
.filter((x) => x.title)
|
||||
|
||||
return {
|
||||
title: mdlTitle,
|
||||
navigable: false,
|
||||
subItems: untItems,
|
||||
} satisfies LayoutMenuItem
|
||||
})
|
||||
.filter((x) => x.title)
|
||||
}
|
||||
|
||||
const toFavoriteLayoutMenuItems = (nodes: MenuNode[]): LayoutMenuItem[] => {
|
||||
const getString = (node: MenuNode, key: string): string | undefined => {
|
||||
const v = node?.[key]
|
||||
return typeof v === 'string' ? v : undefined
|
||||
}
|
||||
|
||||
const getChildren = (node: MenuNode): MenuNode[] => {
|
||||
return Array.isArray(node?.children) ? (node.children as MenuNode[]) : []
|
||||
}
|
||||
|
||||
return nodes
|
||||
.map((unt) => {
|
||||
const untTitle = getString(unt, 'unt_name') ?? ''
|
||||
const fncItems = getChildren(unt)
|
||||
.map((fnc) => {
|
||||
const fncTitle = getString(fnc, 'fnc_name') ?? ''
|
||||
const fncId = getString(fnc, 'fnc_id')
|
||||
return {
|
||||
title: fncTitle,
|
||||
path: fncId ? `/${fncId}` : undefined,
|
||||
} satisfies LayoutMenuItem
|
||||
})
|
||||
.filter((x) => x.title)
|
||||
|
||||
return {
|
||||
title: untTitle,
|
||||
navigable: false,
|
||||
subItems: fncItems,
|
||||
} satisfies LayoutMenuItem
|
||||
})
|
||||
.filter((x) => x.title)
|
||||
}
|
||||
|
||||
const menuItems = computed<LayoutMenuItem[]>(() => toLayoutMenuItems(menu.value))
|
||||
const favoriteItems = computed<LayoutMenuItem[]>(() => toFavoriteLayoutMenuItems(favorite.value))
|
||||
|
||||
watch(
|
||||
menu,
|
||||
(val) => {
|
||||
writeNodes(menuStorageKey, val)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
favorite,
|
||||
(val) => {
|
||||
writeNodes(favoriteStorageKey, val)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(isRail, (val) => {
|
||||
writeValue(isRailStorageKey, val)
|
||||
})
|
||||
|
||||
const clear = () => {
|
||||
menu.value = []
|
||||
favorite.value = []
|
||||
isRail.value = false
|
||||
error.value = null
|
||||
removeValue(menuStorageKey)
|
||||
removeValue(favoriteStorageKey)
|
||||
removeValue(isRailStorageKey)
|
||||
}
|
||||
|
||||
const getMenu = async (id: string) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await menuApi.getMenu({ userID: id })
|
||||
menu.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
|
||||
} catch (error_) {
|
||||
const normalizedError = normalizeError(error_)
|
||||
if (normalizedError.name !== 'CanceledRequestError') {
|
||||
error.value = normalizedError.message
|
||||
}
|
||||
throw normalizedError
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getFavorite = async (id: string) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await menuApi.getFavorite({ userID: id })
|
||||
favorite.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
|
||||
} catch (error_) {
|
||||
const normalizedError = normalizeError(error_)
|
||||
if (normalizedError.name !== 'CanceledRequestError') {
|
||||
error.value = normalizedError.message
|
||||
}
|
||||
throw normalizedError
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
menu,
|
||||
favorite,
|
||||
isRail,
|
||||
menuItems,
|
||||
favoriteItems,
|
||||
error,
|
||||
loading,
|
||||
hydrate,
|
||||
clear,
|
||||
getMenu,
|
||||
getFavorite,
|
||||
}
|
||||
})
|
||||
|
||||
+30
-1
@@ -1 +1,30 @@
|
||||
export * from './stores/messages'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export const useMessageStore = defineStore('messages', () => {
|
||||
const openState = ref(false)
|
||||
|
||||
// 開啟訊息中心 Dialog
|
||||
const open = () => {
|
||||
openState.value = true
|
||||
}
|
||||
|
||||
// 關閉訊息中心 Dialog
|
||||
const close = () => {
|
||||
openState.value = false
|
||||
}
|
||||
|
||||
// 提供 v-model 綁定用的 computed
|
||||
const isOpen = computed({
|
||||
get: () => openState.value,
|
||||
set: (value) => {
|
||||
openState.value = value
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
}
|
||||
})
|
||||
|
||||
+157
-1
@@ -1 +1,157 @@
|
||||
export * from './stores/semesters'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface CourseRecord {
|
||||
code: string
|
||||
name: string
|
||||
credits: number
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface SemesterRecord {
|
||||
id: number
|
||||
studentId: number
|
||||
semesterName: string
|
||||
courses: CourseRecord[]
|
||||
rank: number
|
||||
average: number
|
||||
}
|
||||
|
||||
const seedSemesters: SemesterRecord[] = []
|
||||
|
||||
const randomScore = (min = 60, max = 99) => Math.floor(Math.random() * (max - min + 1)) + min
|
||||
|
||||
export function generateMockSemesters(studentId: number) {
|
||||
const semesters = [
|
||||
{ name: '111 學年度第 1 學期', baseId: 1000 },
|
||||
{ name: '111 學年度第 2 學期', baseId: 2000 },
|
||||
{ name: '112 學年度第 1 學期', baseId: 3000 },
|
||||
{ name: '112 學年度第 2 學期', baseId: 4000 },
|
||||
{ name: '113 學年度第 1 學期', baseId: 5000 },
|
||||
{ name: '113 學年度第 2 學期', baseId: 6000 },
|
||||
]
|
||||
|
||||
const subjects = [
|
||||
{ name: '資料結構', credits: 3 },
|
||||
{ name: '演算法', credits: 3 },
|
||||
{ name: '作業系統', credits: 3 },
|
||||
{ name: '計算機組織', credits: 3 },
|
||||
{ name: '線性代數', credits: 3 },
|
||||
{ name: '機率與統計', credits: 3 },
|
||||
{ name: '資料庫系統', credits: 3 },
|
||||
{ name: '人工智慧導論', credits: 3 },
|
||||
{ name: '網頁程式設計', credits: 3 },
|
||||
{ name: '計算機網路', credits: 3 },
|
||||
]
|
||||
|
||||
const count = 5 + (studentId % 2)
|
||||
const result: SemesterRecord[] = []
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const sem = semesters[i]
|
||||
if (!sem) continue
|
||||
const courseCount = 8 + (studentId % 3)
|
||||
const courses: CourseRecord[] = []
|
||||
const usedSubjects = new Set<number>()
|
||||
|
||||
let totalScore = 0
|
||||
let totalCredits = 0
|
||||
|
||||
while (courses.length < courseCount) {
|
||||
const idx = Math.floor(Math.random() * subjects.length)
|
||||
if (usedSubjects.has(idx)) continue
|
||||
usedSubjects.add(idx)
|
||||
|
||||
const score = randomScore()
|
||||
const subject = subjects[idx]
|
||||
if (!subject) continue
|
||||
|
||||
courses.push({
|
||||
code: `CS${1000 + idx}`,
|
||||
name: subject.name,
|
||||
credits: subject.credits,
|
||||
score,
|
||||
})
|
||||
|
||||
totalScore += score * subject.credits
|
||||
totalCredits += subject.credits
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: sem.baseId + studentId,
|
||||
studentId,
|
||||
semesterName: sem.name,
|
||||
courses,
|
||||
rank: Math.floor(Math.random() * 20) + 1,
|
||||
average: Number((totalScore / totalCredits).toFixed(2)),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
seedSemesters.push(...generateMockSemesters(i))
|
||||
}
|
||||
|
||||
export const useSemesterStore = defineStore('semesters', () => {
|
||||
const semesters = ref<SemesterRecord[]>([...seedSemesters])
|
||||
|
||||
const getStudentSemesters = (studentId: number) => {
|
||||
return semesters.value.filter((s) => s.studentId === studentId)
|
||||
}
|
||||
|
||||
const generateForStudent = (studentId: number) => {
|
||||
const newSemesters = generateMockSemesters(studentId)
|
||||
semesters.value.push(...newSemesters)
|
||||
}
|
||||
|
||||
const addSemester = (studentId: number) => {
|
||||
const newId = Math.max(...semesters.value.map((s) => s.id), 0) + 1
|
||||
const newSemester: SemesterRecord = {
|
||||
id: newId,
|
||||
studentId,
|
||||
semesterName: '新學期',
|
||||
courses: [],
|
||||
rank: 0,
|
||||
average: 0,
|
||||
}
|
||||
semesters.value.push(newSemester)
|
||||
return newSemester
|
||||
}
|
||||
|
||||
const updateSemester = (id: number, payload: Partial<SemesterRecord>) => {
|
||||
const index = semesters.value.findIndex((s) => s.id === id)
|
||||
if (index === -1) return
|
||||
const current = semesters.value[index]
|
||||
if (!current) return
|
||||
|
||||
if (payload.courses) {
|
||||
const totalScore = payload.courses.reduce((sum, c) => sum + c.score * c.credits, 0)
|
||||
const totalCredits = payload.courses.reduce((sum, c) => sum + c.credits, 0)
|
||||
payload.average = totalCredits > 0 ? Number((totalScore / totalCredits).toFixed(2)) : 0
|
||||
}
|
||||
|
||||
Object.assign(current, payload)
|
||||
}
|
||||
|
||||
const removeSemester = (id: number) => {
|
||||
const index = semesters.value.findIndex((s) => s.id === id)
|
||||
if (index !== -1) {
|
||||
semesters.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const removeByStudentId = (studentId: number) => {
|
||||
semesters.value = semesters.value.filter((s) => s.studentId !== studentId)
|
||||
}
|
||||
|
||||
return {
|
||||
semesters,
|
||||
getStudentSemesters,
|
||||
generateForStudent,
|
||||
addSemester,
|
||||
updateSemester,
|
||||
removeSemester,
|
||||
removeByStudentId,
|
||||
}
|
||||
})
|
||||
|
||||
+52
-1
@@ -1 +1,52 @@
|
||||
export * from './stores/snackbar'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
type SnackbarColor = string
|
||||
type SnackbarVariant = 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'
|
||||
|
||||
type SnackbarLocation = string
|
||||
|
||||
interface ShowOptions {
|
||||
message: string
|
||||
color?: SnackbarColor
|
||||
timeout?: number
|
||||
location?: SnackbarLocation
|
||||
variant?: SnackbarVariant
|
||||
}
|
||||
|
||||
export const useSnackbarStore = defineStore('snackbar', () => {
|
||||
const visible = ref(false)
|
||||
const message = ref('')
|
||||
const color = ref<SnackbarColor>('success')
|
||||
const timeout = ref(2000)
|
||||
const location = ref<SnackbarLocation>('top right')
|
||||
const variant = ref<SnackbarVariant>('flat')
|
||||
|
||||
const show = (options: ShowOptions) => {
|
||||
message.value = options.message
|
||||
color.value = options.color ?? 'success'
|
||||
timeout.value = options.timeout ?? 2000
|
||||
location.value = options.location ?? 'top right'
|
||||
variant.value = options.variant ?? 'flat'
|
||||
|
||||
visible.value = false
|
||||
requestAnimationFrame(() => {
|
||||
visible.value = true
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
visible,
|
||||
message,
|
||||
color,
|
||||
timeout,
|
||||
location,
|
||||
variant,
|
||||
show,
|
||||
hide,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import type { CaptchaResponse, LoginPayload, User } from '@/types/api'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { normalizeError } from '@/services/error'
|
||||
import { authApi } from '@/services/modules/auth'
|
||||
import { tokenService } from '@/services/token'
|
||||
import { useMenuStore } from '@/stores/menu'
|
||||
|
||||
// - 只在 store 管理登入狀態:user/token/loading/error
|
||||
// - Component 不直接呼叫 API,避免狀態散落
|
||||
// - token 單一來源:透過 tokenService 同步 ref + localStorage
|
||||
// - store 負責寫入/清除 token(login/logout)
|
||||
// - axios interceptor 只讀 tokenService
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// State
|
||||
const user = ref<User | null>(null)
|
||||
const token = tokenService.token
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const captcha = ref<CaptchaResponse | null>(null)
|
||||
const captchaLoading = ref(false)
|
||||
const captchaErrorMessage = ref<string | null>(null)
|
||||
// 只針對 login 取消重複請求,避免競態與重複提交
|
||||
const loginController = ref<AbortController | null>(null)
|
||||
|
||||
// Getters
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
const roles = computed(() => (user.value?.role ? [user.value.role] : []))
|
||||
|
||||
// Actions
|
||||
const getCaptcha = async () => {
|
||||
captchaLoading.value = true
|
||||
captchaErrorMessage.value = null
|
||||
try {
|
||||
const { data } = await authApi.getCaptcha()
|
||||
captcha.value = data
|
||||
return data
|
||||
} catch (error_) {
|
||||
const normalizedError = normalizeError(error_)
|
||||
captcha.value = null
|
||||
captchaErrorMessage.value = normalizedError.message
|
||||
throw normalizedError
|
||||
} finally {
|
||||
captchaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (payload: LoginPayload) => {
|
||||
loginController.value?.abort()
|
||||
loginController.value = new AbortController()
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
if (!captcha.value?.dntCaptchaTextValue || !captcha.value?.dntCaptchaTokenValue) {
|
||||
throw new Error('驗證碼資料缺失,請先刷新驗證碼')
|
||||
}
|
||||
|
||||
const requestPayload = {
|
||||
UserID: payload.UserID,
|
||||
Password: payload.Password,
|
||||
DNTCaptchaInputText: payload.DNTCaptchaInputText,
|
||||
DNTCaptchaText: captcha.value.dntCaptchaTextValue,
|
||||
DNTCaptchaToken: captcha.value.dntCaptchaTokenValue,
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('UserID', requestPayload.UserID)
|
||||
formData.append('Password', requestPayload.Password)
|
||||
formData.append('DNTCaptchaInputText', requestPayload.DNTCaptchaInputText)
|
||||
formData.append('DNTCaptchaText', requestPayload.DNTCaptchaText)
|
||||
formData.append('DNTCaptchaToken', requestPayload.DNTCaptchaToken)
|
||||
|
||||
const { data } = await authApi.login(formData, {
|
||||
signal: loginController.value.signal,
|
||||
})
|
||||
|
||||
const parseUser = (val: unknown): User | undefined => {
|
||||
if (!val || typeof val !== 'object') return
|
||||
const obj = val as Record<string, unknown>
|
||||
const id = obj.id
|
||||
const name = obj.name
|
||||
const role = obj.role
|
||||
if (typeof id !== 'string' || typeof name !== 'string' || typeof role !== 'string') return
|
||||
return { id, name, role }
|
||||
}
|
||||
|
||||
const parseLoginResult = (
|
||||
raw: unknown
|
||||
): {
|
||||
accessToken?: string
|
||||
tokenType?: string
|
||||
expiresIn?: number
|
||||
user?: User
|
||||
message?: string
|
||||
} => {
|
||||
if (!raw || typeof raw !== 'object') return {}
|
||||
|
||||
const obj = raw as Record<string, unknown>
|
||||
const accessToken = typeof obj.access_token === 'string' ? obj.access_token : undefined
|
||||
const tokenType = typeof obj.token_type === 'string' ? obj.token_type : undefined
|
||||
const expiresIn = typeof obj.expires_in === 'number' ? obj.expires_in : undefined
|
||||
const user = parseUser(obj.user)
|
||||
const message = typeof obj.message === 'string' ? obj.message : undefined
|
||||
|
||||
return { accessToken, tokenType, expiresIn, user, message }
|
||||
}
|
||||
|
||||
const result = parseLoginResult(data)
|
||||
|
||||
if (!result.accessToken) {
|
||||
throw new Error(result.message || '登入回傳缺少 access_token')
|
||||
}
|
||||
|
||||
user.value = result.user ?? null
|
||||
tokenService.setToken(result.accessToken)
|
||||
} catch (error_) {
|
||||
const normalizedError = normalizeError(error_)
|
||||
if (normalizedError.name !== 'CanceledRequestError') {
|
||||
error.value = normalizedError.message
|
||||
}
|
||||
throw normalizedError
|
||||
} finally {
|
||||
loading.value = false
|
||||
loginController.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
user.value = null
|
||||
tokenService.clearToken()
|
||||
useMenuStore().clear()
|
||||
}
|
||||
|
||||
return {
|
||||
getCaptcha,
|
||||
captcha,
|
||||
captchaLoading,
|
||||
captchaErrorMessage,
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
error,
|
||||
isAuthenticated,
|
||||
roles,
|
||||
login,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
@@ -1,124 +0,0 @@
|
||||
import type { LayoutMenuItem } from './menu'
|
||||
import { mdiHome } from '@mdi/js'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
title: string
|
||||
to?: string
|
||||
disabled?: boolean
|
||||
icon?: string
|
||||
}
|
||||
|
||||
interface BreadcrumbPayload {
|
||||
path: string
|
||||
menuItems: LayoutMenuItem[]
|
||||
favoriteItems?: LayoutMenuItem[]
|
||||
fallbackTitle?: string | null
|
||||
homeLabel?: string
|
||||
homeIcon?: string
|
||||
}
|
||||
|
||||
function buildTrail(items: LayoutMenuItem[], targetPath: string): LayoutMenuItem[] | null {
|
||||
const walk = (nodes: LayoutMenuItem[], trail: LayoutMenuItem[]): LayoutMenuItem[] | null => {
|
||||
for (const node of nodes) {
|
||||
const nextTrail = [...trail, node]
|
||||
if (node.path && node.path === targetPath) return nextTrail
|
||||
if (node.subItems?.length) {
|
||||
const found = walk(node.subItems, nextTrail)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return walk(items || [], [])
|
||||
}
|
||||
|
||||
function toBreadcrumbItems(
|
||||
trail: LayoutMenuItem[],
|
||||
homeLabel: string,
|
||||
homeIcon: string
|
||||
): BreadcrumbItem[] {
|
||||
const isHomePath = (path?: string) => path === '/' || path === ''
|
||||
const startsWithHome = trail.length > 0 && isHomePath(trail[0]?.path)
|
||||
const crumbs: BreadcrumbItem[] = []
|
||||
|
||||
if (!startsWithHome) {
|
||||
crumbs.push({
|
||||
title: homeLabel,
|
||||
to: '/',
|
||||
icon: homeIcon,
|
||||
})
|
||||
}
|
||||
|
||||
for (const [index, node] of trail.entries()) {
|
||||
const isLast = index === trail.length - 1
|
||||
crumbs.push({
|
||||
title: node.title,
|
||||
to: isLast ? undefined : node.path,
|
||||
icon: startsWithHome && index === 0 ? homeIcon : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return crumbs
|
||||
}
|
||||
|
||||
export const useBreadcrumbStore = defineStore('breadcrumbs', () => {
|
||||
const items = ref<BreadcrumbItem[]>([])
|
||||
const homeLabel = ref('首頁')
|
||||
const homeIcon = ref(mdiHome)
|
||||
|
||||
const setBreadcrumbs = (payload: BreadcrumbPayload) => {
|
||||
if (!payload?.path) return
|
||||
|
||||
homeLabel.value = payload.homeLabel ?? homeLabel.value
|
||||
homeIcon.value = payload.homeIcon ?? homeIcon.value
|
||||
|
||||
const trailFromMenu = buildTrail(payload.menuItems || [], payload.path)
|
||||
const trailFromFavorite = payload.favoriteItems?.length
|
||||
? buildTrail(payload.favoriteItems, payload.path)
|
||||
: null
|
||||
const trail = trailFromMenu || trailFromFavorite
|
||||
|
||||
if (trail?.length) {
|
||||
items.value = toBreadcrumbItems(trail, homeLabel.value, homeIcon.value)
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.fallbackTitle && payload.fallbackTitle !== homeLabel.value) {
|
||||
items.value = [
|
||||
{
|
||||
title: homeLabel.value,
|
||||
to: '/',
|
||||
icon: homeIcon.value,
|
||||
},
|
||||
{
|
||||
title: payload.fallbackTitle,
|
||||
},
|
||||
]
|
||||
return
|
||||
}
|
||||
|
||||
items.value = [
|
||||
{
|
||||
title: homeLabel.value,
|
||||
to: '/',
|
||||
icon: homeIcon.value,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
items.value = []
|
||||
}
|
||||
|
||||
const breadcrumbItems = computed(() => items.value)
|
||||
|
||||
return {
|
||||
items,
|
||||
breadcrumbItems,
|
||||
setBreadcrumbs,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
@@ -1,147 +0,0 @@
|
||||
import type { LayoutMenuItem } from './menu'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
export interface FavoriteItem {
|
||||
title: string
|
||||
path: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
const storageKey = 'sk_playground_user_favorites'
|
||||
const favoritesBarStorageKey = 'sk_admin_favorites_bar_visible'
|
||||
const breadcrumbBarStorageKey = 'sk_admin_breadcrumb_bar_visible'
|
||||
|
||||
function readFavorites(): FavoriteItem[] {
|
||||
if (typeof window === 'undefined') return []
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? (parsed as FavoriteItem[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeFavorites(items: FavoriteItem[]) {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(items))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export const useFavoritesStore = defineStore('favorites', () => {
|
||||
const items = ref<FavoriteItem[]>(readFavorites())
|
||||
const favoritesBarVisible = ref(true)
|
||||
const breadcrumbBarVisible = ref(true)
|
||||
|
||||
const loadFavoritesBarVisible = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
const stored = window.localStorage.getItem(favoritesBarStorageKey)
|
||||
if (stored === null) return
|
||||
favoritesBarVisible.value = stored === '1'
|
||||
}
|
||||
|
||||
const persistFavoritesBarVisible = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(favoritesBarStorageKey, favoritesBarVisible.value ? '1' : '0')
|
||||
}
|
||||
|
||||
const loadBreadcrumbBarVisible = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
const stored = window.localStorage.getItem(breadcrumbBarStorageKey)
|
||||
if (stored === null) return
|
||||
breadcrumbBarVisible.value = stored === '1'
|
||||
}
|
||||
|
||||
const persistBreadcrumbBarVisible = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(breadcrumbBarStorageKey, breadcrumbBarVisible.value ? '1' : '0')
|
||||
}
|
||||
|
||||
const add = (item: FavoriteItem) => {
|
||||
if (!item?.path) return
|
||||
if (items.value.some((x) => x.path === item.path)) return
|
||||
items.value = [...items.value, item]
|
||||
}
|
||||
|
||||
const remove = (path: string) => {
|
||||
if (!path) return
|
||||
items.value = items.value.filter((x) => x.path !== path)
|
||||
}
|
||||
|
||||
const toggle = (item: FavoriteItem) => {
|
||||
if (!item?.path) return
|
||||
const exists = items.value.some((x) => x.path === item.path)
|
||||
if (exists) remove(item.path)
|
||||
else add(item)
|
||||
}
|
||||
|
||||
const isFavorite = (path: string) => {
|
||||
if (!path) return false
|
||||
return items.value.some((x) => x.path === path)
|
||||
}
|
||||
|
||||
const layoutItems = computed<LayoutMenuItem[]>(() =>
|
||||
items.value.map((item) => ({
|
||||
title: item.title,
|
||||
path: item.path,
|
||||
icon: item.icon,
|
||||
}))
|
||||
)
|
||||
|
||||
watch(
|
||||
items,
|
||||
(val) => {
|
||||
writeFavorites(val)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const setFavoritesBarVisible = (value: boolean) => {
|
||||
favoritesBarVisible.value = value
|
||||
persistFavoritesBarVisible()
|
||||
}
|
||||
|
||||
const toggleFavoritesBarVisible = (nextValue?: boolean) => {
|
||||
if (typeof nextValue === 'boolean') {
|
||||
setFavoritesBarVisible(nextValue)
|
||||
return
|
||||
}
|
||||
setFavoritesBarVisible(!favoritesBarVisible.value)
|
||||
}
|
||||
|
||||
loadFavoritesBarVisible()
|
||||
loadBreadcrumbBarVisible()
|
||||
|
||||
const setBreadcrumbBarVisible = (value: boolean) => {
|
||||
breadcrumbBarVisible.value = value
|
||||
persistBreadcrumbBarVisible()
|
||||
}
|
||||
|
||||
const toggleBreadcrumbBarVisible = (nextValue?: boolean) => {
|
||||
if (typeof nextValue === 'boolean') {
|
||||
setBreadcrumbBarVisible(nextValue)
|
||||
return
|
||||
}
|
||||
setBreadcrumbBarVisible(!breadcrumbBarVisible.value)
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
layoutItems,
|
||||
add,
|
||||
remove,
|
||||
toggle,
|
||||
isFavorite,
|
||||
favoritesBarVisible,
|
||||
setFavoritesBarVisible,
|
||||
toggleFavoritesBarVisible,
|
||||
breadcrumbBarVisible,
|
||||
setBreadcrumbBarVisible,
|
||||
toggleBreadcrumbBarVisible,
|
||||
}
|
||||
})
|
||||
@@ -1,209 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
export interface LoginAnnouncementItem {
|
||||
id: string | number
|
||||
date: string
|
||||
school: string
|
||||
title: string
|
||||
tab?: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface LoginAnnouncementListItem {
|
||||
id: string | number
|
||||
date: string
|
||||
school: string
|
||||
title: string
|
||||
tab?: string
|
||||
}
|
||||
|
||||
export interface LoginMobileAnnouncementItem {
|
||||
id: string | number
|
||||
content: string
|
||||
title?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
const storageKey = 'sk_playground_login_announcements'
|
||||
|
||||
const defaultItems: LoginAnnouncementItem[] = [
|
||||
{
|
||||
id: 'announcement-1',
|
||||
date: '2024-03-19',
|
||||
school: '市立實踐國中',
|
||||
title: '臺北市立實踐國中徵求113學年度教學支援工作人員',
|
||||
tab: 'junior',
|
||||
detail: '公告內容:本校辦理本土語教學支援工作人員甄選,請於期限內完成報名與資料繳交。',
|
||||
},
|
||||
{
|
||||
id: 'announcement-2',
|
||||
date: '2023-12-12',
|
||||
school: '市立華江高中',
|
||||
title: '臺北市立華江高級中學112學年度第二學期本土語教學支援人員甄選',
|
||||
tab: 'senior',
|
||||
detail: '公告內容:甄選包含書面審查與面試,相關時間地點請參閱簡章附件。',
|
||||
},
|
||||
{
|
||||
id: 'announcement-3',
|
||||
date: '2023-12-05',
|
||||
school: '市立麗山高中',
|
||||
title: '內湖區麗山高中誠徵閩南語教支人員數名',
|
||||
tab: 'senior',
|
||||
detail: '公告內容:需具備相關教學經驗,錄取後依課務需求排課。',
|
||||
},
|
||||
{
|
||||
id: 'announcement-4',
|
||||
date: '2023-11-28',
|
||||
school: '市立永吉國中',
|
||||
title: '公告本市學校本土語教學支援人員報名資訊',
|
||||
tab: 'junior',
|
||||
detail: '公告內容:統一受理報名,請依公告流程檢附文件並完成線上登錄。',
|
||||
},
|
||||
{
|
||||
id: 'announcement-5',
|
||||
date: '2023-11-21',
|
||||
school: '市立百齡高中',
|
||||
title: '112學年度本土語文教學支援工作人員甄選簡章',
|
||||
tab: 'senior',
|
||||
detail: '公告內容:簡章含資格條件、甄選方式、成績計算與錄取標準。',
|
||||
},
|
||||
{
|
||||
id: 'announcement-6',
|
||||
date: '2023-11-10',
|
||||
school: '市立成德國中',
|
||||
title: '本土語教學支援工作人員甄選(第二次)',
|
||||
tab: 'junior',
|
||||
detail: '公告內容:第二次甄選開放補件,報名截止日以公告為準。',
|
||||
},
|
||||
]
|
||||
|
||||
function readItems(): LoginAnnouncementItem[] {
|
||||
if (typeof window === 'undefined') return defaultItems
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey)
|
||||
if (!raw) return defaultItems
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? (parsed as LoginAnnouncementItem[]) : defaultItems
|
||||
} catch {
|
||||
return defaultItems
|
||||
}
|
||||
}
|
||||
|
||||
function writeItems(items: LoginAnnouncementItem[]) {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(items))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function mockFetchMobileAnnouncementsApi(): Promise<LoginMobileAnnouncementItem[]> {
|
||||
return [
|
||||
{
|
||||
id: 'mobile-announcement-1',
|
||||
content: '系統正常運行中',
|
||||
title: '系統公告',
|
||||
createdAt: '2026-02-11',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export const useLoginAnnouncementsStore = defineStore('loginAnnouncements', () => {
|
||||
const items = ref<LoginAnnouncementItem[]>(readItems())
|
||||
const selectedId = ref<string | number | null>(null)
|
||||
const mobileAnnouncements = ref<LoginMobileAnnouncementItem[]>([])
|
||||
|
||||
const listItems = computed<LoginAnnouncementListItem[]>(() =>
|
||||
items.value.map((item) => ({
|
||||
id: item.id,
|
||||
date: item.date,
|
||||
school: item.school,
|
||||
title: item.title,
|
||||
tab: item.tab,
|
||||
}))
|
||||
)
|
||||
|
||||
const boardConfig = computed(() => ({
|
||||
title: '學校公告區',
|
||||
tabs: [
|
||||
{ label: '全部', value: '__all__' },
|
||||
{ label: '國中', value: 'junior' },
|
||||
{ label: '高中', value: 'senior' },
|
||||
],
|
||||
items: listItems.value,
|
||||
systemAnnouncements: mobileAnnouncements.value,
|
||||
itemsPerPage: 5,
|
||||
dateHeader: '公告時間',
|
||||
schoolHeader: '公告學校',
|
||||
titleHeader: '公告標題',
|
||||
paginationLabel: '總筆數:',
|
||||
}))
|
||||
|
||||
const selectedAnnouncement = computed(() => {
|
||||
if (selectedId.value === null) return null
|
||||
return items.value.find((item) => item.id === selectedId.value) ?? null
|
||||
})
|
||||
|
||||
const selectedAnnouncementDetail = computed(() => {
|
||||
return selectedAnnouncement.value?.detail ?? ''
|
||||
})
|
||||
|
||||
const mobileAnnouncementConfig = computed(() => ({
|
||||
items: mobileAnnouncements.value,
|
||||
show: mobileAnnouncements.value.length > 0,
|
||||
viewAllText: '查看全部',
|
||||
listTitle: '系統公告',
|
||||
closeText: '關閉',
|
||||
emptyText: '目前沒有公告',
|
||||
}))
|
||||
|
||||
const hydrate = () => {
|
||||
items.value = readItems()
|
||||
}
|
||||
|
||||
const replaceAll = (nextItems: LoginAnnouncementItem[]) => {
|
||||
items.value = Array.isArray(nextItems) ? nextItems : []
|
||||
}
|
||||
|
||||
const selectById = (id: string | number) => {
|
||||
selectedId.value = id
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
selectedId.value = null
|
||||
}
|
||||
|
||||
const fetchMobileAnnouncements = async () => {
|
||||
const result = await mockFetchMobileAnnouncementsApi()
|
||||
mobileAnnouncements.value = Array.isArray(result) ? result : []
|
||||
}
|
||||
|
||||
const fetchMobileAnnouncement = async () => {
|
||||
await fetchMobileAnnouncements()
|
||||
}
|
||||
|
||||
watch(
|
||||
items,
|
||||
(val) => {
|
||||
writeItems(val)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
return {
|
||||
items,
|
||||
listItems,
|
||||
boardConfig,
|
||||
mobileAnnouncementConfig,
|
||||
selectedAnnouncement,
|
||||
selectedAnnouncementDetail,
|
||||
hydrate,
|
||||
replaceAll,
|
||||
selectById,
|
||||
clearSelection,
|
||||
fetchMobileAnnouncements,
|
||||
fetchMobileAnnouncement,
|
||||
}
|
||||
})
|
||||
@@ -1,236 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { normalizeError } from '@/services/error'
|
||||
import { menuApi, type MenuNode } from '@/services/modules/menu'
|
||||
|
||||
export interface LayoutMenuItem {
|
||||
title: string
|
||||
path?: string
|
||||
navigable?: boolean
|
||||
subItems?: LayoutMenuItem[]
|
||||
}
|
||||
|
||||
export const useMenuStore = defineStore('menu', () => {
|
||||
const menu = ref<MenuNode[]>([])
|
||||
const favorite = ref<MenuNode[]>([])
|
||||
const isRail = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const menuStorageKey = 'sk_playground_menu'
|
||||
const favoriteStorageKey = 'sk_playground_favorite'
|
||||
const isRailStorageKey = 'sk_playground_is_rail'
|
||||
|
||||
const readNodes = (key: string): MenuNode[] => {
|
||||
if (typeof window === 'undefined') return []
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? (parsed as MenuNode[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const readBoolean = (key: string, defaultValue = false): boolean => {
|
||||
if (typeof window === 'undefined') return defaultValue
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key)
|
||||
return raw === null ? defaultValue : raw === 'true'
|
||||
} catch {
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
const writeValue = (key: string, value: any) => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(key, String(value))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const writeNodes = (key: string, nodes: MenuNode[]) => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(nodes))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const removeValue = (key: string) => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.removeItem(key)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const hydrate = () => {
|
||||
menu.value = readNodes(menuStorageKey)
|
||||
favorite.value = readNodes(favoriteStorageKey)
|
||||
isRail.value = readBoolean(isRailStorageKey)
|
||||
}
|
||||
|
||||
hydrate()
|
||||
|
||||
const toLayoutMenuItems = (nodes: MenuNode[]): LayoutMenuItem[] => {
|
||||
const getString = (node: MenuNode, key: string): string | undefined => {
|
||||
const v = node?.[key]
|
||||
return typeof v === 'string' ? v : undefined
|
||||
}
|
||||
|
||||
const getChildren = (node: MenuNode): MenuNode[] => {
|
||||
return Array.isArray(node?.children) ? (node.children as MenuNode[]) : []
|
||||
}
|
||||
|
||||
return nodes
|
||||
.map((mdl) => {
|
||||
const mdlTitle = getString(mdl, 'mdl_name') ?? ''
|
||||
const untItems = getChildren(mdl)
|
||||
.map((unt) => {
|
||||
const untTitle = getString(unt, 'unt_name') ?? ''
|
||||
const fncItems = getChildren(unt)
|
||||
.map((fnc) => {
|
||||
const fncTitle = getString(fnc, 'fnc_name') ?? ''
|
||||
const fncId = getString(fnc, 'fnc_id')
|
||||
return {
|
||||
title: fncTitle,
|
||||
path: fncId ? `/${fncId}` : undefined,
|
||||
} satisfies LayoutMenuItem
|
||||
})
|
||||
.filter((x) => x.title)
|
||||
|
||||
return {
|
||||
title: untTitle,
|
||||
navigable: false,
|
||||
subItems: fncItems,
|
||||
} satisfies LayoutMenuItem
|
||||
})
|
||||
.filter((x) => x.title)
|
||||
|
||||
return {
|
||||
title: mdlTitle,
|
||||
navigable: false,
|
||||
subItems: untItems,
|
||||
} satisfies LayoutMenuItem
|
||||
})
|
||||
.filter((x) => x.title)
|
||||
}
|
||||
|
||||
const toFavoriteLayoutMenuItems = (nodes: MenuNode[]): LayoutMenuItem[] => {
|
||||
const getString = (node: MenuNode, key: string): string | undefined => {
|
||||
const v = node?.[key]
|
||||
return typeof v === 'string' ? v : undefined
|
||||
}
|
||||
|
||||
const getChildren = (node: MenuNode): MenuNode[] => {
|
||||
return Array.isArray(node?.children) ? (node.children as MenuNode[]) : []
|
||||
}
|
||||
|
||||
return nodes
|
||||
.map((unt) => {
|
||||
const untTitle = getString(unt, 'unt_name') ?? ''
|
||||
const fncItems = getChildren(unt)
|
||||
.map((fnc) => {
|
||||
const fncTitle = getString(fnc, 'fnc_name') ?? ''
|
||||
const fncId = getString(fnc, 'fnc_id')
|
||||
return {
|
||||
title: fncTitle,
|
||||
path: fncId ? `/${fncId}` : undefined,
|
||||
} satisfies LayoutMenuItem
|
||||
})
|
||||
.filter((x) => x.title)
|
||||
|
||||
return {
|
||||
title: untTitle,
|
||||
navigable: false,
|
||||
subItems: fncItems,
|
||||
} satisfies LayoutMenuItem
|
||||
})
|
||||
.filter((x) => x.title)
|
||||
}
|
||||
|
||||
const menuItems = computed<LayoutMenuItem[]>(() => toLayoutMenuItems(menu.value))
|
||||
const favoriteItems = computed<LayoutMenuItem[]>(() => toFavoriteLayoutMenuItems(favorite.value))
|
||||
|
||||
watch(
|
||||
menu,
|
||||
(val) => {
|
||||
writeNodes(menuStorageKey, val)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
favorite,
|
||||
(val) => {
|
||||
writeNodes(favoriteStorageKey, val)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(isRail, (val) => {
|
||||
writeValue(isRailStorageKey, val)
|
||||
})
|
||||
|
||||
const clear = () => {
|
||||
menu.value = []
|
||||
favorite.value = []
|
||||
isRail.value = false
|
||||
error.value = null
|
||||
removeValue(menuStorageKey)
|
||||
removeValue(favoriteStorageKey)
|
||||
removeValue(isRailStorageKey)
|
||||
}
|
||||
|
||||
const getMenu = async (id: string) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await menuApi.getMenu({ userID: id })
|
||||
menu.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
|
||||
} catch (error_) {
|
||||
const normalizedError = normalizeError(error_)
|
||||
if (normalizedError.name !== 'CanceledRequestError') {
|
||||
error.value = normalizedError.message
|
||||
}
|
||||
throw normalizedError
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getFavorite = async (id: string) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await menuApi.getFavorite({ userID: id })
|
||||
favorite.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
|
||||
} catch (error_) {
|
||||
const normalizedError = normalizeError(error_)
|
||||
if (normalizedError.name !== 'CanceledRequestError') {
|
||||
error.value = normalizedError.message
|
||||
}
|
||||
throw normalizedError
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
menu,
|
||||
favorite,
|
||||
isRail,
|
||||
menuItems,
|
||||
favoriteItems,
|
||||
error,
|
||||
loading,
|
||||
hydrate,
|
||||
clear,
|
||||
getMenu,
|
||||
getFavorite,
|
||||
}
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export const useMessageStore = defineStore('messages', () => {
|
||||
const openState = ref(false)
|
||||
|
||||
// 開啟訊息中心 Dialog
|
||||
const open = () => {
|
||||
openState.value = true
|
||||
}
|
||||
|
||||
// 關閉訊息中心 Dialog
|
||||
const close = () => {
|
||||
openState.value = false
|
||||
}
|
||||
|
||||
// 提供 v-model 綁定用的 computed
|
||||
const isOpen = computed({
|
||||
get: () => openState.value,
|
||||
set: (value) => {
|
||||
openState.value = value
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
}
|
||||
})
|
||||
@@ -1,165 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface CourseRecord {
|
||||
code: string
|
||||
name: string
|
||||
credits: number
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface SemesterRecord {
|
||||
id: number
|
||||
studentId: number
|
||||
semesterName: string
|
||||
courses: CourseRecord[]
|
||||
rank: number
|
||||
average: number
|
||||
}
|
||||
|
||||
const seedSemesters: SemesterRecord[] = []
|
||||
|
||||
// Helper to generate random score
|
||||
const randomScore = (min = 60, max = 99) => Math.floor(Math.random() * (max - min + 1)) + min
|
||||
|
||||
// Helper to generate mock semesters for a student
|
||||
export function generateMockSemesters(studentId: number) {
|
||||
const semesters = [
|
||||
{ name: '111 學年度第 1 學期', baseId: 1000 },
|
||||
{ name: '111 學年度第 2 學期', baseId: 2000 },
|
||||
{ name: '112 學年度第 1 學期', baseId: 3000 },
|
||||
{ name: '112 學年度第 2 學期', baseId: 4000 },
|
||||
{ name: '113 學年度第 1 學期', baseId: 5000 },
|
||||
{ name: '113 學年度第 2 學期', baseId: 6000 },
|
||||
]
|
||||
|
||||
const subjects = [
|
||||
{ name: '資料結構', credits: 3 },
|
||||
{ name: '演算法', credits: 3 },
|
||||
{ name: '作業系統', credits: 3 },
|
||||
{ name: '計算機組織', credits: 3 },
|
||||
{ name: '線性代數', credits: 3 },
|
||||
{ name: '機率與統計', credits: 3 },
|
||||
{ name: '資料庫系統', credits: 3 },
|
||||
{ name: '人工智慧導論', credits: 3 },
|
||||
{ name: '網頁程式設計', credits: 3 },
|
||||
{ name: '計算機網路', credits: 3 },
|
||||
]
|
||||
|
||||
// Assign 5-6 semesters per student
|
||||
const count = 5 + (studentId % 2)
|
||||
const result: SemesterRecord[] = []
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const sem = semesters[i]
|
||||
if (!sem) continue
|
||||
// Pick 8-10 random courses
|
||||
const courseCount = 8 + (studentId % 3)
|
||||
const courses: CourseRecord[] = []
|
||||
const usedSubjects = new Set<number>()
|
||||
|
||||
let totalScore = 0
|
||||
let totalCredits = 0
|
||||
|
||||
while (courses.length < courseCount) {
|
||||
const idx = Math.floor(Math.random() * subjects.length)
|
||||
if (usedSubjects.has(idx)) continue
|
||||
usedSubjects.add(idx)
|
||||
|
||||
const score = randomScore()
|
||||
const subject = subjects[idx]
|
||||
if (!subject) continue
|
||||
|
||||
courses.push({
|
||||
code: `CS${1000 + idx}`,
|
||||
name: subject.name,
|
||||
credits: subject.credits,
|
||||
score,
|
||||
})
|
||||
|
||||
totalScore += score * subject.credits
|
||||
totalCredits += subject.credits
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: sem.baseId + studentId,
|
||||
studentId,
|
||||
semesterName: sem.name,
|
||||
courses,
|
||||
rank: Math.floor(Math.random() * 20) + 1,
|
||||
average: Number((totalScore / totalCredits).toFixed(2)),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Generate for initial seed students (assuming IDs 1-20)
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
seedSemesters.push(...generateMockSemesters(i))
|
||||
}
|
||||
|
||||
export const useSemesterStore = defineStore('semesters', () => {
|
||||
// State
|
||||
const semesters = ref<SemesterRecord[]>([...seedSemesters])
|
||||
|
||||
// Actions
|
||||
const getStudentSemesters = (studentId: number) => {
|
||||
return semesters.value.filter((s) => s.studentId === studentId)
|
||||
}
|
||||
|
||||
const generateForStudent = (studentId: number) => {
|
||||
const newSemesters = generateMockSemesters(studentId)
|
||||
semesters.value.push(...newSemesters)
|
||||
}
|
||||
|
||||
const addSemester = (studentId: number) => {
|
||||
const newId = Math.max(...semesters.value.map((s) => s.id), 0) + 1
|
||||
const newSemester: SemesterRecord = {
|
||||
id: newId,
|
||||
studentId,
|
||||
semesterName: '新學期',
|
||||
courses: [],
|
||||
rank: 0,
|
||||
average: 0,
|
||||
}
|
||||
semesters.value.push(newSemester)
|
||||
return newSemester
|
||||
}
|
||||
|
||||
const updateSemester = (id: number, payload: Partial<SemesterRecord>) => {
|
||||
const index = semesters.value.findIndex((s) => s.id === id)
|
||||
if (index === -1) return
|
||||
const current = semesters.value[index]
|
||||
if (!current) return
|
||||
|
||||
// Recalculate average if courses are updated
|
||||
if (payload.courses) {
|
||||
const totalScore = payload.courses.reduce((sum, c) => sum + c.score * c.credits, 0)
|
||||
const totalCredits = payload.courses.reduce((sum, c) => sum + c.credits, 0)
|
||||
payload.average = totalCredits > 0 ? Number((totalScore / totalCredits).toFixed(2)) : 0
|
||||
}
|
||||
|
||||
Object.assign(current, payload)
|
||||
}
|
||||
|
||||
const removeSemester = (id: number) => {
|
||||
const index = semesters.value.findIndex((s) => s.id === id)
|
||||
if (index !== -1) {
|
||||
semesters.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const removeByStudentId = (studentId: number) => {
|
||||
semesters.value = semesters.value.filter((s) => s.studentId !== studentId)
|
||||
}
|
||||
|
||||
return {
|
||||
semesters,
|
||||
getStudentSemesters,
|
||||
generateForStudent,
|
||||
addSemester,
|
||||
updateSemester,
|
||||
removeSemester,
|
||||
removeByStudentId,
|
||||
}
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
type SnackbarColor = string
|
||||
type SnackbarVariant = 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'
|
||||
|
||||
type SnackbarLocation = string
|
||||
|
||||
interface ShowOptions {
|
||||
message: string
|
||||
color?: SnackbarColor
|
||||
timeout?: number
|
||||
location?: SnackbarLocation
|
||||
variant?: SnackbarVariant
|
||||
}
|
||||
|
||||
export const useSnackbarStore = defineStore('snackbar', () => {
|
||||
const visible = ref(false)
|
||||
const message = ref('')
|
||||
const color = ref<SnackbarColor>('success')
|
||||
const timeout = ref(2000)
|
||||
const location = ref<SnackbarLocation>('top right')
|
||||
const variant = ref<SnackbarVariant>('flat')
|
||||
|
||||
const show = (options: ShowOptions) => {
|
||||
message.value = options.message
|
||||
color.value = options.color ?? 'success'
|
||||
timeout.value = options.timeout ?? 2000
|
||||
location.value = options.location ?? 'top right'
|
||||
variant.value = options.variant ?? 'flat'
|
||||
|
||||
visible.value = false
|
||||
requestAnimationFrame(() => {
|
||||
visible.value = true
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
visible,
|
||||
message,
|
||||
color,
|
||||
timeout,
|
||||
location,
|
||||
variant,
|
||||
show,
|
||||
hide,
|
||||
}
|
||||
})
|
||||
@@ -1,345 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface StudentRecord {
|
||||
id: number
|
||||
studentId: string
|
||||
name: string
|
||||
department: string
|
||||
grade: number
|
||||
enrollYear: number
|
||||
credits: number
|
||||
advisor: string
|
||||
email: string
|
||||
phone: string
|
||||
status: string
|
||||
}
|
||||
|
||||
const seedStudents: StudentRecord[] = [
|
||||
{
|
||||
id: 1,
|
||||
studentId: 'S2024001',
|
||||
name: '王小明',
|
||||
department: '資訊工程',
|
||||
grade: 1,
|
||||
enrollYear: 2024,
|
||||
credits: 18,
|
||||
advisor: '林育成',
|
||||
email: 'ming.wang@school.edu',
|
||||
phone: '02-2345-1001',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
studentId: 'S2023017',
|
||||
name: '陳怡君',
|
||||
department: '企業管理',
|
||||
grade: 2,
|
||||
enrollYear: 2023,
|
||||
credits: 36,
|
||||
advisor: '許雅婷',
|
||||
email: 'yijun.chen@school.edu',
|
||||
phone: '02-2345-1002',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
studentId: 'S2022008',
|
||||
name: '林冠宇',
|
||||
department: '財務金融',
|
||||
grade: 3,
|
||||
enrollYear: 2022,
|
||||
credits: 64,
|
||||
advisor: '張國華',
|
||||
email: 'kuanyu.lin@school.edu',
|
||||
phone: '02-2345-1003',
|
||||
status: '休學',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
studentId: 'S2021022',
|
||||
name: '郭雅婷',
|
||||
department: '視覺設計',
|
||||
grade: 4,
|
||||
enrollYear: 2021,
|
||||
credits: 92,
|
||||
advisor: '蔡怡芳',
|
||||
email: 'yating.kuo@school.edu',
|
||||
phone: '02-2345-1004',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
studentId: 'S2019013',
|
||||
name: '張柏翰',
|
||||
department: '應用外語',
|
||||
grade: 5,
|
||||
enrollYear: 2019,
|
||||
credits: 28,
|
||||
advisor: '吳佳玲',
|
||||
email: 'bohan.chang@school.edu',
|
||||
phone: '02-2345-1005',
|
||||
status: '畢業',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
studentId: 'S2024024',
|
||||
name: '李詩涵',
|
||||
department: '視覺設計',
|
||||
grade: 1,
|
||||
enrollYear: 2024,
|
||||
credits: 16,
|
||||
advisor: '蔡怡芳',
|
||||
email: 'shihan.li@school.edu',
|
||||
phone: '02-2345-1006',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
studentId: 'S2023044',
|
||||
name: '黃俊豪',
|
||||
department: '資訊工程',
|
||||
grade: 2,
|
||||
enrollYear: 2023,
|
||||
credits: 40,
|
||||
advisor: '林育成',
|
||||
email: 'junhao.huang@school.edu',
|
||||
phone: '02-2345-1007',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
studentId: 'S2022066',
|
||||
name: '周佳穎',
|
||||
department: '企業管理',
|
||||
grade: 3,
|
||||
enrollYear: 2022,
|
||||
credits: 58,
|
||||
advisor: '許雅婷',
|
||||
email: 'jiaying.chou@school.edu',
|
||||
phone: '02-2345-1008',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
studentId: 'S2021088',
|
||||
name: '許景皓',
|
||||
department: '財務金融',
|
||||
grade: 4,
|
||||
enrollYear: 2021,
|
||||
credits: 88,
|
||||
advisor: '張國華',
|
||||
email: 'jinghao.hsu@school.edu',
|
||||
phone: '02-2345-1009',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
studentId: 'S2020019',
|
||||
name: '鄭婉如',
|
||||
department: '應用外語',
|
||||
grade: 5,
|
||||
enrollYear: 2020,
|
||||
credits: 22,
|
||||
advisor: '吳佳玲',
|
||||
email: 'wanru.cheng@school.edu',
|
||||
phone: '02-2345-1010',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
studentId: 'S2024031',
|
||||
name: '謝承翰',
|
||||
department: '資訊工程',
|
||||
grade: 1,
|
||||
enrollYear: 2024,
|
||||
credits: 20,
|
||||
advisor: '林育成',
|
||||
email: 'chenghan.hsieh@school.edu',
|
||||
phone: '02-2345-1011',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
studentId: 'S2023055',
|
||||
name: '邱雅雯',
|
||||
department: '視覺設計',
|
||||
grade: 2,
|
||||
enrollYear: 2023,
|
||||
credits: 34,
|
||||
advisor: '蔡怡芳',
|
||||
email: 'yawin.chiu@school.edu',
|
||||
phone: '02-2345-1012',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
studentId: 'S2022073',
|
||||
name: '何柏勳',
|
||||
department: '財務金融',
|
||||
grade: 3,
|
||||
enrollYear: 2022,
|
||||
credits: 62,
|
||||
advisor: '張國華',
|
||||
email: 'boxun.he@school.edu',
|
||||
phone: '02-2345-1013',
|
||||
status: '休學',
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
studentId: 'S2021095',
|
||||
name: '鄒庭安',
|
||||
department: '企業管理',
|
||||
grade: 4,
|
||||
enrollYear: 2021,
|
||||
credits: 96,
|
||||
advisor: '許雅婷',
|
||||
email: 'tingan.tsou@school.edu',
|
||||
phone: '02-2345-1014',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
studentId: 'S2020028',
|
||||
name: '潘子涵',
|
||||
department: '應用外語',
|
||||
grade: 5,
|
||||
enrollYear: 2020,
|
||||
credits: 26,
|
||||
advisor: '吳佳玲',
|
||||
email: 'zihan.pan@school.edu',
|
||||
phone: '02-2345-1015',
|
||||
status: '畢業',
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
studentId: 'S2024042',
|
||||
name: '賴昀潔',
|
||||
department: '視覺設計',
|
||||
grade: 1,
|
||||
enrollYear: 2024,
|
||||
credits: 14,
|
||||
advisor: '蔡怡芳',
|
||||
email: 'yunjie.lai@school.edu',
|
||||
phone: '02-2345-1016',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
studentId: 'S2023068',
|
||||
name: '高宇辰',
|
||||
department: '資訊工程',
|
||||
grade: 2,
|
||||
enrollYear: 2023,
|
||||
credits: 38,
|
||||
advisor: '林育成',
|
||||
email: 'yuchen.kao@school.edu',
|
||||
phone: '02-2345-1017',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
studentId: 'S2022089',
|
||||
name: '游品妤',
|
||||
department: '企業管理',
|
||||
grade: 3,
|
||||
enrollYear: 2022,
|
||||
credits: 60,
|
||||
advisor: '許雅婷',
|
||||
email: 'pinyu.yu@school.edu',
|
||||
phone: '02-2345-1018',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 19,
|
||||
studentId: 'S2021106',
|
||||
name: '羅子軒',
|
||||
department: '財務金融',
|
||||
grade: 4,
|
||||
enrollYear: 2021,
|
||||
credits: 84,
|
||||
advisor: '張國華',
|
||||
email: 'zixuan.lo@school.edu',
|
||||
phone: '02-2345-1019',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
studentId: 'S2020036',
|
||||
name: '謝佳玲',
|
||||
department: '應用外語',
|
||||
grade: 5,
|
||||
enrollYear: 2020,
|
||||
credits: 24,
|
||||
advisor: '吳佳玲',
|
||||
email: 'jialing.hsieh@school.edu',
|
||||
phone: '02-2345-1020',
|
||||
status: '畢業',
|
||||
},
|
||||
]
|
||||
|
||||
export const useStudentStore = defineStore('students', () => {
|
||||
// State
|
||||
const students = ref<StudentRecord[]>([...seedStudents])
|
||||
const deletedIds = ref<Set<number>>(new Set())
|
||||
|
||||
// Actions
|
||||
const addStudent = (payload: Omit<StudentRecord, 'id'>) => {
|
||||
const nextId = students.value.reduce((max, item) => Math.max(max, item.id), 0) + 1
|
||||
const created = { id: nextId, ...payload }
|
||||
students.value.push(created)
|
||||
return created.id
|
||||
}
|
||||
|
||||
const updateStudent = (id: number, payload: Omit<StudentRecord, 'id'>) => {
|
||||
const target = students.value.find((item) => item.id === id)
|
||||
if (!target) return false
|
||||
Object.assign(target, payload)
|
||||
return true
|
||||
}
|
||||
|
||||
const removeStudent = (id: number) => {
|
||||
const before = students.value.length
|
||||
students.value = students.value.filter((item) => item.id !== id)
|
||||
return students.value.length !== before
|
||||
}
|
||||
|
||||
// 標記刪除(軟刪除,還原用)
|
||||
const markAsDeleted = (id: number) => {
|
||||
deletedIds.value.add(id)
|
||||
}
|
||||
|
||||
// 清除所有標記
|
||||
const clearDeletedIds = () => {
|
||||
deletedIds.value.clear()
|
||||
}
|
||||
|
||||
// 提交刪除(實際刪除)
|
||||
const commitDeleted = () => {
|
||||
for (const id of deletedIds.value) {
|
||||
removeStudent(id)
|
||||
}
|
||||
deletedIds.value.clear()
|
||||
}
|
||||
|
||||
// 還原標記(取消刪除)
|
||||
const restoreDeleted = () => {
|
||||
deletedIds.value.clear()
|
||||
}
|
||||
|
||||
// 檢查是否已標記刪除
|
||||
const isMarkedAsDeleted = (id: number) => deletedIds.value.has(id)
|
||||
|
||||
return {
|
||||
students,
|
||||
deletedIds,
|
||||
addStudent,
|
||||
updateStudent,
|
||||
removeStudent,
|
||||
markAsDeleted,
|
||||
clearDeletedIds,
|
||||
commitDeleted,
|
||||
restoreDeleted,
|
||||
isMarkedAsDeleted,
|
||||
}
|
||||
})
|
||||
+343
-1
@@ -1 +1,343 @@
|
||||
export * from './stores/students'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface StudentRecord {
|
||||
id: number
|
||||
studentId: string
|
||||
name: string
|
||||
department: string
|
||||
grade: number
|
||||
enrollYear: number
|
||||
credits: number
|
||||
advisor: string
|
||||
email: string
|
||||
phone: string
|
||||
status: string
|
||||
}
|
||||
|
||||
const seedStudents: StudentRecord[] = [
|
||||
{
|
||||
id: 1,
|
||||
studentId: 'S2024001',
|
||||
name: '王小明',
|
||||
department: '資訊工程',
|
||||
grade: 1,
|
||||
enrollYear: 2024,
|
||||
credits: 18,
|
||||
advisor: '林育成',
|
||||
email: 'ming.wang@school.edu',
|
||||
phone: '02-2345-1001',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
studentId: 'S2023017',
|
||||
name: '陳怡君',
|
||||
department: '企業管理',
|
||||
grade: 2,
|
||||
enrollYear: 2023,
|
||||
credits: 36,
|
||||
advisor: '許雅婷',
|
||||
email: 'yijun.chen@school.edu',
|
||||
phone: '02-2345-1002',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
studentId: 'S2022008',
|
||||
name: '林冠宇',
|
||||
department: '財務金融',
|
||||
grade: 3,
|
||||
enrollYear: 2022,
|
||||
credits: 64,
|
||||
advisor: '張國華',
|
||||
email: 'kuanyu.lin@school.edu',
|
||||
phone: '02-2345-1003',
|
||||
status: '休學',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
studentId: 'S2021022',
|
||||
name: '郭雅婷',
|
||||
department: '視覺設計',
|
||||
grade: 4,
|
||||
enrollYear: 2021,
|
||||
credits: 92,
|
||||
advisor: '蔡怡芳',
|
||||
email: 'yating.kuo@school.edu',
|
||||
phone: '02-2345-1004',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
studentId: 'S2019013',
|
||||
name: '張柏翰',
|
||||
department: '應用外語',
|
||||
grade: 5,
|
||||
enrollYear: 2019,
|
||||
credits: 28,
|
||||
advisor: '吳佳玲',
|
||||
email: 'bohan.chang@school.edu',
|
||||
phone: '02-2345-1005',
|
||||
status: '畢業',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
studentId: 'S2024024',
|
||||
name: '李詩涵',
|
||||
department: '視覺設計',
|
||||
grade: 1,
|
||||
enrollYear: 2024,
|
||||
credits: 16,
|
||||
advisor: '蔡怡芳',
|
||||
email: 'shihan.li@school.edu',
|
||||
phone: '02-2345-1006',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
studentId: 'S2023044',
|
||||
name: '黃俊豪',
|
||||
department: '資訊工程',
|
||||
grade: 2,
|
||||
enrollYear: 2023,
|
||||
credits: 40,
|
||||
advisor: '林育成',
|
||||
email: 'junhao.huang@school.edu',
|
||||
phone: '02-2345-1007',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
studentId: 'S2022066',
|
||||
name: '周佳穎',
|
||||
department: '企業管理',
|
||||
grade: 3,
|
||||
enrollYear: 2022,
|
||||
credits: 58,
|
||||
advisor: '許雅婷',
|
||||
email: 'jiaying.chou@school.edu',
|
||||
phone: '02-2345-1008',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
studentId: 'S2021088',
|
||||
name: '許景皓',
|
||||
department: '財務金融',
|
||||
grade: 4,
|
||||
enrollYear: 2021,
|
||||
credits: 88,
|
||||
advisor: '張國華',
|
||||
email: 'jinghao.hsu@school.edu',
|
||||
phone: '02-2345-1009',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
studentId: 'S2020019',
|
||||
name: '鄭婉如',
|
||||
department: '應用外語',
|
||||
grade: 5,
|
||||
enrollYear: 2020,
|
||||
credits: 22,
|
||||
advisor: '吳佳玲',
|
||||
email: 'wanru.cheng@school.edu',
|
||||
phone: '02-2345-1010',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
studentId: 'S2024031',
|
||||
name: '謝承翰',
|
||||
department: '資訊工程',
|
||||
grade: 1,
|
||||
enrollYear: 2024,
|
||||
credits: 20,
|
||||
advisor: '林育成',
|
||||
email: 'chenghan.hsieh@school.edu',
|
||||
phone: '02-2345-1011',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
studentId: 'S2023055',
|
||||
name: '邱雅雯',
|
||||
department: '視覺設計',
|
||||
grade: 2,
|
||||
enrollYear: 2023,
|
||||
credits: 34,
|
||||
advisor: '蔡怡芳',
|
||||
email: 'yawin.chiu@school.edu',
|
||||
phone: '02-2345-1012',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
studentId: 'S2022073',
|
||||
name: '何柏勳',
|
||||
department: '財務金融',
|
||||
grade: 3,
|
||||
enrollYear: 2022,
|
||||
credits: 62,
|
||||
advisor: '張國華',
|
||||
email: 'boxun.he@school.edu',
|
||||
phone: '02-2345-1013',
|
||||
status: '休學',
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
studentId: 'S2021095',
|
||||
name: '鄒庭安',
|
||||
department: '企業管理',
|
||||
grade: 4,
|
||||
enrollYear: 2021,
|
||||
credits: 96,
|
||||
advisor: '許雅婷',
|
||||
email: 'tingan.tsou@school.edu',
|
||||
phone: '02-2345-1014',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
studentId: 'S2020028',
|
||||
name: '潘子涵',
|
||||
department: '應用外語',
|
||||
grade: 5,
|
||||
enrollYear: 2020,
|
||||
credits: 26,
|
||||
advisor: '吳佳玲',
|
||||
email: 'zihan.pan@school.edu',
|
||||
phone: '02-2345-1015',
|
||||
status: '畢業',
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
studentId: 'S2024042',
|
||||
name: '賴昀潔',
|
||||
department: '視覺設計',
|
||||
grade: 1,
|
||||
enrollYear: 2024,
|
||||
credits: 14,
|
||||
advisor: '蔡怡芳',
|
||||
email: 'yunjie.lai@school.edu',
|
||||
phone: '02-2345-1016',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
studentId: 'S2023068',
|
||||
name: '高宇辰',
|
||||
department: '資訊工程',
|
||||
grade: 2,
|
||||
enrollYear: 2023,
|
||||
credits: 38,
|
||||
advisor: '林育成',
|
||||
email: 'yuchen.kao@school.edu',
|
||||
phone: '02-2345-1017',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
studentId: 'S2022089',
|
||||
name: '游品妤',
|
||||
department: '企業管理',
|
||||
grade: 3,
|
||||
enrollYear: 2022,
|
||||
credits: 60,
|
||||
advisor: '許雅婷',
|
||||
email: 'pinyu.yu@school.edu',
|
||||
phone: '02-2345-1018',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 19,
|
||||
studentId: 'S2021106',
|
||||
name: '羅子軒',
|
||||
department: '財務金融',
|
||||
grade: 4,
|
||||
enrollYear: 2021,
|
||||
credits: 84,
|
||||
advisor: '張國華',
|
||||
email: 'zixuan.lo@school.edu',
|
||||
phone: '02-2345-1019',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
studentId: 'S2020036',
|
||||
name: '謝佳玲',
|
||||
department: '應用外語',
|
||||
grade: 5,
|
||||
enrollYear: 2020,
|
||||
credits: 24,
|
||||
advisor: '吳佳玲',
|
||||
email: 'jialing.hsieh@school.edu',
|
||||
phone: '02-2345-1020',
|
||||
status: '畢業',
|
||||
},
|
||||
]
|
||||
|
||||
export const useStudentStore = defineStore('students', () => {
|
||||
const students = ref<StudentRecord[]>([...seedStudents])
|
||||
const deletedIds = ref<Set<number>>(new Set())
|
||||
|
||||
const addStudent = (payload: Omit<StudentRecord, 'id'>) => {
|
||||
const nextId = students.value.reduce((max, item) => Math.max(max, item.id), 0) + 1
|
||||
const created = { id: nextId, ...payload }
|
||||
students.value.push(created)
|
||||
return created.id
|
||||
}
|
||||
|
||||
const updateStudent = (id: number, payload: Omit<StudentRecord, 'id'>) => {
|
||||
const target = students.value.find((item) => item.id === id)
|
||||
if (!target) return false
|
||||
Object.assign(target, payload)
|
||||
return true
|
||||
}
|
||||
|
||||
const removeStudent = (id: number) => {
|
||||
const before = students.value.length
|
||||
students.value = students.value.filter((item) => item.id !== id)
|
||||
return students.value.length !== before
|
||||
}
|
||||
|
||||
// 標記刪除(軟刪除,還原用)
|
||||
const markAsDeleted = (id: number) => {
|
||||
deletedIds.value.add(id)
|
||||
}
|
||||
|
||||
// 清除所有標記
|
||||
const clearDeletedIds = () => {
|
||||
deletedIds.value.clear()
|
||||
}
|
||||
|
||||
// 提交刪除(實際刪除)
|
||||
const commitDeleted = () => {
|
||||
for (const id of deletedIds.value) {
|
||||
removeStudent(id)
|
||||
}
|
||||
deletedIds.value.clear()
|
||||
}
|
||||
|
||||
// 還原標記(取消刪除)
|
||||
const restoreDeleted = () => {
|
||||
deletedIds.value.clear()
|
||||
}
|
||||
|
||||
// 檢查是否已標記刪除
|
||||
const isMarkedAsDeleted = (id: number) => deletedIds.value.has(id)
|
||||
|
||||
return {
|
||||
students,
|
||||
deletedIds,
|
||||
addStudent,
|
||||
updateStudent,
|
||||
removeStudent,
|
||||
markAsDeleted,
|
||||
clearDeletedIds,
|
||||
commitDeleted,
|
||||
restoreDeleted,
|
||||
isMarkedAsDeleted,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user