404 lines
11 KiB
Vue
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>
|