586 lines
17 KiB
Vue
586 lines
17 KiB
Vue
<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"><text class="req-star">*</text>姓名</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>
|
||
<!-- Telegram / Messenger 已于 2026-10-13 软下线为独立字段, 统一在下方「社交媒体账号」添加 -->
|
||
</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">还没有社交媒体账号;Telegram / Messenger 也已并入此处添加</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)。
|
||
* ⚠️ 2026-10-13: telegram / messenger 软下线为独立标量(传了也被后端忽略),
|
||
* 统一走下方社交媒体动态行; 旧数据读取时已由后端合并进 socials_list。
|
||
* 富化字段(mobiles/socials/custom_fields)由页面动态行单独收集,
|
||
* 后端 Card::update 的 richFieldSaves 仅对显式携带的键做 CardRichFields 清洗。
|
||
*
|
||
* SOCIAL_TYPES / CARD_FIELDS / RICH_FIELD_LIMITS 统一从收口层导入,
|
||
* 与后端白名单单点同步(见 common/js/card-api.js)。
|
||
*/
|
||
import { SOCIAL_TYPES, CARD_FIELDS, RICH_FIELD_LIMITS } from '@/common/js/card-api.js';
|
||
|
||
// 注:telegram/messenger 已软下线(2026-10-13), 不再作为独立标量字段,
|
||
// 统一在「社交媒体账号」动态行里以 SOCIAL_TYPES 中的类型方式添加。
|
||
|
||
// 上限与后端 CardRichFields::MAX_* 一致
|
||
const MAX_MOBILES = RICH_FIELD_LIMITS.MAX_MOBILES;
|
||
const MAX_SOCIALS = RICH_FIELD_LIMITS.MAX_SOCIALS;
|
||
const MAX_CUSTOM = RICH_FIELD_LIMITS.MAX_CUSTOM;
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
cardId: '',
|
||
// 新建模式: 无 card_id 进入, 提交 /card/api/card/create
|
||
isCreate: false,
|
||
form: {
|
||
name: '', company: '', job_title: '', avatar: '',
|
||
mobile: '', phone: '', email: '', website: '', wechat: '',
|
||
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.isCreate = this.cardId === '';
|
||
if (this.isCreate) {
|
||
uni.setNavigationBarTitle({ title: '名片' });
|
||
} else {
|
||
this.loadDetail();
|
||
}
|
||
},
|
||
onReady() {
|
||
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||
uni.setNavigationBarTitle({ title: '名片' });
|
||
// #ifdef H5
|
||
document.title = '名片';
|
||
// #endif
|
||
},
|
||
methods: {
|
||
loadDetail() {
|
||
if (!this.cardId) {
|
||
uni.showToast({ title: '缺少名片编号', icon: 'none' });
|
||
return;
|
||
}
|
||
this.isLoading = true;
|
||
this.$cardApi.cardDetail({
|
||
data: { card_id: this.cardId },
|
||
success: res => {
|
||
this.isLoading = false;
|
||
if (res.code == 0) {
|
||
const r = (res.data && res.data.relation) || {};
|
||
CARD_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 = {};
|
||
CARD_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()
|
||
}));
|
||
|
||
// 新建模式走 card/create, 编辑模式走 card/update(均经 $cardApi 收口层)
|
||
// ⚠️ 必须包在 data 里: sendJson 只合并 params.data(见 common/js/http.js),
|
||
// 顶层直接挂 fields 会被丢弃 → 后端收不到 name, 报「请填写姓名」
|
||
const payload = this.isCreate
|
||
? { fields: fields }
|
||
: { card_id: this.cardId, fields: fields };
|
||
(this.isCreate ? this.$cardApi.cardCreate : this.$cardApi.cardUpdate)({
|
||
data: payload,
|
||
success: res => {
|
||
this.saving = false;
|
||
if (res.code == 0) {
|
||
uni.showToast({ title: this.isCreate ? '创建成功' : '保存成功', icon: 'success' });
|
||
if (this.isCreate) {
|
||
// 新建成功 → 跳详情页(redirectTo 替换当前编辑页, 返回栈回到列表)
|
||
const newCardId = (res.data && res.data.card_id) || '';
|
||
setTimeout(() => {
|
||
if (newCardId) {
|
||
uni.redirectTo({
|
||
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(newCardId)
|
||
});
|
||
} else {
|
||
uni.navigateBack();
|
||
}
|
||
}, 600);
|
||
} else {
|
||
// 返回详情页; 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;
|
||
}
|
||
|
||
/* 必填红星 */
|
||
.req-star {
|
||
color: #f5222d;
|
||
margin-right: 4rpx;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.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>
|