All checks were successful
Build and Deploy Front / deploy (push) Successful in 7m11s
登录仅保留账号与微信;注册先选渠道,代理码仅 URL 携带时自动绑定。 Co-authored-by: Cursor <cursoragent@cursor.com>
543 lines
14 KiB
Vue
543 lines
14 KiB
Vue
<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>
|