- 添加费用类型选择器组件 CostPicker,支持搜索和选择费用类型 - 实现费用录入卡片 ExpenseCostCard,支持编辑和删除操作 - 创建费用录入弹窗 ExpenseCostCreate,包含费用类型、金额和备注字段 - 开发费用列表组件 ExpenseCostList,展示已录入费用并计算合计金额 - 实现计提记录卡片 ExpenseProvisionCard,支持编辑和删除计提信息 - 添加计提录入弹窗 ExpenseProvisionCreate,集成客户选择和金额输入 - 创建计提列表组件 ExpenseProvisionList,按客户分组展示计提记录 - 更新图标组件 Icon,新增 trash-can、pen、chevron-up 和 building 图标 - 导出所有费用相关组件,便于在其他模块中复用
656 lines
21 KiB
TypeScript
656 lines
21 KiB
TypeScript
import { Image, View } from "@tarojs/components";
|
||
import { Button, Input, SafeArea, Toast } from "@nutui/nutui-react-taro";
|
||
import { Icon } from "@/components";
|
||
import hocAuth from "@/hocs/auth";
|
||
import { CommonComponent } from "@/types/typings";
|
||
import { useEffect, useState } from "react";
|
||
import { business } from "@/services";
|
||
import Taro from "@tarojs/taro";
|
||
import { buildUrl, generateShortId, uploadFile } from "@/utils";
|
||
|
||
export default hocAuth(function Page(props: CommonComponent) {
|
||
const { setLoading, router } = props;
|
||
const supplierId = router.params
|
||
.supplierId as BusinessAPI.SupplierVO["supplierId"];
|
||
|
||
const [supplierVO, setSupplierVO] = useState<BusinessAPI.SupplierCreateCmd>({
|
||
supplierId: generateShortId(),
|
||
name: "",
|
||
idCard: "",
|
||
phone: "",
|
||
bankCard: "",
|
||
status: true,
|
||
});
|
||
const [btnLoading, setBtnLoading] = useState(false);
|
||
|
||
// 错误状态
|
||
const [nameError, setNameError] = useState(false);
|
||
const [idCardError, setIdCardError] = useState(false);
|
||
const [bankCardError, setBankCardError] = useState(false);
|
||
const [phoneError, setPhoneError] = useState(false);
|
||
const [nameDuplicateError, setNameDuplicateError] = useState(false); // 姓名重复错误
|
||
|
||
// 初始化微信二维码图片列表
|
||
useEffect(() => {
|
||
if (supplierId) {
|
||
setLoading(true);
|
||
business.supplier
|
||
.showSupplier({
|
||
supplierShowQry: {
|
||
supplierId: supplierId,
|
||
},
|
||
})
|
||
.then(({ data: { data: supplierVO } }) => {
|
||
if (supplierVO) {
|
||
setSupplierVO({
|
||
...supplierVO,
|
||
});
|
||
}
|
||
})
|
||
.finally(() => {
|
||
setLoading(false);
|
||
});
|
||
}
|
||
}, [supplierId]);
|
||
|
||
// 校验姓名函数
|
||
const validateName = (name: string) => {
|
||
if (!name) {
|
||
return false;
|
||
}
|
||
// 姓名至少2个字符
|
||
return name.length >= 2;
|
||
};
|
||
|
||
// 校验身份证号函数
|
||
const validateIdCard = (idCard: string) => {
|
||
if (!idCard) {
|
||
return false;
|
||
}
|
||
// 18位身份证号正则表达式
|
||
const idCardRegex =
|
||
/^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/;
|
||
return idCardRegex.test(idCard);
|
||
};
|
||
|
||
// 校验银行卡号函数
|
||
const validateBankCard = (bankCard: string) => {
|
||
if (!bankCard) {
|
||
return false;
|
||
}
|
||
// 银行卡号一般为16-19位数字
|
||
const bankCardRegex = /^\d{16,19}$/;
|
||
return bankCardRegex.test(bankCard);
|
||
};
|
||
|
||
// 校验手机号函数 (使用项目中已有的规则)
|
||
const validatePhone = (phone: string) => {
|
||
if (!phone) {
|
||
return false;
|
||
}
|
||
// 使用项目规范的手机号正则表达式
|
||
const phoneRegex = /^1[3456789]\d{9}$/;
|
||
return phoneRegex.test(phone);
|
||
};
|
||
|
||
// 处理姓名变化
|
||
const handleNameChange = (value: string) => {
|
||
setSupplierVO({
|
||
...supplierVO,
|
||
name: value,
|
||
});
|
||
|
||
// 校验并更新错误状态
|
||
const isValid = validateName(value);
|
||
setNameError(!isValid && value !== "");
|
||
|
||
if (isValid) {
|
||
setNameError(false);
|
||
}
|
||
|
||
// 清除姓名重复错误提示
|
||
setNameDuplicateError(false);
|
||
};
|
||
|
||
// 处理姓名失焦
|
||
const handleNameBlur = async (value: string) => {
|
||
if (value && !validateName(value)) {
|
||
setNameError(true);
|
||
return;
|
||
} else {
|
||
setNameError(false);
|
||
}
|
||
|
||
// 检查瓜农姓名是否在系统中存在
|
||
if (value) {
|
||
setLoading(true);
|
||
try {
|
||
const { data } = await business.supplier.checkSupplier({
|
||
supplierCheckQry: {
|
||
name: value,
|
||
},
|
||
});
|
||
|
||
if (data.success && data.data) {
|
||
// 系统中已存在该瓜农信息,设置姓名重复错误状态
|
||
setNameDuplicateError(true);
|
||
} else {
|
||
setNameDuplicateError(false);
|
||
}
|
||
} catch (error) {
|
||
console.error("检查瓜农信息失败", error);
|
||
setNameDuplicateError(false);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 处理身份证号变化
|
||
const handleIdCardChange = (value: string) => {
|
||
setSupplierVO({
|
||
...supplierVO,
|
||
idCard: value,
|
||
});
|
||
|
||
// 校验并更新错误状态
|
||
const isValid = validateIdCard(value);
|
||
setIdCardError(!isValid && value !== "");
|
||
|
||
if (isValid) {
|
||
setIdCardError(false);
|
||
}
|
||
};
|
||
|
||
// 处理身份证号失焦
|
||
const handleIdCardBlur = (value: string) => {
|
||
if (value && !validateIdCard(value)) {
|
||
setIdCardError(true);
|
||
} else {
|
||
setIdCardError(false);
|
||
}
|
||
};
|
||
|
||
// 处理银行卡号变化
|
||
const handleBankCardChange = (value: string) => {
|
||
setSupplierVO({
|
||
...supplierVO,
|
||
bankCard: value,
|
||
});
|
||
|
||
// 校验并更新错误状态
|
||
const isValid = validateBankCard(value);
|
||
setBankCardError(!isValid && value !== "");
|
||
|
||
if (isValid) {
|
||
setBankCardError(false);
|
||
}
|
||
};
|
||
|
||
// 处理银行卡号失焦
|
||
const handleBankCardBlur = (value: string) => {
|
||
if (value && !validateBankCard(value)) {
|
||
setBankCardError(true);
|
||
} else {
|
||
setBankCardError(false);
|
||
}
|
||
};
|
||
|
||
// 处理手机号变化
|
||
const handlePhoneChange = (value: string) => {
|
||
setSupplierVO({
|
||
...supplierVO,
|
||
phone: value,
|
||
});
|
||
|
||
// 校验并更新错误状态
|
||
const isValid = validatePhone(value);
|
||
setPhoneError(!isValid && value !== "");
|
||
|
||
if (isValid) {
|
||
setPhoneError(false);
|
||
}
|
||
};
|
||
|
||
// 处理手机号失焦
|
||
const handlePhoneBlur = (value: string) => {
|
||
if (value && !validatePhone(value)) {
|
||
setPhoneError(true);
|
||
} else {
|
||
setPhoneError(false);
|
||
}
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
// 校验表单
|
||
const isNameValid = validateName(supplierVO.name || "");
|
||
const isIdCardValid = validateIdCard(supplierVO.idCard || "");
|
||
const isBankCardValid = validateBankCard(supplierVO.bankCard || "");
|
||
const isPhoneValid = validatePhone(supplierVO.phone || "");
|
||
|
||
setNameError(!isNameValid);
|
||
setIdCardError(!isIdCardValid);
|
||
setBankCardError(!isBankCardValid);
|
||
setPhoneError(!isPhoneValid);
|
||
|
||
// 检查是否已存在同名瓜农
|
||
let isDuplicate = false;
|
||
if (isNameValid) {
|
||
try {
|
||
setBtnLoading(true);
|
||
const { data: checkData } = await business.supplier.checkSupplier({
|
||
supplierCheckQry: {
|
||
name: supplierVO.name,
|
||
},
|
||
});
|
||
|
||
if (checkData.success && checkData.data) {
|
||
isDuplicate = true;
|
||
setNameDuplicateError(true);
|
||
}
|
||
} catch (error) {
|
||
console.error("检查瓜农信息失败", error);
|
||
} finally {
|
||
setBtnLoading(false);
|
||
}
|
||
}
|
||
|
||
const isValid =
|
||
isNameValid &&
|
||
isIdCardValid &&
|
||
isBankCardValid &&
|
||
isPhoneValid &&
|
||
!isDuplicate;
|
||
|
||
if (!isValid) {
|
||
if (isDuplicate) {
|
||
Toast.show("toast", {
|
||
icon: "fail",
|
||
title: "提示",
|
||
content: "该瓜农已存在,请勿重复创建",
|
||
});
|
||
} else {
|
||
Toast.show("toast", {
|
||
icon: "fail",
|
||
title: "提示",
|
||
content: "请完善瓜农信息后再进行保存操作",
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 创建新瓜农
|
||
try {
|
||
setBtnLoading(true);
|
||
const { data } = await business.supplier.createSupplier({
|
||
supplierId: supplierVO.supplierId,
|
||
name: supplierVO.name,
|
||
idCard: supplierVO.idCard,
|
||
phone: supplierVO.phone,
|
||
bankCard: supplierVO.bankCard,
|
||
wechatQr: supplierVO.wechatQr,
|
||
status: supplierVO.status,
|
||
});
|
||
|
||
if (data.success) {
|
||
Toast.show("toast", {
|
||
icon: "success",
|
||
title: "成功",
|
||
content: "瓜农创建成功",
|
||
});
|
||
setTimeout(() => {
|
||
Taro.navigateBack();
|
||
}, 1500);
|
||
} else {
|
||
Toast.show("toast", {
|
||
icon: "fail",
|
||
title: "失败",
|
||
content: data.errMessage || "创建失败",
|
||
});
|
||
}
|
||
} catch (error) {
|
||
Toast.show("toast", {
|
||
icon: "fail",
|
||
title: "错误",
|
||
content: "创建过程中发生错误",
|
||
});
|
||
console.error("创建瓜农失败:", error);
|
||
} finally {
|
||
setBtnLoading(false);
|
||
}
|
||
};
|
||
const handleWechatQrUpload = async () => {
|
||
await Taro.chooseImage({
|
||
count: 1,
|
||
sourceType: ["album", "camera"],
|
||
success: (res) => {
|
||
setLoading(true);
|
||
const file = res.tempFiles[0];
|
||
uploadFile(file.path)
|
||
.then(({ url }) => {
|
||
setSupplierVO({
|
||
...supplierVO,
|
||
wechatQr: url,
|
||
});
|
||
setLoading(false);
|
||
Toast.show("toast", {
|
||
title: "上传成功",
|
||
icon: "success",
|
||
content: "微信收款码已上传",
|
||
});
|
||
})
|
||
.catch((err) => {
|
||
Toast.show("toast", {
|
||
title: "上传失败",
|
||
icon: "fail",
|
||
content: err.message || "上传过程中发生错误",
|
||
});
|
||
setLoading(false);
|
||
});
|
||
},
|
||
fail: (err) => {
|
||
Toast.show("toast", {
|
||
title: "选择图片失败",
|
||
icon: "fail",
|
||
content: err.errMsg,
|
||
});
|
||
},
|
||
});
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<View className="flex-1">
|
||
<View
|
||
className="flex flex-1 flex-col gap-2.5 p-2.5"
|
||
style={{
|
||
//@ts-ignore
|
||
"--nutui-input-padding": 0,
|
||
}}
|
||
>
|
||
{/* 功能提醒 */}
|
||
<View className="flex items-center rounded-lg border border-blue-200 bg-blue-50 p-2.5">
|
||
<Icon
|
||
className={"mr-1"}
|
||
name="circle-info"
|
||
color={"var(--color-blue-700)"}
|
||
size={18}
|
||
/>
|
||
<View className={"text-sm text-blue-700"}>
|
||
收集瓜农信息的目的是用于打款
|
||
</View>
|
||
</View>
|
||
|
||
{/* 快捷工具 */}
|
||
<View className="flex gap-2">
|
||
<View className={"flex-1"}>
|
||
<Button
|
||
block
|
||
icon={<Icon name={"id-card"} size={28} color={"white"} />}
|
||
type={"primary"}
|
||
size={"xlarge"}
|
||
className="bg-primary flex flex-1 items-center justify-center text-white"
|
||
onClick={() => {
|
||
Taro.navigateTo({
|
||
url: buildUrl("/pages/public/camera/ocr", {
|
||
type: "idcard",
|
||
}),
|
||
complete: () => {
|
||
Taro.eventCenter.on("ocr", (res) => {
|
||
console.log("识别结果为:", res.result);
|
||
setSupplierVO({
|
||
...supplierVO,
|
||
name: res.result.name,
|
||
idCard: res.result.idCard,
|
||
});
|
||
|
||
Taro.eventCenter.off("ocr");
|
||
});
|
||
},
|
||
});
|
||
}}
|
||
>
|
||
<View>扫描身份证</View>
|
||
</Button>
|
||
</View>
|
||
<View className={"flex-1"}>
|
||
<Button
|
||
block
|
||
icon={<Icon name={"credit-card"} size={28} color={"white"} />}
|
||
type={"primary"}
|
||
size={"xlarge"}
|
||
className="bg-primary flex flex-1 items-center justify-center text-white"
|
||
onClick={() => {
|
||
Taro.navigateTo({
|
||
url: buildUrl("/pages/public/camera/ocr", {
|
||
type: "bankcard",
|
||
}),
|
||
complete: () => {
|
||
Taro.eventCenter.on("ocr", (res) => {
|
||
console.log("识别结果为:", res.result);
|
||
setSupplierVO({
|
||
...supplierVO,
|
||
bankCard: res.result.number,
|
||
});
|
||
|
||
Taro.eventCenter.off("ocr");
|
||
});
|
||
},
|
||
});
|
||
}}
|
||
>
|
||
<View>扫描银行卡</View>
|
||
</Button>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 瓜农信息 */}
|
||
<View className="border-primary rounded-lg border-4 bg-white p-2.5 shadow-sm">
|
||
<View className="flex flex-col gap-2.5">
|
||
<View className="flex items-center justify-between">
|
||
<View className="text-primary text-base font-bold">
|
||
{supplierVO.name || "瓜农"}的基本信息
|
||
</View>
|
||
</View>
|
||
<View className={"block text-sm font-normal text-[#000000]"}>
|
||
姓名
|
||
</View>
|
||
<View
|
||
className={`flex h-10 w-full items-center rounded-md ${nameError || nameDuplicateError ? "border-4 border-red-500" : "border-4 border-gray-300"}`}
|
||
>
|
||
<Icon name="user" size={16} color="#999" className="mx-2" />
|
||
<Input
|
||
type="text"
|
||
placeholder="请输入姓名"
|
||
value={supplierVO.name}
|
||
onChange={(value) => handleNameChange(value)}
|
||
onBlur={() => handleNameBlur(supplierVO.name || "")}
|
||
className="flex-1"
|
||
/>
|
||
</View>
|
||
{nameError && (
|
||
<View className="mt-1 text-xs text-red-500">
|
||
{`姓名"${supplierVO.name}"至少2个字符`}
|
||
</View>
|
||
)}
|
||
{nameDuplicateError && (
|
||
<View className="mt-1 text-xs text-red-500">
|
||
该瓜农已存在,请勿重复创建
|
||
</View>
|
||
)}
|
||
<View className="block text-sm font-normal text-[#000000]">
|
||
身份证号
|
||
</View>
|
||
<View
|
||
className={`flex h-10 w-full items-center rounded-md ${idCardError ? "border-4 border-red-500" : "border-4 border-gray-300"}`}
|
||
>
|
||
<Icon name="id-card" size={16} color="#999" className="mx-2" />
|
||
<Input
|
||
type="idcard"
|
||
placeholder="请输入身份证号"
|
||
value={supplierVO.idCard}
|
||
onChange={(value) => handleIdCardChange(value)}
|
||
onBlur={() => handleIdCardBlur(supplierVO.idCard || "")}
|
||
className="flex-1"
|
||
/>
|
||
</View>
|
||
{idCardError && (
|
||
<View className="mt-1 text-xs text-red-500">
|
||
请输入正确的身份证号
|
||
</View>
|
||
)}
|
||
<View className="block text-sm font-normal text-[#000000]">
|
||
银行卡号
|
||
</View>
|
||
<View
|
||
className={`flex h-10 w-full items-center rounded-md ${bankCardError ? "border-4 border-red-500" : "border-4 border-gray-300"}`}
|
||
>
|
||
<Icon
|
||
name="credit-card"
|
||
size={16}
|
||
color="#999"
|
||
className="mx-2"
|
||
/>
|
||
<Input
|
||
type="digit"
|
||
placeholder="请输入银行卡号"
|
||
value={supplierVO.bankCard}
|
||
onChange={(value) => handleBankCardChange(value)}
|
||
onBlur={() => handleBankCardBlur(supplierVO.bankCard || "")}
|
||
className="flex-1"
|
||
/>
|
||
</View>
|
||
{bankCardError && (
|
||
<View className="mt-1 text-xs text-red-500">
|
||
请输入正确的银行卡号
|
||
</View>
|
||
)}
|
||
<View className="block text-sm font-normal text-[#000000]">
|
||
手机号码
|
||
</View>
|
||
<View
|
||
className={`flex h-10 w-full items-center rounded-md ${phoneError ? "border-4 border-red-500" : "border-4 border-gray-300"}`}
|
||
>
|
||
<Icon name="phone" size={16} color="#999" className="mx-2" />
|
||
<Input
|
||
type="tel"
|
||
placeholder="请输入手机号码"
|
||
value={supplierVO.phone}
|
||
onChange={(value) => handlePhoneChange(value)}
|
||
onBlur={() => handlePhoneBlur(supplierVO.phone || "")}
|
||
className="flex-1"
|
||
/>
|
||
</View>
|
||
{phoneError && (
|
||
<View className="mt-1 text-xs text-red-500">
|
||
请输入正确的手机号
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
|
||
{/* 若瓜农无法开发票,则可打款到微信 */}
|
||
<View className="border-primary rounded-lg border-4 bg-white p-2.5 shadow-sm">
|
||
<View className="flex flex-col gap-2.5">
|
||
<View className="text-primary text-base font-bold">
|
||
{supplierVO.name || "瓜农"}的微信收款码(可跳过)
|
||
</View>
|
||
{supplierVO.wechatQr ? (
|
||
<View className="flex flex-col gap-2.5 rounded-lg bg-green-50 p-2.5">
|
||
<View className="flex items-center">
|
||
<View className="relative mr-3 h-20 w-20 overflow-hidden rounded-lg">
|
||
<Image
|
||
className="h-full w-full object-cover"
|
||
src={supplierVO.wechatQr}
|
||
/>
|
||
</View>
|
||
<View className="flex-1">
|
||
<View className="font-medium text-green-700">
|
||
微信收款码
|
||
</View>
|
||
<View className="text-sm text-green-600">已上传完成</View>
|
||
<View className="text-neutral-darker mt-1 text-xs">
|
||
若瓜农无法开发票,则可打款到微信
|
||
</View>
|
||
</View>
|
||
</View>
|
||
<View className="flex flex-row gap-2.5">
|
||
<View className={"flex-1"}>
|
||
<Button
|
||
type={"primary"}
|
||
size={"large"}
|
||
fill={"outline"}
|
||
block
|
||
onClick={handleWechatQrUpload}
|
||
>
|
||
<View>重新上传</View>
|
||
</Button>
|
||
</View>
|
||
<View className={"flex-1"}>
|
||
<Button
|
||
type={"warning"}
|
||
size={"large"}
|
||
fill={"outline"}
|
||
block
|
||
onClick={(e) => {
|
||
setSupplierVO({
|
||
...supplierVO,
|
||
wechatQr: undefined,
|
||
});
|
||
Toast.show("toast", {
|
||
title: "删除成功",
|
||
icon: "success",
|
||
content: "微信收款码已删除",
|
||
});
|
||
e.stopPropagation();
|
||
}}
|
||
>
|
||
<View>删除</View>
|
||
</Button>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
) : (
|
||
<View
|
||
className={
|
||
"flex h-40 flex-1 flex-col items-center justify-center rounded-md border-2 border-dashed border-green-300 bg-green-50 p-2.5"
|
||
}
|
||
onClick={handleWechatQrUpload}
|
||
>
|
||
<Icon name={"camera"} size={24} />
|
||
<View className="text-sm font-medium text-green-700">
|
||
上传微信收款码
|
||
</View>
|
||
<View className="text-neutral-darker mt-1 text-xs">
|
||
支持从相册选择或拍照
|
||
</View>
|
||
<View className="mt-2 text-center text-xs text-gray-400">
|
||
若瓜农无法开发票,则可打款到微信
|
||
</View>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
|
||
<View className="sticky bottom-0 z-10 bg-white">
|
||
<View className="flex justify-between gap-2 border-t border-gray-200 p-2.5">
|
||
<View className={"flex-1"}>
|
||
<Button
|
||
block
|
||
type="primary"
|
||
size={"xlarge"}
|
||
onClick={handleSave}
|
||
loading={btnLoading}
|
||
>
|
||
保存瓜农信息
|
||
</Button>
|
||
</View>
|
||
</View>
|
||
<SafeArea position={"bottom"} />
|
||
</View>
|
||
</>
|
||
);
|
||
});
|