front/src/views/login/components/wechat-login.vue
zzh 351b465d5b
All checks were successful
Build and Deploy Front / deploy (push) Successful in 7m11s
feat: 客户注册对齐登录壳,并拆分网页/微信注册入口
登录仅保留账号与微信;注册先选渠道,代理码仅 URL 携带时自动绑定。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 17:07:23 +08:00

543 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="wechat-login">
<div class="qr-card">
<div
class="qr-box"
:class="{
'qr-box--loading': showOverlay,
'qr-box--error': loadError,
'qr-box--scanned': scanned,
'qr-box--expired': expired,
}"
>
<img
v-if="qrCodeUrl"
class="login-wx-qrcode"
:class="{
'login-wx-qrcode--ready': qrImageReady,
'login-wx-qrcode--masked': scanned || expired,
}"
:src="qrCodeUrl"
referrerpolicy="no-referrer"
width="190"
height="190"
alt=""
decoding="sync"
draggable="false"
@contextmenu.prevent
@load="onQrImageLoad"
@error="onQrImageError"
/>
<div v-if="scanned" class="qr-overlay qr-overlay--scanned">
<icon-check-circle-fill :size="36" class="qr-overlay__ok" />
<span class="qr-overlay__text">{{ t('login.wechat.scanned') }}</span>
</div>
<div v-else-if="expired" class="qr-overlay qr-overlay--expired">
<span class="qr-overlay__title">{{ t('login.wechat.expired') }}</span>
<span class="qr-overlay__text">{{ t('login.wechat.expiredTip') }}</span>
<button type="button" class="qr-retry" :disabled="loadingQr" @click="refreshQr">
{{ t('login.wechat.refreshExpired') }}
</button>
</div>
<div v-else-if="showOverlay" class="qr-overlay">
<a-spin :size="28" />
<span class="qr-overlay__text">{{ t('login.wechat.loading') }}</span>
</div>
<div v-else-if="loadError && !qrCodeUrl" class="qr-overlay qr-overlay--error">
<span class="qr-overlay__text">{{ t('login.wechat.loadFailed') }}</span>
<button type="button" class="qr-retry" @click="refreshQr">
{{ t('login.wechat.refresh') }}
</button>
</div>
</div>
<div v-if="!scanned" class="tips" :class="{ 'tips--warn': expired }">
{{ expired ? t('login.wechat.expiredTip') : tipsText }}
</div>
<a-button
v-if="!scanned"
class="refresh"
:type="expired ? 'primary' : undefined"
:loading="loadingQr"
:disabled="loadingQr"
@click="refreshQr"
>
{{ expired ? t('login.wechat.refreshExpired') : t('login.wechat.refresh') }}
</a-button>
<div class="account-login-entry">
<a-tooltip :content="backTooltip">
<button
type="button"
class="account-entry-btn"
:aria-label="backTooltip"
@click="emit('switchPwd')"
>
<icon-user :size="24" />
</button>
</a-tooltip>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, onBeforeUnmount, inject, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import { IconUser, IconCheckCircleFill } from '@arco-design/web-vue/es/icon';
import { OmsLoginType } from '@/api/rbac';
import { getLoginQrcode, getWxLoginStatus, isWxLoginPendingError, wxLogin } from '@/api/wechat';
import { resolveWechatShowQrDisplayUrl } from '@/utils/wechatShowQrUrl';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import { setToken } from '@/utils/auth';
import { getDefaultHomePathByType, resolveCustomerEntryPath } from '@/utils/login-portal';
import { enterSystemAfterLogin, applyRbacUserToUserStore } from '@/utils/enter-system';
import { LOGIN_EXIT_KEY, type LoginExitApi } from '../composables/useLoginExit';
const QR_EXPIRE_MS = 600_000;
/** 首轮更快感知扫码,后续略放宽 */
const POLL_INTERVAL_MS = 1800;
const props = withDefaults(
defineProps<{
/** login:登录页扫码;register:注册页扫码 */
intent?: 'login' | 'register';
}>(),
{
intent: 'login',
},
);
const { t } = useI18n();
const router = useRouter();
const rbacUserStore = useRbacUserStore();
const loginExit = inject<LoginExitApi | null>(LOGIN_EXIT_KEY, null);
const emit = defineEmits<{
switchPwd: [];
}>();
const isRegisterIntent = computed(() => props.intent === 'register');
const tipsText = computed(() =>
isRegisterIntent.value ? t('login.wechat.tipsRegister') : t('login.wechat.tips'),
);
const backTooltip = computed(() =>
isRegisterIntent.value ? t('login.switchToRegisterChoose') : t('login.switchToAccount'),
);
const qrCodeUrl = ref('');
const loginUuid = ref('');
const loadingQr = ref(false);
const loadError = ref(false);
const qrImageReady = ref(false);
const polling = ref(false);
/** 已扫码:遮罩二维码,防止重复扫 */
const scanned = ref(false);
/** 二维码已过期:遮罩并引导刷新 */
const expired = ref(false);
const showOverlay = computed(
() =>
!scanned.value &&
!expired.value &&
(loadingQr.value || (Boolean(qrCodeUrl.value) && !qrImageReady.value)),
);
let pollTimer: ReturnType<typeof setInterval> | undefined;
let expireTimer: ReturnType<typeof setTimeout> | undefined;
let loadSeq = 0;
let pollInFlight = false;
let disposed = false;
function createLoginUuid(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID().replace(/-/g, '');
}
return `${Date.now()}${Math.random().toString(16).slice(2)}`;
}
function clearTimers() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = undefined;
}
if (expireTimer) {
clearTimeout(expireTimer);
expireTimer = undefined;
}
polling.value = false;
}
function preloadImage(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const img = new Image();
img.decoding = 'sync';
img.referrerPolicy = 'no-referrer';
img.onload = () => {
if (typeof img.decode === 'function') {
img
.decode()
.then(() => resolve())
.catch(() => resolve());
return;
}
resolve();
};
img.onerror = () => reject(new Error('qr image load failed'));
img.src = url;
});
}
function onQrImageError() {
qrImageReady.value = false;
loadError.value = true;
}
async function onQrImageLoad(event?: Event) {
const img = event?.target as HTMLImageElement | undefined;
if (img && typeof img.decode === 'function') {
try {
await img.decode();
} catch {
// ignore
}
}
if (disposed) return;
qrImageReady.value = true;
loadError.value = false;
}
async function navigateAfterLogin() {
const userInfoData = rbacUserStore.userInfo;
if (!userInfoData) return;
if (rbacUserStore.token) {
setToken(rbacUserStore.token);
}
applyRbacUserToUserStore(userInfoData);
const { redirect, ...othersQuery } = router.currentRoute.value.query;
const redirectStr = redirect as string | undefined;
const defaultHomePath = getDefaultHomePathByType(OmsLoginType.Customer);
const redirectParams: Record<string, unknown> = {
query: { ...othersQuery },
};
if (redirectStr?.startsWith('/')) {
redirectParams.path = resolveCustomerEntryPath(redirectStr);
} else if (redirectStr) {
redirectParams.name = redirectStr;
} else {
redirectParams.path = defaultHomePath;
}
Message.success(t('login.form.login.success'));
clearTimers();
await nextTick();
await enterSystemAfterLogin({
target: redirectParams,
fallbackPath: defaultHomePath,
playExit: () => loginExit?.playExitAnimation() ?? Promise.resolve(),
});
}
async function pollWxLogin() {
if (!loginUuid.value || pollInFlight || disposed || expired.value) return;
pollInFlight = true;
try {
const status = await getWxLoginStatus(loginUuid.value);
if (!status?.ready || disposed) return;
scanned.value = true;
expired.value = false;
clearTimers();
const result = await wxLogin(loginUuid.value);
if (!result?.accessToken || disposed) return;
const success = await rbacUserStore.loginByWxResult(result);
if (success && !disposed) {
await navigateAfterLogin();
}
} catch (error) {
if (!isWxLoginPendingError(error)) {
// 非等待扫码错误:保持轮询
}
} finally {
pollInFlight = false;
}
}
function markExpired() {
if (disposed || scanned.value) return;
clearTimers();
expired.value = true;
}
function startPolling(expireMs = QR_EXPIRE_MS) {
clearTimers();
if (disposed) return;
expired.value = false;
polling.value = true;
pollTimer = setInterval(() => {
void pollWxLogin();
}, POLL_INTERVAL_MS);
const ttl = Number.isFinite(expireMs) && expireMs > 0 ? expireMs : QR_EXPIRE_MS;
expireTimer = setTimeout(markExpired, ttl);
void pollWxLogin();
}
async function loadQrCode(options?: { keepPrevious?: boolean }) {
if (disposed) return;
const seq = ++loadSeq;
clearTimers();
loadError.value = false;
scanned.value = false;
expired.value = false;
loadingQr.value = true;
qrImageReady.value = false;
if (!options?.keepPrevious) {
qrCodeUrl.value = '';
}
const uuid = createLoginUuid();
loginUuid.value = uuid;
try {
const result = await getLoginQrcode(uuid);
if (seq !== loadSeq || disposed) return;
if (!result?.qrCodeUrl) {
loadError.value = true;
if (!qrCodeUrl.value) {
Message.error(t('login.wechat.loadFailed'));
}
return;
}
loginUuid.value = result.loginKey || uuid;
const proxiedUrl = resolveWechatShowQrDisplayUrl(result.qrCodeUrl);
let displayUrl = proxiedUrl;
try {
await preloadImage(proxiedUrl);
} catch {
if (proxiedUrl !== result.qrCodeUrl) {
try {
await preloadImage(result.qrCodeUrl);
displayUrl = result.qrCodeUrl;
} catch {
// 预加载失败仍尝试展示,由 img @error 兜底
}
}
}
if (seq !== loadSeq || disposed) return;
qrCodeUrl.value = displayUrl;
const expireSec = Number(result.expiresIn);
const expireMs =
Number.isFinite(expireSec) && expireSec > 0
? Math.max(expireSec - 5, 30) * 1000
: QR_EXPIRE_MS;
startPolling(expireMs);
} catch {
if (seq !== loadSeq || disposed) return;
loadError.value = true;
if (!qrCodeUrl.value) {
Message.error(t('login.wechat.loadFailed'));
}
} finally {
if (seq === loadSeq) {
loadingQr.value = false;
}
}
}
function refreshQr() {
void loadQrCode({ keepPrevious: Boolean(qrCodeUrl.value) });
}
/** 供父级悬停预取:已有有效码则跳过 */
function prefetch() {
if (qrCodeUrl.value || loadingQr.value) return;
void loadQrCode();
}
defineExpose({ prefetch, refreshQr });
onMounted(() => {
disposed = false;
void loadQrCode();
});
onBeforeUnmount(() => {
disposed = true;
loadSeq += 1;
clearTimers();
});
</script>
<style lang="less" scoped>
.wechat-login {
display: flex;
justify-content: center;
}
.qr-card {
display: flex;
flex-direction: column;
align-items: center;
}
.qr-box {
position: relative;
width: 210px;
height: 210px;
padding: 10px;
background: var(--app-acrylic-bg);
border: 1px solid var(--app-acrylic-border);
border-radius: 14px;
box-shadow: none;
backdrop-filter: var(--app-acrylic-blur);
-webkit-backdrop-filter: var(--app-acrylic-blur);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.qr-overlay {
position: absolute;
inset: 10px;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: 4px;
background: rgba(250, 250, 250, 0.92);
backdrop-filter: blur(2px);
&--error {
background: #fafafa;
}
&--scanned {
background: rgba(255, 255, 255, 0.94);
}
&--expired {
gap: 6px;
background: rgba(255, 255, 255, 0.94);
}
&__ok {
color: var(--app-primary-color, #007979);
}
&__title {
font-size: 14px;
font-weight: 700;
color: #ff7d00;
line-height: 1.3;
}
&__text {
font-size: 12px;
color: #86909c;
text-align: center;
padding: 0 12px;
line-height: 1.4;
}
}
.qr-retry {
margin-top: 2px;
padding: 4px 12px;
border: 1px solid var(--app-primary-color, #007979);
border-radius: 4px;
background: #fff;
color: var(--app-primary-color, #007979);
font-size: 12px;
cursor: pointer;
line-height: 1.4;
&:hover {
background: rgba(0, 121, 121, 0.06);
}
}
.login-wx-qrcode {
display: block;
max-width: 100%;
max-height: 100%;
visibility: hidden;
border-radius: 0;
image-rendering: -webkit-optimize-contrast;
/* 默认禁 iOS「共享/存储图像」;PC 微信登录主要靠扫码,长按识别非主路径 */
-webkit-touch-callout: none;
user-select: none;
&--ready {
visibility: visible;
}
&--masked {
filter: blur(6px);
opacity: 0.35;
pointer-events: none;
}
}
.tips {
margin: 12px 0 0;
color: #666;
font-size: 12px;
text-align: center;
line-height: 1.5;
max-width: 240px;
&--warn {
color: #ff7d00;
font-weight: 600;
}
}
.refresh {
width: 210px;
margin-top: 8px;
}
.account-login-entry {
display: flex;
justify-content: center;
margin-top: 14px;
}
.account-entry-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
border: 1px solid var(--app-acrylic-border);
border-radius: 50%;
background: var(--app-acrylic-bg);
backdrop-filter: var(--app-acrylic-blur);
-webkit-backdrop-filter: var(--app-acrylic-blur);
color: rgba(26, 45, 74, 0.72);
cursor: pointer;
line-height: 1;
transition: border-color 0.2s, background 0.2s, color 0.2s;
&:hover {
border-color: var(--app-acrylic-border-strong);
background: var(--app-acrylic-hover-bg);
color: var(--app-primary-color, #007979);
}
}
</style>