feat: add SingleRecordMnt component for student record maintenance with search, add, edit, view, and delete functionalities
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export * from './stores/auth'
|
||||
@@ -0,0 +1 @@
|
||||
export * from './stores/breadcrumbs'
|
||||
@@ -0,0 +1 @@
|
||||
export * from './stores/favorites'
|
||||
@@ -0,0 +1 @@
|
||||
export * from './stores/loginAnnouncements'
|
||||
@@ -0,0 +1 @@
|
||||
export * from './stores/menu'
|
||||
@@ -0,0 +1 @@
|
||||
export * from './stores/messages'
|
||||
@@ -0,0 +1 @@
|
||||
export * from './stores/semesters'
|
||||
@@ -0,0 +1 @@
|
||||
export * from './stores/snackbar'
|
||||
@@ -0,0 +1,149 @@
|
||||
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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { LayoutMenuItem } from './menu'
|
||||
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('mdi-home')
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
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
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,345 @@
|
||||
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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export * from './stores/students'
|
||||
Reference in New Issue
Block a user