feat: add Space aggregate login

This commit is contained in:
237899745
2026-05-27 20:39:46 +08:00
parent e725db79a9
commit 056948612a
136 changed files with 2405 additions and 322 deletions

View File

@@ -12,6 +12,7 @@
"axios": "^1.12.2",
"element-plus": "^2.11.3",
"pinia": "^3.0.3",
"qrcode.vue": "^3.6.0",
"socket.io-client": "^4.8.1",
"vue": "^3.5.24",
"vue-router": "^4.6.3"
@@ -1991,6 +1992,15 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/qrcode.vue": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/qrcode.vue/-/qrcode.vue-3.9.1.tgz",
"integrity": "sha512-CpHVRz5iveqwRFh+nzzSYV9hPWU6q+YSOKyq5ZievjQIBv4bIIDzajGgtNz/yYSlczjAkYM3GNAQJHwwCukMEQ==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",

View File

@@ -13,6 +13,7 @@
"axios": "^1.12.2",
"element-plus": "^2.11.3",
"pinia": "^3.0.3",
"qrcode.vue": "^3.6.0",
"socket.io-client": "^4.8.1",
"vue": "^3.5.24",
"vue-router": "^4.6.3"

View File

@@ -44,3 +44,23 @@ export async function confirmPasswordReset(payload) {
const { data } = await publicApi.post('/reset-password-confirm', payload)
return data
}
export async function fetchSocialConfig() {
const { data } = await publicApi.get('/auth/social/config')
return data
}
export async function socialLoginUrl(payload) {
const { data } = await publicApi.post('/auth/social/login-url', payload || {})
return data
}
export async function socialPoll(payload) {
const { data } = await publicApi.post('/auth/social/poll', payload || {})
return data
}
export async function socialCallback(payload) {
const { data } = await publicApi.post('/auth/social/callback', payload || {})
return data
}

View File

@@ -69,3 +69,18 @@ export async function reportUserPasskeyClientError(payload) {
const { data } = await publicApi.post('/user/passkeys/client-error', payload || {})
return data
}
export async function fetchSocialBindings() {
const { data } = await publicApi.get('/user/social-bindings')
return data
}
export async function bindSocial(payload) {
const { data } = await publicApi.post('/user/social-bindings', payload || {})
return data
}
export async function unbindSocial(provider) {
const { data } = await publicApi.delete(`/user/social-bindings/${encodeURIComponent(provider)}`)
return data
}

View File

@@ -0,0 +1,238 @@
<script setup>
import { computed, onBeforeUnmount, ref } from 'vue'
import { ElMessage } from 'element-plus'
import QrcodeVue from 'qrcode.vue'
import { socialLoginUrl, socialPoll } from '../api/auth'
const props = defineProps({
providers: {
type: Array,
default: () => [],
},
mode: {
type: String,
default: 'login',
},
redirectUri: {
type: String,
required: true,
},
block: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['error'])
const providerLabels = {
qq: 'QQ',
wx: '微信',
alipay: '支付宝',
}
const loadingProvider = ref('')
const qrOpen = ref(false)
const qrValue = ref('')
const qrProvider = ref('wx')
let pollTimer = null
let pollStartedAt = 0
const enabledProviders = computed(() => props.providers.filter((item) => providerLabels[item]))
function stopPolling() {
if (pollTimer) {
window.clearTimeout(pollTimer)
pollTimer = null
}
}
function closeQr() {
stopPolling()
qrOpen.value = false
qrValue.value = ''
}
function qrPrompt(provider) {
if (provider === 'wx') return '请使用微信扫描二维码点关注后登录'
if (provider === 'qq') return '请使用 QQ 扫描二维码登录'
return '请使用支付宝扫描二维码登录'
}
function providerIcon(provider) {
if (provider === 'wx') return '微'
if (provider === 'qq') return 'Q'
return '支'
}
function emitError(error, fallback) {
const data = error?.response?.data
const message = data?.error || data?.message || fallback
emit('error', message)
ElMessage.error(message)
}
function schedulePoll(provider, state, intervalSeconds) {
stopPolling()
pollStartedAt = Date.now()
const tick = async () => {
if (Date.now() - pollStartedAt > 5 * 60 * 1000) {
closeQr()
ElMessage.warning('二维码已过期,请重新获取')
return
}
try {
const result = await socialPoll({ provider, state })
if (result?.status === 'authorized' && result?.url) {
closeQr()
window.location.assign(result.url)
return
}
pollTimer = window.setTimeout(tick, Math.max(Number(intervalSeconds || 2), 2) * 1000)
} catch (error) {
closeQr()
emitError(error, '扫码状态获取失败,请重新尝试')
}
}
pollTimer = window.setTimeout(tick, Math.max(Number(intervalSeconds || 2), 2) * 1000)
}
async function start(provider) {
if (loadingProvider.value) return
loadingProvider.value = provider
try {
const data = await socialLoginUrl({
provider,
mode: props.mode === 'bind' ? 'bind' : 'login',
redirect_uri: props.redirectUri,
})
if (provider !== 'wx') {
window.location.assign(data.url)
return
}
const value = data.scan_url || data.qrcode || data.url
if (!value || !data.scan_state) {
ElMessage.error('微信二维码获取失败')
return
}
qrProvider.value = provider
qrValue.value = value
qrOpen.value = true
schedulePoll(provider, data.scan_state, data.scan_poll_interval || 2)
} catch (error) {
emitError(error, '获取聚合登录地址失败')
} finally {
loadingProvider.value = ''
}
}
onBeforeUnmount(() => {
stopPolling()
})
</script>
<template>
<div v-if="enabledProviders.length" class="social-login-buttons" :class="{ block }">
<button
v-for="provider in enabledProviders"
:key="provider"
type="button"
class="social-btn"
:class="`provider-${provider}`"
:disabled="Boolean(loadingProvider)"
@click="start(provider)"
>
<span class="social-icon">{{ providerIcon(provider) }}</span>
<span>{{ mode === 'bind' ? `绑定${providerLabels[provider]}` : `${providerLabels[provider]}登录` }}</span>
</button>
<el-dialog v-model="qrOpen" :title="`${providerLabels[qrProvider]}登录`" width="min(340px, 92vw)" @close="closeQr">
<div class="social-qr-box">
<QrcodeVue v-if="qrValue" :value="qrValue" :size="220" level="M" />
<div class="social-qr-prompt">{{ qrPrompt(qrProvider) }}</div>
</div>
</el-dialog>
</div>
</template>
<style scoped>
.social-login-buttons {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
.social-login-buttons.block {
align-items: stretch;
flex-direction: column;
}
.social-btn {
height: 40px;
border-radius: 10px;
border: 1px solid rgba(17, 24, 39, 0.14);
background: #fff;
color: #111827;
font-size: 13px;
font-weight: 800;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 12px;
}
.block .social-btn {
width: 100%;
}
.social-btn:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.social-btn:hover:not(:disabled) {
background: #f8fafc;
}
.social-icon {
width: 22px;
height: 22px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 12px;
line-height: 1;
}
.provider-wx .social-icon {
background: #16a34a;
}
.provider-qq .social-icon {
background: #2563eb;
}
.provider-alipay .social-icon {
background: #1677ff;
}
.social-qr-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.social-qr-prompt {
font-size: 13px;
color: #374151;
text-align: center;
}
</style>

View File

@@ -7,8 +7,10 @@ import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/message-box/style/css'
import { fetchActiveAnnouncement, dismissAnnouncement } from '../api/announcements'
import { fetchSocialConfig } from '../api/auth'
import { fetchMyFeedbacks, submitFeedback } from '../api/feedback'
import {
bindSocial,
bindEmail,
changePassword,
createUserPasskeyOptions,
@@ -18,11 +20,14 @@ import {
fetchUserPasskeys,
fetchUserEmail,
fetchKdocsSettings,
fetchSocialBindings,
reportUserPasskeyClientError,
unbindSocial,
unbindEmail,
updateKdocsSettings,
updateEmailNotify,
} from '../api/settings'
import SocialLoginButtons from '../components/SocialLoginButtons.vue'
import { useUserStore } from '../stores/user'
import { createPasskey, getPasskeyClientErrorMessage, isPasskeyAvailable } from '../utils/passkey'
import { validateStrongPassword } from '../utils/password'
@@ -132,6 +137,12 @@ const passkeyRegisterOptions = ref(null)
const passkeyRegisterOptionsAt = ref(0)
const PASSKEY_OPTIONS_PREFETCH_MAX_AGE_MS = 240000
const socialConfig = ref({ enabled: false, providers: [] })
const socialBindings = ref([])
const socialBindingsLoading = ref(false)
const socialBindLoading = ref(false)
const pendingSettingsBindKey = 'zsglpt_social_settings_bind_token'
function syncIsMobile() {
isMobile.value = Boolean(mediaQuery?.matches)
if (!isMobile.value) drawerOpen.value = false
@@ -262,7 +273,76 @@ async function openSettings() {
}
async function loadSettings() {
await Promise.all([loadEmailInfo(), loadEmailNotify(), loadKdocsSettings(), loadPasskeys()])
await Promise.all([loadEmailInfo(), loadEmailNotify(), loadKdocsSettings(), loadPasskeys(), loadSocialBindings()])
}
function socialBindRedirectUri() {
const url = new URL(window.location.href)
url.pathname = '/social-bind-callback'
url.search = ''
url.hash = ''
return url.toString()
}
async function loadSocialBindings() {
socialBindingsLoading.value = true
try {
const [config, bindings] = await Promise.all([fetchSocialConfig(), fetchSocialBindings()])
socialConfig.value = {
enabled: Boolean(config?.enabled),
providers: Array.isArray(config?.providers) ? config.providers : [],
}
socialBindings.value = Array.isArray(bindings?.items) ? bindings.items : []
await consumePendingSocialBind()
} catch {
socialConfig.value = { enabled: false, providers: [] }
socialBindings.value = []
} finally {
socialBindingsLoading.value = false
}
}
async function consumePendingSocialBind() {
let token = ''
try {
token = window.sessionStorage.getItem(pendingSettingsBindKey) || ''
} catch {
token = ''
}
if (!token || socialBindLoading.value) return
socialBindLoading.value = true
try {
await bindSocial({ bind_token: token })
window.sessionStorage.removeItem(pendingSettingsBindKey)
ElMessage.success('快捷登录已绑定')
const bindings = await fetchSocialBindings()
socialBindings.value = Array.isArray(bindings?.items) ? bindings.items : []
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '快捷登录绑定失败')
} finally {
socialBindLoading.value = false
}
}
async function onUnbindSocial(item) {
try {
await ElMessageBox.confirm(`确定解绑${item?.provider_label || '快捷登录'}吗?`, '解绑快捷登录', {
confirmButtonText: '解绑',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
await unbindSocial(item.provider)
ElMessage.success('已解绑')
await loadSocialBindings()
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '解绑失败')
}
}
async function loadEmailInfo() {
@@ -838,6 +918,37 @@ async function dismissAnnouncementPermanently() {
</div>
</el-tab-pane>
<el-tab-pane label="快捷登录" name="social">
<div class="settings-section" v-loading="socialBindingsLoading || socialBindLoading">
<el-empty v-if="!socialConfig.enabled" description="暂未启用快捷登录" />
<template v-else>
<div class="social-binding-list">
<div v-for="item in socialBindings" :key="item.provider" class="social-binding-row">
<div class="social-binding-main">
<div class="social-binding-title">
{{ item.provider_label }}
<el-tag v-if="item.bound" size="small" type="success">已绑定</el-tag>
<el-tag v-else size="small" type="info">未绑定</el-tag>
</div>
<div v-if="item.bound" class="app-muted social-binding-meta">
{{ item.nickname || '已授权账号' }}
</div>
</div>
<el-button v-if="item.bound" type="danger" text @click="onUnbindSocial(item)">解绑</el-button>
</div>
</div>
<SocialLoginButtons
:providers="socialConfig.providers"
mode="bind"
:redirect-uri="socialBindRedirectUri()"
block
@error="(message) => ElMessage.error(message)"
/>
</template>
</div>
</el-tab-pane>
<el-tab-pane label="表格上传" name="kdocs">
<div v-loading="kdocsLoading" class="settings-section">
<el-form label-position="top">
@@ -1101,6 +1212,43 @@ async function dismissAnnouncementPermanently() {
margin-top: 10px;
}
.social-binding-list {
display: grid;
gap: 10px;
margin-bottom: 14px;
}
.social-binding-row {
min-height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--app-border);
border-radius: 10px;
background: #fff;
}
.social-binding-main {
min-width: 0;
}
.social-binding-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 800;
}
.social-binding-meta {
margin-top: 4px;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vip-info {
margin-top: 12px;
display: grid;

View File

@@ -1,6 +1,8 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import SocialLoginButtons from '../components/SocialLoginButtons.vue'
const form = reactive({
username: '',
password: '',
@@ -43,6 +45,15 @@ const resendError = ref('')
const showResendLink = computed(() => true)
const verifyStatusLoaded = ref(false)
const socialConfig = ref({ enabled: false, providers: [] })
const socialCallbackLoading = ref(false)
const socialPendingKeys = {
token: 'zsglpt_social_pending_bind_token',
provider: 'zsglpt_social_pending_bind_provider',
nickname: 'zsglpt_social_pending_bind_nickname',
avatar: 'zsglpt_social_pending_bind_avatar_url',
}
function getCookie(name) {
const escaped = String(name || '').replace(/([.*+?^${}()|[\]\\])/g, '\\$1')
@@ -104,6 +115,8 @@ const passkeyLoginOptions = (payload) => apiRequest('/passkeys/login/options', {
const passkeyLoginVerify = (payload) => apiRequest('/passkeys/login/verify', { method: 'POST', body: payload || {} })
const resendVerifyEmail = (payload) => apiRequest('/resend-verify-email', { method: 'POST', body: payload || {} })
const forgotPassword = (payload) => apiRequest('/forgot-password', { method: 'POST', body: payload || {} })
const fetchSocialConfig = () => apiRequest('/auth/social/config')
const socialCallbackRequest = (payload) => apiRequest('/auth/social/callback', { method: 'POST', body: payload || {} })
function base64UrlToUint8Array(base64url) {
const value = String(base64url || '')
@@ -251,6 +264,70 @@ function redirectAfterLogin() {
}, 300)
}
function currentLoginRedirectUri() {
const url = new URL(window.location.href)
url.pathname = '/login'
url.search = ''
url.hash = ''
return url.toString()
}
async function loadSocialConfig() {
try {
const data = await fetchSocialConfig()
socialConfig.value = {
enabled: Boolean(data?.enabled),
providers: Array.isArray(data?.providers) ? data.providers : [],
}
} catch {
socialConfig.value = { enabled: false, providers: [] }
}
}
function savePendingSocialBind(data) {
try {
window.sessionStorage.setItem(socialPendingKeys.token, data?.bind_token || '')
window.sessionStorage.setItem(socialPendingKeys.provider, data?.provider || '')
window.sessionStorage.setItem(socialPendingKeys.nickname, data?.nickname || '')
window.sessionStorage.setItem(socialPendingKeys.avatar, data?.avatar_url || '')
} catch {
// ignore storage failures
}
}
async function handleSocialCallback() {
const params = new URLSearchParams(window.location.search || '')
const provider = String(params.get('provider') || params.get('type') || '').trim()
const code = String(params.get('code') || '').trim()
if (!provider || !code) return false
socialCallbackLoading.value = true
setNotice('success', '正在完成快捷登录...')
try {
const data = await socialCallbackRequest({ provider, code, mode: 'login' })
if (data?.requires_register && data?.bind_token) {
savePendingSocialBind(data)
setNotice('success', '请完成注册后继续')
window.setTimeout(() => {
window.location.href = '/register'
}, 500)
return true
}
if (data?.success || data?.bound) {
setNotice('success', '快捷登录成功,正在跳转...')
redirectAfterLogin()
return true
}
setNotice('error', '快捷登录失败')
} catch (e) {
const data = e?.response?.data
setNotice('error', data?.error || data?.message || '快捷登录失败')
} finally {
socialCallbackLoading.value = false
}
return true
}
async function onSubmit() {
clearNotice()
@@ -421,6 +498,8 @@ function goRegister() {
}
onMounted(async () => {
await loadSocialConfig()
await handleSocialCallback()
if (needCaptcha.value) {
await refreshLoginCaptcha()
}
@@ -486,13 +565,24 @@ onMounted(async () => {
</div>
</div>
<button type="button" class="btn-login" :disabled="loading" @click="onSubmit">
{{ loading ? '登录中...' : '登录系统' }}
<button type="button" class="btn-login" :disabled="loading || socialCallbackLoading" @click="onSubmit">
{{ loading || socialCallbackLoading ? '登录中...' : '登录系统' }}
</button>
<button type="button" class="btn-passkey" :disabled="passkeyLoading" @click="onPasskeyLogin">
{{ passkeyLoading ? 'Passkey验证中...' : '使用 Passkey 登录' }}
</button>
<div v-if="socialConfig.enabled" class="social-login-area">
<div class="divider"><span>快捷登录</span></div>
<SocialLoginButtons
:providers="socialConfig.providers"
mode="login"
:redirect-uri="currentLoginRedirectUri()"
block
@error="(message) => setNotice('error', message)"
/>
</div>
<div class="action-links">
<button type="button" class="link-btn" @click="openForgot">忘记密码</button>
<button v-if="showResendLink" type="button" class="link-btn" @click="openResend">重发验证邮件</button>
@@ -747,6 +837,28 @@ onMounted(async () => {
background: #f1f5f9;
}
.social-login-area {
margin-top: 14px;
}
.divider {
display: flex;
align-items: center;
gap: 10px;
margin: 14px 0 10px;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.divider::before,
.divider::after {
content: '';
height: 1px;
flex: 1;
background: rgba(17, 24, 39, 0.12);
}
.btn-passkey:disabled,
.btn-login:disabled,
.btn-ghost:disabled,

View File

@@ -25,6 +25,26 @@ const errorText = ref('')
const successTitle = ref('')
const successDesc = ref('')
const socialPendingKeys = {
token: 'zsglpt_social_pending_bind_token',
provider: 'zsglpt_social_pending_bind_provider',
nickname: 'zsglpt_social_pending_bind_nickname',
avatar: 'zsglpt_social_pending_bind_avatar_url',
}
const providerLabels = {
qq: 'QQ',
wx: '微信',
alipay: '支付宝',
}
const pendingSocial = reactive({
token: '',
provider: '',
nickname: '',
avatar_url: '',
})
const emailLabel = computed(() => (emailVerifyEnabled.value ? '邮箱 *' : '邮箱(可选)'))
const emailHint = computed(() => (emailVerifyEnabled.value ? '必填,用于账号验证' : '选填,用于找回密码和接收通知'))
@@ -55,6 +75,35 @@ function clearAlerts() {
successDesc.value = ''
}
function loadPendingSocialBind() {
try {
pendingSocial.token = window.sessionStorage.getItem(socialPendingKeys.token) || ''
pendingSocial.provider = window.sessionStorage.getItem(socialPendingKeys.provider) || ''
pendingSocial.nickname = window.sessionStorage.getItem(socialPendingKeys.nickname) || ''
pendingSocial.avatar_url = window.sessionStorage.getItem(socialPendingKeys.avatar) || ''
if (pendingSocial.nickname && !form.username) {
form.username = pendingSocial.nickname.replace(/[^\w\u4e00-\u9fa5]/g, '').slice(0, 20)
}
} catch {
pendingSocial.token = ''
pendingSocial.provider = ''
pendingSocial.nickname = ''
pendingSocial.avatar_url = ''
}
}
function clearPendingSocialBind() {
try {
Object.values(socialPendingKeys).forEach((key) => window.sessionStorage.removeItem(key))
} catch {
// ignore storage failures
}
pendingSocial.token = ''
pendingSocial.provider = ''
pendingSocial.nickname = ''
pendingSocial.avatar_url = ''
}
async function onSubmit() {
clearAlerts()
@@ -104,11 +153,15 @@ async function onSubmit() {
email,
captcha_session: captchaSession.value,
captcha,
social_bind_token: pendingSocial.token || undefined,
})
successTitle.value = res?.message || '注册成功'
successDesc.value = res?.need_verify ? '请检查您的邮箱(包括垃圾邮件文件夹)' : ''
ElMessage.success('注册成功')
if (pendingSocial.token) {
clearPendingSocialBind()
}
form.username = ''
form.password = ''
@@ -134,6 +187,7 @@ function goLogin() {
}
onMounted(async () => {
loadPendingSocialBind()
await refreshCaptcha()
await loadEmailVerifyStatus()
})
@@ -158,6 +212,15 @@ onMounted(async () => {
class="alert"
/>
<el-alert
v-if="pendingSocial.token"
type="info"
:closable="false"
show-icon
class="alert"
:title="`注册后绑定${providerLabels[pendingSocial.provider] || '快捷登录'}`"
/>
<el-form label-position="top">
<el-form-item label="用户名 *">
<el-input v-model="form.username" placeholder="至少3个字符" autocomplete="username" />

View File

@@ -0,0 +1,87 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { socialCallback } from '../api/auth'
import { bindSocial } from '../api/settings'
const router = useRouter()
const statusText = ref('正在完成绑定')
const pendingSettingsBindKey = 'zsglpt_social_settings_bind_token'
onMounted(async () => {
const params = new URLSearchParams(window.location.search || '')
const provider = String(params.get('provider') || params.get('type') || '').trim()
const code = String(params.get('code') || '').trim()
if (!provider || !code) {
ElMessage.error('快捷登录回调参数不完整')
router.replace('/app/accounts')
return
}
try {
const data = await socialCallback({ provider, code, mode: 'bind' })
if (data?.success && data?.bound) {
ElMessage.success('快捷登录已绑定')
router.replace('/app/accounts')
return
}
if (!data?.bind_token) {
ElMessage.warning('未获取到绑定凭证')
router.replace('/app/accounts')
return
}
try {
await bindSocial({ bind_token: data.bind_token })
ElMessage.success('快捷登录已绑定')
} catch (error) {
if (error?.response?.status === 401) {
window.sessionStorage.setItem(pendingSettingsBindKey, data.bind_token)
ElMessage.info('请先登录后完成绑定')
router.replace('/login')
return
}
throw error
}
router.replace('/app/accounts')
} catch (error) {
const payload = error?.response?.data
statusText.value = payload?.error || '快捷登录绑定失败'
ElMessage.error(statusText.value)
router.replace('/app/accounts')
}
})
</script>
<template>
<div class="callback-wrap">
<el-card shadow="never" class="callback-card">
<el-skeleton :rows="3" animated />
<div class="callback-text">{{ statusText }}</div>
</el-card>
</div>
</template>
<style scoped>
.callback-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.callback-card {
width: min(420px, 94vw);
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.callback-text {
margin-top: 12px;
color: var(--app-muted);
font-size: 13px;
text-align: center;
}
</style>

View File

@@ -3,6 +3,7 @@ import { createRouter, createWebHistory } from 'vue-router'
const LoginPage = () => import('../pages/LoginPage.vue')
const RegisterPage = () => import('../pages/RegisterPage.vue')
const ResetPasswordPage = () => import('../pages/ResetPasswordPage.vue')
const SocialBindCallbackPage = () => import('../pages/SocialBindCallbackPage.vue')
const VerifyResultPage = () => import('../pages/VerifyResultPage.vue')
const AppLayout = () => import('../layouts/AppLayout.vue')
@@ -15,6 +16,7 @@ const routes = [
{ path: '/login', name: 'login', component: LoginPage },
{ path: '/register', name: 'register', component: RegisterPage },
{ path: '/reset-password/:token', name: 'reset_password', component: ResetPasswordPage },
{ path: '/social-bind-callback', name: 'social_bind_callback', component: SocialBindCallbackPage },
{ path: '/api/verify-email/:token', name: 'verify_email', component: VerifyResultPage },
{ path: '/api/verify-bind-email/:token', name: 'verify_bind_email', component: VerifyResultPage },
{