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