Files
template-10582/src/dealer/customer/add.tsx
赵忠林 316aab2616 feat(customer): 添加客户报备重复检查功能
- 新增 DUP_CHECK_LIMIT 和 DUP_CHECK_MAX_PAGES 常量配置
- 实现三要素重复检查逻辑,支持地址、楼栋号、单元号、房号、姓名、电话等字段组合匹配
- 添加 normalizeText 函数处理文本标准化
- 实现 combinationsOf3 函数生成三个元素的组合
- 添加 checkDuplicateBeforeSubmit 函数执行重复检查
- 在提交前验证重复报备并显示提示信息
- 移除用户页面中的 IsDealer 组件
- 更新用户组件删除实名认证和收货地址相关功能
2026-02-02 13:05:19 +08:00

628 lines
22 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 {useEffect, useState, useRef} from "react";
import {Loading, CellGroup, Cell, Input, Form, Calendar} from '@nutui/nutui-react-taro'
import {Edit, Calendar as CalendarIcon} from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import {useRouter} from '@tarojs/taro'
import {View, Text} from '@tarojs/components'
import FixedButton from "@/components/FixedButton";
import {ShopDealerApply} from "@/api/shop/shopDealerApply/model";
import {
addShopDealerApply, getShopDealerApply, pageShopDealerApply,
updateShopDealerApply
} from "@/api/shop/shopDealerApply";
import {
formatDateForDatabase,
extractDateForCalendar, formatDateForDisplay
} from "@/utils/dateUtils";
import {ShopDealerUser} from "@/api/shop/shopDealerUser/model";
import {getShopDealerUser, pageShopDealerUser} from "@/api/shop/shopDealerUser";
const AddShopDealerApply = () => {
const {params} = useRouter();
const [loading, setLoading] = useState<boolean>(true)
const [FormData, setFormData] = useState<ShopDealerApply>()
const formRef = useRef<any>(null)
const [isEditMode, setIsEditMode] = useState<boolean>(false)
const [existingApply, setExistingApply] = useState<ShopDealerApply | null>(null)
const [referee, setReferee] = useState<ShopDealerUser>()
const PROTECTION_DAYS = 15;
const DUP_CHECK_LIMIT = 200;
const DUP_CHECK_MAX_PAGES = 50;
// 房号信息:用 dealerCode 存储唯一键dealerName 存储展示文案
const buildHouseKey = (community: string, buildingNo: string, unitNo: string | undefined, roomNo: string) => {
const c = (community || '').trim();
const b = (buildingNo || '').trim();
const u = (unitNo || '').trim();
const r = (roomNo || '').trim();
return [c, b, u, r].join('|');
};
const buildHouseDisplay = (community: string, buildingNo: string, unitNo: string | undefined, roomNo: string) => {
const c = (community || '').trim();
const b = (buildingNo || '').trim();
const u = (unitNo || '').trim();
const r = (roomNo || '').trim();
return `${c}${b ? `${b}` : ''}${u ? `${u}单元` : ''}${r ? `${r}` : ''}`;
};
const parseHouseKey = (key?: string) => {
const parts = (key || '').split('|');
return {
community: parts[0] || '',
buildingNo: parts[1] || '',
unitNo: parts[2] || '',
roomNo: parts[3] || '',
};
};
// 日期选择器状态
const [showApplyTimePicker, setShowApplyTimePicker] = useState<boolean>(false)
const [showContractTimePicker, setShowContractTimePicker] = useState<boolean>(false)
const [applyTime, setApplyTime] = useState<string>('')
const [contractTime, setContractTime] = useState<string>('')
// 获取审核状态文字
const getApplyStatusText = (status?: number) => {
switch (status) {
case 10:
return '待审核'
case 20:
return '已签约'
case 30:
return '已取消'
default:
return '未知状态'
}
}
console.log(getApplyStatusText)
// 处理签约时间选择
const handleApplyTimeConfirm = (param: string) => {
const selectedDate = param[3] // 选中的日期字符串 (YYYY-M-D)
const formattedDate = formatDateForDatabase(selectedDate) // 转换为数据库格式
setApplyTime(selectedDate) // 保存原始格式用于显示
setShowApplyTimePicker(false)
// 更新表单数据(使用数据库格式)
if (formRef.current) {
formRef.current.setFieldsValue({
applyTime: formattedDate
})
}
}
// 处理合同日期选择
const handleContractTimeConfirm = (param: string) => {
const selectedDate = param[3] // 选中的日期字符串 (YYYY-M-D)
const formattedDate = formatDateForDatabase(selectedDate) // 转换为数据库格式
setContractTime(selectedDate) // 保存原始格式用于显示
setShowContractTimePicker(false)
// 更新表单数据(使用数据库格式)
if (formRef.current) {
formRef.current.setFieldsValue({
contractTime: formattedDate
})
}
}
const reload = async () => {
// 查询推荐人信息
const dealerUser = await getShopDealerUser(Number(Taro.getStorageSync('UserId')))
setReferee(dealerUser)
if (!params.id) {
setLoading(false);
return false;
}
// 查询当前用户ID是否已有申请记录
try {
const dealerApply = await getShopDealerApply(Number(params.id));
if (dealerApply) {
setFormData(dealerApply)
setIsEditMode(true);
setExistingApply(dealerApply)
// 初始化日期数据从数据库格式转换为Calendar组件格式
if (dealerApply.applyTime) {
setApplyTime(extractDateForCalendar(dealerApply.applyTime))
}
if (dealerApply.contractTime) {
setContractTime(extractDateForCalendar(dealerApply.contractTime))
}
Taro.setNavigationBarTitle({title: '签约'})
}
} catch (error) {
setLoading(false)
console.error('查询申请记录失败:', error);
setIsEditMode(false);
setExistingApply(null);
}
}
// 提交表单
// 计算保护期过期时间15天后
const calculateExpirationTime = (): string => {
const now = new Date();
const expirationDate = new Date(now);
expirationDate.setDate(now.getDate() + PROTECTION_DAYS); // 15天后
// 格式化为数据库需要的格式YYYY-MM-DD HH:mm:ss
const year = expirationDate.getFullYear();
const month = String(expirationDate.getMonth() + 1).padStart(2, '0');
const day = String(expirationDate.getDate()).padStart(2, '0');
const hours = String(expirationDate.getHours()).padStart(2, '0');
const minutes = String(expirationDate.getMinutes()).padStart(2, '0');
const seconds = String(expirationDate.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
type DupKey = 'address' | 'buildingNo' | 'unitNo' | 'roomNo' | 'realName' | 'mobile';
const DUP_LABELS: Record<DupKey, string> = {
address: '小区名称',
buildingNo: '楼栋号',
unitNo: '单元号',
roomNo: '房号',
realName: '姓名',
mobile: '电话',
};
const normalizeText = (v: any) => (v ?? '').toString().trim();
const getApplyDupFields = (apply: ShopDealerApply): Record<DupKey, string> => {
const parsed = parseHouseKey(apply.dealerCode);
return {
address: normalizeText(parsed.community || apply.address),
buildingNo: normalizeText(parsed.buildingNo),
unitNo: normalizeText(parsed.unitNo),
roomNo: normalizeText(parsed.roomNo),
realName: normalizeText(apply.realName),
mobile: normalizeText(apply.mobile),
};
};
const getNewDupFields = (values: any): Record<DupKey, string> => ({
address: normalizeText(values.address),
buildingNo: normalizeText(values.buildingNo),
unitNo: normalizeText(values.unitNo),
roomNo: normalizeText(values.roomNo),
realName: normalizeText(values.realName),
mobile: normalizeText(values.mobile),
});
const combinationsOf3 = <T,>(arr: T[]): [T, T, T][] => {
const res: [T, T, T][] = [];
for (let i = 0; i < arr.length; i++) {
for (let j = i + 1; j < arr.length; j++) {
for (let k = j + 1; k < arr.length; k++) {
res.push([arr[i], arr[j], arr[k]]);
}
}
}
return res;
};
const findMatchedTriad = (a: Record<DupKey, string>, b: Record<DupKey, string>) => {
const availableKeys = (Object.keys(a) as DupKey[]).filter((k) => a[k] !== '');
if (availableKeys.length < 3) return null;
const triads = combinationsOf3(availableKeys);
for (const triad of triads) {
if (triad.every((k) => a[k] === b[k] && b[k] !== '')) return triad;
}
return null;
};
const checkDuplicateBeforeSubmit = async (values: any, opts?: { skipDealerCode?: string }) => {
const inputFields = getNewDupFields(values);
const nonEmptyCount = Object.values(inputFields).filter((v) => v !== '').length;
if (nonEmptyCount < 3) return null;
const seen = new Set<number>();
const scanPages = async (params: any) => {
for (let page = 1; page <= DUP_CHECK_MAX_PAGES; page++) {
const res = await pageShopDealerApply({...params, page, limit: DUP_CHECK_LIMIT});
const list = res?.list || [];
for (const item of list) {
if (opts?.skipDealerCode && item.dealerCode === opts.skipDealerCode) continue;
if (item.applyId && seen.has(item.applyId)) continue;
if (item.applyId) seen.add(item.applyId);
const triad = findMatchedTriad(inputFields, getApplyDupFields(item));
if (triad) return {item, triad};
}
if (list.length < DUP_CHECK_LIMIT) break;
}
return null;
};
// 优先按手机号(精确)查询,数据量更小
if (inputFields.mobile) {
const hit = await scanPages({type: 4, mobile: inputFields.mobile});
if (hit) return hit;
}
// 再按小区关键字查询,覆盖房号相关组合
if (inputFields.address) {
const hit = await scanPages({type: 4, keywords: inputFields.address});
if (hit) return hit;
}
// 最后按姓名关键字兜底(用于覆盖不包含“小区/电话”的组合)
if (inputFields.realName) {
const hit = await scanPages({type: 4, keywords: inputFields.realName});
if (hit) return hit;
}
return null;
};
const submitSucceed = async (values: any) => {
try {
// 房号相关必填校验
if (!values.address || values.address.trim() === '') {
Taro.showToast({title: '请选择/填写小区', icon: 'error'});
return;
}
if (!values.buildingNo || values.buildingNo.trim() === '') {
Taro.showToast({title: '请填写楼栋号', icon: 'error'});
return;
}
if (!values.roomNo || values.roomNo.trim() === '') {
Taro.showToast({title: '请填写房号', icon: 'error'});
return;
}
if (!values.realName || values.realName.trim() === '') {
Taro.showToast({title: '请填写姓名', icon: 'error'});
return;
}
// 验证必填字段
if (!values.mobile || values.mobile.trim() === '') {
Taro.showToast({
title: '请填写联系方式',
icon: 'error'
});
return;
}
// 验证手机号格式
const phoneRegex = /^1[3-9]\d{9}$/;
if (!phoneRegex.test(values.mobile)) {
Taro.showToast({
title: '请填写正确的手机号',
icon: 'error'
});
return;
}
// 验证报备人是否存在
if (values.userId > 0) {
const isExist = await pageShopDealerUser({userId: Number(values.userId)});
if(isExist && isExist.count == 0){
Taro.showToast({
title: '报备人不存在',
icon: 'error'
});
return;
}
}
const houseKey = buildHouseKey(values.address, values.buildingNo, values.unitNo, values.roomNo);
const houseDisplay = buildHouseDisplay(values.address, values.buildingNo, values.unitNo, values.roomNo);
// 检查房号是否已报备
const res = await pageShopDealerApply({dealerCode: houseKey, type: 4});
if (res && res.count > 0) {
const existingCustomer = res.list[0];
if (!isEditMode) {
// 已签约/已取消:直接提示已报备
if (existingCustomer.applyStatus && existingCustomer.applyStatus !== 10) {
Taro.showToast({
title: `该房号信息已报备(${getApplyStatusText(existingCustomer.applyStatus)}),本次报备未生效`,
icon: 'none',
duration: 2500
});
return false;
}
// 跟进中:保护期逻辑
if (existingCustomer.applyTime) {
const applyTimeStamp = new Date(existingCustomer.applyTime).getTime();
const currentTimeStamp = new Date().getTime();
const protectionMs = PROTECTION_DAYS * 24 * 60 * 60 * 1000;
if (currentTimeStamp - applyTimeStamp < protectionMs) {
const remainingDays = Math.ceil((protectionMs - (currentTimeStamp - applyTimeStamp)) / (24 * 60 * 60 * 1000));
Taro.showToast({
title: `该房号信息已报备(${getApplyStatusText(existingCustomer.applyStatus)}),保护期剩余${remainingDays}天,本次报备未生效`,
icon: 'none',
duration: 3000
});
return false;
}
// 超过保护期:询问是否重新报备
const modalResult = await new Promise<boolean>((resolve) => {
Taro.showModal({
title: '提示',
content: `该房号已超过${PROTECTION_DAYS}天保护期,是否重新报备跟进?`,
showCancel: true,
cancelText: '取消',
confirmText: '确定',
success: (modalRes) => resolve(modalRes.confirm),
fail: () => resolve(false)
});
});
if (!modalResult) return false;
} else {
Taro.showToast({
title: `该房号信息已报备(${getApplyStatusText(existingCustomer.applyStatus)}),本次报备未生效`,
icon: 'none',
duration: 2500
});
return false;
}
}
}
// 新增报备:提交前做“三要素”重复校验(小区/楼栋/单元/房号/姓名/电话 任一三要素重复提示已报备)
if (!isEditMode) {
const dup = await checkDuplicateBeforeSubmit(values, {skipDealerCode: houseKey});
if (dup) {
const triadLabels = dup.triad.map((k: DupKey) => DUP_LABELS[k]).join('、');
const existingDisplay = dup.item.dealerName || dup.item.address || '地址未知';
Taro.showToast({
title: `疑似重复报备:${triadLabels}一致(${existingDisplay}),已报备`,
icon: 'none',
duration: 3000
});
return false;
}
}
// 计算过期时间
const expirationTime = isEditMode ? existingApply?.expirationTime : calculateExpirationTime();
// 准备提交的数据
// 避免把表单里的楼栋/单元/房号等临时字段原样提交给后端
const {buildingNo, unitNo, roomNo, ...restValues} = values;
const submitData = {
...restValues,
type: 4,
// 展示用:小区+楼栋+单元+房号
dealerName: houseDisplay,
// 唯一键:用于后续重复报备提示
dealerCode: houseKey,
// 客户姓名/手机号
realName: values.realName,
mobile: values.mobile,
refereeId: referee?.refereeId,
applyStatus: isEditMode ? 20 : 10,
auditTime: undefined,
// 设置保护期过期时间15天后
expirationTime: expirationTime,
// 确保日期数据正确提交(使用数据库格式)
applyTime: values.applyTime || (applyTime ? formatDateForDatabase(applyTime) : ''),
contractTime: values.contractTime || (contractTime ? formatDateForDatabase(contractTime) : '')
};
// 调试信息
console.log('=== 提交数据调试 ===');
console.log('是否编辑模式:', isEditMode);
console.log('计算的过期时间:', expirationTime);
console.log('提交的数据:', submitData);
console.log('==================');
// 如果是编辑模式添加现有申请的id
if (isEditMode && existingApply?.applyId) {
submitData.applyId = existingApply.applyId;
}
// 执行新增或更新操作
if (isEditMode) {
await updateShopDealerApply(submitData);
} else {
await addShopDealerApply(submitData);
}
Taro.showToast({
title: `${isEditMode ? '更新' : '提交'}成功`,
icon: 'success'
});
setTimeout(() => {
Taro.navigateBack();
}, 1000);
} catch (error) {
console.error('提交失败:', error);
Taro.showToast({
title: '提交失败,请重试',
icon: 'error'
});
}
}
// 处理固定按钮点击事件
const handleFixedButtonClick = () => {
// 触发表单提交
formRef.current?.submit();
};
const submitFailed = (error: any) => {
console.log(error, 'err...')
}
useEffect(() => {
reload().then(() => {
setLoading(false)
}).catch((error) => {
console.error('页面加载失败:', error);
setLoading(false);
// Taro.showToast({
// title: '页面加载失败',
// icon: 'error'
// });
})
}, []); // 依赖用户ID当用户变化时重新加载
// 编辑模式下,从 dealerCode 反解出楼栋/单元/房号,回填表单(只读展示)
useEffect(() => {
if (!formRef.current || !FormData) return;
const parsed = parseHouseKey(FormData.dealerCode);
formRef.current.setFieldsValue({
address: parsed.community || FormData.address,
buildingNo: parsed.buildingNo,
unitNo: parsed.unitNo,
roomNo: parsed.roomNo,
realName: FormData.realName,
mobile: FormData.mobile,
userId: FormData.userId
});
}, [FormData]);
if (loading) {
return <Loading className={'px-2'}></Loading>
}
return (
<>
<Form
ref={formRef}
divider
initialValues={FormData}
labelPosition="left"
onFinish={(values) => submitSucceed(values)}
onFinishFailed={(errors) => submitFailed(errors)}
>
<View className={'bg-gray-100 h-3'}></View>
<CellGroup style={{padding: '4px 0'}}>
<Form.Item name="address" label="小区" initialValue={FormData?.address} required>
<Input placeholder="幸福里" disabled={isEditMode}/>
</Form.Item>
<Form.Item name="buildingNo" label="楼栋号" required>
<Input placeholder="3" disabled={isEditMode}/>
</Form.Item>
<Form.Item name="unitNo" label="单元号">
<Input placeholder="1" disabled={isEditMode}/>
</Form.Item>
<Form.Item name="roomNo" label="房号" required>
<Input placeholder="1201" disabled={isEditMode}/>
</Form.Item>
<Form.Item name="realName" label="姓名" initialValue={FormData?.realName} required>
<Input placeholder="张三" disabled={isEditMode}/>
</Form.Item>
<Form.Item name="mobile" label="手机号" initialValue={FormData?.mobile} required>
<Input placeholder="手机号" disabled={isEditMode} maxLength={11}/>
</Form.Item>
{isEditMode && (
<>
<Form.Item name="money" label="签约价格" initialValue={FormData?.money} required>
<Input placeholder="(元/兆瓦时)" disabled={false}/>
</Form.Item>
<Form.Item name="applyTime" label="签约时间" initialValue={FormData?.applyTime}>
<View
className="flex items-center justify-between py-2"
onClick={() => setShowApplyTimePicker(true)}
>
<View className="flex items-center">
<CalendarIcon size={16} color="#999" className="mr-2"/>
<Text style={{color: applyTime ? '#333' : '#999'}}>
{applyTime ? formatDateForDisplay(applyTime) : '请选择签约时间'}
</Text>
</View>
</View>
</Form.Item>
<Form.Item name="contractTime" label="合同日期" initialValue={FormData?.contractTime}>
<View
className="flex items-center justify-between py-2"
onClick={() => setShowContractTimePicker(true)}
>
<View className="flex items-center">
<CalendarIcon size={16} color="#999" className="mr-2"/>
<Text style={{color: contractTime ? '#333' : '#999'}}>
{contractTime ? formatDateForDisplay(contractTime) : '请选择合同生效起止时间'}
</Text>
</View>
</View>
</Form.Item>
{/*<Form.Item name="refereeId" label="邀请人ID" initialValue={FormData?.refereeId} required>*/}
{/* <Input placeholder="邀请人ID"/>*/}
{/*</Form.Item>*/}
</>
)}
<Form.Item name="userId" label="报备人(ID)" initialValue={FormData?.userId}>
<Input
placeholder="自己报备请留空"
disabled={isEditMode}
type="number"
/>
</Form.Item>
</CellGroup>
</Form>
{/* 签约时间选择器 */}
<Calendar
visible={showApplyTimePicker}
defaultValue={applyTime}
onClose={() => setShowApplyTimePicker(false)}
onConfirm={handleApplyTimeConfirm}
/>
{/* 合同日期选择器 */}
<Calendar
visible={showContractTimePicker}
defaultValue={contractTime}
onClose={() => setShowContractTimePicker(false)}
onConfirm={handleContractTimeConfirm}
/>
{/* 审核状态显示(仅在编辑模式下显示) */}
{isEditMode && (
<CellGroup>
{/*<Cell*/}
{/* title={'审核状态'}*/}
{/* extra={*/}
{/* <span style={{*/}
{/* color: FormData?.applyStatus === 20 ? '#52c41a' :*/}
{/* FormData?.applyStatus === 30 ? '#ff4d4f' : '#faad14'*/}
{/* }}>*/}
{/* {getApplyStatusText(FormData?.applyStatus)}*/}
{/* </span>*/}
{/* }*/}
{/*/>*/}
{FormData?.applyStatus === 20 && (
<Cell title={'签约时间'} extra={FormData?.auditTime || '无'}/>
)}
{FormData?.applyStatus === 30 && (
<Cell title={'驳回原因'} extra={FormData?.rejectReason || '无'}/>
)}
</CellGroup>
)}
{/* 底部浮动按钮 */}
{(!isEditMode || FormData?.applyStatus === 10) && (
<FixedButton
icon={<Edit/>}
text={'立即提交'}
onClick={handleFixedButtonClick}
/>
)}
</>
);
};
export default AddShopDealerApply;