feat(card): add leads and share pages with platform-specific handling

- Implemented leads.vue for managing lead statuses and displaying lead information.
- Added share.vue for sharing card links with platform-specific web-view handling for WeChat Mini Programs, HarmonyOS, and H5.
- Introduced tests for cross-platform compatibility, ensuring proper handling of conditional compilation and API usage across WeChat, H5, and HarmonyOS.
- Ensured that sharing functionality correctly encodes and decodes URLs for different platforms.
This commit is contained in:
2026-10-12 14:32:20 +08:00
parent a957679957
commit 9e6dc8665f
8 changed files with 1822 additions and 4 deletions

View File

@@ -3,7 +3,8 @@
<!-- 名片头部 -->
<view class="detail-head" v-if="relation">
<view class="head-avatar">
<text>{{ avatarText(relation.name) }}</text>
<image v-if="relation.avatar" :src="$util.img(relation.avatar)" class="head-img" mode="aspectFill" />
<text v-else>{{ avatarText(relation.name) }}</text>
</view>
<view class="head-info">
<view class="head-name">{{ relation.name || '未命名名片' }}</view>
@@ -11,13 +12,17 @@
{{ [relation.job_title, relation.company].filter(Boolean).join(' · ') }}
</view>
</view>
<view class="head-actions">
<view class="head-btn" @tap="openShare">分享</view>
<view class="head-btn" @tap="goEdit">编辑</view>
</view>
</view>
<!-- 字段列表 -->
<view class="detail-fields" v-if="fieldRows.length > 0">
<view v-for="(row, index) in fieldRows" :key="index" class="field-row">
<view v-for="(row, index) in fieldRows" :key="index" class="field-row" :class="{ 'field-row-multi': row.multiline }">
<view class="field-label">{{ row.label }}</view>
<view class="field-value">{{ row.value }}</view>
<view class="field-value" :class="{ 'field-value-multi': row.multiline }">{{ row.value }}</view>
<view class="field-action" v-if="row.action"
@tap="row.action.type === 'call' ? callPhone(row.value) : copyText2(row.value)">
{{ row.action.text }}
@@ -25,6 +30,38 @@
</view>
</view>
<!-- 分享面板(微信式底部抽屉) -->
<view class="share-mask" v-if="showShare" @tap="closeShare"></view>
<view class="share-sheet" :class="{ 'share-sheet-show': showShare }">
<view class="ss-bar"></view>
<view class="ss-title">分享名片</view>
<view class="ss-sub">客户打开链接即可查看名片并留言留资</view>
<view class="ss-qr-box">
<image v-if="qrUrl" :src="qrUrl" class="ss-qr" mode="widthFix" show-menu-by-longpress="true" />
</view>
<view class="ss-link">{{ shareUrl }}</view>
<view class="ss-btns">
<view class="ss-btn ss-btn-main" @tap="copyLink">复制链接</view>
<!-- #ifdef MP-WEIXIN -->
<view class="ss-btn" @tap="saveQr">保存二维码</view>
<view class="ss-btn" @tap="shareToFriend">发给好友</view>
<!-- #endif -->
<!-- #ifdef H5 -->
<view class="ss-btn" @tap="h5Share">浏览器分享</view>
<!-- #endif -->
<!-- #ifdef MP-HARMONY -->
<!-- 鸿蒙元服务:uni.saveImageToPhotosAlbum/downloadFile 均支持(先下载后保存的模式不变);
无小程序右上角转发菜单,系统级分享用「复制链接」代替;原生 image 无长按菜单 -->
<view class="ss-btn" @tap="saveQr">保存二维码</view>
<!-- #endif -->
</view>
<view class="ss-tip">
<!-- #ifdef MP-WEIXIN -->长按二维码也可保存到相册<!-- #endif -->
<!-- #ifdef H5 -->长按二维码可保存到相册<!-- #endif -->
<!-- #ifdef MP-HARMONY -->点击「保存二维码」存入相册<!-- #endif -->
</view>
</view>
<!-- 加载中 -->
<view class="detail-loading" v-if="isLoading">加载中…</view>
@@ -43,7 +80,10 @@ export default {
source: null,
shareList: [],
isLoading: true,
errorMsg: ''
errorMsg: '',
showShare: false,
shareUrl: '',
qrUrl: ''
};
},
computed: {
@@ -68,6 +108,27 @@ export default {
push('Telegram', r.telegram);
push('Messenger', r.messenger);
// 附加手机号(mobiles_list, 后端 CardRichFields 解码) → 可拨打
(r.mobiles_list || []).forEach((m, i) => {
push('手机' + (i + 2), m, { type: 'call', text: '拨打' });
});
// 社交账号(socials_list: {type,label,value}) → label 作行名, 值可复制
// (完整主页链接与纯账号统一复制 value, 链接可粘贴到浏览器打开)
(r.socials_list || []).forEach(s => {
push(s.label || s.type, s.value, { type: 'copy', text: '复制' });
});
// 自定义字段(custom_list: {label,value})
(r.custom_list || []).forEach(c => {
push(c.label, c.value);
});
// 简介(多行, 放最后)
if (r.bio && String(r.bio).trim() !== '') {
rows.push({ label: '简介', value: String(r.bio), action: null, multiline: true });
}
return rows;
}
},
@@ -75,7 +136,30 @@ export default {
this.cardId = decodeURIComponent(options.card_id || '');
this.loadDetail();
},
onShow() {
// 首次进入由 onLoad 负责加载; 从编辑页返回时(onShow 再次触发)刷新
if (this._loadedOnce) {
this.loadDetail();
}
this._loadedOnce = true;
},
// 小程序端转发: 分享 web-view 中转页(其内部加载 wap H5 名片页),
// 因为 onShareAppMessage 的 path 只能指向小程序页面, 不能直接分享 H5 链接
onShareAppMessage() {
const name = (this.relation && this.relation.name) || '我的名片';
return {
title: name + '的名片',
path: '/pages_tool/card/share?url=' + encodeURIComponent(this.shareUrl || ''),
imageUrl: (this.relation && this.relation.avatar) ? this.$util.img(this.relation.avatar) : ''
};
},
methods: {
goEdit() {
if (!this.cardId) return;
uni.navigateTo({
url: '/pages_tool/card/edit?card_id=' + encodeURIComponent(this.cardId)
});
},
avatarText(name) {
return (name || '?').trim().charAt(0).toUpperCase();
},
@@ -120,6 +204,68 @@ export default {
uni.showToast({ title: '已复制', icon: 'success' });
}
});
},
/* ---------- 分享 ---------- */
siteId() {
return uni.getStorageSync('uniacid') || this.$config.uniacid || 0;
},
openShare() {
if (!this.relation) return;
const sid = this.siteId();
const rid = this.relation.id;
const base = this.$config.baseUrl || '';
// wap 公开名片页(客户视角, 无需登录)
this.shareUrl = base + '/card/wap/index/detail?site_id=' + sid + '&id=' + rid;
// 二维码由后端同源生成(项目内置 extend\QRcode), 与链接一一对应
this.qrUrl = base + '/card/wap/index/qr?site_id=' + sid + '&id=' + rid;
this.showShare = true;
},
closeShare() {
this.showShare = false;
},
copyLink() {
if (!this.shareUrl) return;
uni.setClipboardData({
data: this.shareUrl,
success: () => {
uni.showToast({ title: '链接已复制', icon: 'success' });
}
});
},
saveQr() {
if (!this.qrUrl) return;
uni.downloadFile({
url: this.qrUrl,
success: res => {
if (res.statusCode !== 200 || !res.tempFilePath) {
uni.showToast({ title: '二维码下载失败', icon: 'none' });
return;
}
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => uni.showToast({ title: '已保存到相册', icon: 'success' }),
// 文案不提「长按保存」: 鸿蒙元服务的原生 image 无长按菜单, 只微信/H5 有
fail: () => uni.showToast({ title: '保存失败,可复制链接分享', icon: 'none' })
});
},
fail: () => uni.showToast({ title: '二维码下载失败', icon: 'none' })
});
},
shareToFriend() {
// 小程序右上角菜单转发(onshareappmessage 已配置)或提示用户点击右上角
uni.showToast({ title: '请点击右上角「···」转发给好友', icon: 'none' });
},
h5Share() {
// H5: 优先 navigator.share(移动浏览器), 不支持则复制链接兜底
if (navigator && typeof navigator.share === 'function') {
navigator.share({
title: ((this.relation && this.relation.name) || '我的名片') + '的名片',
url: this.shareUrl
}).catch(() => {});
return;
}
this.copyLink();
}
}
};
@@ -152,9 +298,16 @@ export default {
justify-content: center;
flex-shrink: 0;
margin-right: 28rpx;
overflow: hidden;
.head-img {
width: 100%;
height: 100%;
}
}
.head-info {
flex: 1;
min-width: 0;
.head-name {
@@ -168,6 +321,139 @@ export default {
margin-top: 12rpx;
}
}
.head-actions {
flex-shrink: 0;
margin-left: 20rpx;
display: flex;
align-items: center;
}
.head-btn {
font-size: 24rpx;
color: #ffffff;
border: 1rpx solid rgba(255, 255, 255, 0.8);
border-radius: 28rpx;
padding: 8rpx 24rpx;
& + .head-btn {
margin-left: 12rpx;
}
}
}
/* 分享面板(微信式底部抽屉, 与 wap 留资抽屉同风格) */
.share-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s;
}
.share-mask {
opacity: 1;
pointer-events: auto;
}
.share-sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: #ffffff;
border-radius: 24rpx 24rpx 0 0;
padding: 20rpx 32rpx 40rpx;
z-index: 101;
transform: translateY(100%);
transition: transform 0.25s;
&.share-sheet-show {
transform: translateY(0);
}
.ss-bar {
width: 36rpx;
height: 4rpx;
border-radius: 2rpx;
background-color: #e3e7ee;
margin: 0 auto 20rpx;
}
.ss-title {
font-size: 32rpx;
font-weight: 600;
color: #1a1f36;
text-align: center;
}
.ss-sub {
font-size: 24rpx;
color: #9aa4b8;
text-align: center;
margin: 8rpx 0 24rpx;
}
.ss-qr-box {
display: flex;
justify-content: center;
margin-bottom: 20rpx;
.ss-qr {
width: 320rpx;
height: 320rpx;
}
}
.ss-link {
font-size: 22rpx;
color: #9aa4b8;
background-color: #f6f7f9;
border-radius: 8rpx;
padding: 14rpx 20rpx;
margin-bottom: 24rpx;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ss-btns {
display: flex;
justify-content: space-between;
.ss-btn {
flex: 1;
height: 80rpx;
line-height: 80rpx;
text-align: center;
font-size: 28rpx;
color: #333;
border: 1rpx solid #dcdfe6;
border-radius: 12rpx;
& + .ss-btn {
margin-left: 20rpx;
}
}
.ss-btn-main {
background-color: var(--main-color, #0054a5);
border-color: var(--main-color, #0054a5);
color: #ffffff;
}
}
.ss-tip {
text-align: center;
font-size: 22rpx;
color: #c0c4cc;
margin-top: 20rpx;
}
}
.detail-fields {
@@ -182,6 +468,10 @@ export default {
padding: 28rpx 24rpx;
border-bottom: 1rpx solid #f0f0f0;
&.field-row-multi {
align-items: flex-start;
}
&:last-child {
border-bottom: none;
}
@@ -200,6 +490,11 @@ export default {
word-break: break-all;
}
.field-value-multi {
line-height: 1.6;
white-space: pre-wrap;
}
.field-action {
margin-left: 20rpx;
flex-shrink: 0;

570
pages_tool/card/edit.vue Normal file
View File

@@ -0,0 +1,570 @@
<template>
<view class="card-edit">
<!-- 基本信息 -->
<view class="form-group">
<view class="group-title">基本信息</view>
<view class="form-row">
<view class="row-label">头像</view>
<view class="avatar-box" @tap="chooseAvatar">
<image v-if="form.avatar" :src="$util.img(form.avatar)" class="avatar-img" mode="aspectFill" />
<text v-else class="avatar-plus">+</text>
</view>
<view class="avatar-tip">点击从相册选择</view>
</view>
<view class="form-row">
<view class="row-label">姓名</view>
<input class="row-input" :value="form.name" placeholder="请输入姓名"
@input="e => form.name = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">公司</view>
<input class="row-input" :value="form.company" placeholder="请输入公司名称"
@input="e => form.company = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">职位</view>
<input class="row-input" :value="form.job_title" placeholder="请输入职位"
@input="e => form.job_title = e.detail.value" />
</view>
</view>
<!-- 联系方式 -->
<view class="form-group">
<view class="group-title">联系方式</view>
<view class="form-row">
<view class="row-label">手机</view>
<input class="row-input" type="number" :value="form.mobile" placeholder="请输入手机号"
@input="e => form.mobile = e.detail.value" />
</view>
<!-- 附加手机号(动态行, 与主手机号并列提交 mobiles) -->
<view v-for="(m, i) in mobiles" :key="'mob' + i" class="form-row dyn-row">
<view class="row-label">手机{{ i + 2 }}</view>
<input class="row-input" type="number" :value="m" placeholder="其他手机号"
@input="e => onMobileInput(i, e)" />
<text class="dyn-del" @tap="delMobile(i)">×</text>
</view>
<view class="form-row" v-if="mobiles.length < MAX_MOBILES">
<text class="dyn-add" @tap="addMobile">+ 添加手机号(最多{{ MAX_MOBILES }}个)</text>
</view>
<view class="form-row">
<view class="row-label">电话</view>
<input class="row-input" :value="form.phone" placeholder="固定电话(选填)"
@input="e => form.phone = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">邮箱</view>
<input class="row-input" :value="form.email" placeholder="请输入邮箱"
@input="e => form.email = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">网站</view>
<input class="row-input" :value="form.website" placeholder="个人主页 / 官网"
@input="e => form.website = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">微信号</view>
<input class="row-input" :value="form.wechat" placeholder="微信号"
@input="e => form.wechat = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">Telegram</view>
<input class="row-input" :value="form.telegram" placeholder="Telegram 账号(选填)"
@input="e => form.telegram = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">Messenger</view>
<input class="row-input" :value="form.messenger" placeholder="Messenger 账号(选填)"
@input="e => form.messenger = e.detail.value" />
</view>
</view>
<!-- 社交媒体账号 -->
<view class="form-group">
<view class="group-title">社交媒体账号</view>
<view v-for="(s, i) in socials" :key="'soc' + i" class="form-row dyn-row">
<picker class="dyn-picker" :range="socialTypeLabels" :value="socialTypeIndex(s.type)"
@change="e => onSocialTypeChange(i, e)">
<view class="picker-text">{{ socialTypeLabel(s.type) }} ▾</view>
</picker>
<input class="row-input" :value="s.value" placeholder="账号 / 链接"
@input="e => onSocialValueInput(i, e)" />
<text class="dyn-del" @tap="delSocial(i)">×</text>
</view>
<view class="form-row" v-if="socials.length < MAX_SOCIALS">
<text class="dyn-add" @tap="addSocial">+ 添加社交媒体(最多{{ MAX_SOCIALS }}个)</text>
</view>
<view class="group-tip" v-if="socials.length === 0">还没有社交媒体账号,添加后可展示在名片上</view>
</view>
<!-- 自定义字段 -->
<view class="form-group">
<view class="group-title">自定义字段</view>
<view v-for="(c, i) in customFields" :key="'cst' + i" class="form-row dyn-row">
<input class="cf-label" :value="c.label" placeholder="名称"
@input="e => onCustomLabelInput(i, e)" />
<input class="row-input" :value="c.value" placeholder="内容"
@input="e => onCustomValueInput(i, e)" />
<text class="dyn-del" @tap="delCustom(i)">×</text>
</view>
<view class="form-row" v-if="customFields.length < MAX_CUSTOM">
<text class="dyn-add" @tap="addCustom">+ 添加自定义字段(最多{{ MAX_CUSTOM }}个)</text>
</view>
<view class="group-tip" v-if="customFields.length === 0">如「展厅号 E3-108」「收款方式」等个性信息</view>
</view>
<!-- 地址 -->
<view class="form-group">
<view class="group-title">地址</view>
<view class="form-row">
<view class="row-label">国家</view>
<input class="row-input" :value="form.country" placeholder="选填"
@input="e => form.country = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">省份</view>
<input class="row-input" :value="form.province" placeholder="选填"
@input="e => form.province = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">城市</view>
<input class="row-input" :value="form.city" placeholder="选填"
@input="e => form.city = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">详细地址</view>
<input class="row-input" :value="form.address" placeholder="街道门牌等"
@input="e => form.address = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">邮编</view>
<input class="row-input" :value="form.postal_code" placeholder="选填"
@input="e => form.postal_code = e.detail.value" />
</view>
</view>
<!-- 个人简介 -->
<view class="form-group">
<view class="group-title">个人简介</view>
<view class="form-row bio-row">
<textarea class="bio-input" :value="form.bio" placeholder="介绍一下自己…(保留换行)"
@input="e => form.bio = e.detail.value" />
</view>
</view>
<!-- 保存 -->
<view class="save-bar">
<button class="save-btn" :disabled="saving" @tap="save">{{ saving ? '保存中…' : '保存' }}</button>
</view>
</view>
</template>
<script>
// 与后端 BusinessCardFieldNormalizer 输出对齐的标量字段(含 2026-10 补的 bio)。
// 富化字段(mobiles/socials/custom_fields)由页面动态行单独收集,
// 后端 Card::update 的 richFieldSaves 仅对显式携带的键做 CardRichFields 清洗。
const FIELDS = [
'name', 'company', 'job_title', 'avatar',
'mobile', 'phone', 'email', 'website', 'wechat', 'telegram', 'messenger',
'country', 'province', 'city', 'address', 'postal_code',
'bio'
];
// 社交媒体类型: 必须与后端 CardRichFields::SOCIAL_TYPES 白名单保持一致(增减需两边同步)
const SOCIAL_TYPES = [
{ key: 'alipay', label: '支付宝' },
{ key: 'douyin', label: '抖音' },
{ key: 'wxvideo', label: '视频号' },
{ key: 'xiaohongshu', label: '小红书' },
{ key: 'weibo', label: '微博' },
{ key: 'kuaishou', label: '快手' },
{ key: 'bilibili', label: '哔哩哔哩' },
{ key: 'qq', label: 'QQ' },
{ key: 'linkedin', label: 'LinkedIn' },
{ key: 'facebook', label: 'Facebook' },
{ key: 'instagram', label: 'Instagram' },
{ key: 'x', label: 'X' },
{ key: 'other', label: '其他' }
];
// 上限与后端 CardRichFields::MAX_* 一致
const MAX_MOBILES = 5;
const MAX_SOCIALS = 10;
const MAX_CUSTOM = 10;
export default {
data() {
return {
cardId: '',
form: {
name: '', company: '', job_title: '', avatar: '',
mobile: '', phone: '', email: '', website: '', wechat: '', telegram: '', messenger: '',
country: '', province: '', city: '', address: '', postal_code: '',
bio: ''
},
mobiles: [],
socials: [],
customFields: [],
socialTypeLabels: SOCIAL_TYPES.map(t => t.label),
MAX_MOBILES,
MAX_SOCIALS,
MAX_CUSTOM,
isLoading: false,
saving: false
};
},
onLoad(options) {
this.cardId = decodeURIComponent(options.card_id || '');
this.loadDetail();
},
methods: {
loadDetail() {
if (!this.cardId) {
uni.showToast({ title: '缺少名片编号', icon: 'none' });
return;
}
this.isLoading = true;
this.$api.sendRequest({
url: '/card/api/card/detail',
data: { card_id: this.cardId },
success: res => {
this.isLoading = false;
if (res.code == 0) {
const r = (res.data && res.data.relation) || {};
FIELDS.forEach(k => {
if (r[k] !== undefined && r[k] !== null) {
this.form[k] = String(r[k]);
}
});
// 富化字段: detail 由后端 CardRichFields 解码后挂在 relation 上
// (注意是 res.data.relation.mobiles_list, 不是 res.data.mobiles_list)
this.mobiles = (r.mobiles_list || []).map(v => String(v || ''));
this.socials = (r.socials_list || []).map(s => ({
type: s.type || 'other',
value: String(s.value || '')
}));
this.customFields = (r.custom_list || []).map(c => ({
label: String(c.label || ''),
value: String(c.value || '')
}));
} else {
uni.showModal({
title: '提示',
content: res.message || '卡片不存在',
showCancel: false,
success: () => uni.navigateBack()
});
}
},
fail: () => {
this.isLoading = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
/* ---------- 头像: 相册选择 → 系统上传 → 回填路径 ---------- */
chooseAvatar() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
success: res => {
const filePath = res.tempFilePaths && res.tempFilePaths[0];
if (!filePath) return;
uni.showLoading({ title: '上传中…', mask: true });
this.$api.upload({
url: '/api/upload/headimg',
filePath: filePath,
fileType: 'image',
success: r => {
uni.hideLoading();
if (r && r.code == 0 && r.data && r.data.pic_path) {
this.form.avatar = r.data.pic_path;
} else {
uni.showToast({ title: (r && r.message) || '上传失败', icon: 'none' });
}
},
fail: () => {
uni.hideLoading();
uni.showToast({ title: '上传失败', icon: 'none' });
}
});
}
});
},
/* ---------- 附加手机号 ---------- */
onMobileInput(i, e) {
this.$set(this.mobiles, i, e.detail.value);
},
addMobile() {
if (this.mobiles.length >= MAX_MOBILES) {
uni.showToast({ title: '最多 ' + MAX_MOBILES + ' 个手机号', icon: 'none' });
return;
}
this.mobiles.push('');
},
delMobile(i) {
this.mobiles.splice(i, 1);
},
/* ---------- 社交媒体 ---------- */
socialTypeLabel(key) {
const t = SOCIAL_TYPES.find(x => x.key === key);
return t ? t.label : '其他';
},
socialTypeIndex(key) {
const i = SOCIAL_TYPES.findIndex(x => x.key === key);
return i < 0 ? SOCIAL_TYPES.length - 1 : i;
},
onSocialTypeChange(i, e) {
const idx = Number(e.detail.value);
const t = SOCIAL_TYPES[idx];
if (t) this.$set(this.socials, i, { type: t.key, value: this.socials[i].value });
},
onSocialValueInput(i, e) {
const s = this.socials[i];
this.$set(this.socials, i, { type: s.type, value: e.detail.value });
},
addSocial() {
if (this.socials.length >= MAX_SOCIALS) {
uni.showToast({ title: '最多 ' + MAX_SOCIALS + ' 个社交媒体', icon: 'none' });
return;
}
this.socials.push({ type: 'other', value: '' });
},
delSocial(i) {
this.socials.splice(i, 1);
},
/* ---------- 自定义字段 ---------- */
onCustomLabelInput(i, e) {
const c = this.customFields[i];
this.$set(this.customFields, i, { label: e.detail.value, value: c.value });
},
onCustomValueInput(i, e) {
const c = this.customFields[i];
this.$set(this.customFields, i, { label: c.label, value: e.detail.value });
},
addCustom() {
if (this.customFields.length >= MAX_CUSTOM) {
uni.showToast({ title: '最多 ' + MAX_CUSTOM + ' 个自定义字段', icon: 'none' });
return;
}
this.customFields.push({ label: '', value: '' });
},
delCustom(i) {
this.customFields.splice(i, 1);
},
/* ---------- 保存 ---------- */
save() {
if (this.saving) return;
if (!this.form.name.trim()) {
uni.showToast({ title: '请填写姓名', icon: 'none' });
return;
}
this.saving = true;
// 标量全量提交(空值=清空, 与后端 normalizer 语义一致);
// 富化字段始终显式携带(空数组=清空), 后端 richFieldSaves 只对携带的键清洗落库。
// 走 sendJson(JSON body): 嵌套数组序列化跨端零歧义, TP6 原生解析
const fields = {};
FIELDS.forEach(k => { fields[k] = this.form[k]; });
fields.mobiles = this.mobiles.map(v => String(v || '').trim());
fields.socials = this.socials.map(s => ({ type: s.type, value: String(s.value || '').trim() }));
fields.custom_fields = this.customFields.map(c => ({
label: String(c.label || '').trim(),
value: String(c.value || '').trim()
}));
this.$api.sendJson({
url: '/card/api/card/update',
data: {
card_id: this.cardId,
fields: fields
},
success: res => {
this.saving = false;
if (res.code == 0) {
uni.showToast({ title: '保存成功', icon: 'success' });
// 返回详情页; detail.vue 的 onShow 会自动刷新
setTimeout(() => uni.navigateBack(), 600);
} else {
uni.showToast({ title: res.message || '保存失败', icon: 'none' });
}
},
fail: () => {
this.saving = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
}
}
};
</script>
<style lang="scss" scoped>
.card-edit {
min-height: 100vh;
background-color: #f6f7f9;
padding: 24rpx 24rpx 60rpx;
box-sizing: border-box;
}
.form-group {
background-color: #ffffff;
border-radius: 16rpx;
margin-bottom: 24rpx;
overflow: hidden;
.group-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
padding: 24rpx 24rpx 8rpx;
}
.group-tip {
font-size: 22rpx;
color: #999;
padding: 0 24rpx 20rpx;
}
.form-row {
display: flex;
align-items: center;
padding: 24rpx;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.row-label {
width: 160rpx;
font-size: 28rpx;
color: #666;
flex-shrink: 0;
}
.row-input {
flex: 1;
font-size: 28rpx;
color: #333;
}
}
}
/* 头像行 */
.avatar-box {
width: 108rpx;
height: 108rpx;
border-radius: 12rpx;
background-color: #f2f3f5;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
.avatar-img {
width: 100%;
height: 100%;
}
.avatar-plus {
font-size: 44rpx;
color: #c0c4cc;
}
}
.avatar-tip {
margin-left: 20rpx;
font-size: 24rpx;
color: #999;
}
/* 动态行(附加手机号/社交媒体/自定义字段) */
.dyn-row {
.row-label {
width: 160rpx;
}
}
.dyn-del {
width: 44rpx;
text-align: center;
font-size: 34rpx;
color: #c0c4cc;
flex-shrink: 0;
padding: 6rpx 0 6rpx 12rpx;
}
.dyn-add {
flex: 1;
font-size: 26rpx;
color: var(--main-color, #0054a5);
}
.dyn-picker {
flex-shrink: 0;
width: 170rpx;
height: 44rpx;
line-height: 44rpx;
border: 1rpx solid #dcdfe6;
border-radius: 8rpx;
margin-right: 16rpx;
.picker-text {
font-size: 24rpx;
color: #333;
text-align: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.cf-label {
width: 170rpx;
flex-shrink: 0;
font-size: 26rpx;
color: #333;
background-color: #f6f7f9;
border-radius: 8rpx;
padding: 10rpx 16rpx;
margin-right: 16rpx;
}
/* 简介 */
.bio-row {
align-items: flex-start;
.bio-input {
width: 100%;
min-height: 160rpx;
font-size: 28rpx;
color: #333;
line-height: 1.6;
}
}
.save-bar {
padding: 20rpx 0 0;
.save-btn {
height: 88rpx;
line-height: 88rpx;
background-color: var(--main-color, #0054a5);
color: #ffffff;
font-size: 30rpx;
border-radius: 44rpx;
&[disabled] {
opacity: 0.6;
color: #ffffff;
background-color: var(--main-color, #0054a5);
}
}
}
</style>

314
pages_tool/card/leads.vue Normal file
View File

@@ -0,0 +1,314 @@
<template>
<view class="lead-page">
<!-- 状态筛选 -->
<scroll-view scroll-x class="tab-bar">
<view class="tab-inner">
<view v-for="tab in tabs" :key="tab.value" class="tab-item"
:class="{ active: status === tab.value }" @tap="switchTab(tab.value)">
{{ tab.label }}
</view>
</view>
</scroll-view>
<!-- 线索列表 -->
<view class="lead-list">
<view v-for="(item, index) in list" :key="item.id || index" class="lead-item">
<view class="lead-top" @tap="callPhone(item.mobile)">
<view class="lead-name">{{ item.name || '未留姓名' }}</view>
<view class="lead-status" :class="'st-' + item.status">{{ item.status_text || '新线索' }}</view>
</view>
<view class="lead-mobile" v-if="item.mobile" @tap="callPhone(item.mobile)">📱 {{ item.mobile }}</view>
<view class="lead-note" v-if="item.note">「{{ item.note }}」</view>
<view class="lead-meta">
<text class="meta-card">来自名片:{{ item.card_name }}</text>
<text class="meta-time">{{ item.create_time_txt }}</text>
</view>
<view class="lead-actions">
<view class="action-btn copy" v-if="item.wechat" @tap="copyText(item.wechat)">复制微信</view>
<view class="action-btn primary" @tap="changeStatus(item, index)">更新状态</view>
</view>
</view>
<view class="load-more" v-if="list.length > 0">
<text v-if="isLoading">加载中…</text>
<text v-else-if="page < pageCount">上拉加载更多</text>
<text v-else class="load-end">— 没有更多了 —</text>
</view>
<ns-empty v-if="!isLoading && list.length === 0" isIndex="false"
:text="'暂无线索,访客在你的名片页留资后会出现在这里'"></ns-empty>
</view>
</view>
</template>
<script>
// 与后端 Lead::STATUSES 一致: 0=新线索 1=已联系 2=已成交 3=无效
const STATUS_MAP = [
{ value: 0, label: '新线索' },
{ value: 1, label: '已联系' },
{ value: 2, label: '已成交' },
{ value: 3, label: '无效' }
];
export default {
data() {
return {
tabs: [{ value: '', label: '全部' }].concat(STATUS_MAP),
status: '',
list: [],
page: 1,
pageSize: 20,
pageCount: 1,
isLoading: false
};
},
onLoad() {
this.refresh();
},
onPullDownRefresh() {
this.refresh(() => uni.stopPullDownRefresh());
},
onReachBottom() {
if (this.isLoading) return;
if (this.page >= this.pageCount) return;
this.loadPage(this.page + 1);
},
methods: {
switchTab(value) {
if (this.status === value) return;
this.status = value;
this.refresh();
},
refresh(done) {
this.page = 1;
this.loadPage(1, done);
},
loadPage(pageNo, done) {
this.isLoading = true;
const data = { page: pageNo, page_size: this.pageSize };
// 空字符串 = 全部, 后端对 '' 不加 status 条件
if (this.status !== '') {
data.status = this.status;
}
this.$api.sendRequest({
url: '/card/api/lead/lists',
data: data,
success: res => {
if (res.code == 0) {
const d = res.data || {};
const rows = d.list || [];
this.pageCount = d.page_count || 1;
this.page = pageNo;
this.list = pageNo === 1 ? rows : this.list.concat(rows);
} else {
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
}
this.isLoading = false;
typeof done === 'function' && done();
},
fail: () => {
this.isLoading = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
typeof done === 'function' && done();
}
});
},
changeStatus(item, index) {
uni.showActionSheet({
itemList: STATUS_MAP.map(s => s.label),
success: res => {
const target = STATUS_MAP[res.tapIndex];
if (!target || target.value === item.status) return;
this.doSetStatus(item, index, target.value);
}
});
},
doSetStatus(item, index, status) {
this.$api.sendRequest({
url: '/card/api/lead/setStatus',
data: { id: item.id, status: status },
success: res => {
if (res.code == 0) {
// 就地更新, 不整页刷新
this.list[index].status = status;
this.list[index].status_text = STATUS_MAP.find(s => s.value === status).label;
uni.showToast({ title: '已更新', icon: 'success' });
} else {
uni.showToast({ title: res.message || '更新失败', icon: 'none' });
}
},
fail: () => {
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
callPhone(mobile) {
if (!mobile) return;
uni.makePhoneCall({ phoneNumber: mobile });
},
copyText(text) {
uni.setClipboardData({
data: text,
success: () => uni.showToast({ title: '已复制', icon: 'success' })
});
}
}
};
</script>
<style lang="scss" scoped>
.lead-page {
min-height: 100vh;
background-color: #f6f7f9;
padding-bottom: 40rpx;
box-sizing: border-box;
}
.tab-bar {
background-color: #ffffff;
white-space: nowrap;
.tab-inner {
display: inline-flex;
padding: 0 12rpx;
}
.tab-item {
display: inline-block;
padding: 24rpx 28rpx;
font-size: 28rpx;
color: #666;
position: relative;
&.active {
color: var(--main-color, #0054a5);
font-weight: 600;
&::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 10rpx;
width: 40rpx;
height: 6rpx;
border-radius: 3rpx;
background-color: var(--main-color, #0054a5);
}
}
}
}
.lead-list {
padding: 20rpx 24rpx 0;
}
.lead-item {
background-color: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
.lead-top {
display: flex;
align-items: center;
.lead-name {
flex: 1;
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.lead-status {
font-size: 22rpx;
padding: 6rpx 18rpx;
border-radius: 22rpx;
&.st-0 {
color: var(--main-color, #0054a5);
background-color: var(--main-color-shallow, #e8f1fb);
}
&.st-1 {
color: #e6a23c;
background-color: #fdf3e6;
}
&.st-2 {
color: #1daa39;
background-color: #e8f8ea;
}
&.st-3 {
color: #999;
background-color: #f2f2f2;
}
}
}
.lead-mobile {
font-size: 28rpx;
color: #333;
margin-top: 14rpx;
}
.lead-note {
font-size: 26rpx;
color: #666;
margin-top: 12rpx;
line-height: 1.5;
}
.lead-meta {
display: flex;
justify-content: space-between;
font-size: 22rpx;
color: #999;
margin-top: 14rpx;
.meta-card {
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.lead-actions {
display: flex;
justify-content: flex-end;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f0f0f0;
.action-btn {
font-size: 24rpx;
border-radius: 30rpx;
padding: 10rpx 30rpx;
margin-left: 20rpx;
&.copy {
color: #666;
border: 1rpx solid #ddd;
}
&.primary {
color: #ffffff;
background-color: var(--main-color, #0054a5);
}
}
}
}
.load-more {
text-align: center;
color: #999;
font-size: 24rpx;
padding: 24rpx 0;
.load-end {
color: #ccc;
}
}
</style>

217
pages_tool/card/share.vue Normal file
View File

@@ -0,0 +1,217 @@
<template>
<view class="card-share">
<!-- #ifdef MP-WEIXIN || MP-HARMONY -->
<!-- 微信小程序/鸿蒙元服务:web-view 是原生组件,必须用 cover-view 盖在上面(cover-view 不支持 iconfont,用文本) -->
<cover-view class="mp-header">
<cover-view class="mp-status-bar" :style="{ height: statusBarHeight + 'px' }"></cover-view>
<cover-view class="mp-header-inner">
<cover-view class="nav-btn" @click="goBack">
<cover-view class="nav-back-text">‹</cover-view>
</cover-view>
<cover-view class="header-title">名片分享</cover-view>
<cover-view class="nav-btn nav-btn-placeholder"></cover-view>
</cover-view>
</cover-view>
<!-- web-view 是全屏原生组件,父容器 padding 对它无效,用等高占位 view 顶下去 -->
<view class="mp-spacer" :style="{ height: headerTotalHeightPx + 'px' }"></view>
<!-- #endif -->
<!-- 小程序/鸿蒙:web-view 加载 wap H5 名片页(客户视角,含留资表单) -->
<!-- #ifdef MP-WEIXIN || MP-HARMONY -->
<web-view v-if="cardUrl" :src="cardUrl"></web-view>
<view v-else class="empty-tip">链接无效</view>
<!-- #endif -->
<!-- #ifdef H5 -->
<!-- H5 直接就是浏览器,onLoad 时已 location 跳转,这里仅兜底展示 -->
<view class="h5-fallback">
<view class="h5-fallback-tip">正在打开名片…</view>
<button class="h5-fallback-btn" @tap="openUrl">如未自动跳转,点此打开</button>
</view>
<!-- #endif -->
<!-- #ifdef APP-PLUS -->
<web-view v-if="cardUrl" :src="cardUrl"></web-view>
<view v-else class="empty-tip">链接无效</view>
<!-- #endif -->
</view>
</template>
<script>
/**
* 名片分享中转页
*
* 背景: 小程序 onShareAppMessage 的 path 只能指向小程序页面, 不能直接分享 H5 链接,
* 而名片客户页(wap detail)是 H5。因此员工转发名片给微信好友时, 分享的是本页,
* 本页再用 web-view 把客户带到 wap 名片页(含留资表单)。
* H5 端分享就是普通 URL, 不需要中转, onLoad 直接跳转。
* 鸿蒙元服务(MP-HARMONY): web-view 同为原生全屏组件(参考 pages_tool/webview/webview.vue
* 的 cover-view 返回键方案), 与微信小程序共用同一套头部+占位+web-view 结构;
* 无 onShareAppMessage 生命周期, 鸿蒙端分享入口是名片详情页内「分享」按钮。
* 注意: web-view 加载的外链域名需在 manifest → HarmonyOS 网络配置里放行(元服务要求)。
*/
export default {
data() {
return {
cardUrl: '',
statusBarHeight: 20,
windowWidth: 375
};
},
computed: {
// #ifdef MP-WEIXIN || MP-HARMONY
headerTotalHeightPx() {
const rpxToPx = this.windowWidth / 750;
return this.statusBarHeight + Math.round(88 * rpxToPx);
}
// #endif
},
onLoad(options) {
this.cardUrl = options.url ? decodeURIComponent(options.url) : '';
// #ifdef MP-WEIXIN || MP-HARMONY
try {
const sys = uni.getSystemInfoSync();
this.statusBarHeight = sys.statusBarHeight || 20;
this.windowWidth = sys.windowWidth || 375;
} catch (e) {
// 鸿蒙个别版本 getSystemInfoSync 可能抛错, 用默认值兜底(头部高度略差不错位)
console.warn('[card/share] getSystemInfoSync fail, use fallback', e);
}
// #endif
// #ifdef H5
// H5 端无 web-view 中转必要,直接跳转到名片页
if (this.cardUrl) {
window.location.href = this.cardUrl;
}
// #endif
},
methods: {
goBack() {
const pages = getCurrentPages();
if (pages && pages.length > 1) {
uni.navigateBack();
} else {
// 栈底回首页必须走 $util.redirectTo: 鸿蒙元服务上 uni.switchTab 会静默失败
// (success/fail 都不触发, .catch 兜底失效), util 内部有
// switchTab → 500ms 强制 reLaunch 的兜底(见 util.js#redirectTo tabBar 分支)
this.$util.redirectTo('/pages/index/index');
}
},
// #ifdef H5
openUrl() {
if (this.cardUrl) window.location.href = this.cardUrl;
}
// #endif
}
};
</script>
<style lang="scss" scoped>
.card-share {
min-height: 100vh;
background-color: #f5f5f5;
}
.empty-tip {
display: flex;
align-items: center;
justify-content: center;
height: 60vh;
color: #999;
font-size: 28rpx;
}
/* #ifdef MP-WEIXIN || MP-HARMONY */
.mp-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
background-color: #ffffff;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.mp-status-bar {
width: 100%;
}
.mp-header-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 20rpx;
width: 100%;
box-sizing: border-box;
}
.nav-btn {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
.nav-back-text {
font-size: 48rpx;
color: #333;
line-height: 1;
}
}
.nav-btn-placeholder {
visibility: hidden;
}
.header-title {
flex: 1;
min-width: 0;
font-size: 32rpx;
font-weight: 600;
color: #333;
text-align: center;
margin: 0 16rpx;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.mp-spacer {
width: 100%;
}
/* #endif */
/* #ifdef H5 */
.h5-fallback {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 80vh;
.h5-fallback-tip {
font-size: 28rpx;
color: #999;
margin-bottom: 24rpx;
}
.h5-fallback-btn {
font-size: 28rpx;
color: #333;
border: 1rpx solid #dcdfe6;
border-radius: 36rpx;
padding: 0 40rpx;
height: 72rpx;
line-height: 72rpx;
background: #fff;
margin: 0;
&::after {
border: none;
}
}
}
/* #endif */
</style>