feat: add admin social login bindings

This commit is contained in:
237899745
2026-05-27 21:24:48 +08:00
parent 5dbe666420
commit 89cb98233f
39 changed files with 904 additions and 123 deletions

View File

@@ -44,3 +44,28 @@ export async function reportAdminPasskeyClientError(payload = {}) {
const { data } = await api.post('/admin/passkeys/client-error', payload)
return data
}
export async function fetchAdminSocialBindings() {
const { data } = await api.get('/admin/social-bindings')
return data
}
export async function createAdminSocialLoginUrl(payload = {}) {
const { data } = await api.post('/admin/social-login-url', payload)
return data
}
export async function pollAdminSocialLogin(payload = {}) {
const { data } = await api.post('/admin/social-poll', payload)
return data
}
export async function bindAdminSocialCallback(provider, payload = {}) {
const { data } = await api.post(`/admin/social-bindings/${encodeURIComponent(provider)}/callback`, payload)
return data
}
export async function unbindAdminSocial(provider) {
const { data } = await api.delete(`/admin/social-bindings/${encodeURIComponent(provider)}`)
return data
}

View File

@@ -0,0 +1,73 @@
<script setup>
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute } from 'vue-router'
import { bindAdminSocialCallback } from '../api/admin'
const route = useRoute()
const statusText = ref('正在完成绑定')
onMounted(async () => {
const hashQuery = String(window.location.hash || '').split('?')[1] || ''
const params = new URLSearchParams(window.location.search || hashQuery)
const provider = String(params.get('provider') || params.get('type') || '').trim().toLowerCase()
const code = String(params.get('code') || '').trim()
const routeProvider = String(route.query?.provider || route.query?.type || '').trim().toLowerCase()
const routeCode = String(route.query?.code || '').trim()
const finalProvider = provider || routeProvider
const finalCode = code || routeCode
if (!finalProvider || !finalCode) {
ElMessage.error('快捷登录回调参数不完整')
window.location.replace('/yuyx/admin#/settings')
return
}
try {
await bindAdminSocialCallback(finalProvider, { provider: finalProvider, code: finalCode })
ElMessage.success('管理员快捷登录已绑定')
window.location.replace('/yuyx/admin#/settings')
} catch (error) {
const payload = error?.response?.data
statusText.value = payload?.error || '快捷登录绑定失败'
ElMessage.error(statusText.value)
window.setTimeout(() => {
window.location.replace('/yuyx/admin#/settings')
}, 1200)
}
})
</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;
background: #f6f7fb;
}
.callback-card {
width: min(420px, 94vw);
border-radius: 12px;
border: 1px solid var(--app-border);
}
.callback-text {
margin-top: 12px;
color: var(--app-muted);
font-size: 13px;
text-align: center;
}
</style>

View File

@@ -1,14 +1,19 @@
<script setup>
import { onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import QrcodeVue from 'qrcode.vue'
import {
createAdminSocialLoginUrl,
createAdminPasskeyOptions,
createAdminPasskeyVerify,
fetchAdminSocialBindings,
deleteAdminPasskey,
fetchAdminPasskeys,
logout,
pollAdminSocialLogin,
reportAdminPasskeyClientError,
unbindAdminSocial,
updateAdminPassword,
updateAdminUsername,
} from '../api/admin'
@@ -26,6 +31,23 @@ const passkeyItems = ref([])
const passkeyRegisterOptions = ref(null)
const passkeyRegisterOptionsAt = ref(0)
const PASSKEY_OPTIONS_PREFETCH_MAX_AGE_MS = 240000
const socialBindingsLoading = ref(false)
const socialBindLoadingProvider = ref('')
const socialBindings = ref([])
const socialEnabled = ref(false)
const qrOpen = ref(false)
const qrValue = ref('')
const qrProvider = ref('wx')
let socialPollTimer = null
let socialPollStartedAt = 0
const providerLabels = {
qq: 'QQ',
wx: '微信',
alipay: '支付宝',
}
const visibleSocialBindings = computed(() => socialBindings.value.filter((item) => providerLabels[item.provider]))
function validateStrongPassword(value) {
const text = String(value || '')
@@ -233,8 +255,137 @@ async function removePasskey(item) {
}
}
function socialBindRedirectUri() {
const url = new URL(window.location.href)
url.pathname = '/yuyx/admin-social-bind-callback'
url.search = ''
url.hash = ''
return url.toString()
}
function socialProviderIcon(provider) {
if (provider === 'wx') return '微'
if (provider === 'qq') return 'Q'
return '支'
}
function socialQrPrompt(provider) {
if (provider === 'wx') return '请使用微信扫描二维码完成绑定'
if (provider === 'qq') return '请使用 QQ 扫描二维码完成绑定'
return '请使用支付宝扫描二维码完成绑定'
}
function stopSocialPolling() {
if (socialPollTimer) {
window.clearTimeout(socialPollTimer)
socialPollTimer = null
}
}
function closeSocialQr() {
stopSocialPolling()
qrOpen.value = false
qrValue.value = ''
}
function scheduleSocialPoll(provider, state, intervalSeconds) {
stopSocialPolling()
socialPollStartedAt = Date.now()
const tick = async () => {
if (Date.now() - socialPollStartedAt > 5 * 60 * 1000) {
closeSocialQr()
ElMessage.warning('二维码已过期,请重新获取')
return
}
try {
const result = await pollAdminSocialLogin({ provider, state })
if (result?.status === 'authorized' && result?.url) {
closeSocialQr()
window.location.assign(result.url)
return
}
socialPollTimer = window.setTimeout(tick, Math.max(Number(intervalSeconds || 2), 2) * 1000)
} catch (error) {
closeSocialQr()
const data = error?.response?.data
ElMessage.error(data?.error || '扫码状态获取失败,请重新尝试')
}
}
socialPollTimer = window.setTimeout(tick, Math.max(Number(intervalSeconds || 2), 2) * 1000)
}
async function loadSocialBindings() {
socialBindingsLoading.value = true
try {
const data = await fetchAdminSocialBindings()
socialEnabled.value = Boolean(data?.enabled)
socialBindings.value = Array.isArray(data?.items) ? data.items : []
} catch {
socialEnabled.value = false
socialBindings.value = []
} finally {
socialBindingsLoading.value = false
}
}
async function bindSocial(item) {
const provider = String(item?.provider || '').trim()
if (!provider || socialBindLoadingProvider.value) return
socialBindLoadingProvider.value = provider
try {
const data = await createAdminSocialLoginUrl({
provider,
redirect_uri: socialBindRedirectUri(),
})
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
scheduleSocialPoll(provider, data.scan_state, data.scan_poll_interval || 2)
} catch (error) {
const data = error?.response?.data
ElMessage.error(data?.error || '获取聚合登录地址失败')
} finally {
socialBindLoadingProvider.value = ''
}
}
async function unbindSocial(item) {
try {
await ElMessageBox.confirm(`确定解绑${item?.provider_label || '快捷登录'}吗?`, '解绑快捷登录', {
confirmButtonText: '解绑',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
await unbindAdminSocial(item.provider)
ElMessage.success('已解绑')
await loadSocialBindings()
} catch (error) {
const data = error?.response?.data
ElMessage.error(data?.error || '解绑失败')
}
}
onMounted(() => {
loadPasskeys()
loadSocialBindings()
})
onBeforeUnmount(() => {
stopSocialPolling()
})
</script>
@@ -331,6 +482,66 @@ onMounted(() => {
</el-table>
</div>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<div class="section-head">
<h3 class="section-title">快捷登录绑定</h3>
<el-tag v-if="socialEnabled" size="small" type="success">已启用</el-tag>
<el-tag v-else size="small" type="info">未启用</el-tag>
</div>
<el-alert
v-if="!socialEnabled"
type="warning"
:closable="false"
title="聚合登录未启用,请先在系统配置中填写并启用 Space 聚合登录。"
show-icon
class="help-alert"
/>
<div v-loading="socialBindingsLoading">
<el-empty v-if="visibleSocialBindings.length === 0" description="暂无可绑定的快捷登录方式" />
<div v-else class="social-list">
<div v-for="item in visibleSocialBindings" :key="item.provider" class="social-row">
<div class="social-provider">
<span class="social-icon" :class="`provider-${item.provider}`">{{ socialProviderIcon(item.provider) }}</span>
<div class="social-info">
<strong>{{ item.provider_label }}</strong>
<span v-if="item.bound">{{ item.nickname || '已绑定' }}</span>
<span v-else>未绑定</span>
</div>
</div>
<div class="social-actions">
<el-button
v-if="item.bound"
type="danger"
text
@click="unbindSocial(item)"
>
解绑
</el-button>
<el-button
v-else
type="primary"
plain
:disabled="!socialEnabled"
:loading="socialBindLoadingProvider === item.provider"
@click="bindSocial(item)"
>
绑定
</el-button>
</div>
</div>
</div>
</div>
<el-dialog v-model="qrOpen" :title="`${providerLabels[qrProvider]}绑定`" width="min(340px, 92vw)" @close="closeSocialQr">
<div class="social-qr-box">
<QrcodeVue v-if="qrValue" :value="qrValue" :size="220" level="M" />
<div class="social-qr-prompt">{{ socialQrPrompt(qrProvider) }}</div>
</div>
</el-dialog>
</el-card>
</div>
</template>
@@ -356,6 +567,18 @@ onMounted(() => {
letter-spacing: 0.2px;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
}
.section-head .section-title {
margin-bottom: 0;
}
.help {
margin-top: 10px;
font-size: 12px;
@@ -365,4 +588,104 @@ onMounted(() => {
.help-alert {
margin-bottom: 12px;
}
.social-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.social-row {
min-height: 58px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--app-border);
border-radius: 10px;
background: rgba(248, 250, 252, 0.72);
}
.social-provider {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.social-icon {
width: 30px;
height: 30px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
color: #fff;
font-size: 13px;
font-weight: 800;
}
.provider-wx {
background: #16a34a;
}
.provider-qq {
background: #2563eb;
}
.provider-alipay {
background: #1677ff;
}
.social-info {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.social-info strong {
font-size: 14px;
}
.social-info span {
max-width: min(52vw, 360px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--app-muted);
}
.social-actions {
flex: 0 0 auto;
}
.social-qr-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.social-qr-prompt {
font-size: 13px;
color: #374151;
text-align: center;
}
@media (max-width: 640px) {
.social-row {
align-items: flex-start;
flex-direction: column;
}
.social-actions {
width: 100%;
display: flex;
justify-content: flex-end;
}
}
</style>

View File

@@ -11,8 +11,10 @@ const EmailPage = () => import('../pages/EmailPage.vue')
const SecurityPage = () => import('../pages/SecurityPage.vue')
const SystemPage = () => import('../pages/SystemPage.vue')
const SettingsPage = () => import('../pages/SettingsPage.vue')
const AdminSocialBindCallbackPage = () => import('../pages/AdminSocialBindCallbackPage.vue')
const routes = [
{ path: '/social-bind-callback', name: 'admin_social_bind_callback', component: AdminSocialBindCallbackPage },
{
path: '/',
component: AdminLayout,