Files
lucky_shop/pages_tool/card/save-contact.vue
ZF sun 0b3a49f0d1 feat(card): save-contact 原生页新增 action=mail 分支——web-view 内 mailto 死链的复制兜底
- onLoad action=mail 时: uni.setClipboardData 复制邮箱(showToast:false) +
  自定义 toast「邮箱已复制,去邮件 App 粘贴发送」+ 1.4s 后 navigateBack
  返回 wap 名片页
- 复制失败时把邮箱原文带进 toast, 便于用户手动抄写
- 与后端 _mini_bridge.html 双环境分发配套(backend ebf355a)
2026-10-23 15:28:47 +08:00

252 lines
7.1 KiB
Vue

<template>
<view class="save-contact">
<!-- 名片摘要卡 -->
<view class="sc-card">
<view class="sc-avatar">{{ initial }}</view>
<view class="sc-name">{{ form.name || '未命名名片' }}</view>
<view class="sc-job" v-if="form.job_title">{{ form.job_title }}</view>
<view class="sc-company" v-if="form.company">{{ form.company }}</view>
<view class="sc-mobile" v-if="form.mobile" @tap="callNow">
<text class="sc-mobile-icon">📞</text>
<text>{{ form.mobile }}</text>
</view>
</view>
<!-- 保存按钮: 调系统 API 直接写入手机通讯录(系统会弹出确认框) -->
<button class="sc-btn" :disabled="saving" @tap="saveToContacts">
{{ saving ? '保存中…' : '保存到通讯录' }}
</button>
<view class="sc-tip">保存后可在手机通讯录中直接拨号</view>
</view>
</template>
<script>
/**
* 保存名片到手机通讯录(原生页)
*
* 背景: 分享出去的名片是后端 wap H5 页, 经 web-view 打开。H5 原来的
* 「保存到通讯录」是下载 .vcf 文件, 而微信小程序 web-view 禁止下载文件,
* 会提示「可在浏览器打开此网页来下载文件」。
*
* 方案: wap H5 检测到运行在小程序 web-view 内(/miniProgram/i.test(UA))时,
* 不再触发 .vcf 下载, 改为调 wx.miniProgram.navigateTo 跳到本页,
* 由本页(小程序原生)调 uni.addPhoneContact 直接写入系统通讯录。
*
* 入参(query, 均需 encodeURIComponent):
* name/mobile/company/job_title/email/website/address/remark/wechat 名片字段
* action=call 时进入页面直接拨号(wap 页「打电话」按钮也可走本页)
* action=mail 时复制 email 到剪贴板并返回(web-view 内 mailto: 不生效的兜底)
*/
export default {
data() {
return {
form: {
name: '',
mobile: '',
company: '',
job_title: '',
email: '',
website: '',
address: '',
remark: '',
wechat: ''
},
saving: false
};
},
computed: {
initial() {
const n = this.form.name || this.form.mobile || '?';
return n.slice(0, 1);
}
},
onLoad(options) {
const keys = ['name', 'mobile', 'company', 'job_title', 'email', 'website', 'address', 'remark', 'wechat'];
keys.forEach(k => {
if (options && options[k] != null) {
this.form[k] = decodeURIComponent(options[k]);
}
});
// 打电话动作: 直接唤起拨号盘并退出本页
if (options && options.action === 'call' && this.form.mobile) {
this.callNow();
}
// 发邮件动作: web-view 内 mailto: 不生效, 复制邮箱后返回名片页
if (options && options.action === 'mail' && this.form.email) {
this.copyEmail();
}
},
onReady() {
// 微信真机才生效(模拟器 bug 见 docs/weixin-bug.md), onShow 兜底见下
uni.setNavigationBarTitle({ title: '保存到通讯录' });
// 进入页面即自动调起系统"添加联系人"确认框:
// web-view 内 H5 无法直接调 addPhoneContact, 必须经本原生页中转,
// 若再让用户手动点一次等于两步操作, 所以这里自动触发, 用户零点击直接看到系统弹窗
this.saveToContacts();
},
onShow() {
uni.setNavigationBarTitle({ title: '保存到通讯录' });
},
methods: {
callNow() {
if (!this.form.mobile) return;
uni.makePhoneCall({
phoneNumber: this.form.mobile,
// 用户取消拨号会走 fail, 不提示
fail: () => {}
});
},
// 发邮件兜底: 小程序无唤起邮件 App 的 API, 复制邮箱 + 提示后返回原名片页
copyEmail() {
uni.setClipboardData({
data: this.form.email,
showToast: false,
success: () => {
uni.showToast({ title: '邮箱已复制,去邮件 App 粘贴发送', icon: 'none', duration: 2000 });
setTimeout(() => {
uni.navigateBack({ fail: () => {} });
}, 1400);
},
fail: () => {
uni.showToast({ title: '复制失败,邮箱:' + this.form.email, icon: 'none', duration: 3000 });
}
});
},
saveToContacts() {
if (this.saving) return;
if (!this.form.name && !this.form.mobile) {
uni.showToast({ title: '名片资料缺失,无法保存', icon: 'none' });
return;
}
this.saving = true;
uni.addPhoneContact({
firstName: this.form.name || this.form.mobile,
mobilePhoneNumber: this.form.mobile || '',
organization: this.form.company || '',
title: this.form.job_title || '',
email: this.form.email || '',
url: this.form.website || '',
weChatNumber: this.form.wechat || '',
remark: [this.form.address, this.form.remark].filter(Boolean).join(' '),
success: () => {
uni.showToast({ title: '已保存到通讯录', icon: 'success' });
setTimeout(() => {
uni.navigateBack({ fail: () => {} });
}, 1200);
},
fail: err => {
// 细分失败原因, 便于真机上快速定位(系统确认框点「取消」不算错误)
const msg = (err && err.errMsg) || '';
if (msg.indexOf('cancel') > -1) return;
if (msg.indexOf('privacy') > -1 || msg.indexOf('declared') > -1) {
// 小程序后台「用户隐私保护指引」未声明 addPhoneContact(见 docs/CARD_SAVE_CONTACT_BACKEND.md)
uni.showToast({ title: '小程序隐私协议未声明该能力,请管理员配置', icon: 'none', duration: 3000 });
} else if (msg.indexOf('auth') > -1 || msg.indexOf('deny') > -1) {
uni.showToast({ title: '未获通讯录权限,请在系统设置中允许微信访问通讯录', icon: 'none', duration: 3000 });
} else {
// 未知错误把 errMsg 原样带出, 截图即可定位
uni.showToast({ title: '保存失败: ' + (msg || '未知'), icon: 'none', duration: 3000 });
}
},
complete: () => {
this.saving = false;
}
});
}
}
};
</script>
<style lang="scss" scoped>
.save-contact {
min-height: 100vh;
box-sizing: border-box;
padding: 40rpx 32rpx;
background: linear-gradient(160deg, #29a3ff 0%, #1f8fef 40%, #f5f7fa 40.2%);
}
.sc-card {
background-color: #ffffff;
border-radius: 24rpx;
padding: 48rpx 32rpx 40rpx;
box-shadow: 0 8rpx 30rpx rgba(31, 143, 239, 0.12);
display: flex;
flex-direction: column;
align-items: center;
.sc-avatar {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
background: linear-gradient(150deg, #29a3ff, #1f8fef);
color: #ffffff;
font-size: 56rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.sc-name {
margin-top: 24rpx;
font-size: 40rpx;
font-weight: 600;
color: #222;
}
.sc-job {
margin-top: 8rpx;
font-size: 26rpx;
color: #666;
}
.sc-company {
margin-top: 4rpx;
font-size: 26rpx;
color: #888;
}
.sc-mobile {
margin-top: 24rpx;
display: flex;
align-items: center;
font-size: 32rpx;
color: #1f8fef;
font-weight: 500;
.sc-mobile-icon {
margin-right: 8rpx;
font-size: 28rpx;
}
}
}
.sc-btn {
margin-top: 64rpx;
height: 92rpx;
line-height: 92rpx;
border-radius: 46rpx;
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
background: linear-gradient(150deg, #29a3ff, #1f8fef);
border: none;
&::after {
border: none;
}
&[disabled] {
opacity: 0.6;
color: #ffffff;
}
}
.sc-tip {
margin-top: 24rpx;
text-align: center;
font-size: 24rpx;
color: #999;
}
</style>