Files
template-10560/src/dealer/customer/index.tsx
赵忠林 b7e8d52cf0 feat(shop): 新增客户跟进记录功能
- 新增客户跟进记录模型定义
- 实现客户跟进记录的增删改查接口
- 在客户详情页添加跟进记录提交功能
-优化文章列表组件的UI展示效果
- 调整分享功能回调参数处理方式
2025-10-04 17:23:13 +08:00

591 lines
19 KiB
TypeScript
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.

import {useState, useEffect, useCallback} from 'react'
import {View, Text} from '@tarojs/components'
import Taro, {useDidShow} from '@tarojs/taro'
import {Loading, InfiniteLoading, Empty, Space, Tabs, TabPane, Tag, Button, SearchBar} from '@nutui/nutui-react-taro'
import {Phone, AngleDoubleLeft} from '@nutui/icons-react-taro'
import type {ShopDealerApply, ShopDealerApply as UserType} from "@/api/shop/shopDealerApply/model";
import {
CustomerStatus,
getStatusText,
getStatusTagType,
getStatusOptions,
mapApplyStatusToCustomerStatus,
mapCustomerStatusToApplyStatus
} from '@/utils/customerStatus';
import FixedButton from "@/components/FixedButton";
import navTo from "@/utils/common";
import {pageShopDealerApply, removeShopDealerApply, updateShopDealerApply} from "@/api/shop/shopDealerApply";
import {addShopDealerRecord} from "@/api/shop/shopDealerRecord";
// 扩展User类型添加客户状态和保护天数
interface CustomerUser extends UserType {
customerStatus?: CustomerStatus;
protectDays?: number; // 剩余保护天数
}
const CustomerIndex = () => {
const [list, setList] = useState<CustomerUser[]>([])
const [loading, setLoading] = useState<boolean>(false)
const [activeTab, setActiveTab] = useState<CustomerStatus>('all')
const [searchValue, setSearchValue] = useState<string>('')
const [displaySearchValue, setDisplaySearchValue] = useState<string>('')
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
// Tab配置
const tabList = getStatusOptions();
// 复制手机号
const copyPhone = (phone: string) => {
Taro.setClipboardData({
data: phone,
success: () => {
Taro.showToast({
title: '手机号已复制',
icon: 'success',
duration: 1500
});
}
});
};
// 一键拨打
const makePhoneCall = (phone: string) => {
Taro.makePhoneCall({
phoneNumber: phone,
fail: () => {
Taro.showToast({
title: '拨打取消',
icon: 'error'
});
}
});
};
// 编辑跟进情况
const editComments = (customer: CustomerUser) => {
Taro.showModal({
title: '编辑跟进情况',
// @ts-ignore
editable: true,
placeholderText: '请输入跟进情况',
content: customer.comments || '',
success: async (res) => {
// @ts-ignore
if (res.confirm && res.content !== undefined) {
try {
// 添加跟进记录
await addShopDealerRecord({
dealerId: customer.userId,
// @ts-ignore
content: res.content.trim()
})
// 更新跟进情况
await updateShopDealerApply({
...customer,
// @ts-ignore
comments: res.content.trim()
});
Taro.showToast({
title: '更新成功',
icon: 'success'
});
// 刷新列表
setList([]);
setPage(1);
setHasMore(true);
fetchCustomerData(activeTab, true);
} catch (error) {
console.error('更新跟进情况失败:', error);
Taro.showToast({
title: '更新失败,请重试',
icon: 'error'
});
}
}
}
});
};
// 计算剩余保护天数(基于过期时间)
const calculateProtectDays = (expirationTime?: string, applyTime?: string): number => {
try {
// 优先使用过期时间字段
if (expirationTime) {
const expDate = new Date(expirationTime.replace(' ', 'T'));
const now = new Date();
// 计算剩余毫秒数
const remainingMs = expDate.getTime() - now.getTime();
// 转换为天数,向上取整
const remainingDays = Math.ceil(remainingMs / (1000 * 60 * 60 * 24));
console.log('=== 基于过期时间计算 ===');
console.log('过期时间:', expirationTime);
console.log('当前时间:', now.toLocaleString());
console.log('剩余天数:', remainingDays);
console.log('======================');
return Math.max(0, remainingDays);
}
// 如果没有过期时间,回退到基于申请时间计算
if (!applyTime) return 0;
const protectionPeriod = 7; // 保护期7天
// 解析申请时间
let applyDate: Date;
if (applyTime.includes('T')) {
applyDate = new Date(applyTime);
} else {
applyDate = new Date(applyTime.replace(' ', 'T'));
}
// 获取当前时间
const now = new Date();
// 只比较日期部分,忽略时间
const applyDateOnly = new Date(applyDate.getFullYear(), applyDate.getMonth(), applyDate.getDate());
const currentDateOnly = new Date(now.getFullYear(), now.getMonth(), now.getDate());
// 计算已经过去的天数
const timeDiff = currentDateOnly.getTime() - applyDateOnly.getTime();
const daysPassed = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
// 计算剩余保护天数
const remainingDays = protectionPeriod - daysPassed;
console.log('=== 基于申请时间计算 ===');
console.log('申请时间:', applyTime);
console.log('已过去天数:', daysPassed);
console.log('剩余保护天数:', remainingDays);
console.log('======================');
return Math.max(0, remainingDays);
} catch (error) {
console.error('日期计算错误:', error);
return 0;
}
};
// 获取客户数据
const fetchCustomerData = useCallback(async (statusFilter?: CustomerStatus, resetPage = false, targetPage?: number) => {
setLoading(true);
try {
const currentPage = resetPage ? 1 : (targetPage || page);
// 构建API参数根据状态筛选
const params: any = {
type: 4,
page: currentPage
};
const applyStatus = mapCustomerStatusToApplyStatus(statusFilter || activeTab);
if (applyStatus !== undefined) {
params.applyStatus = applyStatus;
}
const res = await pageShopDealerApply(params);
if (res?.list && res.list.length > 0) {
// 正确映射状态并计算保护天数
const mappedList = res.list.map(customer => ({
...customer,
customerStatus: mapApplyStatusToCustomerStatus(customer.applyStatus || 10),
protectDays: calculateProtectDays(customer.expirationTime, customer.applyTime || customer.createTime || '')
}));
// 如果是重置页面或第一页,直接设置新数据;否则追加数据
if (resetPage || currentPage === 1) {
setList(mappedList);
} else {
setList(prevList => prevList.concat(mappedList));
}
// 正确判断是否还有更多数据
const hasMoreData = res.list.length >= 10; // 假设每页10条数据
setHasMore(hasMoreData);
} else {
if (resetPage || currentPage === 1) {
setList([]);
}
setHasMore(false);
}
setPage(currentPage);
} catch (error) {
console.error('获取客户数据失败:', error);
Taro.showToast({
title: '加载失败,请重试',
icon: 'none'
});
} finally {
setLoading(false);
}
}, [activeTab, page]);
const reloadMore = async () => {
if (loading || !hasMore) return; // 防止重复加载
const nextPage = page + 1;
await fetchCustomerData(activeTab, false, nextPage);
}
// 防抖搜索功能
useEffect(() => {
const timer = setTimeout(() => {
setDisplaySearchValue(searchValue);
}, 300); // 300ms 防抖
return () => clearTimeout(timer);
}, [searchValue]);
// 根据搜索条件筛选数据状态筛选已在API层面处理
const getFilteredList = () => {
let filteredList = list;
// 按搜索关键词筛选
if (displaySearchValue.trim()) {
const keyword = displaySearchValue.trim().toLowerCase();
filteredList = filteredList.filter(customer =>
(customer.realName && customer.realName.toLowerCase().includes(keyword)) ||
(customer.dealerName && customer.dealerName.toLowerCase().includes(keyword)) ||
(customer.dealerCode && customer.dealerCode.toLowerCase().includes(keyword)) ||
(customer.mobile && customer.mobile.includes(keyword)) ||
(customer.userId && customer.userId.toString().includes(keyword))
);
}
return filteredList;
};
// 获取各状态的统计数量
const [statusCounts, setStatusCounts] = useState({
all: 0,
pending: 0,
signed: 0,
cancelled: 0
});
// 获取所有状态的统计数量
const fetchStatusCounts = useCallback(async () => {
try {
// 并行获取各状态的数量
const [allRes, pendingRes, signedRes, cancelledRes] = await Promise.all([
pageShopDealerApply({type: 4}), // 全部
pageShopDealerApply({applyStatus: 10, type: 4}), // 跟进中
pageShopDealerApply({applyStatus: 20, type: 4}), // 已签约
pageShopDealerApply({applyStatus: 30, type: 4}) // 已取消
]);
setStatusCounts({
all: allRes?.count || 0,
pending: pendingRes?.count || 0,
signed: signedRes?.count || 0,
cancelled: cancelledRes?.count || 0
});
} catch (error) {
console.error('获取状态统计失败:', error);
}
}, []);
const getStatusCounts = () => statusCounts;
// 取消操作
const handleCancel = (customer: ShopDealerApply) => {
updateShopDealerApply({
...customer,
applyStatus: 30
}).then(() => {
Taro.showToast({
title: '取消成功',
icon: 'success'
});
// 重新加载当前tab的数据
setList([]);
setPage(1);
setHasMore(true);
fetchCustomerData(activeTab, true).then();
fetchStatusCounts().then();
})
};
// 删除
const handleDelete = (customer: ShopDealerApply) => {
removeShopDealerApply(customer.applyId).then(() => {
Taro.showToast({
title: '删除成功',
icon: 'success'
});
// 刷新当前tab的数据
setList([]);
setPage(1);
setHasMore(true);
fetchCustomerData(activeTab, true).then();
fetchStatusCounts().then();
})
}
// 初始化数据
useEffect(() => {
fetchCustomerData(activeTab, true).then();
fetchStatusCounts().then();
}, []);
// 当activeTab变化时重新获取数据
useEffect(() => {
setList([]); // 清空列表
setPage(1); // 重置页码
setHasMore(true); // 重置加载状态
fetchCustomerData(activeTab, true);
}, [activeTab]);
// 监听页面显示,当从其他页面返回时刷新数据
useDidShow(() => {
// 刷新当前tab的数据和统计信息
setList([]);
setPage(1);
setHasMore(true);
fetchCustomerData(activeTab, true);
fetchStatusCounts();
});
// 渲染客户项
const renderCustomerItem = (customer: CustomerUser) => (
<View key={customer.userId} className="bg-white rounded-lg p-4 mb-3 shadow-sm">
<View className="flex items-center mb-3">
<View className="flex-1">
<View className="flex items-center justify-between mb-1">
<Text className="font-semibold text-gray-800 mr-2">
{customer.dealerName}
</Text>
{customer.customerStatus && (
<Tag type={getStatusTagType(customer.customerStatus)}>
{getStatusText(customer.customerStatus)}
</Tag>
)}
</View>
<View className="flex items-center mb-1">
<Space direction="vertical">
<Text className="text-xs text-gray-500">{customer.realName}</Text>
<View className="flex items-center">
<Text className="text-xs text-gray-500" onClick={(e) => {
e.stopPropagation();
makePhoneCall(customer.mobile || '');
}}>{customer.mobile}</Text>
<View className="flex items-center ml-2">
<Phone
size={12}
className="text-green-500 mr-2"
onClick={(e) => {
e.stopPropagation();
makePhoneCall(customer.mobile || '');
}}
/>
<Text
className="text-xs text-blue-500 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
copyPhone(customer.mobile || '');
}}
>
</Text>
</View>
</View>
<Text className="text-xs text-gray-500">
{customer.createTime}
</Text>
</Space>
</View>
{/* 保护天数显示 */}
{customer.applyStatus === 10 && (
<View className="flex items-center my-1">
<Text className="text-xs text-gray-500 mr-2"></Text>
{customer.protectDays && customer.protectDays > 0 ? (
<Text className={`text-xs px-2 py-1 rounded ${
customer.protectDays <= 2
? 'bg-red-100 text-red-600'
: customer.protectDays <= 4
? 'bg-orange-100 text-orange-600'
: 'bg-green-100 text-green-600'
}`}>
{customer.protectDays}
</Text>
) : (
<Text className="text-xs px-2 py-1 rounded bg-gray-100 text-gray-500">
</Text>
)}
</View>
)}
<View className={'flex items-center gap-2'}>
<Text className="text-xs text-gray-500">{customer?.nickName}</Text>
<AngleDoubleLeft size={12} className={'text-blue-500'} />
<Text className={'text-xs text-gray-500'}>{customer?.refereeName}</Text>
</View>
{/* 显示 comments 字段 */}
<Space className="flex items-center">
<Text className="text-xs text-gray-500">{customer.comments || '暂无'}</Text>
<Text
className="text-xs text-blue-500 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
editComments(customer);
}}
>
</Text>
</Space>
</View>
</View>
{/* 跟进中状态显示操作按钮 */}
{(customer.applyStatus === 10 && customer.userId == Taro.getStorageSync('UserId')) && (
<Space className="flex justify-end">
<Button
size="small"
onClick={() => navTo(`/dealer/customer/add?id=${customer.applyId}`, true)}
style={{marginRight: '8px', backgroundColor: '#52c41a', color: 'white'}}
>
</Button>
<Button
size="small"
onClick={() => handleCancel(customer)}
style={{backgroundColor: '#ff4d4f', color: 'white'}}
>
</Button>
</Space>
)}
{(customer.applyStatus === 30 && customer.userId == Taro.getStorageSync('UserId')) && (
<Space className="flex justify-end">
<Button
size="small"
onClick={() => handleDelete(customer)}
style={{backgroundColor: '#ff4d4f', color: 'white'}}
>
</Button>
</Space>
)}
</View>
);
// 渲染客户列表
const renderCustomerList = () => {
const filteredList = getFilteredList();
const isSearching = displaySearchValue.trim().length > 0;
return (
<View className="flex-1">
{/* 搜索结果统计 */}
{isSearching && (
<View className="bg-white px-4 py-2 border-b border-gray-100">
<Text className="text-sm text-gray-600">
"{displaySearchValue}" {filteredList.length}
</Text>
</View>
)}
<View className="p-4" style={{
height: isSearching ? 'calc(90vh - 40px)' : '90vh',
overflowY: 'auto',
overflowX: 'hidden'
}}>
<InfiniteLoading
target="scroll"
hasMore={hasMore}
onLoadMore={reloadMore}
onScroll={() => {
// 滚动事件处理
}}
onScrollToUpper={() => {
// 滚动到顶部事件处理
}}
loadingText={
<>
...
</>
}
loadMoreText={
filteredList.length === 0 ? (
<Empty
style={{backgroundColor: 'transparent'}}
description={loading ? "加载中..." : "暂无客户数据"}
/>
) : (
<View className={'h-3 flex items-center justify-center'}>
<Text className="text-gray-500 text-sm"></Text>
</View>
)
}
>
{loading && filteredList.length === 0 ? (
<View className="flex items-center justify-center py-8">
<Loading/>
<Text className="text-gray-500 mt-2 ml-2">...</Text>
</View>
) : (
filteredList.map(renderCustomerItem)
)}
</InfiniteLoading>
</View>
</View>
);
};
return (
<View className="min-h-screen bg-gray-50">
{/* 搜索栏 */}
<View className="bg-white py-2 border-b border-gray-100">
<SearchBar
value={searchValue}
placeholder="搜索客户名称、手机号"
onChange={(value) => setSearchValue(value)}
onClear={() => {
setSearchValue('');
setDisplaySearchValue('');
}}
clearable
/>
</View>
{/* 顶部Tabs */}
<View className="bg-white">
<Tabs
value={activeTab}
onChange={(value) => setActiveTab(value as CustomerStatus)}
>
{tabList.map(tab => {
const counts = getStatusCounts();
const count = counts[tab.value as keyof typeof counts] || 0;
return (
<TabPane
key={tab.value}
title={`${tab.label}${count > 0 ? `(${count})` : ''}`}
value={tab.value}
/>
);
})}
</Tabs>
</View>
{/* 客户列表 */}
{renderCustomerList()}
<FixedButton text={'客户报备'} onClick={() => Taro.navigateTo({url: '/dealer/customer/add'})}/>
</View>
);
};
export default CustomerIndex;