Compare commits

..

4 Commits

Author SHA1 Message Date
skytek_xinliang f3eb9782c6 feat: 記住帳號, 忘記密碼開關 2026-05-22 10:43:17 +08:00
skytek_xinliang 8378c44ad7 feat: 公告開關 2026-05-22 10:30:04 +08:00
skytek_xinliang 8cf5aacf21 fix: captcha 開關 2026-05-22 09:51:11 +08:00
skytek_xinliang 59d04a4d7e fix: 環境變數讀取 2026-05-22 09:50:54 +08:00
9 changed files with 228 additions and 114 deletions
+13
View File
@@ -1,3 +1,16 @@
# vite / vite dev:預設 mode = development
# vite build:預設 mode = production
# vite --mode staging:改成 staging
# vite build --mode developmentbuild 但用 development mode
# 覆蓋優先從低至高
# .env
# .env.local
# .env.[mode]
# .env.[mode].local
# Vite dev proxy 目標後端 URL。
VITE_PROXY_TARGET=http://192.168.89.54:9002
+17 -1
View File
@@ -18,6 +18,7 @@
<LoginBrand :title="props.branding.title" />
</div>
<v-sheet
v-if="props.withAnnouncement"
class="board-wrapper pa-2 pa-lg-0"
color="rgba(var(--v-theme-surface), 0.8)"
elevation="0"
@@ -109,6 +110,8 @@
:remember-me-label="props.form.rememberMeLabel"
:remember-storage-key="props.form.rememberStorageKey"
:submit-text="props.form.submitText"
:with-forgot-password="props.form.withForgotPassword"
:with-remember-account="props.form.withRememberAccount"
@forgot-password="handleForgotPassword"
@submit="handleLogin"
>
@@ -171,6 +174,8 @@
:remember-me-label="props.form.rememberMeLabel"
:remember-storage-key="props.form.rememberStorageKey"
:submit-text="props.form.submitText"
:with-forgot-password="props.form.withForgotPassword"
:with-remember-account="props.form.withRememberAccount"
@forgot-password="handleForgotPassword"
@submit="handleLogin"
>
@@ -195,7 +200,11 @@
</v-card>
</v-row>
<v-bottom-sheet v-model="mobileAnnouncementSheetVisible" class="d-sm-none">
<v-bottom-sheet
v-if="props.withAnnouncement"
v-model="mobileAnnouncementSheetVisible"
class="d-sm-none"
>
<v-card rounded="t-xl">
<v-card-title class="text-subtitle-1 font-weight-bold">
{{ props.mobileAnnouncement.listTitle }}
@@ -306,6 +315,8 @@ interface FormConfig {
rememberMeLabel?: string
submitText?: string
rememberStorageKey?: string
withForgotPassword?: boolean
withRememberAccount?: boolean
withCaptcha?: boolean
captcha?: {
imgUrl?: string
@@ -330,6 +341,7 @@ interface ToolBarConfig {
interface Props {
layout: 'side-left' | 'side-right' | 'card'
withAnnouncement?: boolean
branding: BrandingConfig
illustration: IllustrationConfig
announcementBoard: AnnouncementBoardConfig
@@ -341,6 +353,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
layout: 'side-left',
withAnnouncement: true,
branding: () => ({
title: 'Skyteck Login',
organization: 'school',
@@ -393,6 +406,8 @@ const props = withDefaults(defineProps<Props>(), {
rememberMeLabel: '記住帳號',
submitText: '登入',
rememberStorageKey: 'sklogin.remember.username',
withForgotPassword: true,
withRememberAccount: true,
withCaptcha: true,
captcha: undefined,
captchaValue: '',
@@ -430,6 +445,7 @@ const mobileAnnouncementSheetVisible = ref(false)
const mobileAnnouncementItems = computed(() => props.mobileAnnouncement.items ?? [])
const showMobileAnnouncementBanner = computed(() => {
if (!props.withAnnouncement) return false
if (props.mobileAnnouncement.show === false) return false
return mobileAnnouncementItems.value.length > 0
})
+29 -2
View File
@@ -1,5 +1,5 @@
<template>
<v-form @submit.prevent="$emit('submit', { username, password, rememberMe })">
<v-form @submit.prevent="handleSubmit">
<v-text-field
v-model="username"
bg-color="surface"
@@ -27,8 +27,12 @@
<slot name="verify"></slot>
<div class="d-flex align-center justify-space-between mb-6 mb-md-4">
<div
v-if="props.withRememberAccount || props.withForgotPassword"
class="d-flex align-center justify-space-between mb-6 mb-md-4"
>
<v-checkbox
v-if="props.withRememberAccount"
v-model="rememberMe"
color="primary"
density="compact"
@@ -36,6 +40,7 @@
:label="props.rememberMeLabel"
></v-checkbox>
<a
v-if="props.withForgotPassword"
class="text-body-2 text-primary text-decoration-none"
:href="props.forgotPasswordHref || '#'"
:target="props.forgotPasswordTarget"
@@ -100,11 +105,21 @@ const props = defineProps({
type: String,
default: 'sklogin.remember.username',
},
withRememberAccount: {
type: Boolean,
default: true,
},
withForgotPassword: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['submit', 'forgot-password'])
onMounted(() => {
if (!props.withRememberAccount) return
const saved = localStorage.getItem(props.rememberStorageKey)
if (saved) {
username.value = saved
@@ -113,6 +128,8 @@ onMounted(() => {
})
watch([rememberMe, username], ([nextRemember, nextUsername]) => {
if (!props.withRememberAccount) return
if (!nextRemember) {
localStorage.removeItem(props.rememberStorageKey)
return
@@ -126,7 +143,17 @@ watch([rememberMe, username], ([nextRemember, nextUsername]) => {
localStorage.setItem(props.rememberStorageKey, nextUsername)
})
function handleSubmit() {
emit('submit', {
username: username.value,
password: password.value,
rememberMe: props.withRememberAccount ? rememberMe.value : false,
})
}
function handleForgotPasswordClick(e: MouseEvent) {
if (!props.withForgotPassword) return
emit('forgot-password', e)
if (!props.forgotPasswordHref) {
e.preventDefault()
@@ -1,5 +1,4 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { computed, ref, toValue, watch, type MaybeRefOrGetter } from 'vue'
export interface LoginAnnouncementItem {
id: string | number
@@ -25,6 +24,10 @@ export interface LoginMobileAnnouncementItem {
createdAt?: string
}
interface UseLoginAnnouncementsOptions {
enabled: MaybeRefOrGetter<boolean>
}
const storageKey = 'sk_playground_login_announcements'
const defaultItems: LoginAnnouncementItem[] = [
@@ -110,10 +113,11 @@ async function mockFetchMobileAnnouncementsApi(): Promise<LoginMobileAnnouncemen
]
}
export const useLoginAnnouncementsStore = defineStore('loginAnnouncements', () => {
const items = ref<LoginAnnouncementItem[]>(readItems())
export function useLoginAnnouncements(options: UseLoginAnnouncementsOptions) {
const items = ref<LoginAnnouncementItem[]>([])
const selectedId = ref<string | number | null>(null)
const mobileAnnouncements = ref<LoginMobileAnnouncementItem[]>([])
const enabled = computed(() => toValue(options.enabled))
const listItems = computed<LoginAnnouncementListItem[]>(() =>
items.value.map((item) => ({
@@ -132,8 +136,8 @@ export const useLoginAnnouncementsStore = defineStore('loginAnnouncements', () =
{ label: '國中', value: 'junior' },
{ label: '高中', value: 'senior' },
],
items: listItems.value,
systemAnnouncements: mobileAnnouncements.value,
items: enabled.value ? listItems.value : [],
systemAnnouncements: enabled.value ? mobileAnnouncements.value : [],
itemsPerPage: 5,
dateHeader: '公告時間',
schoolHeader: '公告學校',
@@ -142,7 +146,7 @@ export const useLoginAnnouncementsStore = defineStore('loginAnnouncements', () =
}))
const selectedAnnouncement = computed(() => {
if (selectedId.value === null) return null
if (!enabled.value || selectedId.value === null) return null
return items.value.find((item) => item.id === selectedId.value) ?? null
})
@@ -151,59 +155,54 @@ export const useLoginAnnouncementsStore = defineStore('loginAnnouncements', () =
})
const mobileAnnouncementConfig = computed(() => ({
items: mobileAnnouncements.value,
show: mobileAnnouncements.value.length > 0,
items: enabled.value ? mobileAnnouncements.value : [],
show: enabled.value && mobileAnnouncements.value.length > 0,
viewAllText: '查看全部',
listTitle: '系統公告',
closeText: '關閉',
emptyText: '目前沒有公告',
}))
const hydrate = () => {
function hydrate() {
if (!enabled.value) return
items.value = readItems()
}
const replaceAll = (nextItems: LoginAnnouncementItem[]) => {
items.value = Array.isArray(nextItems) ? nextItems : []
}
async function fetchMobileAnnouncements() {
if (!enabled.value) return
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 () => {
async function load() {
hydrate()
await fetchMobileAnnouncements()
}
function selectById(id: string | number) {
if (!enabled.value) return
selectedId.value = id
}
watch(
items,
(val) => {
if (!enabled.value) return
writeItems(val)
},
{ deep: true }
)
return {
items,
listItems,
boardConfig,
mobileAnnouncementConfig,
selectedAnnouncement,
selectedAnnouncementDetail,
hydrate,
replaceAll,
load,
selectById,
clearSelection,
fetchMobileAnnouncements,
fetchMobileAnnouncement,
}
})
}
+84
View File
@@ -0,0 +1,84 @@
import type { CaptchaResponse } from '@/types/api'
import { computed, ref, toValue, type MaybeRefOrGetter } from 'vue'
import { normalizeError } from '@/services/error'
import { authApi } from '@/services/modules/auth'
interface UseLoginCaptchaOptions {
enabled: MaybeRefOrGetter<boolean>
}
export function useLoginCaptcha(options: UseLoginCaptchaOptions) {
const captcha = ref<CaptchaResponse | null>(null)
const captchaValue = ref('')
const captchaLoading = ref(false)
const captchaErrorMessage = ref<string | null>(null)
const enabled = computed(() => toValue(options.enabled))
const formCaptcha = computed(() => {
if (!enabled.value || !captcha.value) return undefined
return {
imgUrl: captcha.value.dntCaptchaImgUrl,
id: captcha.value.dntCaptchaId,
tokenValue: captcha.value.dntCaptchaTokenValue,
}
})
async function loadCaptcha() {
if (!enabled.value) return null
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
}
}
async function refreshCaptcha() {
if (!enabled.value) return null
captchaValue.value = ''
return await loadCaptcha()
}
function setCaptchaValue(value: string) {
if (!enabled.value) return
captchaValue.value = value
}
function getLoginCaptchaPayload() {
if (!enabled.value) return undefined
if (!captcha.value?.dntCaptchaTextValue || !captcha.value?.dntCaptchaTokenValue) {
throw new Error('驗證碼資料缺失,請先刷新驗證碼')
}
return {
DNTCaptchaInputText: captchaValue.value,
DNTCaptchaText: captcha.value.dntCaptchaTextValue,
DNTCaptchaToken: captcha.value.dntCaptchaTokenValue,
}
}
return {
captchaValue,
captchaLoading,
captchaErrorMessage,
formCaptcha,
loadCaptcha,
refreshCaptcha,
setCaptchaValue,
getLoginCaptchaPayload,
}
}
+9 -42
View File
@@ -1,4 +1,4 @@
import type { CaptchaResponse, LoginPayload, User } from '@/types/api'
import type { LoginPayload, User } from '@/types/api'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { normalizeError } from '@/services/error'
@@ -16,32 +16,12 @@ export const useAuthStore = defineStore('auth', () => {
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()
@@ -49,24 +29,15 @@ export const useAuthStore = defineStore('auth', () => {
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)
formData.append('UserID', payload.UserID)
formData.append('Password', payload.Password)
if (payload.captcha) {
formData.append('DNTCaptchaInputText', payload.captcha.DNTCaptchaInputText)
formData.append('DNTCaptchaText', payload.captcha.DNTCaptchaText)
formData.append('DNTCaptchaToken', payload.captcha.DNTCaptchaToken)
}
const { data } = await authApi.login(formData, {
signal: loginController.value.signal,
@@ -130,10 +101,6 @@ export const useAuthStore = defineStore('auth', () => {
}
return {
getCaptcha,
captcha,
captchaLoading,
captchaErrorMessage,
user,
token,
loading,
+7 -1
View File
@@ -16,8 +16,14 @@ export interface CaptchaResponse {
dntCaptchaTextValue: string
}
export interface LoginCaptchaPayload {
DNTCaptchaInputText: string
DNTCaptchaText: string
DNTCaptchaToken: string
}
export interface LoginPayload {
UserID: string
Password: string
DNTCaptchaInputText: string
captcha?: LoginCaptchaPayload
}
+32 -33
View File
@@ -8,6 +8,7 @@
:layout="formPositionLayout"
:mobile-announcement="mobileAnnouncement"
:toolbar="toolbar"
:with-announcement="withAnnouncement"
@captcha-change="handleCaptchaChange"
@captcha-refresh="handleCaptchaRefresh"
@change-locale="handleChangeLocale"
@@ -52,17 +53,17 @@
</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 PageLogin from '@/components/PageLogin.vue'
import { useAuthStore } from '@/stores/auth'
import {
type LoginAnnouncementListItem,
useLoginAnnouncementsStore,
} from '@/stores/loginAnnouncements'
useLoginAnnouncements,
} from '@/composables/useLoginAnnouncements'
import { useLoginCaptcha } from '@/composables/useLoginCaptcha'
import { useAuthStore } from '@/stores/auth'
import { useMenuStore } from '@/stores/menu'
import { useSnackbarStore } from '@/stores/snackbar'
@@ -73,15 +74,8 @@ 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']
@@ -91,9 +85,21 @@ const illustrationImage = ref(HyakkaouAcademyImage)
// 功能開關與版型
const formPositionLayout = ref<LayoutType>('side-left')
// 是否啟用公告
const withAnnouncement = ref(true)
const withForgotPassword = ref(true)
const withRememberAccount = ref(true)
// 功能開關:是否啟用驗證碼
const withCaptcha = ref(true)
const loginCaptcha = useLoginCaptcha({ enabled: withCaptcha })
const loginAnnouncements = useLoginAnnouncements({ enabled: withAnnouncement })
const {
boardConfig: announcementBoard,
mobileAnnouncementConfig: mobileAnnouncement,
selectedAnnouncement,
selectedAnnouncementDetail,
} = loginAnnouncements
// 文字內容(i18n
const title = computed(() => t('pages.login.title'))
@@ -117,9 +123,6 @@ const forgotPasswordHref = ref('/forgot-password')
const forgotPasswordTarget = ref<string | undefined>(undefined)
// 記住帳號的 localStorage key
const rememberStorageKey = ref('login.remember.username')
// 驗證碼 API
const captchaValue = ref('')
// 驗證與對話框狀態
const dialogVisible = ref(false)
const dialogTitle = ref('')
@@ -153,18 +156,14 @@ const form = computed(() => ({
captchaPlaceholder: captchaPlaceholder.value,
refreshTitle: refreshTitle.value,
rememberStorageKey: rememberStorageKey.value,
withForgotPassword: withForgotPassword.value,
withRememberAccount: withRememberAccount.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 ?? '',
captcha: loginCaptcha.formCaptcha.value,
captchaValue: loginCaptcha.captchaValue.value,
captchaLoading: loginCaptcha.captchaLoading.value,
captchaErrorMessage: loginCaptcha.captchaErrorMessage.value ?? '',
captchaVerified: false,
forgotPassword: {
text: forgotPasswordText.value,
@@ -183,6 +182,8 @@ const toolbar = computed(() => ({
// 事件處理
function handleForgotPassword(e: MouseEvent) {
if (!withForgotPassword.value) return
console.log('Forgot Password Click:', e)
}
@@ -192,12 +193,11 @@ function handleChangeLocale(nextLocale: string) {
}
async function handleCaptchaRefresh() {
captchaValue.value = ''
await authStore.getCaptcha()
await loginCaptcha.refreshCaptcha().catch(() => undefined)
}
function handleCaptchaChange(value: string) {
captchaValue.value = value
loginCaptcha.setCaptchaValue(value)
}
function handleToggleLayout() {
@@ -208,12 +208,12 @@ function handleToggleLayout() {
}
function handleSelectAnnouncement(item: LoginAnnouncementListItem) {
loginAnnouncementsStore.selectById(item.id)
loginAnnouncements.selectById(item.id)
announcementDialogVisible.value = true
}
async function onLogin(data: Record<string, unknown>) {
if (withCaptcha.value && !captchaValue.value) {
if (withCaptcha.value && !loginCaptcha.captchaValue.value) {
dialogTitle.value = t('common.notice')
dialogMessage.value = t('pages.login.alert.verifyRequired')
dialogVisible.value = true
@@ -234,7 +234,7 @@ async function onLogin(data: Record<string, unknown>) {
await authStore.login({
UserID: userId,
Password: password,
DNTCaptchaInputText: captchaValue.value,
captcha: loginCaptcha.getLoginCaptchaPayload(),
})
menuStore.getMenu(authStore.user?.id ?? '')
@@ -260,8 +260,7 @@ async function onLogin(data: Record<string, unknown>) {
}
onMounted(() => {
loginAnnouncementsStore.hydrate()
loginAnnouncementsStore.fetchMobileAnnouncements()
authStore.getCaptcha()
void loginAnnouncements.load()
void loginCaptcha.loadCaptcha().catch(() => undefined)
})
</script>
+7 -4
View File
@@ -1,10 +1,13 @@
import { fileURLToPath, URL } from 'node:url'
import Vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
// https://vitejs.dev/config/
export default defineConfig({
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [
Vue({
template: { transformAssetUrls },
@@ -36,7 +39,7 @@ export default defineConfig({
port: 3700,
proxy:{
"/service/": {
target: process.env.VITE_PROXY_TARGET || "http://localhost:8080",
target: env.VITE_PROXY_TARGET || "http://localhost:8080",
changeOrigin: true,
},
}
@@ -47,4 +50,4 @@ export default defineConfig({
},
},
},
})
}})