OfficeSystem/components/CustomerManagement.vue
2025-11-11 11:02:12 +08:00

881 lines
20 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="customer-management">
<!-- 顶部标题栏 -->
<view class="header">
<text class="header-title">客户管理</text>
<view class="filter-btn" @click="showFilter = !showFilter">
<text class="filter-text">筛选</text>
<text class="filter-icon" :class="{ 'rotate': showFilter }"></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': filterStatus === '' }"
@click="filterStatus = ''"
>全部</text>
<text
class="filter-option"
:class="{ 'active': filterStatus === 'potential' }"
@click="filterStatus = 'potential'"
>潜在</text>
<text
class="filter-option"
:class="{ 'active': filterStatus === 'intent' }"
@click="filterStatus = 'intent'"
>意向</text>
<text
class="filter-option"
:class="{ 'active': filterStatus === 'deal' }"
@click="filterStatus = 'deal'"
>成交</text>
<text
class="filter-option"
:class="{ 'active': filterStatus === 'invalid' }"
@click="filterStatus = '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)"
>
<!-- 客户信息区域 -->
<view class="customer-header">
<view class="customer-info">
<text class="customer-name">{{ customer.name }}</text>
<text class="last-followup">最后跟进: {{ formatDateTime(customer.lastFollowTime) }}</text>
</view>
<view class="status-indicator">
<view
class="status-dot"
:class="getStatusClass(customer.status)"
></view>
<text class="status-text">{{ getStatusText(customer.status) }}</text>
</view>
</view>
<!-- 客户详细信息 -->
<view class="customer-details">
<!-- 客户意向 -->
<view class="detail-row" v-if="customer.intents && customer.intents.length > 0">
<text class="detail-label">客户意向:</text>
<view class="intent-tags">
<text
v-for="(intent, index) in customer.intents"
:key="index"
class="intent-tag"
>{{ intent }}</text>
</view>
</view>
<!-- 意向强度 -->
<view class="detail-row">
<text class="detail-label">意向强度:</text>
<text class="detail-value">{{ getIntentLevelText(customer.intentLevel) }}</text>
</view>
<!-- 微信号 -->
<view class="detail-row">
<text class="detail-label">微信号:</text>
<text class="detail-value">{{ customer.wechat || '--' }}</text>
</view>
<!-- 手机号 -->
<view class="detail-row">
<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">
<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 FabPlus from '@/components/FabPlus.vue';
import { usePagination } from '@/composables/usePagination';
import { getCustomerList, deleteCustomer } from '@/common/api/customer';
// 筛选状态
const showFilter = ref(false);
const filterStatus = ref('');
// 使用分页组合式函数
const {
list,
loading,
noMore,
isEmpty,
getList,
loadMore,
updateParams,
refresh,
queryParams,
reset
} = usePagination({
fetchData: getCustomerList,
mode: 'loadMore',
pageSize: 10,
defaultParams: {}
});
// 获取状态样式类
const getStatusClass = (status) => {
return {
'status-potential': status === '1', // 潜在
'status-intent': status === '2', // 意向
'status-deal': status === '3', // 成交
'status-invalid': status === '4' // 失效
};
};
// 获取状态文本
const getStatusText = (status) => {
const statusMap = {
'1': '潜在',
'2': '意向',
'3': '成交',
'4': '失效'
};
return statusMap[status] || '未知';
};
// 获取意向强度文本
const getIntentLevelText = (intentLevel) => {
const levelMap = {
'1': '高',
'2': '中',
'3': '低'
};
return levelMap[intentLevel] || '--';
};
// 截断文本
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 buildQueryParams = () => {
const params = {};
// 只有有效的筛选状态才添加statusList参数
if (filterStatus.value) {
const statusMap = {
'potential': ['1'], // 潜在
'intent': ['2'], // 意向
'deal': ['3'], // 成交
'invalid': ['4'] // 失效
};
const statusList = statusMap[filterStatus.value];
if (statusList) {
params.statusList = statusList;
console.log(`筛选状态: ${filterStatus.value} -> statusList:`, statusList);
} else {
console.log(`未知的筛选状态: ${filterStatus.value},跳过状态筛选`);
}
} else {
console.log('无筛选状态,返回空参数');
}
return params;
};
// 处理客户点击
const handleCustomerClick = (customer) => {
console.log('点击客户:', customer);
// 跳转到客户详情页
uni.navigateTo({
url: `/pages/customer/detail/index?id=${customer.id}`
});
};
// 处理跟进
const handleFollowup = (customer) => {
console.log('跟进客户:', customer);
// TODO: 调用 API 更新跟进状态
// 这里可以跳转到跟进记录页面或打开跟进对话框
uni.navigateTo({
url: `/pages/customer-follow/index?customerId=${customer.id}&customerName=${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(filterStatus, () => {
console.log('筛选状态变化:', filterStatus.value);
// 点击"全部"时,直接重置并刷新,清除所有缓存参数
if (filterStatus.value === '') {
// 重置分页状态
reset();
// 清除所有查询参数,只保留基础分页参数
queryParams.value = {
pageNum: 1,
pageSize: 10
};
// 直接刷新列表
refresh();
} else {
// 其他筛选状态,使用 updateParams 更新参数
const params = buildQueryParams();
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 {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background-color: #f5f7fa;
position: relative;
}
/* 顶部标题栏 */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
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);
}
.header-title {
font-size: 18px;
font-weight: 600;
color: #303133;
letter-spacing: 0.5px;
}
.filter-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
background-color: #f5f7fa;
transition: all 0.3s ease;
cursor: pointer;
&:active {
background-color: #e4e7ed;
transform: scale(0.95);
}
}
.filter-text {
font-size: 14px;
color: #606266;
font-weight: 500;
}
.filter-icon {
font-size: 12px;
color: #909399;
transition: transform 0.3s ease;
&.rotate {
transform: rotate(180deg);
}
}
/* 筛选面板 */
.filter-panel {
background-color: #fff;
padding: 16px;
border-bottom: 1px solid #e4e7ed;
position: fixed;
top: 52px;
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: 28px;
padding-bottom: 100px; /* 为底部导航栏和悬浮按钮留出空间 */
background-color: #f5f7fa;
/* 移除 overflow-y: auto让页面本身滚动以支持 onReachBottom */
transition: padding-top 0.3s ease;
&.with-filter {
padding-top: 132px; /* 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;
justify-content: space-between;
align-items: flex-start;
padding-bottom: 12px;
border-bottom: 1px solid #f0f2f5;
}
.customer-info {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-width: 0;
}
.customer-name {
font-size: 16px;
font-weight: 600;
color: #303133;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.last-followup {
font-size: 12px;
color: #909399;
line-height: 1.4;
}
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
padding: 4px 10px;
background-color: #f5f7fa;
border-radius: 12px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&.status-potential {
background-color: #e6a23c;
box-shadow: 0 0 4px rgba(230, 162, 60, 0.4);
}
&.status-intent {
background-color: #409eff;
box-shadow: 0 0 4px rgba(64, 158, 255, 0.4);
}
&.status-deal {
background-color: #67c23a;
box-shadow: 0 0 4px rgba(103, 194, 58, 0.4);
}
&.status-invalid {
background-color: #f56c6c;
box-shadow: 0 0 4px rgba(245, 108, 108, 0.4);
}
}
.status-text {
font-size: 12px;
color: #606266;
font-weight: 500;
}
/* 分配用户 */
.assigned-user {
display: flex;
align-items: center;
gap: 10px;
background-color: #fafbfc;
border-radius: 8px;
}
.user-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background-color: #e4e7ed;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.user-name {
font-size: 13px;
color: #606266;
font-weight: 500;
}
/* 客户详细信息 */
.customer-details {
padding: 12px;
background-color: #fafbfc;
border-radius: 8px;
}
.detail-row {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 10px;
font-size: 13px;
line-height: 1.6;
&:last-child {
margin-bottom: 0;
}
}
.detail-label {
color: #909399;
font-weight: 500;
flex-shrink: 0;
margin-right: 4px;
white-space: nowrap;
}
.detail-value {
color: #606266;
word-break: break-all;
flex: 1;
min-width: 0;
}
.follow-content {
color: #303133;
line-height: 1.6;
}
/* 客户意向标签 */
.intent-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex: 1;
}
.intent-tag {
display: inline-block;
padding: 4px 10px;
font-size: 12px;
color: #2885ff;
background-color: #e6f2ff;
border-radius: 12px;
border: 1px solid #b3d8ff;
white-space: nowrap;
}
/* 操作按钮 */
.action-buttons {
display: flex;
justify-content: space-around;
align-items: center;
padding-top: 14px;
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>