front/src/views/h5/customer-center/components/WechatQrPanel.vue
zzh c6a4484448 feat: 查价价卡展示贸易条款与产品分组,并优化 H5/登录与顾问体验
销售产品可配置 DDP/DDU;查价返回分组与贸易条款并展示在价卡上。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 11:48:36 +08:00

364 lines
8.0 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="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>