feat(miniprogram): add web QR phone confirm page for admin login.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-18 20:21:47 +08:00
1 parent 29898a107b
commit cce8fae673
5 files changed
+482 -1

No files matched your search

+2
View File
@@ -37,6 +37,8 @@ export const AUTH_API = {
LOGIN_RSA_PUBLIC_KEY: '/web/auth/login-rsa-public-key',
LOGIN: '/web/auth/login',
WECHAT_LOGIN: '/mini/auth/wechat-login',
WEB_QR_SCANNED: '/mini/auth/web-qr-scanned',
WEB_QR_CONFIRM: '/mini/auth/web-qr-confirm',
LOGOUT: '/web/auth/logout',
CURRENT_USER: '/web/auth/current-user',
REFRESH_TOKEN: '/web/auth/refresh-token',
+8
View File
@@ -22,6 +22,14 @@
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/login/web-qr/index",
"style": {
"navigationBarTitleText": "网页登录授权",
"navigationBarBackgroundColor": "#f3f7f5",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/workbench/workbench",
"style": {
+437
View File
@@ -0,0 +1,437 @@
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useUserStore } from '@/stores/user'
import { DEFAULT_PLACEHOLDER_IMAGE_URL } from '@/utils/staticAsset'
const userStore = useUserStore()
const sessionId = ref('')
const done = ref(false)
const agreeProtocol = ref(false)
const isLoading = ref(false)
const protocolDialogVisible = ref(false)
const protocolDialogForWechat = ref(false)
onLoad((options) => {
const opts = options || {}
let sid = ''
if (opts.scene) {
try {
sid = decodeURIComponent(String(opts.scene))
} catch (e) {
sid = String(opts.scene)
}
} else if (opts.sessionId) {
sid = String(opts.sessionId)
}
sid = (sid || '').trim()
sessionId.value = sid
if (!sid) return
userStore.webQrScanned(sid).catch(() => {
/* 软失败:PC 仍可继续轮询或用户可重试授权 */
})
})
const goUserAgreement = () => {
uni.navigateTo({ url: '/pages/login/user-agreement' })
}
const goPrivacyPolicy = () => {
uni.navigateTo({ url: '/pages/login/privacy-policy' })
}
const toggleProtocolAgreement = () => {
agreeProtocol.value = !agreeProtocol.value
}
const resolveProtocolDialog = (confirmed) => {
protocolDialogVisible.value = false
protocolDialogForWechat.value = false
if (confirmed) {
agreeProtocol.value = true
}
}
const onWechatGate = () => {
if (isLoading.value || done.value) return
protocolDialogForWechat.value = true
protocolDialogVisible.value = true
}
const onAgreeGetPhoneNumber = (e) => {
agreeProtocol.value = true
protocolDialogVisible.value = false
protocolDialogForWechat.value = false
onGetPhoneNumber(e)
}
const onGetPhoneNumber = async (e) => {
if (isLoading.value || done.value || !sessionId.value) return
if (!agreeProtocol.value) {
onWechatGate()
return
}
const detail = (e && e.detail) || {}
const errMsg = detail.errMsg || ''
if ((errMsg && errMsg.indexOf('ok') === -1) || !detail.code) {
uni.showToast({
title: '需要授权手机号才能完成扫码登录',
icon: 'none'
})
return
}
isLoading.value = true
try {
const loginRes = await new Promise((resolve, reject) => {
uni.login({ provider: 'weixin', success: resolve, fail: reject })
})
const loginCode = loginRes && loginRes.code
if (!loginCode) {
throw new Error('微信登录失败,请重试')
}
const result = await userStore.webQrConfirm({
sessionId: sessionId.value,
loginCode,
phoneCode: detail.code
})
if (result && result.ok) {
done.value = true
uni.showToast({
title: result.message || '电脑端即将登录,可关闭本页',
icon: 'none'
})
return
}
uni.showToast({
title: (result && result.message) || '授权失败',
icon: 'none'
})
} catch (err) {
uni.showToast({
title: (err && err.message) || '授权失败',
icon: 'none'
})
} finally {
isLoading.value = false
}
}
</script>
<template>
<view class="web-qr-page">
<view class="web-qr-brand">
<image class="web-qr-brand__logo" :src="DEFAULT_PLACEHOLDER_IMAGE_URL" mode="widthFix" />
<view class="web-qr-brand__title">网页登录授权</view>
</view>
<view v-if="!sessionId" class="web-qr-card web-qr-card--error">
<text class="web-qr-msg">请从电脑登录页重新扫码</text>
</view>
<view v-else-if="done" class="web-qr-card web-qr-card--ok">
<text class="web-qr-msg web-qr-msg--ok">电脑端即将登录,可关闭本页</text>
</view>
<view v-else class="web-qr-card">
<text class="web-qr-desc">正在登录电脑端管理后台,请授权手机号完成验证</text>
<view class="footer-line">
<view class="agree-check" @tap="toggleProtocolAgreement">
<view class="agree-check__icon" :class="{ 'agree-check__icon--checked': agreeProtocol }">
<text class="agree-check__tick">✓</text>
</view>
</view>
<text class="footer-text">授权即表示同意</text>
<text class="link" @tap="goUserAgreement">《用户服务协议》</text>
<text class="footer-text">和</text>
<text class="link" @tap="goPrivacyPolicy">《隐私政策》</text>
</view>
<button
v-if="agreeProtocol"
class="web-qr-btn"
:disabled="isLoading"
open-type="getPhoneNumber"
@getphonenumber="onGetPhoneNumber"
>
<text class="web-qr-btn__label">{{ isLoading ? '授权中...' : '授权手机号并登录电脑端' }}</text>
</button>
<view
v-else
class="web-qr-btn"
:class="{ 'is-disabled': isLoading }"
@tap="onWechatGate"
>
<text class="web-qr-btn__label">授权手机号并登录电脑端</text>
</view>
</view>
<view
v-if="protocolDialogVisible"
class="protocol-dialog-mask"
@tap="resolveProtocolDialog(false)"
>
<view class="protocol-dialog" @tap.stop>
<text class="protocol-dialog__title">协议确认</text>
<text class="protocol-dialog__desc">授权前请先同意《用户服务协议》和《隐私政策》</text>
<view class="protocol-dialog__actions">
<view
class="protocol-dialog__btn protocol-dialog__btn--cancel"
@tap="resolveProtocolDialog(false)"
>
否
</view>
<button
v-if="protocolDialogForWechat"
class="protocol-dialog__btn protocol-dialog__btn--confirm"
open-type="getPhoneNumber"
@getphonenumber="onAgreeGetPhoneNumber"
>
同意并授权
</button>
<view
v-else
class="protocol-dialog__btn protocol-dialog__btn--confirm"
@tap="resolveProtocolDialog(true)"
>
是,继续
</view>
</view>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.web-qr-page {
min-height: 100vh;
padding: 120rpx 48rpx 64rpx;
box-sizing: border-box;
background: #fff;
}
.web-qr-brand {
text-align: center;
margin-bottom: 48rpx;
}
.web-qr-brand__logo {
display: block;
width: 320rpx;
margin: 0 auto;
border-radius: 16rpx;
}
.web-qr-brand__title {
margin-top: 24rpx;
font-size: 34rpx;
font-weight: 600;
color: #0a9f6d;
}
.web-qr-card {
background: #f7faf8;
border: 1px solid #e2ebe6;
border-radius: 24rpx;
padding: 40rpx 32rpx;
}
.web-qr-card--error .web-qr-msg {
color: #c45656;
}
.web-qr-card--ok {
background: #e9f9f2;
border-color: #a7f3d0;
}
.web-qr-desc {
display: block;
font-size: 28rpx;
line-height: 1.6;
color: #334155;
margin-bottom: 36rpx;
}
.web-qr-msg {
display: block;
font-size: 28rpx;
line-height: 1.6;
text-align: center;
color: #334155;
}
.web-qr-msg--ok {
color: #087f57;
font-weight: 500;
}
.footer-line {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
column-gap: 4rpx;
row-gap: 8rpx;
margin-bottom: 8rpx;
}
.agree-check {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8rpx;
margin-right: 4rpx;
flex-shrink: 0;
}
.agree-check__icon {
width: 30rpx;
height: 30rpx;
border-radius: 50%;
border: 2rpx solid #c0c4cc;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
.agree-check__tick {
font-size: 18rpx;
font-weight: 700;
line-height: 1;
color: transparent;
}
.agree-check__icon--checked {
border-color: #0a9f6d;
background: #0a9f6d;
}
.agree-check__icon--checked .agree-check__tick {
color: #fff;
}
.footer-text,
.link {
font-size: 22rpx;
line-height: 1.6;
}
.footer-text {
color: #909399;
}
.link {
color: #0a9f6d;
}
.web-qr-btn {
margin-top: 32rpx;
height: 88rpx;
border-radius: 44rpx;
background: #0a9f6d;
color: #fff;
font-size: 30rpx;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
border: none;
padding: 0;
line-height: 1;
width: 100%;
box-sizing: border-box;
}
.web-qr-btn.is-disabled {
opacity: 0.75;
}
.web-qr-btn__label {
color: #fff;
font-size: 30rpx;
font-weight: 500;
}
button.web-qr-btn::after {
border: none;
}
.protocol-dialog-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.42);
z-index: 99;
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
box-sizing: border-box;
}
.protocol-dialog {
width: 100%;
max-width: 620rpx;
background: #fff;
border-radius: 24rpx;
padding: 36rpx 30rpx 28rpx;
box-sizing: border-box;
box-shadow: 0 14rpx 48rpx rgba(0, 0, 0, 0.16);
}
.protocol-dialog__title {
display: block;
text-align: center;
font-size: 34rpx;
font-weight: 700;
color: #303133;
}
.protocol-dialog__desc {
display: block;
margin-top: 16rpx;
text-align: center;
font-size: 26rpx;
line-height: 1.6;
color: #606266;
}
.protocol-dialog__actions {
margin-top: 30rpx;
display: flex;
gap: 16rpx;
}
.protocol-dialog__btn {
flex: 1;
min-height: 78rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 600;
}
.protocol-dialog__btn--cancel {
background: #f4f7f6;
color: #5b6b64;
border: 1px solid #ddebe5;
}
.protocol-dialog__btn--confirm {
background: #0a9f6d;
color: #fff;
border: none;
margin: 0;
padding: 0;
line-height: normal;
}
button.protocol-dialog__btn--confirm::after {
border: none;
}
</style>
+34
View File
@@ -159,6 +159,38 @@ export const useUserStore = defineStore('user', () => {
}
}
/** 网页扫码:通知已扫;不写本地登录态 */
async function webQrScanned(sessionId) {
const res = await request({
url: AUTH_API.WEB_QR_SCANNED,
method: 'POST',
data: { sessionId }
})
const result = parseResponseJson(res)
return !!(result && result.code === 200)
}
/** 网页扫码:手机号授权确认;不写本地登录态、不跳转工作台 */
async function webQrConfirm(params) {
const res = await request({
url: AUTH_API.WEB_QR_CONFIRM,
method: 'POST',
data: {
sessionId: params.sessionId,
loginCode: params.loginCode,
phoneCode: params.phoneCode
}
})
const result = parseResponseJson(res)
if (result && result.code === 200) {
return {
ok: true,
message: (result.data && result.data.message) || '电脑端即将登录,可关闭本页'
}
}
return { ok: false, message: (result && result.message) || '授权失败' }
}
function clearLoginInfo() {
token.value = ''
refreshToken.value = ''
@@ -223,6 +255,8 @@ export const useUserStore = defineStore('user', () => {
checkLoginStatus,
login,
wechatLogin,
webQrScanned,
webQrConfirm,
logout,
clearLoginInfo,
navigateToHome,
+1 -1
View File
@@ -9,7 +9,7 @@ const API_ENCRYPT_FAIL_OPEN = false
const API_ENCRYPT_FIXED_SECRET = 'shanxia-web-api-crypto-fixed-secret'
// TODO(config-hardcoded): 排除路径清单后续改为集中配置,避免多端重复维护。
const API_ENCRYPT_EXCLUDE_PATHS = String(
'/web/auth/login-rsa-public-key,/web/auth/login,/web/auth/refresh-token,/mini/auth/wechat-login'
'/web/auth/login-rsa-public-key,/web/auth/login,/web/auth/refresh-token,/mini/auth/wechat-login,/mini/auth/web-qr-scanned,/mini/auth/web-qr-confirm'
)
.split(',')
.map((item) => item.trim())