feat: add SingleRecordMnt component for student record maintenance with search, add, edit, view, and delete functionalities

This commit is contained in:
skytek_xinliang
2026-03-26 11:24:37 +08:00
parent 507afcc99c
commit 069141794e
116 changed files with 15247 additions and 107 deletions
+259
View File
@@ -0,0 +1,259 @@
<template>
<SKLogin
:announcement-board="announcementBoard"
:branding="branding"
:form="form"
:header="header"
:illustration="illustration"
:layout="formPositionLayout" :mobile-announcement="mobileAnnouncement" :toolbar="toolbar"
@captcha-change="handleCaptchaChange" @captcha-refresh="handleCaptchaRefresh" @change-locale="handleChangeLocale"
@forgot-password="handleForgotPassword" @select-announcement="handleSelectAnnouncement" @submit="onLogin"
@toggle-layout="handleToggleLayout" />
<v-dialog v-model="dialogVisible" width="360">
<v-card>
<v-card-title>{{ dialogTitle }}</v-card-title>
<v-card-text>{{ dialogMessage }}</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="flat" @click="dialogVisible = false">
{{ t('common.ok') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="announcementDialogVisible" max-width="720">
<v-card>
<v-card-title class="text-h6">
{{ selectedAnnouncement?.title }}
</v-card-title>
<v-card-subtitle class="pt-2">
{{ selectedAnnouncement?.date }} {{ selectedAnnouncement?.school }}
</v-card-subtitle>
<v-card-text class="text-body-1">
{{ selectedAnnouncementDetail }}
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="flat" @click="announcementDialogVisible = false">
關閉
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import HyakkaouAcademyImage from '@/assets/logo.png'
import SKLogin from '@/components/SKLogin.vue'
import { useAuthStore } from '@/stores/auth'
import { type LoginAnnouncementListItem, useLoginAnnouncementsStore } from '@/stores/loginAnnouncements'
import { useMenuStore } from '@/stores/menu'
import { useSnackbarStore } from '@/stores/snackbar'
type LayoutType = 'side-left' | 'side-right' | 'card'
// i18n
const { t, locale } = useI18n()
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const loginAnnouncementsStore = useLoginAnnouncementsStore()
const menuStore = useMenuStore()
const snackbarStore = useSnackbarStore()
const {
boardConfig: announcementBoard,
mobileAnnouncementConfig: mobileAnnouncement,
selectedAnnouncement,
selectedAnnouncementDetail,
} =
storeToRefs(loginAnnouncementsStore)
// 語系選項
const locales = ['zh-TW', 'en-US']
// 插圖圖片來源
const illustrationImage = ref(HyakkaouAcademyImage)
// 功能開關與版型
const formPositionLayout = ref<LayoutType>('side-left')
// 功能開關:是否啟用驗證碼
const withCaptcha = ref(true)
// 文字內容(i18n
const title = computed(() => t('pages.login.title'))
const organization = computed(() => t('pages.login.organization'))
const accPlaceholder = computed(() => t('pages.login.accPlaceholder'))
const passwPlaceholder = computed(() => t('pages.login.passwPlaceholder'))
const illustrationTitle = computed(() => t('pages.login.illustrationTitle'))
const illustrationDescription = computed(() => t('pages.login.illustrationDescription'))
const welcomeText = computed(() => t('pages.login.welcomeText'))
const welcomeDescription = computed(() => t('pages.login.welcomeDescription'))
const rememberMeLabel = computed(() => t('pages.login.rememberMeLabel'))
const forgotPasswordText = computed(() => t('pages.login.forgotPasswordText'))
const submitText = computed(() => t('pages.login.submitText'))
const verifyText = computed(() => t('pages.login.verifyText'))
const captchaPlaceholder = computed(() => t('pages.login.captchaPlaceholder'))
const refreshTitle = computed(() => t('pages.login.refreshTitle'))
// 連結與儲存設定
// 忘記密碼連結(由 form.forgotPassword 設定)
const forgotPasswordHref = ref('/forgot-password')
const forgotPasswordTarget = ref<string | undefined>(undefined)
// 記住帳號的 localStorage key
const rememberStorageKey = ref('playground.login.remember.username')
// 驗證碼 API
const captchaValue = ref('')
// 驗證與對話框狀態
const dialogVisible = ref(false)
const dialogTitle = ref('')
const dialogMessage = ref('')
const announcementDialogVisible = ref(false)
// 內容組合(傳入 SKLogin
const branding = computed(() => ({
title: title.value,
organization: organization.value,
}))
const illustration = computed(() => ({
image: illustrationImage.value,
title: illustrationTitle.value,
description: illustrationDescription.value,
}))
const header = computed(() => ({
welcomeText: welcomeText.value,
welcomeDescription: welcomeDescription.value,
}))
// 表單區塊設定(含功能開關)
const form = computed(() => ({
accPlaceholder: accPlaceholder.value,
passwPlaceholder: passwPlaceholder.value,
rememberMeLabel: rememberMeLabel.value,
submitText: submitText.value,
verifyText: verifyText.value,
captchaPlaceholder: captchaPlaceholder.value,
refreshTitle: refreshTitle.value,
rememberStorageKey: rememberStorageKey.value,
// 功能開關:是否顯示驗證碼
withCaptcha: withCaptcha.value,
captcha: authStore.captcha
? {
imgUrl: authStore.captcha.dntCaptchaImgUrl,
id: authStore.captcha.dntCaptchaId,
tokenValue: authStore.captcha.dntCaptchaTokenValue,
}
: undefined,
captchaValue: captchaValue.value,
captchaLoading: authStore.captchaLoading,
captchaErrorMessage: authStore.captchaErrorMessage ?? '',
captchaVerified: false,
forgotPassword: {
text: forgotPasswordText.value,
href: forgotPasswordHref.value,
target: forgotPasswordTarget.value,
},
}))
// 右上工具列設定(含顯示開關)
const toolbar = computed(() => ({
// 功能開關:是否顯示語系切換工具列
show: true,
locale: locale.value,
locales,
}))
// 事件處理
function handleForgotPassword (e: MouseEvent) {
console.log('Playground Forgot Password Click:', e)
}
function handleChangeLocale (nextLocale: string) {
locale.value = nextLocale
localStorage.setItem('playground.locale', nextLocale)
}
async function handleCaptchaRefresh () {
captchaValue.value = ''
await authStore.getCaptcha()
}
function handleCaptchaChange (value: string) {
captchaValue.value = value
}
function handleToggleLayout () {
const layoutOrder: LayoutType[] = ['side-left', 'side-right', 'card']
const currentIndex = layoutOrder.indexOf(formPositionLayout.value)
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % layoutOrder.length
formPositionLayout.value = layoutOrder[nextIndex] ?? 'side-left'
}
function handleSelectAnnouncement (item: LoginAnnouncementListItem) {
loginAnnouncementsStore.selectById(item.id)
announcementDialogVisible.value = true
}
async function onLogin (data: Record<string, unknown>) {
if (withCaptcha.value && !captchaValue.value) {
dialogTitle.value = t('common.notice')
dialogMessage.value = t('pages.login.alert.verifyRequired')
dialogVisible.value = true
return
}
try {
dialogTitle.value = t('common.notice')
const inputUserId = String(data.username ?? '').trim()
const inputPassword = String(data.password ?? '').trim()
const isDev = import.meta.env.DEV
const devDefaultUserId = String(import.meta.env.VITE_DEV_DEFAULT_USER_ID ?? '').trim()
const devDefaultPassword = String(import.meta.env.VITE_DEV_DEFAULT_PASSWORD ?? '').trim()
const userId = isDev && !inputUserId ? devDefaultUserId : inputUserId
const password = isDev && !inputPassword ? devDefaultPassword : inputPassword
await authStore.login({
UserID: userId,
Password: password,
DNTCaptchaInputText: captchaValue.value,
})
menuStore.getMenu(authStore.user?.id ?? '')
// menuStore.getFavorite(authStore.user?.id ?? '')
snackbarStore.show({
message: t('pages.login.alert.loginSuccess'),
color: 'success',
timeout: 2000,
location: 'top right',
variant: 'flat',
})
const redirect = (route.query.redirect as string) || '/'
await router.push(redirect.startsWith('/') ? redirect : '/')
} catch (error) {
console.error('Login error:', error)
dialogTitle.value = t('common.notice')
dialogMessage.value = t('pages.login.alert.loginFailed')
dialogVisible.value = true
}
}
onMounted(() => {
loginAnnouncementsStore.hydrate()
loginAnnouncementsStore.fetchMobileAnnouncements()
authStore.getCaptcha()
})
</script>