Files
lucky_shop/pages_tool/card/edit.vue
ZF sun 9e6dc8665f 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.
2026-10-12 14:32:20 +08:00

571 lines
16 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>
<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>