chore:二维码

This commit is contained in:
2026-10-27 10:14:55 +08:00
parent 69e59d7902
commit 18875cda43
4 changed files with 152 additions and 124 deletions

View File

@@ -42,9 +42,9 @@
<image v-if="showLogo && card.logo" class="mini-logo" :src="$util.img(card.logo)" mode="aspectFit" />
<!-- 二维码·右上角浮层(corner 档: 外观由 qrStyle 决定, 资料行自动让位; 点击预览名片) -->
<view v-if="showQrCorner" class="mini-qr mini-qr-corner" :style="qrBlockStyle" @tap.stop="onQrTap">
<image class="qr-img" :style="qrImgStyle" :src="$util.img(qrSrc)" mode="aspectFit" />
<text v-if="showQrTip" class="qr-tip" :style="labelStyle"><text v-if="qrTipEm" class="qr-tip-em" :style="qrTipEmStyle">{{ qrTipEm }}</text>{{ qrTipLine1 }}</text><text v-if="qrTipSub" class="qr-tip-sub" :style="labelStyle">{{ qrTipSub }}</text><text v-if="qrUrlShow && qrUrlText" class="qr-url" :style="qrUrlStyle">{{ qrUrlText }}</text>
<view v-if="showQrCorner" class="mini-qr mini-qr-corner" :style="qrBlockStyle">
<view class="qr-frame" :style="qrFrameStyle"><image class="qr-img" :style="qrImgStyle" :src="$util.img(qrSrc)" mode="aspectFit" show-menu-by-longpress="true" /></view>
<text v-if="showQrTip" class="qr-tip" :style="labelStyle" @tap="previewQrImg"><text v-if="qrTipEm" class="qr-tip-em" :style="qrTipEmStyle">{{ qrTipEm }}</text>{{ qrTipLine1 }}</text><text v-if="qrTipSub" class="qr-tip-sub" :style="labelStyle">{{ qrTipSub }}</text><text v-if="qrUrlShow && qrUrlText" class="qr-url" :style="qrUrlStyle">{{ qrUrlText }}</text>
</view>
<!-- 头部·主题渐变整块档(theme): 渐变通铺卡边 + 装饰圆, plain 档不渲染 -->
@@ -70,8 +70,8 @@
</view>
<!-- 二维码·头部右侧(head 档: 头像|信息|二维码 三段 flex, 零遮挡) -->
<view v-if="showQrHead" class="mini-qr mini-qr-head" :style="qrBlockStyle">
<image class="qr-img" :style="qrImgStyle" :src="$util.img(qrSrc)" mode="aspectFit" />
<text v-if="showQrTip" class="qr-tip" :style="labelStyle"><text v-if="qrTipEm" class="qr-tip-em" :style="qrTipEmStyle">{{ qrTipEm }}</text>{{ qrTipLine1 }}</text><text v-if="qrTipSub" class="qr-tip-sub" :style="labelStyle">{{ qrTipSub }}</text><text v-if="qrUrlShow && qrUrlText" class="qr-url" :style="qrUrlStyle">{{ qrUrlText }}</text>
<view class="qr-frame" :style="qrFrameStyle"><image class="qr-img" :style="qrImgStyle" :src="$util.img(qrSrc)" mode="aspectFit" show-menu-by-longpress="true" /></view>
<text v-if="showQrTip" class="qr-tip" :style="labelStyle" @tap="previewQrImg"><text v-if="qrTipEm" class="qr-tip-em" :style="qrTipEmStyle">{{ qrTipEm }}</text>{{ qrTipLine1 }}</text><text v-if="qrTipSub" class="qr-tip-sub" :style="labelStyle">{{ qrTipSub }}</text><text v-if="qrUrlShow && qrUrlText" class="qr-url" :style="qrUrlStyle">{{ qrUrlText }}</text>
</view>
</view>
</view>
@@ -95,9 +95,9 @@
<text v-if="subLine" class="mini-sub" :style="subTextStyle">{{ subLine }}</text>
</view>
<!-- 二维码·头部右侧(head 档: 头像|信息|二维码 三段 flex, 零遮挡; 点击预览名片) -->
<view v-if="showQrHead" class="mini-qr mini-qr-head" :style="qrBlockStyle" @tap.stop="onQrTap">
<image class="qr-img" :style="qrImgStyle" :src="$util.img(qrSrc)" mode="aspectFit" />
<text v-if="showQrTip" class="qr-tip" :style="labelStyle"><text v-if="qrTipEm" class="qr-tip-em" :style="qrTipEmStyle">{{ qrTipEm }}</text>{{ qrTipLine1 }}</text><text v-if="qrTipSub" class="qr-tip-sub" :style="labelStyle">{{ qrTipSub }}</text><text v-if="qrUrlShow && qrUrlText" class="qr-url" :style="qrUrlStyle">{{ qrUrlText }}</text>
<view v-if="showQrHead" class="mini-qr mini-qr-head" :style="qrBlockStyle">
<view class="qr-frame" :style="qrFrameStyle"><image class="qr-img" :style="qrImgStyle" :src="$util.img(qrSrc)" mode="aspectFit" show-menu-by-longpress="true" /></view>
<text v-if="showQrTip" class="qr-tip" :style="labelStyle" @tap="previewQrImg"><text v-if="qrTipEm" class="qr-tip-em" :style="qrTipEmStyle">{{ qrTipEm }}</text>{{ qrTipLine1 }}</text><text v-if="qrTipSub" class="qr-tip-sub" :style="labelStyle">{{ qrTipSub }}</text><text v-if="qrUrlShow && qrUrlText" class="qr-url" :style="qrUrlStyle">{{ qrUrlText }}</text>
</view>
</view>
@@ -151,9 +151,9 @@
</view>
<!-- 二维码·资料行下方(rows 档, 默认; 点击预览名片); mini-qr-center: 块级居中 -->
<view v-if="showQrRows" class="mini-qr mini-qr-center" :style="qrBlockFinalStyle" @tap.stop="onQrTap">
<image class="qr-img" :style="qrImgStyle" :src="$util.img(qrSrc)" mode="aspectFit" />
<text v-if="showQrTip" class="qr-tip" :style="labelStyle"><text v-if="qrTipEm" class="qr-tip-em" :style="qrTipEmStyle">{{ qrTipEm }}</text>{{ qrTipLine1 }}</text><text v-if="qrTipSub" class="qr-tip-sub" :style="labelStyle">{{ qrTipSub }}</text><text v-if="qrUrlShow && qrUrlText" class="qr-url" :style="qrUrlStyle">{{ qrUrlText }}</text>
<view v-if="showQrRows" class="mini-qr mini-qr-center" :style="qrBlockFinalStyle">
<view class="qr-frame" :style="qrFrameStyle"><image class="qr-img" :style="qrImgStyle" :src="$util.img(qrSrc)" mode="aspectFit" show-menu-by-longpress="true" /></view>
<text v-if="showQrTip" class="qr-tip" :style="labelStyle" @tap="previewQrImg"><text v-if="qrTipEm" class="qr-tip-em" :style="qrTipEmStyle">{{ qrTipEm }}</text>{{ qrTipLine1 }}</text><text v-if="qrTipSub" class="qr-tip-sub" :style="labelStyle">{{ qrTipSub }}</text><text v-if="qrUrlShow && qrUrlText" class="qr-url" :style="qrUrlStyle">{{ qrUrlText }}</text>
</view>
<!-- 快捷操作·图标宫格档(grid): 彩色浅底图标 + 下方文字 -->
@@ -179,9 +179,9 @@
</view>
<!-- 二维码·主按钮上方(bottom 档; 点击预览名片) -->
<view v-if="showQrBottom" class="mini-qr mini-qr-center" :style="qrBlockFinalStyle" @tap.stop="onQrTap">
<image class="qr-img" :style="qrImgStyle" :src="$util.img(qrSrc)" mode="aspectFit" />
<text v-if="showQrTip" class="qr-tip" :style="labelStyle"><text v-if="qrTipEm" class="qr-tip-em" :style="qrTipEmStyle">{{ qrTipEm }}</text>{{ qrTipLine1 }}</text><text v-if="qrTipSub" class="qr-tip-sub" :style="labelStyle">{{ qrTipSub }}</text><text v-if="qrUrlShow && qrUrlText" class="qr-url" :style="qrUrlStyle">{{ qrUrlText }}</text>
<view v-if="showQrBottom" class="mini-qr mini-qr-center" :style="qrBlockFinalStyle">
<view class="qr-frame" :style="qrFrameStyle"><image class="qr-img" :style="qrImgStyle" :src="$util.img(qrSrc)" mode="aspectFit" show-menu-by-longpress="true" /></view>
<text v-if="showQrTip" class="qr-tip" :style="labelStyle" @tap="previewQrImg"><text v-if="qrTipEm" class="qr-tip-em" :style="qrTipEmStyle">{{ qrTipEm }}</text>{{ qrTipLine1 }}</text><text v-if="qrTipSub" class="qr-tip-sub" :style="labelStyle">{{ qrTipSub }}</text><text v-if="qrUrlShow && qrUrlText" class="qr-url" :style="qrUrlStyle">{{ qrUrlText }}</text>
</view>
<!-- 发名片(对齐名片详情页: 主题色大按钮; 点击跳原生转发中转页拉起微信转发面板) -->
@@ -374,14 +374,14 @@ export default {
}
return this.$util.objToStyle(style);
},
/** 二维码图样式: 尺寸 + 外观衬底(plain 原图 / card 白卡 / theme 主题色 / gradient 渐变) + 微调位移(只动图, 白卡容器不动) */
qrImgStyle() {
const size = resolveQrSize(this.qrPos, this.c.qrSize);
const style = {
width: size + 'rpx',
height: size + 'rpx',
boxSizing: 'border-box',
};
/**
* 二维码衬底 · 外层框(qrStyle 装饰 + 微调位移都在这里)
* 真实机长按「识别图中二维码」取的是这张图自身的渲染像素:
* 主题色衬底/白卡边/内边距/transform 全部包在 <image> 上时微信会解码失败
* (开发者工具按原始 src 判定所以必出, 真机必失败 —— 2026-10-26 定位)
*/
qrFrameStyle() {
const style = {};
const theme = (this.card && this.card.theme_color) || CardTokens.color.primary;
if (this.qrStyle === 'card') {
style.background = '#ffffff';
@@ -398,11 +398,29 @@ export default {
style.borderRadius = '16rpx';
style.padding = '6rpx';
}
// 微调位移(水平/垂直)只作用于二维码图本身, 外层独立白卡(.mini-qr-center)保持不动
// 微调位移(水平/垂直)作用于整个二维码块, 外层独立白卡(.mini-qr-center)保持不动
const transform = qrTransform(this.c.qrOffsetX, this.c.qrOffsetY);
if (transform) style.transform = transform;
return this.$util.objToStyle(style);
},
/** 衬底内边距数值(rpx): card/theme=10, gradient=6, plain=0(与 qrFrameStyle 同口径) */
qrPad() {
return this.qrStyle === 'plain' ? 0 : (this.qrStyle === 'gradient' ? 6 : 10);
},
/**
* 二维码图: 只承载干净二维码像素(尺寸已扣除衬底内边距, 视觉与改造前完全一致),
* 不带任何背景/边框/位移, 保证微信真机长按可识别
*/
qrImgStyle() {
const size = resolveQrSize(this.qrPos, this.c.qrSize);
const inner = size - this.qrPad * 2;
const style = {
width: inner + 'rpx',
height: inner + 'rpx',
boxSizing: 'border-box',
};
return this.$util.objToStyle(style);
},
showDetailBtn() {
return flagOn(this.c.showDetailBtn, true);
},
@@ -1017,7 +1035,11 @@ export default {
// 小程序/鸿蒙: 下载二维码后存相册(长按图片也可存, 抽屉底部有提示)
saveCardQr() {
const qrUrl = (this.card && this.card.qr_url) || '';
if (!qrUrl) return;
if (!qrUrl) {
// 不再静默返回: qr_url 为空说明后端 card/detail 未返回二维码地址, 必须可见否则无法定位
uni.showToast({ title: '名片二维码未生成(接口未返回 qr_url)', icon: 'none' });
return;
}
uni.downloadFile({
url: this.$util.img(qrUrl),
success: res => {
@@ -1034,12 +1056,105 @@ export default {
fail: () => uni.showToast({ title: '二维码下载失败', icon: 'none' }),
});
},
// 分享抽屉二维码: 点击放大预览(预览页长按必出「识别图中二维码」菜单, 比 image 直接长按更可靠)
// 分享抽屉二维码: 点击放大预览(同一张透明底码, 统一走垫白底通路)
previewQr() {
const url = (this.card && this.card.qr_url) || '';
if (!url) return;
const src = this.$util.img(url);
uni.previewImage({ urls: [src], current: src });
this.openQrPreview(this.$util.img(url));
},
/**
* 名片二维码识别兜底通路(真机可用):
* 真机实锤(2026-10-27 预览页长按菜单只有转发/保存/收藏/翻译, 无「识别图中二维码」):
* 后端 qr 接口生成的二维码 PNG 是透明底 —— 白卡上看着正常, 但微信解码器把透明当深色,
* 页面/预览页长按都解不出码。故先下载垫白底再预览, 微信即可解码(根治仍需后端出白底码)。
*/
previewQrImg() {
if (!this.qrSrc) {
uni.showToast({ title: '暂无可识别的二维码', icon: 'none' });
return;
}
this.openQrPreview(this.$util.img(this.qrSrc));
},
/** 二维码预览统一通路: 下载到本地(规避真机域名校验) → 垫白底 → 预览; 任一步失败回退原图预览 */
openQrPreview(url) {
if (!url) return;
// #ifdef MP-WEIXIN
uni.downloadFile({
url,
success: res => {
if (res.statusCode === 200 && res.tempFilePath) {
this.previewQrOnWhite(res.tempFilePath);
} else {
uni.previewImage({ urls: [url], current: url });
}
},
fail: () => uni.previewImage({ urls: [url], current: url }),
});
// #endif
// #ifndef MP-WEIXIN
uni.previewImage({ urls: [url], current: url });
// #endif
},
/** 透明底二维码垫白底: 离屏 canvas 白底 + 四周留白重绘 → 导出临时图 → 预览(微信解码必需白底留白) */
previewQrOnWhite(tempPath) {
try {
const probe = wx.createOffscreenCanvas({ type: '2d', width: 10, height: 10 });
const img = probe.createImage();
img.onload = () => {
try {
const w = img.width || 640;
const h = img.height || 640;
const side = Math.max(w, h);
const pad = Math.round(side * 0.06); // 四周留白, 微信解码必要条件
const size = side + pad * 2;
const cv = wx.createOffscreenCanvas({ type: '2d', width: size, height: size });
const ctx = cv.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, size, size);
const scale = Math.min(size / w, size / h) * 0.88;
const dw = w * scale;
const dh = h * scale;
ctx.drawImage(img, (size - dw) / 2, (size - dh) / 2, dw, dh);
const done = file => {
console.log('[qr] 白底垫底成功 →', file);
uni.previewImage({ urls: [file], current: file });
};
const fallbackRaw = tag => {
console.warn('[qr] 垫白底失败(' + tag + '), 回退原图预览');
uni.previewImage({ urls: [tempPath], current: tempPath });
};
// 通路1: canvasToTempFilePath; 通路2(更兼容): toDataURL + 文件系统写本地 png
wx.canvasToTempFilePath({
canvas: cv,
fileType: 'png',
success: r => done(r.tempFilePath),
fail: err => {
console.warn('[qr] canvasToTempFilePath 失败:', err && err.errMsg);
try {
const base64 = cv.toDataURL('image/png').replace(/^data:image\/\w+;base64,/, '');
const file = wx.env.USER_DATA_PATH + '/qr_white_' + Date.now() + '.png';
wx.getFileSystemManager().writeFile({
filePath: file,
data: base64,
encoding: 'base64',
success: () => done(file),
fail: e2 => fallbackRaw('writeFile ' + (e2 && e2.errMsg)),
});
} catch (e3) {
fallbackRaw('toDataURL ' + (e3 && e3.message));
}
},
});
} catch (e) {
console.warn('[qr] 垫白底异常:', e && e.message);
uni.previewImage({ urls: [tempPath], current: tempPath });
}
};
img.onerror = () => uni.previewImage({ urls: [tempPath], current: tempPath });
img.src = tempPath;
} catch (e) {
uni.previewImage({ urls: [tempPath], current: tempPath });
}
},
/** 二维码点击 → 预览名片(进详情页; qrTapPreview 开关控制, 默认开) */
onQrTap() {

View File

@@ -1220,20 +1220,11 @@
"diy-text": "view",
"diy-video": "view",
"diy-video-list": "view"
}
}
},
{
"path": "webview/webview",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "index/diy",
}
}
},
{
"path": "index/diy",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",

View File

@@ -769,7 +769,11 @@ export default {
});
},
saveQr() {
if (!this.qrUrl) return;
if (!this.qrUrl) {
// 不再静默返回: qr_url 为空说明后端接口未返回二维码地址, 提示出来便于定位
uni.showToast({ title: '名片二维码未生成(接口未返回 qr_url)', icon: 'none' });
return;
}
uni.downloadFile({
url: this.qrUrl,
success: res => {

View File

@@ -1,82 +0,0 @@
<template>
<view :style="themeColor">
<!-- #ifndef MP-HARMONY -->
<view class="iconfont iconshang navigate-back" @click="navigateBack"></view>
<!-- #endif -->
<web-view :src="src" @titleUpdate="updateNavbarTitle"></web-view>
<!-- #ifdef MP-HARMONY -->
<cover-view class="iconfont iconshang navigate-back" @click="navigateBack"></cover-view>
<!-- #endif -->
<!-- 隐私弹窗 -->
<privacy-popup ref="privacyPopup"></privacy-popup>
</view>
</template>
<script>
import { decodeEntity } from '@/common/js/util.js';
/**
* 必须真机调试
* 在微信模拟器中,行为和真机不一样
* 1. 模拟器中,设置标题不生效,但是,在真机是可以的。
*/
export default {
data() {
return {
src: '',
title: '', // 标题栏标题
enableSetTitle: false, // 是否强制设置标题,用来替换Webview中的默认标题
};
},
onLoad(option) {
this.title = decodeURIComponent(option?.title ?? '');
this.enableSetTitle = decodeURIComponent(option?.enable_set_title) == 1;
// #ifdef H5
this.src = decodeEntity(decodeURIComponent(option.src));
// #endif
// #ifndef H5
this.src = decodeEntity(decodeURIComponent(`${option.src}&from=mp-webview`));
// #endif
},
onShow() {
this.updateNavbarTitle();
},
watch: {
title(newVal) {
this.updateNavbarTitle(newVal);
},
enableSetTitle() {
this.updateNavbarTitle();
}
},
methods: {
navigateBack() {
uni.navigateBack({
delta: 1
});
},
updateNavbarTitle(newTitle) {
const title = newTitle ?? this.title;
if (this.enableSetTitle) {
uni.setNavigationBarTitle({title});
}
}
}
};
</script>
<style lang="scss">
.navigate-back {
position: absolute;
top: 34rpx;
left: 34rpx;
z-index: 5;
font-size: $font-size-toolbar;
}
</style>