chore:名片样式更改

This commit is contained in:
2026-10-13 15:39:56 +08:00
parent f2619ae3b6
commit 389d87b82c
4 changed files with 342 additions and 147 deletions

View File

@@ -1477,7 +1477,9 @@
{
"path": "card/detail",
"style": {
"navigationBarTitleText": "名片详情"
"navigationBarTitleText": "我的名片",
"navigationBarBackgroundColor": "#2aa5ff",
"navigationBarTextStyle": "white"
}
},
{

View File

@@ -1,33 +1,89 @@
<template>
<view class="card-detail">
<!-- 名片头部 -->
<view class="detail-head" v-if="relation">
<view class="head-avatar">
<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>
<view class="head-sub" v-if="relation.job_title || relation.company">
{{ [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="page-layer">
<!-- 名片区: 蓝色背景 + 白卡。蓝色自导航栏下沿一路向下延伸到卡片内部后渐隐 -->
<view class="card-wrap" v-if="relation">
<!-- 蓝色背景层: 下沿锚定在卡片底部上方, 故卡片高度变化时蓝色下沿跟着走 -->
<view class="card-wrap-bg"></view>
<!-- 字段列表 -->
<view class="detail-fields" v-if="fieldRows.length > 0">
<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" :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 }}
<!-- 名片卡: 对齐设计稿 —— 姓名/职位/公司与全部联系方式都收在同一张白卡内, 不再拆成上下两块 -->
<view class="detail-card">
<view class="dc-name">{{ relation.name || '未命名名片' }}</view>
<view class="dc-job" v-if="relation.job_title">{{ relation.job_title }}</view>
<view class="dc-company" v-if="relation.company">{{ relation.company }}</view>
<view class="dc-rows" v-if="fieldRows.length > 0">
<view v-for="(row, index) in fieldRows" :key="index" class="field-row"
:class="{ 'field-row-multi': row.multiline }" @tap="onRowTap(row)">
<text v-if="row.icon" class="field-icon iconfont" :class="row.icon"></text>
<!-- 没有对应图标字形的字段(邮箱/网站/社交/自定义)退化为文字标签, 保持与图标列同宽 -->
<text v-else class="field-tag">{{ row.label }}</text>
<view class="field-value" :class="{ 'field-value-multi': row.multiline }">{{ row.value }}</view>
</view>
</view>
</view>
</view>
<!-- 卡下操作条(参考图: 三项等分, 项与项之间不加分隔线) -->
<view class="card-actions" v-if="relation">
<view class="ca-item" @tap="goEdit">
<text class="ca-icon iconfont icon-bianji"></text>
<text>编辑资料</text>
</view>
<view class="ca-item" @tap="openShare">
<text class="ca-icon iconfont icon-erweima"></text>
<text>分享二维码</text>
</view>
<view class="ca-item" @tap="goList">
<text class="ca-icon iconfont icon-shuaxin"></text>
<text>切换名片</text>
</view>
</view>
<!-- 发名片: 微信小程序端必须用原生 <button open-type="share"> ——
微信未开放「主动唤起转发面板」的 API, 普通 view 的 @tap 只能弹提示,
原生 button 点一下即拉起「发送给朋友」选择器, 内容取本页 onShareAppMessage。
其它端无等价能力, 退化为打开分享面板(复制链接 / 浏览器分享)。 -->
<view class="send-bar" v-if="relation">
<!-- #ifdef MP-WEIXIN -->
<!-- 不加 :plain —— plain 会强制背景透明, 主按钮需要渐变底, 故用 CSS 完整重置原生样式 -->
<button class="send-btn" open-type="share">发名片</button>
<!-- #endif -->
<!-- #ifdef H5 || APP-PLUS || MP-HARMONY -->
<view class="send-btn" @tap="openShare">发名片</view>
<!-- #endif -->
</view>
<!-- 功能入口宫格(参考图: 发名片下方五等分图标入口) -->
<view class="entry-grid" v-if="relation">
<view class="eg-item" @tap="goList">
<text class="eg-icon iconfont icon-list"></text>
<text class="eg-text">名片夹</text>
</view>
<view class="eg-item" @tap="goLeads">
<text class="eg-icon iconfont icon-yonghu3"></text>
<text class="eg-text">潜在客户</text>
</view>
<view class="eg-item" @tap="goContacts">
<text class="eg-icon iconfont icon-pengyouquan"></text>
<text class="eg-text">我的人脉</text>
</view>
<view class="eg-item" @tap="openShare">
<text class="eg-icon iconfont icon-erweima"></text>
<text class="eg-text">名片二维码</text>
</view>
<view class="eg-item" @tap="goEdit">
<text class="eg-icon iconfont icon-bianji"></text>
<text class="eg-text">编辑名片</text>
</view>
</view>
<!-- 加载中 -->
<view class="detail-loading" v-if="isLoading">加载中…</view>
<!-- 不存在 / 无权查看 -->
<ns-empty v-if="!isLoading && !relation" :isIndex="false"
:text="errorMsg || '卡片不存在或已被删除'"></ns-empty>
</view>
<!-- 分享面板(微信式底部抽屉) -->
@@ -44,7 +100,9 @@
<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>
<!-- 发给好友: 必须用原生 button(open-type=share) 才能直接拉起转发面板;
纯 @tap 的 view 调不起该面板, 只能弹一句「去点右上角」的提示 -->
<button class="ss-btn" :plain="true" open-type="share">发给好友</button>
<!-- #endif -->
<!-- #ifdef H5 -->
<view class="ss-btn" @tap="h5Share">浏览器分享</view>
@@ -62,12 +120,6 @@
</view>
</view>
<!-- 加载中 -->
<view class="detail-loading" v-if="isLoading">加载中…</view>
<!-- 不存在 / 无权查看 -->
<ns-empty v-if="!isLoading && !relation" :isIndex="false"
:text="errorMsg || '卡片不存在或已被删除'"></ns-empty>
</view>
</template>
@@ -92,25 +144,26 @@ export default {
if (!r) return [];
const rows = [];
const push = (label, value, action) => {
// icon: 图标字形见 common/css/iconfont.css; 邮箱/网站无对应 glyph, 留空 → 模板退化为文字标签
const push = (label, value, action, icon) => {
if (value !== undefined && value !== null && String(value).trim() !== '') {
rows.push({ label, value: String(value), action: action || null });
rows.push({ label, value: String(value), action: action || null, icon: icon || '' });
}
};
push('手机', r.mobile, { type: 'call', text: '拨打' });
push('电话', r.phone, { type: 'call', text: '拨打' });
push('手机', r.mobile, { type: 'call', text: '拨打' }, 'icon-dianhua');
push('电话', r.phone, { type: 'call', text: '拨打' }, 'icon-dianhua');
push('邮箱', r.email, { type: 'copy', text: '复制' });
push('微信', r.wechat, { type: 'copy', text: '复制' });
push('微信', r.wechat, { type: 'copy', text: '复制' }, 'icon-weixin');
push('网站', r.website);
push('地址', [r.country, r.province, r.city, r.address].filter(Boolean).join(' '));
push('地址', [r.country, r.province, r.city, r.address].filter(Boolean).join(' '), null, 'icon-dizhi');
push('邮编', r.postal_code);
// Telegram / Messenger 已于 2026-10-13 软下线为独立标量:
// 旧值由后端自动合并进 socials_list, 此处不再单独渲染(避免重复行)
// 附加手机号(mobiles_list, 后端 CardRichFields 解码) → 可拨打
(r.mobiles_list || []).forEach((m, i) => {
push('手机' + (i + 2), m, { type: 'call', text: '拨打' });
push('手机' + (i + 2), m, { type: 'call', text: '拨打' }, 'icon-dianhua');
});
// 社交账号(socials_list: {type,label,value}) → label 作行名, 值可复制
@@ -146,6 +199,9 @@ export default {
// 小程序端转发: 分享 web-view 中转页(其内部加载 wap H5 名片页),
// 因为 onShareAppMessage 的 path 只能指向小程序页面, 不能直接分享 H5 链接
onShareAppMessage() {
// 右上角「···」转发与原生转发按钮都会直接走这里, 不经过 openShare;
// 兜底生成一次链接, 否则 path 里的 url 为空 → 中转页只会显示「链接无效」
this.buildShareInfo();
const name = (this.relation && this.relation.name) || '我的名片';
return {
title: name + '的名片',
@@ -160,8 +216,27 @@ export default {
url: '/pages_tool/card/edit?card_id=' + encodeURIComponent(this.cardId)
});
},
avatarText(name) {
return (name || '?').trim().charAt(0).toUpperCase();
// 切换名片: 回名片夹选另一张
goList() {
this.$util.redirectTo('/pages_tool/card/list');
},
goLeads() {
this.$util.redirectTo('/pages_tool/card/leads');
},
goContacts() {
this.$util.redirectTo('/pages_tool/card/contacts');
},
/**
* 资料行整行可点: 电话类拨打, 其余复制
* (设计稿行内没有独立的「拨打/复制」按钮, 交互收到整行上)
*/
onRowTap(row) {
if (!row || !row.action) return;
if (row.action.type === 'call') {
this.callPhone(row.value);
return;
}
this.copyText2(row.value);
},
loadDetail() {
if (!this.cardId) {
@@ -179,6 +254,8 @@ export default {
this.relation = data.relation || null;
this.source = data.source || null;
this.shareList = data.share_list || [];
// 详情一到手就算出分享链接/二维码: 用户可能直接点「发名片」或右上角转发
this.buildShareInfo();
} else {
// 后端对"不存在"与"非本人名下"同一报错口径,不区分展示
this.errorMsg = res.message || '卡片不存在或已被删除';
@@ -209,7 +286,12 @@ export default {
siteId() {
return uni.getStorageSync('uniacid') || this.$config.uniacid || 0;
},
openShare() {
/**
* 生成分享链接与二维码(幂等, 可重复调用)
* ⚠️ 必须在 onShareAppMessage 触发前就绪: 微信右上角「···」转发与本页原生转发按钮
* 都直接读 shareUrl, 不经过 openShare(), 空值会让转出去的名片打不开。
*/
buildShareInfo() {
if (!this.relation) return;
const sid = this.siteId();
const rid = this.relation.id;
@@ -218,6 +300,10 @@ export default {
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;
},
openShare() {
if (!this.relation) return;
this.buildShareInfo();
this.showShare = true;
},
closeShare() {
@@ -251,10 +337,6 @@ export default {
fail: () => uni.showToast({ title: '二维码下载失败', icon: 'none' })
});
},
shareToFriend() {
// 小程序右上角菜单转发(onshareappmessage 已配置)或提示用户点击右上角
uni.showToast({ title: '请点击右上角「···」转发给好友', icon: 'none' });
},
h5Share() {
// H5: 优先 navigator.share(移动浏览器), 不支持则复制链接兜底
if (navigator && typeof navigator.share === 'function') {
@@ -273,71 +355,166 @@ export default {
<style lang="scss" scoped>
.card-detail {
min-height: 100vh;
background-color: #f6f7f9;
padding-bottom: 60rpx;
// 参考图: 蓝色渐隐到白底(非灰底)
background-color: #ffffff;
padding: 0 0 60rpx;
box-sizing: border-box;
}
.detail-head {
// 内容层
.page-layer {
position: relative;
}
// 名片区: 高度 = 上留白 + 卡片高度, 蓝色背景层锚在其中
.card-wrap {
position: relative;
padding-top: 40rpx;
}
// 蓝色背景层: 顶部与导航栏 #2aa5ff 无缝衔接, 下沿长距离渐隐进白底
// bottom: 0 —— 蓝色一直铺到卡片下沿, 靠渐变尾巴收掉, 与参考图一致
.card-wrap-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
background: linear-gradient(180deg, #2aa5ff 0%, #0d7bf0 52%, rgba(13, 123, 240, 0) 100%);
pointer-events: none;
}
// 名片卡: 设计稿式白卡 —— 姓名/职位/公司与全部联系方式同卡
// position/z-index: 保证白卡压在蓝色背景层之上
.detail-card {
position: relative;
z-index: 1;
margin: 0 24rpx;
background-color: #ffffff;
border-radius: 20rpx;
padding: 40rpx 36rpx 32rpx;
box-shadow: 0 6rpx 20rpx rgba(0, 30, 80, 0.06);
.dc-name {
font-size: 44rpx;
font-weight: 700;
color: #1a1a1a;
line-height: 1.3;
}
.dc-job {
margin-top: 6rpx;
font-size: 28rpx;
line-height: 1.3;
color: #4a4a4a;
}
.dc-company {
margin-top: 2rpx;
font-size: 26rpx;
line-height: 1.3;
color: #9aa4b2;
}
.dc-rows {
// 参考图: 公司名与资料行之间留一大段空白
margin-top: 56rpx;
}
// 资料行: 全局 view{line-height:1.8} 会把每行撑高, 这里显式收紧到参考图行距
.field-row {
display: flex;
align-items: center;
line-height: 1.2;
padding: 12rpx 0;
&.field-row-multi {
align-items: flex-start;
}
.field-icon {
width: 40rpx;
margin-right: 18rpx;
font-size: 32rpx;
line-height: 1;
color: #8a94a6;
text-align: center;
flex-shrink: 0;
}
.field-tag {
min-width: 40rpx;
margin-right: 18rpx;
font-size: 26rpx;
line-height: 1.2;
color: #9aa4b2;
flex-shrink: 0;
}
.field-value {
flex: 1;
min-width: 0;
font-size: 28rpx;
line-height: 1.25;
color: #333333;
word-break: break-all;
}
.field-value-multi {
line-height: 1.4;
white-space: pre-wrap;
}
}
}
// 卡下操作条: 三项等分, 无白底无边框, 项与项之间不加分隔线(对齐参考图)
.card-actions {
display: flex;
align-items: center;
padding: 48rpx 32rpx;
background: linear-gradient(135deg, var(--main-color, #0054a5), var(--main-color-shallow, #3a7bc8));
color: #ffffff;
margin: 28rpx 24rpx 0;
.head-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.25);
font-size: 52rpx;
font-weight: 600;
.ca-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 28rpx;
overflow: hidden;
font-size: 26rpx;
line-height: 1.2;
color: #333333;
.head-img {
width: 100%;
height: 100%;
.ca-icon {
margin-right: 8rpx;
font-size: 30rpx;
color: #8a94a6;
}
}
}
.head-info {
// 发名片下方的功能入口宫格: 五等分, 图标在上、文字在下
.entry-grid {
display: flex;
align-items: flex-start;
margin: 40rpx 24rpx 0;
.eg-item {
flex: 1;
min-width: 0;
.head-name {
font-size: 40rpx;
font-weight: 600;
}
.head-sub {
font-size: 26rpx;
opacity: 0.85;
margin-top: 12rpx;
}
}
.head-actions {
flex-shrink: 0;
margin-left: 20rpx;
display: flex;
flex-direction: column;
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;
.eg-icon {
font-size: 52rpx;
line-height: 1;
color: #2aa5ff;
}
& + .head-btn {
margin-left: 12rpx;
}
.eg-text {
margin-top: 14rpx;
font-size: 22rpx;
line-height: 1.2;
color: #7b8794;
}
}
@@ -434,6 +611,15 @@ export default {
color: #333;
border: 1rpx solid #dcdfe6;
border-radius: 12rpx;
// 「发给好友」在小程序端是原生 <button>: 清掉其默认 margin/padding/背景与 ::after 边框
margin: 0;
padding: 0;
background-color: transparent;
overflow: visible;
&::after {
border: none;
}
& + .ss-btn {
margin-left: 20rpx;
@@ -455,60 +641,40 @@ export default {
}
}
.detail-fields {
margin: 24rpx;
background-color: #ffffff;
border-radius: 16rpx;
overflow: hidden;
.field-row {
display: flex;
align-items: center;
padding: 28rpx 24rpx;
border-bottom: 1rpx solid #f0f0f0;
&.field-row-multi {
align-items: flex-start;
}
// 「发名片」主按钮: 全宽胶囊, 与名片卡左右对齐(24rpx)
.send-bar {
padding: 0 24rpx;
margin-top: 44rpx;
&:last-child {
border-bottom: none;
}
.send-btn {
display: block;
width: 100%;
height: 96rpx;
line-height: 96rpx;
text-align: center;
font-size: 34rpx;
font-weight: 600;
color: #ffffff;
// 设计稿: 亮蓝渐变胶囊
background: linear-gradient(135deg, #2aa5ff 0%, #0d7bf0 100%);
box-shadow: 0 10rpx 24rpx rgba(13, 123, 240, 0.35);
border-radius: 48rpx;
// 小程序原生 <button> 自带 margin/padding/背景/::after 边框, 需清掉才能当普通块用
margin: 0;
padding: 0;
border: none;
.field-label {
width: 140rpx;
font-size: 28rpx;
color: #999;
flex-shrink: 0;
}
.field-value {
flex: 1;
font-size: 28rpx;
color: #333;
word-break: break-all;
}
.field-value-multi {
line-height: 1.6;
white-space: pre-wrap;
}
.field-action {
margin-left: 20rpx;
flex-shrink: 0;
font-size: 24rpx;
color: var(--main-color, #0054a5);
border: 1rpx solid var(--main-color, #0054a5);
border-radius: 28rpx;
padding: 6rpx 24rpx;
&::after {
border: none;
}
}
}
.detail-loading {
text-align: center;
color: #999;
color: #999999;
font-size: 26rpx;
padding: 120rpx 0;
}

View File

@@ -363,10 +363,13 @@ export default {
}));
// 新建模式走 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) {

View File

@@ -280,7 +280,8 @@ test('端到端: 三端分享面板按钮矩阵(静态推导)', () => {
assert.ok(/@tap="copyLink"/.test(src), '复制链接应三端可用');
// 保存二维码: MP-WEIXIN + MP-HARMONY
// 浏览器分享: H5
// 发给好友: MP-WEIXIN(鸿蒙无转发菜单, 用复制链接代替)
// 发给好友: MP-WEIXIN, 且必须是原生 button(open-type=share) —— 普通 view 的 @tap
// 调不起转发面板(微信未开放该 API), 只能弹「请点击右上角···」的死胡同提示
const h5Btns = [];
const wxBtns = [];
const harmonyBtns = [];
@@ -289,23 +290,46 @@ test('端到端: 三端分享面板按钮矩阵(静态推导)', () => {
src.split('\n').forEach((line, i) => {
const b = blocks.find(b => i >= b.start && i <= b.end);
const cond = b ? b.cond : null;
if (!/ss-btn/.test(line)) return;
// 原生转发按钮没有 @tap, 统一记成 nativeShare 便于断言
const tm = line.match(/@tap="(\w+)"/);
if (!tm || !/ss-btn/.test(line)) return;
if (!cond) { h5Btns.push(tm[1]); wxBtns.push(tm[1]); harmonyBtns.push(tm[1]); return; }
if (condHits(cond, 'MP-WEIXIN')) wxBtns.push(tm[1]);
if (condHits(cond, 'MP-HARMONY')) harmonyBtns.push(tm[1]);
if (condHits(cond, 'H5')) h5Btns.push(tm[1]);
const token = tm ? tm[1] : (/open-type="share"/.test(line) ? 'nativeShare' : null);
if (!token) return;
if (!cond) { h5Btns.push(token); wxBtns.push(token); harmonyBtns.push(token); return; }
if (condHits(cond, 'MP-WEIXIN')) wxBtns.push(token);
if (condHits(cond, 'MP-HARMONY')) harmonyBtns.push(token);
if (condHits(cond, 'H5')) h5Btns.push(token);
});
assert.ok(wxBtns.includes('copyLink') && wxBtns.includes('saveQr') && wxBtns.includes('shareToFriend'),
`微信按钮矩阵: ${JSON.stringify(wxBtns)}`);
assert.ok(wxBtns.includes('copyLink') && wxBtns.includes('saveQr') && wxBtns.includes('nativeShare'),
`微信按钮矩阵(发给好友须为 nativeShare): ${JSON.stringify(wxBtns)}`);
assert.ok(!wxBtns.includes('shareToFriend'),
'「发给好友」不能退回 @tap 弹提示的死胡同, 必须原生 button 直接拉起转发面板');
assert.ok(harmonyBtns.includes('copyLink') && harmonyBtns.includes('saveQr'),
`鸿蒙按钮矩阵(无发给好友): ${JSON.stringify(harmonyBtns)}`);
assert.ok(!harmonyBtns.includes('shareToFriend'), '鸿蒙不应出现「发给好友」(无转发菜单)');
`鸿蒙按钮矩阵: ${JSON.stringify(harmonyBtns)}`);
assert.ok(!harmonyBtns.includes('nativeShare'), '鸿蒙元服务无转发菜单, 不应出现原生转发按钮');
assert.ok(h5Btns.includes('copyLink') && h5Btns.includes('h5Share'),
`H5 按钮矩阵: ${JSON.stringify(h5Btns)}`);
assert.ok(!h5Btns.includes('saveQr'), 'H5 不应出现「保存二维码」(无 saveImageToPhotosAlbum)');
});
// =============================================================
// 9.1 「发名片」主按钮: 微信端一键转发给好友
// =============================================================
test('发名片: 微信端原生 button 一键转发 + 分享链接预先就绪', () => {
const src = read('detail.vue');
const btnLine = src.split('\n').find(l => /send-btn/.test(l) && /open-type="share"/.test(l));
assert.ok(btnLine, 'detail.vue 应有「发名片」按钮(open-type="share")');
assert.ok(/<button/.test(btnLine),
'「发名片」必须是原生 <button> —— 普通 view 无法调起「发送给朋友」面板');
// 分享链接必须在转发前就绪: 右上角「···」转发与原生按钮都不经过 openShare()
assert.ok(/buildShareInfo\s*\(\)/.test(src), 'shareUrl/qrUrl 应由 buildShareInfo 统一生成');
const osm = src.match(/onShareAppMessage\(\)\s*\{([\s\S]*?)\n\t\}/);
assert.ok(osm, 'detail.vue 应声明 onShareAppMessage');
assert.ok(/buildShareInfo/.test(osm[1]), 'onShareAppMessage 内需兜底生成分享链接(否则中转页拿到空 url)');
assert.ok(!/请点击右上角/.test(src), '不应保留「请点击右上角···」的提示式分享');
});
// ============================================================
// 1.8.0 后台「选择链接」注册: PAGE_KEY_MAP ↔ pages.json 对齐
//