OfficeSystem/components/customer/CustomerManagement.vue
2025-11-26 09:10:46 +08:00

989 lines
24 KiB
Vue
Raw Permalink 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="customer-management">
<!-- 顶部标题栏 -->
<view class="header">
<view @click="goToCustomerSearch">
<view style="height: 5px;"></view>
<img src="https://api.ccttiot.com/image-1763782244238.png" alt="" style="width: 20px !important; height: 20px !important;">
</view>
<view class="header-tabs">
<view
class="tab-item"
:class="{ 'active': isFilterSelected(TODAY_FOLLOW_FILTER) }"
@click="toggleFilter(TODAY_FOLLOW_FILTER)"
>
待跟进
</view>
<view
class="tab-item"
:class="{ 'active': isAllSelected }"
@click="handleSelectAll"
>
全部
</view>
</view>
<view class="filter-btn" @click="showFilter = !showFilter">
<text class="filter-text">筛选</text>
</view>
</view>
<!-- 筛选区域可选 -->
<view class="filter-panel" v-if="showFilter">
<view class="filter-item">
<text class="filter-label">状态</text>
<view class="filter-options">
<text
class="filter-option"
:class="{ 'active': isAllSelected }"
@click="handleSelectAll"
>全部</text>
<text
class="filter-option"
:class="{ 'active': isFilterSelected('potential') }"
@click="toggleFilter('potential')"
>潜在</text>
<text
class="filter-option"
:class="{ 'active': isFilterSelected('highIntent') }"
@click="toggleFilter('highIntent')"
>高意向</text>
<text
class="filter-option"
:class="{ 'active': isFilterSelected('lowIntent') }"
@click="toggleFilter('lowIntent')"
>低意向</text>
<text
class="filter-option"
:class="{ 'active': isFilterSelected('deal') }"
@click="toggleFilter('deal')"
>成交</text>
<text
class="filter-option"
:class="{ 'active': isFilterSelected('invalid') }"
@click="toggleFilter('invalid')"
>失效</text>
</view>
</view>
</view>
<!-- 客户列表 -->
<view
class="customer-list"
:class="{ 'with-filter': showFilter }"
>
<view style="padding-inline: 8px">
<view
class="customer-card"
v-for="customer in list"
:key="customer.id"
@click="handleCustomerClick(customer)"
>
<CustomerSummaryBrief
:name="customer.name"
:intents="customer.intents"
:status="customer.status"
:show-edit="true"
@edit="handleEdit(customer)"
/>
<!-- 客户详细信息区域 -->
<view class="customer-details">
<!-- 备注 -->
<view class="detail-row" v-if="customer.remark">
<text class="detail-label">备注:</text>
<text class="detail-value">{{ customer.remark || '--' }}</text>
</view>
<!-- 微信号 -->
<!-- <view class="detail-row">-->
<!-- <text class="detail-label">微信号:</text>-->
<!-- <text class="detail-value">{{ customer.wechat || '&#45;&#45;' }}</text>-->
<!-- </view>-->
<!-- 手机号 -->
<view class="detail-row" v-if="customer.mobile">
<text class="detail-label">手机号:</text>
<text class="detail-value">{{ customer.mobile || '--' }}</text>
</view>
<!-- 跟进内容 -->
<view class="detail-row" v-if="customer.lastFollowRecord && customer.lastFollowRecord.content">
<text class="detail-label">跟进内容:</text>
<text class="detail-value follow-content">{{ truncateText(customer.lastFollowRecord.content, 30) }}</text>
</view>
<!-- 最后跟进时间 -->
<!-- <view class="detail-row" v-if="customer.lastFollowTime">-->
<!-- <text class="detail-label">最后跟进:</text>-->
<!-- <text class="detail-value">{{ formatDateTime(customer.lastFollowTime) }}</text>-->
<!-- </view>-->
<!-- 下次跟进时间 -->
<view class="detail-row" v-if="customer.nextFollowTime">
<text class="detail-label">下次跟进:</text>
<text class="detail-value">{{ formatDateTime(customer.nextFollowTime) }}</text>
</view>
<!-- 分配用户 -->
<view class="detail-row" v-if="customer.followName">
<text class="detail-label">跟进人:</text>
<text class="detail-value">{{ customer.followName || '--' }}</text>
</view>
</view>
<!-- 操作按钮 -->
<view class="action-buttons">
<view class="action-item" @click.stop="handleFollowup(customer)">
<text class="action-icon">✓</text>
<text class="action-text">跟进</text>
</view>
<view class="action-item" @click.stop="handleTasks(customer)">
<text class="action-icon">☰</text>
<text class="action-text">任务</text>
</view>
<view class="action-item" @click.stop="handleCall(customer)">
<text class="action-icon">☏</text>
<text class="action-text">电话</text>
</view>
<view class="action-item" @click.stop="handleMore(customer)">
<text class="action-icon">+</text>
<text class="action-text">更多</text>
</view>
</view>
</view>
<!-- 空状态 -->
<view class="empty-state" v-if="isEmpty">
<text class="empty-text">暂无客户数据</text>
</view>
<!-- 加载状态 -->
<view class="loading-state" v-if="loading && list.length === 0">
<text class="loading-text">加载中...</text>
</view>
<!-- 加载更多提示 -->
<view class="load-more" v-if="list.length > 0">
<text class="load-more-text" v-if="loading">加载中...</text>
<text class="load-more-text" v-else-if="noMore">没有更多数据了</text>
<text class="load-more-text" v-else>上拉加载更多</text>
</view>
</view>
</view>
<!-- 悬浮添加按钮 -->
<FabPlus @click="handleAddCustomer" />
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { storeToRefs } from 'pinia';
import FabPlus from '@/components/FabPlus.vue';
import CustomerSummaryBrief from '@/components/customer/CustomerSummaryBrief.vue';
import { usePagination } from '@/composables/usePagination';
import { getCustomerList, deleteCustomer } from '@/api/customer';
import{useUserStore} from "@/store/user";
import {
getStatusListByFilter
} from '@/utils/customerMappings';
const SearchIcon = () => h('svg', {
width: 16,
height: 16,
viewBox: '0 0 16 16',
fill: 'currentColor'
}, [
h('path', { d: 'M15.1 14.45L11.5 10.9C12.45 9.85 13 8.45 13 6.9C13 3.5 10.25 0.8 6.9 0.8C3.5 0.8 0.8 3.55 0.8 6.9C0.8 10.3 3.55 13 6.9 13C8.45 13 9.85 12.45 10.9 11.5L14.5 15.05C14.65 15.2 14.95 15.2 15.1 15.05C15.3 14.85 15.3 14.6 15.1 14.45ZM1.6 6.9C1.6 4 3.95 1.6 6.9 1.6C9.85 1.6 12.2 3.95 12.2 6.9C12.2 8.3 11.65 9.6 10.75 10.55C10.7 10.55 10.7 10.6 10.65 10.6C10.6 10.65 10.6 10.65 10.6 10.7C9.65 11.6 8.35 12.15 6.95 12.15C4 12.2 1.6 9.8 1.6 6.9Z' })
]);
// 筛选状态
const userStore = useUserStore();
const { userInfo, privateView } = storeToRefs(userStore);
const PAGE_SIZE = 10;
const DEFAULT_SORT = {
orderByColumn: 'next_follow_time',
isAsc: 'descending'
};
const TODAY_FOLLOW_FILTER = 'todayFollow';
const filterSelf = computed({
get: () => privateView.value,
set: (val) => userStore.setPrivateView(val)
});
const currentUserId = computed(() =>
userInfo.value?.user?.userId || userInfo.value?.userId || null
);
const showPrivateSwitch = computed(() =>
userInfo.value?.roles?.some(r => ['admin','sys_admin'].includes(r))
);
const showFilter = ref(false);
const selectedFilters = ref([TODAY_FOLLOW_FILTER]);
const isAllSelected = computed(() => selectedFilters.value.length === 0);
// 使用分页组合式函数
const {
list,
loading,
noMore,
isEmpty,
getList,
loadMore,
updateParams,
refresh,
queryParams,
reset
} = usePagination({
fetchData: getCustomerList,
mode: 'loadMore',
pageSize: PAGE_SIZE,
defaultParams: {
...DEFAULT_SORT
}
});
// 本页状态映射与样式由 CustomerSummaryBrief 统一处理
// 处理编辑
const handleEdit = (customer) => {
console.log('编辑客户:', customer);
uni.navigateTo({
url: `/pages/customer/edit/index?id=${customer.id}`
});
};
// 截断文本
const truncateText = (text, maxLength) => {
if (!text) return '--';
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
};
// 格式化日期时间
const formatDateTime = (dateTime) => {
if (!dateTime) return '暂无';
// 将 "2025-10-29 09:00:00" 格式化为 "2025-10-29 09:00"
try {
const date = new Date(dateTime);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
} catch (e) {
return dateTime;
}
};
// 构建查询参数(包含筛选条件)
const getTodayDate = () => {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const buildQueryParams = () => {
const params = {
...DEFAULT_SORT,
joinUserId: filterSelf.value && currentUserId.value ? currentUserId.value : null,
nextFollowDateStart: null,
nextFollowDateEnd: null,
statusList: undefined
};
const hasTodayFollowFilter = selectedFilters.value.includes(TODAY_FOLLOW_FILTER);
const statusFilters = selectedFilters.value.filter(item => item !== TODAY_FOLLOW_FILTER);
if (hasTodayFollowFilter) {
// 待跟进:潜在 + 意向,且下次跟进时间 <= 今天
const today = getTodayDate();
params.nextFollowDateStart = null;
params.nextFollowDateEnd = today;
// 如果当前没有选择任何状态筛选,则默认添加潜在 + 意向
if (statusFilters.length === 0) {
statusFilters.push('potential', 'highIntent', 'lowIntent');
}
console.log('筛选待跟进,日期 <=', today, ',状态:', statusFilters);
}
if (statusFilters.length > 0) {
const mergedStatusList = Array.from(
new Set(
statusFilters.flatMap(filterKey => getStatusListByFilter(filterKey) || [])
)
);
if (mergedStatusList.length > 0) {
params.statusList = mergedStatusList;
console.log('筛选状态组合:', statusFilters, '-> statusList:', mergedStatusList);
} else {
console.log('筛选状态组合未匹配到 statusList跳过状态筛选');
}
}
if (!hasTodayFollowFilter && statusFilters.length === 0) {
console.log('无筛选状态,返回默认参数');
} else if (hasTodayFollowFilter && statusFilters.length === 0) {
console.log('仅筛选待跟进,但未能识别到状态筛选键');
}
return params;
};
const isFilterSelected = (filterKey) => selectedFilters.value.includes(filterKey);
const toggleFilter = (filterKey) => {
if (!filterKey) return;
const exists = selectedFilters.value.includes(filterKey);
// 如果点击的是已选中的待跟进,则切换到全部(清空筛选)
if (exists && filterKey === TODAY_FOLLOW_FILTER) {
selectedFilters.value = [];
return;
}
// 如果点击待跟进,则只选中待跟进(清空其他筛选)
if (filterKey === TODAY_FOLLOW_FILTER) {
selectedFilters.value = [TODAY_FOLLOW_FILTER];
} else {
// 其他筛选逻辑保持不变
selectedFilters.value = [
...selectedFilters.value.filter(item => item !== TODAY_FOLLOW_FILTER),
filterKey
];
}
};
const goToCustomerSearch = () => {
uni.navigateTo({
url: '/pages/customer/search/index'
});
};
const handleSelectAll = () => {
if (selectedFilters.value.length === 0) return;
selectedFilters.value = [];
};
// 处理客户点击
const handleCustomerClick = (customer) => {
console.log('点击客户:', customer);
// 跳转到客户详情页
uni.navigateTo({
url: `/pages/customer/detail/index?id=${customer.id}`
});
};
// 处理跟进
const handleFollowup = (customer) => {
console.log('跟进客户:', customer);
// 跳转到跟进新增页
uni.navigateTo({
url: `/pages/customer/follow/add/index?customerId=${customer.id}&customerName=${encodeURIComponent(customer.name || '')}`
});
};
// 处理任务
const handleTasks = (customer) => {
console.log('查看客户任务:', customer);
// 可以跳转到客户任务列表页
uni.navigateTo({
url: `/pages/customer-tasks/index?customerId=${customer.id}&customerName=${customer.name}`
});
};
// 处理电话
const handleCall = (customer) => {
console.log('拨打客户电话:', customer);
// 使用 mobile 字段
if (customer.mobile) {
uni.makePhoneCall({
phoneNumber: customer.mobile,
fail: (err) => {
console.error('拨打电话失败:', err);
uni.$uv.toast('拨打电话失败');
}
});
} else {
uni.$uv.toast('客户未设置电话号码');
}
};
// 处理更多
const handleMore = (customer) => {
console.log('更多操作:', customer);
// 显示操作菜单
uni.showActionSheet({
itemList: ['编辑客户', '删除客户', '查看详情'],
success: (res) => {
if (res.tapIndex === 0) {
// 编辑客户
uni.navigateTo({
url: `/pages/customer/edit/index?id=${customer.id}`
});
} else if (res.tapIndex === 1) {
// 删除客户
uni.showModal({
title: '确认删除',
content: `确定要删除客户"${customer.name}"吗?`,
success: async (modalRes) => {
if (modalRes.confirm) {
try {
// 显示加载提示
uni.showLoading({
title: '删除中...',
mask: true
});
// 调用删除API
await deleteCustomer(customer.id);
// 隐藏加载提示
uni.hideLoading();
// 显示成功提示
uni.$uv.toast('删除成功');
// 刷新列表
refresh();
} catch (error) {
// 隐藏加载提示
uni.hideLoading();
// 显示错误提示
console.error('删除客户失败:', error);
uni.$uv.toast(error?.message || '删除失败,请重试');
}
}
}
});
} else if (res.tapIndex === 2) {
// 查看详情
uni.navigateTo({
url: `/pages/customer/detail/index?id=${customer.id}`
});
}
}
});
};
// 处理添加客户
const handleAddCustomer = () => {
console.log('添加新客户');
// 跳转到添加客户页面
uni.navigateTo({
url: '/pages/customer/add/index'
});
};
// 监听筛选状态变化,更新查询参数并重新加载
watch(selectedFilters, () => {
console.log('筛选状态变化:', selectedFilters.value);
if (selectedFilters.value.length === 0) {
reset();
queryParams.value = {
...DEFAULT_SORT,
joinUserId: filterSelf.value && currentUserId.value ? currentUserId.value : null,
pageNum: 1,
pageSize: PAGE_SIZE,
nextFollowDateStart: null,
nextFollowDateEnd: null,
statusList: undefined
};
refresh();
} else {
const params = buildQueryParams();
updateParams(params);
}
}, { deep: true });
watch(filterSelf, () => {
console.log('筛选是否自己变化:', filterSelf.value);
const params = buildQueryParams();
console.log('客户列表查询参数', params);
updateParams(params);
})
// 监听客户列表刷新事件
const handleCustomerListRefresh = () => {
console.log('收到客户列表刷新事件');
refresh();
// 清除存储标志,避免 onShow 时重复刷新
uni.removeStorageSync('customerListNeedRefresh');
};
// 组件挂载时加载数据
onMounted(() => {
const params = buildQueryParams();
// updateParams 内部会调用 getList(),所以不需要重复调用
updateParams(params);
// 监听客户列表刷新事件
uni.$on('customerListRefresh', handleCustomerListRefresh);
});
// 组件卸载时移除事件监听
onUnmounted(() => {
uni.$off('customerListRefresh', handleCustomerListRefresh);
});
// 暴露方法供父组件调用(用于 onReachBottom
const winB_LoadMore= () => {
if (!loading.value && !noMore.value) {
loadMore();
}
};
// 使用 defineExpose 暴露方法
defineExpose({
winB_LoadMore,
refresh // 暴露 refresh 方法,供外部调用
});
</script>
<style lang="scss" scoped>
.customer-management {
// margin-top: var(--status-bar-height, 0);
// display: flex;
// flex-direction: column;
// height: 100%;
// width: 100%;
// background-color: #f5f7fa;
// position: relative;
}
/* 顶部标题栏 */
.header {
display: flex;
align-items: center;
gap: 16px;
padding: 5px 24px;
background-color: #fff;
border-bottom: 1px solid #e4e7ed;
position: fixed;
// top: 0;
right: 0;
left: 0;
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
//.search-icon {
// font-size: 18px;
// color: #606266;
// cursor: pointer;
// flex-shrink: 0;
//
// &:active {
// opacity: 0.7;
// }
//}
.header-tabs {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
flex: 1;
}
.tab-item {
font-size: 14px;
color: #909399;
padding: 8px 0;
cursor: pointer;
position: relative;
transition: all 0.3s ease;
&.active {
color: #303133;
font-weight: 500;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background-color: #409eff;
}
}
&:active {
opacity: 0.7;
}
}
.filter-btn {
display: flex;
align-items: center;
padding: 6px 0;
cursor: pointer;
flex-shrink: 0;
&:active {
opacity: 0.7;
}
}
.filter-text {
font-size: 14px;
color: #303133;
font-weight: 500;
}
/* 筛选面板 */
.filter-panel {
background-color: #fff;
padding: 16px;
border-bottom: 1px solid #e4e7ed;
position: relative;
top: 48px;
right: 0;
left: 0;
z-index: 99;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.filter-item {
display: flex;
align-items: center;
gap: 16px;
}
.filter-label {
font-size: 14px;
color: #606266;
flex-shrink: 0;
font-weight: 500;
}
.filter-options {
display: flex;
gap: 10px;
flex-wrap: wrap;
flex: 1;
}
.filter-option {
padding: 6px 16px;
font-size: 14px;
color: #606266;
background-color: #f5f7fa;
border-radius: 20px;
transition: all 0.3s ease;
cursor: pointer;
border: 1px solid transparent;
&:active {
transform: scale(0.95);
}
&.active {
color: #2885ff;
background-color: #e6f2ff;
border-color: #2885ff;
font-weight: 500;
}
}
/* 客户列表 */
.customer-list {
flex: 1;
padding-top: 56px;
padding-bottom: 100px; /* 为底部导航栏和悬浮按钮留出空间 */
background-color: #f5f7fa;
/* 移除 overflow-y: auto让页面本身滚动以支持 onReachBottom */
transition: padding-top 0.3s ease;
&.with-filter {
//padding-top: 160px; /* header(52px) + filter panel(约56px) */
}
}
.customer-card {
background-color: #fff;
border-radius: 12px;
padding: 16px;
margin: 0 12px 12px 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
border: 1px solid #f0f0f0;
&:active {
transform: scale(0.98);
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.12);
}
&:last-child {
margin-bottom: 20px;
}
}
.customer-header {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.customer-name-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.customer-name {
font-size: 16px;
font-weight: 600;
color: #2885ff;
line-height: 1.4;
}
.edit-icon {
color: #909399;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.3s ease;
&:active {
opacity: 1;
}
}
/* 标签区域 */
.tags-section {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
/* 状态标签 */
.status-tag {
display: inline-block;
padding: 4px 12px;
font-size: 13px;
font-weight: 500;
border-radius: 12px;
white-space: nowrap;
&.status-tag-potential {
color: #e6a23c;
background-color: #fdf6ec;
border: 1px solid #f5dab1;
}
&.status-tag-intent {
color: #409eff;
background-color: #ecf5ff;
border: 1px solid #b3d8ff;
}
&.status-tag-deal {
color: #67c23a;
background-color: #f0f9ff;
border: 1px solid #b3e19d;
}
&.status-tag-invalid {
color: #f56c6c;
background-color: #fef0f0;
border: 1px solid #fbc4c4;
}
&.status-tag-low-intent {
color: #909399;
background-color: #f4f4f5;
border: 1px solid #d3d4d6;
}
}
/* 意向强度标签 */
.intent-level-tag {
display: inline-block;
padding: 4px 12px;
font-size: 13px;
font-weight: 500;
color: #e6a23c;
background-color: #fef9e7;
border: 1px solid #f5dab1;
border-radius: 12px;
white-space: nowrap;
}
/* 客户意向标签 */
.intent-tag {
display: inline-block;
padding: 4px 12px;
font-size: 13px;
font-weight: 500;
color: #67c23a;
background-color: #f0f9ff;
border: 1px solid #b3e19d;
border-radius: 12px;
white-space: nowrap;
}
/* 客户详细信息区域 */
.customer-details {
margin-top: 12px;
margin-bottom: 12px;
padding-top: 12px;
border-top: 1px solid #f0f2f5;
}
.detail-row {
display: flex;
align-items: flex-start;
margin-bottom: 8px;
font-size: 14px;
line-height: 1.5;
&:last-child {
margin-bottom: 0;
}
}
.detail-label {
color: #909399;
margin-right: 8px;
flex-shrink: 0;
min-width: 70px;
}
.detail-value {
color: #303133;
flex: 1;
word-break: break-all;
}
/* 操作按钮 */
.action-buttons {
display: flex;
justify-content: space-around;
align-items: center;
border-top: 1px solid #f0f2f5;
gap: 8px;
}
.action-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 12px;
border-radius: 8px;
transition: all 0.3s ease;
cursor: pointer;
flex: 1;
min-width: 0;
&:active {
background-color: #f5f7fa;
transform: scale(0.95);
}
}
.action-icon {
font-size: 18px;
color: #606266;
font-weight: 500;
line-height: 1;
transition: all 0.3s ease;
&.checked {
color: #2885ff;
font-weight: 600;
transform: scale(1.1);
}
}
.action-text {
font-size: 12px;
color: #606266;
font-weight: 500;
white-space: nowrap;
}
.action-arrow {
font-size: 16px;
color: #909399;
margin-left: 4px;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 80px 20px;
min-height: 300px;
}
.empty-text {
font-size: 14px;
color: #909399;
margin-top: 12px;
}
/* 加载状态 */
.loading-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 60px 20px;
min-height: 200px;
}
.loading-text {
font-size: 14px;
color: #909399;
margin-top: 12px;
}
/* 加载更多提示 */
.load-more {
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
margin-bottom: 20px;
}
.load-more-text {
font-size: 13px;
color: #909399;
}
</style>