feat: add Space aggregate login
This commit is contained in:
10
app-frontend/package-lock.json
generated
10
app-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
238
app-frontend/src/components/SocialLoginButtons.vue
Normal file
238
app-frontend/src/components/SocialLoginButtons.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
87
app-frontend/src/pages/SocialBindCallbackPage.vue
Normal file
87
app-frontend/src/pages/SocialBindCallbackPage.vue
Normal 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>
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user