Files
lucky_shop/pages_tool/card/exchange.vue
2026-10-19 16:44:02 +08:00

404 lines
11 KiB
Vue

<template>
<view class="ex-page">
<!-- 状态筛选: 全部 / 待处理(N) / 已同意 -->
<view class="ex-tabs">
<view v-for="tab in tabs" :key="tab.value" class="ex-tab"
:class="{ active: current === tab.value }" @tap="switchTab(tab.value)">
{{ tab.label }}
</view>
</view>
<!-- 交换请求列表 -->
<view class="ex-list">
<view v-for="(item, index) in shownList" :key="item.id" class="ex-item">
<view class="ex-top">
<view class="ex-avatar-wrap" :data-index="index" @tap.stop="viewCard">
<image v-if="item.avatar" class="ex-avatar" :src="item.avatar" mode="aspectFill" />
<view v-else class="ex-avatar ex-avatar-ph">{{ item.name.charAt(0) }}</view>
</view>
<view class="ex-info">
<view class="ex-name-row">
<text class="ex-name">{{ item.name }}</text>
<text class="ex-badge iconfont icon-dui"></text>
</view>
<text class="ex-job">{{ item.job_title }}</text>
<view class="ex-company-row">
<text class="ex-company">{{ item.company }}</text>
</view>
</view>
<view v-if="item.status === 'pending'" class="ex-agree" :data-index="index" @tap.stop="onAgree">同意</view>
<text v-else class="ex-agreed">已同意</text>
</view>
<view class="ex-reason">对方发起了名片交换请求</view>
<view class="ex-time">{{ item.create_time_txt }} 向您发起名片交换</view>
</view>
<ns-empty v-if="shownList.length === 0" :isIndex="false" :text="emptyText"></ns-empty>
</view>
</view>
</template>
<script>
// 名片交换请求列表
// ⚠️ 目前为静态占位数据: 后端 /card/api/exchange/* 接口就绪后,
// 改为在 onLoad 里用 this.$cardApi.exchangeLists({ data: { page: 1 }, ... }) 拉取,
// 「同意」改成 cardApi.exchangeAgree({ data: { id: item.id } })。
export default {
data() {
return {
current: 'pending',
cardId: '',
list: [],
page: 1,
pageCount: 1,
loading: false
};
},
onLoad(options) {
this.cardId = decodeURIComponent(options.card_id || '');
this.loadList();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
computed: {
tabs() {
const pending = this.list.filter(i => i.status === 'pending').length;
return [
{ value: 'all', label: '全部' },
{ value: 'pending', label: '待处理(' + pending + ')' },
{ value: 'agreed', label: '已同意' }
];
},
shownList() {
if (this.current === 'all') return this.list;
return this.list.filter(item => item.status === this.current);
},
emptyText() {
return this.current === 'agreed' ? '暂无已同意的交换' : '暂无名片交换请求';
}
},
methods: {
switchTab(value) {
if (this.current === value) return;
this.current = value;
},
// 待处理交换请求(后端只返回 status=0); 归一化 status 供模板区分
loadList() {
this.loading = true;
this.$cardApi.exchangeLists({
data: { page: this.page, page_size: 20 },
success: res => {
this.loading = false;
if (res.code == 0 && res.data) {
this.list = (res.data.list || []).map(it => ({
id: it.id,
from_card_id: it.from_card_id,
name: it.name,
avatar: it.avatar,
job_title: it.job_title,
company: it.company,
create_time_txt: it.create_time_txt,
status: it.status === 0 ? 'pending' : 'agreed'
}));
this.pageCount = res.data.page_count || 1;
} else if (res.message) {
uni.showToast({ title: res.message, icon: 'none' });
}
},
fail: () => {
this.loading = false;
}
});
},
/**
* 取事件对应的行数据。小程序端 v-for 内联传对象(viewCard(item))会丢参,
* 统一改为 :data-index + 不传参, 这里从 dataset 取索引回表。
*/
resolveItem(payload) {
if (payload == null) return null;
if (typeof payload === 'number') return this.shownList[payload] || null;
const ds = payload.currentTarget && payload.currentTarget.dataset;
if (ds) {
const i = Number(ds.index);
if (!isNaN(i) && this.shownList[i]) return this.shownList[i];
}
return payload.id ? payload : null;
},
// 点对方名片 → 跳转对方名片详情(用 from_card_id 字符串)
viewCard(payload) {
const item = this.resolveItem(payload);
if (!item || !item.from_card_id) return;
uni.navigateTo({
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(item.from_card_id)
});
},
/**
* 契约 §4(2026-10-15): 同意后跳对方公开名片页。
* wap_url 原样使用, 仅追加 &back_url=encodeURIComponent(我方名片页完整地址);
* wap_url 为空则不跳转(对方刚删卡等, 人脉已互建)。
*/
jumpToOtherPublic(wapUrl) {
if (!wapUrl) return;
const url = this.withBackUrl(wapUrl);
// #ifdef H5
window.location.href = url;
// #endif
// #ifndef H5
uni.navigateTo({
url: '/pages_tool/card/share?url=' + encodeURIComponent(url)
});
// #endif
},
/**
* 给公开页地址追加 back_url=本页完整地址(契约 §4 前端唯一允许的改动)。
* 对方名片页(PHP wap 模板)左上角返回键据此跳回本页; 已带 back_url 的原样返回, 不重复追加。
*/
withBackUrl(wapUrl) {
if (!wapUrl) return '';
if (wapUrl.indexOf('back_url=') >= 0) return wapUrl;
const back = this.myPageFullUrl();
if (!back) return wapUrl;
return wapUrl + (wapUrl.indexOf('?') >= 0 ? '&' : '?') + 'back_url=' + encodeURIComponent(back);
},
// H5: 当前页面完整地址(禁止写死域名); 非 H5 无等价 H5 地址返回 '' 由 share.vue 中转页原生返回
myPageFullUrl() {
// #ifdef H5
return window.location.href;
// #endif
// #ifndef H5
return '';
// #endif
},
onAgree(payload) {
const item = this.resolveItem(payload);
if (!item) return;
this.$cardApi.exchangeAgree({
data: { id: item.id },
success: res => {
if (res.code == 0) {
// receiver_created=0 也是正常成功(同人脉已存在被跳过), 不当失败
const d = res.data || {};
const txt = d.receiver_created === 0 ? '已交换(人脉已存在)' : '已交换,人脉已互加';
uni.showToast({ title: txt, icon: 'none' });
// 需求: 点「同意」后对方名片必须出现在我的人脉中。
// 后端虽声明 agree 双向建人脉, 实测出现「同意成功、人脉里却没有对方」,
// 故不再用 receiver_created 判断, 一律在同意后校验/补建(幂等, 不会重复);
// 实现见 card/detail 页同名方法(取对方名片详情 → 按手机号查我的人脉 → 没有才新建)。
this.ensureExchangeContact(item.from_card_id, ensureRes => {
// 命中同手机号人脉但姓名不同(多为对方改过名片名): 提示实际名称,
// 避免「交换后对方没出现」的误判(该联系人其实已在人脉里, 只是显示旧名)
if (ensureRes && ensureRes.existed && ensureRes.existed.name
&& item.name && ensureRes.existed.name !== item.name) {
uni.showToast({
title: '人脉中已有同手机号联系人「' + ensureRes.existed.name + '」',
icon: 'none',
duration: 2500
});
}
// 契约 §4(2026-10-15): 同意后用响应里的 wap_url 跳对方公开名片页,
// 仅追加 &back_url=; wap_url 为空则退回刷新列表(对方刚删卡等)
if (d.wap_url) {
this.jumpToOtherPublic(d.wap_url);
} else {
this.loadList();
}
});
} else if (res.message) {
uni.showToast({ title: res.message, icon: 'none' });
}
},
fail: () => {}
});
},
/**
* 同意交换后校验并兜底建人脉: 保证对方名片出现在我的人脉中。
* 走 card-api 的 ensureExchangeContact(取对方名片详情 → 按手机号去重 → 没有才 source=exchange 新建), 幂等。
* 无论成败都执行 done(res) —— 兜底失败不阻塞同意后的跳转(交换本身已成功), 人脉页仍可手动补录。
*/
ensureExchangeContact(fromCardId, done) {
if (!fromCardId) { typeof done === 'function' && done(null); return; }
this.$cardApi.ensureExchangeContact({
fromCardId: fromCardId,
success: res => {
if (res && res.created) {
console.log('[card/exchange] 兜底新建人脉成功:', fromCardId);
}
typeof done === 'function' && done(res);
},
fail: err => {
console.warn('[card/exchange] 兜底建人脉失败(不影响交换结果):', err);
typeof done === 'function' && done(null);
}
});
},
onIgnore(item) {
this.$cardApi.exchangeIgnore({
data: { id: item.id },
success: res => {
if (res.code == 0) {
uni.showToast({ title: '已忽略', icon: 'none' });
this.loadList();
} else if (res.message) {
uni.showToast({ title: res.message, icon: 'none' });
}
},
fail: () => {}
});
}
}
};
</script>
<style lang="scss" scoped>
.ex-page {
min-height: 100vh;
background-color: #f6f7f9;
padding-bottom: 40rpx;
box-sizing: border-box;
}
// 筛选胶囊: 选中为浅蓝底蓝字, 未选中白底灰字
.ex-tabs {
display: flex;
padding: 20rpx 24rpx;
.ex-tab {
padding: 12rpx 28rpx;
margin-right: 16rpx;
border-radius: 32rpx;
background-color: #ffffff;
font-size: 26rpx;
color: #666666;
&.active {
background-color: #e8f3ff;
color: #2aa5ff;
font-weight: 600;
}
}
}
.ex-list {
padding: 0 24rpx;
}
.ex-item {
background-color: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.ex-top {
display: flex;
align-items: center;
}
.ex-avatar-wrap {
position: relative;
width: 100rpx;
height: 100rpx;
flex-shrink: 0;
}
.ex-avatar {
width: 100%;
height: 100%;
border-radius: 16rpx;
background-color: #2aa5ff;
}
.ex-avatar-ph {
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 40rpx;
font-weight: 600;
}
.ex-info {
flex: 1;
min-width: 0;
margin: 0 16rpx 0 20rpx;
}
.ex-name-row {
display: flex;
align-items: center;
}
.ex-name {
font-size: 30rpx;
font-weight: 600;
color: #333333;
}
.ex-badge {
margin-left: 6rpx;
font-size: 26rpx;
color: #2aa5ff;
}
.ex-job {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: #7b8794;
}
.ex-company-row {
display: flex;
align-items: center;
margin-top: 10rpx;
}
.ex-company {
flex: 1;
min-width: 0;
font-size: 26rpx;
color: #333333;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ex-agree {
flex-shrink: 0;
padding: 12rpx 34rpx;
border-radius: 34rpx;
background-color: #2aa5ff;
color: #ffffff;
font-size: 26rpx;
}
.ex-agreed {
flex-shrink: 0;
font-size: 24rpx;
color: #9aa4b0;
}
.ex-reason {
margin-top: 20rpx;
padding: 20rpx;
border-radius: 10rpx;
background-color: #f5f6f8;
font-size: 24rpx;
color: #666666;
line-height: 1.5;
}
.ex-time {
margin-top: 14rpx;
font-size: 22rpx;
color: #9aa4b0;
}
</style>