364 lines
8.0 KiB
Vue
364 lines
8.0 KiB
Vue
<template>
|
||
<div class="h5-wx-qr" :class="{ 'is-compact': compact }">
|
||
<div
|
||
class="h5-wx-qr__box"
|
||
:class="{
|
||
'is-loading': showOverlay,
|
||
'is-error': loadError && !qrCodeUrl,
|
||
'is-scanned': scanned,
|
||
'is-expired': expired,
|
||
}"
|
||
>
|
||
<!--
|
||
必须用原生 <img>:微信对 canvas 长按常无「识别图中二维码」菜单。
|
||
统一禁 touch-callout / contextmenu,避免 iOS/微信弹出共享、存图;
|
||
微信客户端仍可接管「识别图中二维码」。
|
||
-->
|
||
<img
|
||
v-if="qrCodeUrl"
|
||
class="h5-wx-qr__img"
|
||
:class="{
|
||
'is-ready': qrImageReady,
|
||
'is-masked': scanned || expired,
|
||
}"
|
||
:src="qrCodeUrl"
|
||
alt="wechat-login-qrcode"
|
||
:width="compact ? 280 : 220"
|
||
:height="compact ? 280 : 220"
|
||
referrerpolicy="no-referrer"
|
||
decoding="sync"
|
||
draggable="false"
|
||
@contextmenu.prevent
|
||
@load="onQrImageLoad"
|
||
@error="onQrImageError"
|
||
/>
|
||
<div v-if="scanned" class="h5-wx-qr__overlay is-scanned">
|
||
<icon-check-circle-fill :size="32" class="h5-wx-qr__ok" />
|
||
<span>{{ t('h5.wxQr.scanned') }}</span>
|
||
</div>
|
||
<div v-else-if="expired" class="h5-wx-qr__overlay is-expired">
|
||
<span class="h5-wx-qr__expired-title">{{ t('h5.wxQr.expired') }}</span>
|
||
<span class="h5-wx-qr__expired-desc">{{ t('h5.wxQr.expiredTip') }}</span>
|
||
<button type="button" class="h5-wx-qr__retry" :disabled="loadingQr" @click="refreshQr">
|
||
{{ t('h5.wxQr.refresh') }}
|
||
</button>
|
||
</div>
|
||
<div v-else-if="showOverlay" class="h5-wx-qr__overlay">
|
||
<a-spin :size="28" />
|
||
<span>{{ t('h5.wxQr.loading') }}</span>
|
||
</div>
|
||
<div v-else-if="loadError && !qrCodeUrl" class="h5-wx-qr__overlay is-error">
|
||
<span>{{ t('h5.wxQr.loadFailed') }}</span>
|
||
<button type="button" class="h5-wx-qr__retry" @click="refreshQr">
|
||
{{ t('h5.wxQr.refresh') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<template v-if="!scanned">
|
||
<p v-if="!expired" class="h5-wx-qr__tip h5-wx-qr__tip--primary">{{ t('h5.wxQr.longPress') }}</p>
|
||
<template v-if="!compact">
|
||
<p v-if="!expired" class="h5-wx-qr__tip">{{ t('h5.wxQr.valid') }}</p>
|
||
<a-button long class="h5-wx-qr__refresh" :loading="loadingQr" @click="refreshQr">
|
||
{{ expired ? t('h5.wxQr.refreshExpired') : t('h5.wxQr.refresh') }}
|
||
</a-button>
|
||
</template>
|
||
<button
|
||
v-else
|
||
type="button"
|
||
class="h5-wx-qr__refresh-link"
|
||
:class="{ 'is-emphasize': expired }"
|
||
:disabled="loadingQr"
|
||
@click="refreshQr"
|
||
>
|
||
{{
|
||
loadingQr
|
||
? t('h5.wxQr.loading')
|
||
: expired
|
||
? t('h5.wxQr.refreshExpired')
|
||
: t('h5.wxQr.refresh')
|
||
}}
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { watch } from 'vue';
|
||
import { useI18n } from 'vue-i18n';
|
||
import { IconCheckCircleFill } from '@arco-design/web-vue/es/icon';
|
||
import type { LoginResultDto } from '@/api/rbac';
|
||
import { useWechatQrLogin } from '@/composables/useWechatQrLogin';
|
||
|
||
defineOptions({ name: 'H5WechatQrPanel' });
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
autoStart?: boolean;
|
||
/** 登录页等场景:少文案、刷新改为文字链 */
|
||
compact?: boolean;
|
||
/** 为 false 时暂停轮询;重新为 true 时刷新二维码 */
|
||
active?: boolean;
|
||
}>(),
|
||
{
|
||
autoStart: true,
|
||
compact: false,
|
||
active: true,
|
||
},
|
||
);
|
||
|
||
const emit = defineEmits<{
|
||
success: [result: LoginResultDto];
|
||
}>();
|
||
|
||
const { t } = useI18n();
|
||
|
||
const {
|
||
qrCodeUrl,
|
||
loadingQr,
|
||
loadError,
|
||
qrImageReady,
|
||
scanned,
|
||
expired,
|
||
showOverlay,
|
||
refreshQr,
|
||
pauseQr,
|
||
loadQrCode,
|
||
onQrImageError,
|
||
onQrImageLoad,
|
||
} = useWechatQrLogin({
|
||
autoStart: props.autoStart !== false && props.active !== false,
|
||
onSuccess: async (result) => {
|
||
emit('success', result);
|
||
},
|
||
});
|
||
|
||
watch(
|
||
() => props.active,
|
||
(active, prev) => {
|
||
if (active === false) {
|
||
pauseQr();
|
||
return;
|
||
}
|
||
if (prev === false && active === true) {
|
||
void loadQrCode();
|
||
}
|
||
},
|
||
);
|
||
|
||
defineExpose({ refreshQr, pauseQr });
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
.h5-wx-qr {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
width: 100%;
|
||
}
|
||
|
||
.h5-wx-qr__box {
|
||
position: relative;
|
||
width: 244px;
|
||
height: 244px;
|
||
padding: 12px;
|
||
background: #fff;
|
||
border: 1px solid #e5e6eb;
|
||
border-radius: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-sizing: border-box;
|
||
/* 禁 iOS/微信网页 callout(共享、存图);识别由微信客户端接管 */
|
||
-webkit-touch-callout: none;
|
||
-webkit-user-select: none;
|
||
user-select: none;
|
||
}
|
||
|
||
.h5-wx-qr.is-compact .h5-wx-qr__box {
|
||
width: 304px;
|
||
height: 304px;
|
||
max-width: calc(100vw - 56px);
|
||
max-height: calc(100vw - 56px);
|
||
padding: 12px;
|
||
border-radius: 18px;
|
||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.06);
|
||
}
|
||
|
||
.h5-wx-qr.is-compact .h5-wx-qr__img {
|
||
width: 280px;
|
||
height: 280px;
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
}
|
||
|
||
.h5-wx-qr__img {
|
||
display: block;
|
||
width: 220px;
|
||
height: 220px;
|
||
object-fit: contain;
|
||
/* 未就绪时不透明渐变:半透图片长按识别极不稳定 */
|
||
visibility: hidden;
|
||
border-radius: 0;
|
||
image-rendering: -webkit-optimize-contrast;
|
||
/* 勿加 pointer-events:none,否则无法长按识别 */
|
||
-webkit-touch-callout: none;
|
||
-webkit-user-select: none;
|
||
user-select: none;
|
||
-webkit-user-drag: none;
|
||
|
||
&.is-ready {
|
||
visibility: visible;
|
||
}
|
||
|
||
&.is-masked {
|
||
filter: blur(6px);
|
||
opacity: 0.35;
|
||
pointer-events: none;
|
||
}
|
||
}
|
||
|
||
.h5-wx-qr__overlay {
|
||
position: absolute;
|
||
inset: 12px;
|
||
z-index: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 10px;
|
||
border-radius: 8px;
|
||
background: rgba(255, 255, 255, 0.96);
|
||
font-size: 12px;
|
||
color: #86909c;
|
||
/* 遮罩挡住长按,就绪后必须移除 */
|
||
pointer-events: auto;
|
||
|
||
&.is-scanned {
|
||
pointer-events: none;
|
||
}
|
||
|
||
&.is-expired {
|
||
gap: 6px;
|
||
background: rgba(255, 255, 255, 0.92);
|
||
color: #1d2129;
|
||
pointer-events: auto;
|
||
}
|
||
}
|
||
|
||
.h5-wx-qr__expired-title {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: #ff7d00;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.h5-wx-qr__expired-desc {
|
||
font-size: 12px;
|
||
color: #86909c;
|
||
line-height: 1.4;
|
||
text-align: center;
|
||
padding: 0 8px;
|
||
}
|
||
|
||
.h5-wx-qr__ok {
|
||
color: var(--app-primary-color);
|
||
}
|
||
|
||
.h5-wx-qr.is-compact .h5-wx-qr__overlay {
|
||
inset: 12px;
|
||
border-radius: 12px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.h5-wx-qr__retry {
|
||
margin-top: 4px;
|
||
padding: 4px 12px;
|
||
border: 1px solid var(--app-primary-color);
|
||
border-radius: 4px;
|
||
background: #fff;
|
||
color: var(--app-primary-color);
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.h5-wx-qr__tip {
|
||
margin: 8px 0 0;
|
||
font-size: 12px;
|
||
color: #86909c;
|
||
text-align: center;
|
||
line-height: 1.5;
|
||
|
||
&--primary {
|
||
margin-top: 12px;
|
||
color: var(--app-primary-color);
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
}
|
||
|
||
&--warn {
|
||
margin-top: 12px;
|
||
color: #ff7d00;
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
|
||
.h5-wx-qr.is-compact .h5-wx-qr__tip--primary,
|
||
.h5-wx-qr.is-compact .h5-wx-qr__tip--warn {
|
||
margin-top: 14px;
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.h5-wx-qr.is-compact .h5-wx-qr__tip--primary {
|
||
color: #4e5969;
|
||
}
|
||
|
||
.h5-wx-qr.is-compact .h5-wx-qr__refresh-link {
|
||
margin-top: 8px;
|
||
font-size: 14px;
|
||
padding: 6px 10px;
|
||
|
||
&.is-emphasize {
|
||
color: var(--app-primary-color);
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
|
||
.h5-wx-qr.is-compact :deep(.arco-spin-icon) {
|
||
font-size: 32px;
|
||
}
|
||
|
||
.h5-wx-qr__refresh {
|
||
width: 244px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.h5-wx-qr__refresh-link {
|
||
margin-top: 6px;
|
||
border: none;
|
||
background: transparent;
|
||
font-size: 12px;
|
||
color: #86909c;
|
||
cursor: pointer;
|
||
padding: 4px 8px;
|
||
transition: color 0.2s ease;
|
||
|
||
&:disabled {
|
||
opacity: 0.6;
|
||
cursor: default;
|
||
}
|
||
|
||
&:not(:disabled):active {
|
||
color: var(--app-primary-color);
|
||
}
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.h5-wx-qr__img.is-masked {
|
||
filter: none;
|
||
}
|
||
}
|
||
</style>
|