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

482 lines
12 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="admin-contacts">
<!-- 商家端三大入口平级切换(鸿蒙安全: $util.redirectTo) -->
<view class="page-switch">
<view class="switch-item" @tap="goManage">名片管理</view>
<view class="switch-item active" @tap="goSelf">人脉管理</view>
<view class="switch-item" @tap="goLeads">潜在客户</view>
</view>
<!-- 搜索栏 + 添加入口 -->
<view class="search-bar">
<input class="search-input" :value="keyword" placeholder="搜索姓名 / 手机 / 公司"
confirm-type="search" @input="e => keyword = e.detail.value" @confirm="doSearch" />
<view class="search-btn" @tap="doSearch">搜索</view>
<view class="add-btn" @tap="goEdit()">+ 添加</view>
</view>
<!-- 来源筛选(与后端 Contact::SOURCES 对齐) -->
<scroll-view scroll-x class="tab-bar">
<view class="tab-inner">
<view v-for="tab in tabs" :key="tab.value" class="tab-item"
:class="{ active: source === tab.value }" @tap="switchTab(tab.value)">
{{ tab.label }}
</view>
</view>
</scroll-view>
<!-- 人脉列表 -->
<view class="contact-list">
<view v-for="(item, index) in list" :key="item.id" class="contact-item">
<view class="item-top" @tap="callPhone(item.mobile)">
<view class="item-name">{{ item.name }}</view>
<view class="item-source" :class="'src-' + item.source">{{ item.source_text }}</view>
</view>
<view class="item-company" v-if="item.company || item.job_title">
{{ item.company }}<text v-if="item.company && item.job_title"> · </text>{{ item.job_title }}
</view>
<view class="item-mobile" v-if="item.mobile" @tap="callPhone(item.mobile)">{{ item.mobile }}</view>
<view class="item-remark" v-if="item.remark">「{{ item.remark }}」</view>
<view class="item-meta">
<text class="meta-time">{{ item.create_time_txt }}</text>
<text class="meta-status" v-if="item.status == 1">已归档</text>
</view>
<view class="item-actions">
<view class="action-btn plain" :data-index="index" @tap="goEdit">编辑</view>
<view class="action-btn primary" :data-index="index" @tap="convertToLead">转为潜在客户</view>
<view class="action-btn danger" :data-index="index" @tap="delContact">删除</view>
</view>
</view>
<view class="load-more" v-if="list.length > 0">
<text v-if="isLoading">加载中…</text>
<text v-else-if="page < pageCount">上拉加载更多</text>
<text v-else class="load-end">— 没有更多了 —</text>
</view>
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false"
:text="'暂无人脉,点右上角添加'"></ns-empty>
</view>
</view>
</template>
<script>
/**
* 商家端 · 人脉管理
*
* 接口全部来自后端已就绪的 /card/api/contact/*(收口在 common/js/card-api.js):
* contact/lists 列表 / contact/add+update 增改(复用 contact-edit 页) /
* contact/toLead 转线索 / contact/delete 真删。
* 与员工端「我的人脉」的区别: 本页是商家后台视角, 带来源筛选与三大入口互链;
* 编辑页共用 contact-edit.vue(from=admin 时保存后回本页)。
*/
import { CONTACT_SOURCES } from '@/common/js/card-api.js';
// 来源筛选: scan/exchange 暂无产生入口, 只放出 manual / lead_in 两个 tab
const SOURCE_TABS = [{ value: '', label: '全部' }].concat(
CONTACT_SOURCES.filter(s => s.value === 'manual' || s.value === 'lead_in')
);
export default {
data() {
return {
tabs: SOURCE_TABS,
source: '',
keyword: '',
list: [],
page: 1,
pageSize: 20,
pageCount: 1,
isLoading: false
};
},
onLoad() {
this.refresh();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
onShow() {
// 从编辑页保存返回(redirectTo 回本页)时刷新
if (this._loadedOnce) this.refresh();
this._loadedOnce = true;
},
onPullDownRefresh() {
this.refresh(() => uni.stopPullDownRefresh());
},
onReachBottom() {
if (this.isLoading) return;
if (this.page >= this.pageCount) return;
this.loadPage(this.page + 1);
},
methods: {
// 平级入口: 鸿蒙禁裸 uni.switchTab, 走 $util.redirectTo
goManage() {
this.$util.redirectTo('/pages_tool/card/admin/manage');
},
goLeads() {
this.$util.redirectTo('/pages_tool/card/admin/leads');
},
goSelf() {},
// 编辑页共用 contact-edit; from=admin 时保存后回本页(见 contact-edit.vue#backToList)
/**
* 取事件对应的行数据。小程序端 v-for 内联传对象会丢参, 统一 :data-index + 不传参。
* 兼容: 无参(新建) / 事件对象(data-index) / 直接 id。
*/
resolveItem(payload) {
if (payload == null) return null;
if (typeof payload === 'number' || typeof payload === 'string') return null;
const ds = payload.currentTarget && payload.currentTarget.dataset;
if (ds) {
const i = Number(ds.index);
if (!isNaN(i) && this.list[i]) return this.list[i];
}
return payload.id ? payload : null;
},
goEdit(payload) {
let id = payload;
if (payload && payload.currentTarget) {
const i = Number(payload.currentTarget.dataset.index);
const item = this.list[i];
id = item ? item.id : '';
}
const url = id
? '/pages_tool/card/contact-edit?from=admin&id=' + id
: '/pages_tool/card/contact-edit?from=admin';
this.$util.redirectTo(url);
},
switchTab(value) {
if (this.source === value) return;
this.source = value;
this.refresh();
},
doSearch() {
this.refresh();
},
refresh(done) {
this.page = 1;
this.loadPage(1, done);
},
loadPage(pageNo, done) {
this.isLoading = true;
const data = { page: pageNo, page_size: this.pageSize };
// 空字符串 = 全部, 后端对 '' 不加 source 条件
if (this.source !== '') data.source = this.source;
if (this.keyword.trim() !== '') data.keyword = this.keyword.trim();
this.$cardApi.contactLists({
data: data,
success: res => {
this.isLoading = false;
if (res.code == 0) {
const d = res.data || {};
const rows = d.list || [];
this.pageCount = d.page_count || 1;
this.page = pageNo;
this.list = pageNo === 1 ? rows : this.list.concat(rows);
} else {
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
}
typeof done === 'function' && done();
},
fail: () => {
this.isLoading = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
typeof done === 'function' && done();
}
});
},
convertToLead(payload) {
const item = this.resolveItem(payload);
if (!item) return;
uni.showModal({
title: '转为潜在客户',
content: '将「' + (item.name || '') + '」转入潜在客户跟进?人脉记录会保留。',
success: res => {
if (!res.confirm) return;
this.doConvert(item);
}
});
},
doConvert(item) {
uni.showLoading({ title: '转换中…', mask: true });
// 后端: 写一条线索且不删人脉; 同员工同手机号已转过则报错(message 可直接展示)
this.$cardApi.contactToLead({
data: { id: item.id },
success: res => {
uni.hideLoading();
if (res.code == 0) {
uni.showToast({ title: '已转入潜在客户', icon: 'success' });
} else {
uni.showToast({ title: res.message || '转换失败', icon: 'none' });
}
},
fail: () => {
uni.hideLoading();
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
delContact(payload) {
const item = this.resolveItem(payload);
if (!item) return;
// 真删不可恢复, 二次确认
uni.showModal({
title: '删除人脉',
content: '删除后不可恢复,确定删除「' + (item.name || '') + '」?',
confirmColor: '#f5222d',
success: res => {
if (!res.confirm) return;
this.doDelete(item);
}
});
},
doDelete(item) {
this.$cardApi.contactDelete({
data: { id: item.id },
success: res => {
if (res.code == 0) {
this.list = this.list.filter(c => c.id !== item.id);
uni.showToast({ title: '已删除', icon: 'success' });
} else {
// 后端对"不存在"与"非本人"同一口径, 按"不存在或已删除"提示
uni.showToast({ title: res.message || '删除失败', icon: 'none' });
}
},
fail: () => {
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
callPhone(mobile) {
if (!mobile) return;
uni.makePhoneCall({ phoneNumber: mobile });
}
}
};
</script>
<style lang="scss" scoped>
.admin-contacts {
min-height: 100vh;
background-color: #f6f7f9;
padding-bottom: 40rpx;
box-sizing: border-box;
}
.page-switch {
display: flex;
background-color: #ffffff;
padding: 0 12rpx;
.switch-item {
flex: 1;
text-align: center;
padding: 24rpx 0 20rpx;
font-size: 28rpx;
color: #666;
position: relative;
&.active {
color: var(--main-color, #0054a5);
font-weight: 600;
&::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 8rpx;
width: 48rpx;
height: 6rpx;
border-radius: 3rpx;
background-color: var(--main-color, #0054a5);
}
}
}
}
.search-bar {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
.search-input {
flex: 1;
height: 64rpx;
line-height: 64rpx;
background-color: #ffffff;
border-radius: 32rpx;
padding: 0 28rpx;
font-size: 26rpx;
color: #333;
}
.search-btn {
font-size: 26rpx;
color: #666;
padding: 0 24rpx;
}
.add-btn {
font-size: 26rpx;
color: #ffffff;
background-color: var(--main-color, #0054a5);
border-radius: 32rpx;
padding: 12rpx 28rpx;
}
}
.tab-bar {
background-color: #ffffff;
white-space: nowrap;
.tab-inner {
display: inline-flex;
padding: 0 12rpx;
}
.tab-item {
display: inline-block;
padding: 24rpx 28rpx;
font-size: 28rpx;
color: #666;
position: relative;
&.active {
color: var(--main-color, #0054a5);
font-weight: 600;
&::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 10rpx;
width: 40rpx;
height: 6rpx;
border-radius: 3rpx;
background-color: var(--main-color, #0054a5);
}
}
}
}
.contact-list {
padding: 20rpx 24rpx 0;
}
.contact-item {
background-color: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
.item-top {
display: flex;
align-items: center;
.item-name {
flex: 1;
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.item-source {
font-size: 22rpx;
padding: 6rpx 18rpx;
border-radius: 22rpx;
&.src-manual {
color: var(--main-color, #0054a5);
background-color: var(--main-color-shallow, #e8f1fb);
}
&.src-lead_in {
color: #1daa39;
background-color: #e8f8ea;
}
&.src-scan,
&.src-exchange {
color: #999;
background-color: #f2f2f2;
}
}
}
.item-company {
font-size: 26rpx;
color: #666;
margin-top: 10rpx;
}
.item-mobile {
font-size: 28rpx;
color: #333;
margin-top: 14rpx;
}
.item-remark {
font-size: 26rpx;
color: #666;
margin-top: 12rpx;
line-height: 1.5;
}
.item-meta {
display: flex;
justify-content: space-between;
font-size: 22rpx;
color: #999;
margin-top: 14rpx;
}
.item-actions {
display: flex;
justify-content: flex-end;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f0f0f0;
.action-btn {
font-size: 24rpx;
border-radius: 30rpx;
padding: 10rpx 26rpx;
margin-left: 16rpx;
&.plain {
color: #666;
border: 1rpx solid #ddd;
}
&.primary {
color: #ffffff;
background-color: var(--main-color, #0054a5);
}
&.danger {
color: #f5222d;
border: 1rpx solid #f5222d;
}
}
}
}
.load-more {
text-align: center;
color: #999;
font-size: 24rpx;
padding: 24rpx 0;
.load-end {
color: #ccc;
}
}
</style>