feat(credit): 完善客户跟进流程功能

- 添加了客户跟进流程的第2至第7步页面路由配置
- 实现了完整的七步跟进流程,包括加微信、建群沟通、合同定稿等环节
- 添加了各步骤的状态管理、审批机制和进度跟踪功能
- 集成了录音功能,支持现场录音和从聊天记录选择音频
- 优化了图片上传组件,使用Image组件替代背景图方式显示
- 添加了步骤间的导航控制和前置条件检查逻辑
- 实现了审核接口和批量审批功能
- 增加了跟进统计和流程结束功能
- 完善了用户界面,提供更清晰的流程指引和状态展示
This commit is contained in:
2026-03-22 22:32:39 +08:00
parent ad39a9c1aa
commit 71c943fc60
19 changed files with 5009 additions and 29 deletions

View File

@@ -0,0 +1,459 @@
import { useCallback, useMemo, useState } from 'react'
import Taro, { useDidShow, useRouter } from '@tarojs/taro'
import { Text, View } from '@tarojs/components'
import { Button, ConfigProvider, Empty, Loading } from '@nutui/nutui-react-taro'
import dayjs from 'dayjs'
import { getCreditMpCustomer, updateCreditMpCustomer } from '@/api/credit/creditMpCustomer'
import type { CreditMpCustomer } from '@/api/credit/creditMpCustomer/model'
const safeParseJSON = <T,>(v: any): T | null => {
try {
if (!v) return null
if (typeof v === 'object') return v as T
if (typeof v === 'string') return JSON.parse(v) as T
return null
} catch (_e) {
return null
}
}
type PaymentRecord = {
id: string
amount: number
principal: number
interest: number
paymentTime: string
status: 'pending' | 'completed'
}
type ExpectedPayment = {
id: string
amount: number
expectedDate: string
createdAt: string
}
type PaymentStats = {
totalAmount: number
totalPrincipal: number
totalInterest: number
paidPrincipal: number
paidInterest: number
unpaidPrincipal: number
unpaidInterest: number
}
const makeId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`
const parsePaymentRecords = (raw?: string): PaymentRecord[] => {
const parsed = safeParseJSON<any[]>(raw)
if (!Array.isArray(parsed)) return []
return parsed.map(item => ({
id: makeId(),
amount: Number(item?.amount || 0),
principal: Number(item?.principal || 0),
interest: Number(item?.interest || 0),
paymentTime: String(item?.paymentTime || '').trim(),
status: item?.status || 'completed'
}))
}
const parseExpectedPayments = (raw?: string): ExpectedPayment[] => {
const parsed = safeParseJSON<any[]>(raw)
if (!Array.isArray(parsed)) return []
return parsed.map(item => ({
id: makeId(),
amount: Number(item?.amount || 0),
expectedDate: String(item?.expectedDate || '').trim(),
createdAt: String(item?.createdAt || dayjs().format('YYYY-MM-DD HH:mm:ss'))
}))
}
export default function CreditMpCustomerFollowStep6Page() {
const router = useRouter()
const customerId = useMemo(() => {
const id = Number((router?.params as any)?.id)
return Number.isFinite(id) && id > 0 ? id : undefined
}, [router?.params])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [row, setRow] = useState<CreditMpCustomer | null>(null)
const [paymentRecords, setPaymentRecords] = useState<PaymentRecord[]>([])
const [expectedPayments, setExpectedPayments] = useState<ExpectedPayment[]>([])
const [paymentStats, setPaymentStats] = useState<PaymentStats>({
totalAmount: 0,
totalPrincipal: 0,
totalInterest: 0,
paidPrincipal: 0,
paidInterest: 0,
unpaidPrincipal: 0,
unpaidInterest: 0
})
const reload = useCallback(async () => {
setError(null)
setLoading(true)
try {
if (!customerId) throw new Error('缺少客户ID')
const res = await getCreditMpCustomer(customerId)
const next = (res || null) as CreditMpCustomer | null
setRow(next)
const anyRow = next as any
const submitted = Boolean(anyRow?.followStep5Submitted) || Boolean(anyRow?.followStep5SubmittedAt)
// 解析财务录入的回款记录
const records = parsePaymentRecords(anyRow?.followStep6PaymentRecords)
setPaymentRecords(records)
// 解析预计回款
const expected = parseExpectedPayments(anyRow?.followStep6ExpectedPayments)
setExpectedPayments(expected)
// 计算统计信息
const stats = calculatePaymentStats(records, anyRow)
setPaymentStats(stats)
// 如果第五步未完成,显示提示
if (!submitted) {
setError('请先完成第五步合同签订')
}
} catch (e) {
console.error('加载失败:', e)
setRow(null)
setError(String((e as any)?.message || '加载失败'))
} finally {
setLoading(false)
}
}, [customerId])
const calculatePaymentStats = useCallback((records: PaymentRecord[], rowData: any): PaymentStats => {
const contracts = safeParseJSON<any[]>(rowData?.followStep5Contracts) || []
// 计算应回款总额(从合同中获取)
let totalPrincipal = 0
let totalInterest = 0
contracts.forEach((contract: any) => {
totalPrincipal += Number(contract?.principal || 0)
totalInterest += Number(contract?.interest || 0)
})
const totalAmount = totalPrincipal + totalInterest
// 计算已回款金额
let paidPrincipal = 0
let paidInterest = 0
records.forEach(record => {
if (record.status === 'completed') {
paidPrincipal += record.principal
paidInterest += record.interest
}
})
const unpaidPrincipal = totalPrincipal - paidPrincipal
const unpaidInterest = totalInterest - paidInterest
return {
totalAmount,
totalPrincipal,
totalInterest,
paidPrincipal,
paidInterest,
unpaidPrincipal,
unpaidInterest
}
}, [])
useDidShow(() => {
reload().then()
})
const step5Done = useMemo(() => {
const anyRow = row as any
return Boolean(anyRow?.followStep5Submitted) || Boolean(anyRow?.followStep5SubmittedAt)
}, [row])
const addExpectedPayment = useCallback(async () => {
if (!step5Done) {
Taro.showToast({ title: '请先完成第五步合同签订', icon: 'none' })
return
}
try {
// 简化实现,直接使用当前日期和固定金额作为示例
// 在实际项目中,您可能需要使用自定义输入组件
const amount = 1000 // 默认金额
const date = dayjs().add(7, 'day').format('YYYY-MM-DD') // 默认7天后
const newExpected: ExpectedPayment = {
id: makeId(),
amount,
expectedDate: date,
createdAt: dayjs().format('YYYY-MM-DD HH:mm:ss')
}
setExpectedPayments(prev => [...prev, newExpected])
// 保存到后端
if (row && customerId) {
const payload = [...expectedPayments, newExpected].map(item => ({
amount: item.amount,
expectedDate: item.expectedDate,
createdAt: item.createdAt
}))
await updateCreditMpCustomer({
...(row as any),
id: customerId,
followStep6ExpectedPayments: JSON.stringify(payload)
} as any)
}
Taro.showToast({ title: '添加成功', icon: 'success' })
} catch (e) {
Taro.showToast({ title: (e as any)?.message || '添加失败', icon: 'none' })
}
}, [step5Done, expectedPayments, row, customerId])
const deleteExpectedPayment = useCallback(async (expectedId: string) => {
try {
const res = await Taro.showModal({
title: '确认删除',
content: '确定要删除这条预计回款记录吗?'
})
if (!res.confirm) return
const updated = expectedPayments.filter(item => item.id !== expectedId)
setExpectedPayments(updated)
// 保存到后端
if (row && customerId) {
const payload = updated.map(item => ({
amount: item.amount,
expectedDate: item.expectedDate,
createdAt: item.createdAt
}))
await updateCreditMpCustomer({
...(row as any),
id: customerId,
followStep6ExpectedPayments: JSON.stringify(payload)
} as any)
}
Taro.showToast({ title: '删除成功', icon: 'success' })
} catch (e) {
Taro.showToast({ title: (e as any)?.message || '删除失败', icon: 'none' })
}
}, [expectedPayments, row, customerId])
const goStep5 = () => {
if (!customerId) return
Taro.navigateTo({ url: `/credit/mp-customer/follow-step5?id=${customerId}` }).catch(() => {})
}
const goStep7 = () => {
if (!customerId) return
Taro.navigateTo({ url: `/credit/mp-customer/follow-step7?id=${customerId}` }).catch(() => {})
}
const formatCurrency = (amount: number) => {
return `¥${amount.toFixed(2)}`
}
const headerOffset = 12
return (
<View className="bg-pink-50 min-h-screen">
<ConfigProvider>
<View style={{ paddingTop: `${headerOffset}px` }} className="max-w-md mx-auto px-4 pb-32">
{loading ? (
<View className="py-16 flex justify-center items-center text-gray-500">
<Loading />
<Text className="ml-2">...</Text>
</View>
) : error ? (
<View className="bg-white rounded-xl border border-pink-100 p-6">
<View className="text-red-500 text-sm">{error}</View>
<View className="mt-4">
<Button type="primary" onClick={reload}>
</Button>
</View>
{!step5Done && (
<View className="mt-4">
<Button type="primary" onClick={goStep5}>
</Button>
</View>
)}
</View>
) : !row ? (
<View className="bg-white rounded-xl border border-pink-100 py-10">
<Empty description="暂无客户信息" />
</View>
) : (
<View className="space-y-4">
{/* 回款统计 */}
<View className="bg-white rounded-xl border border-pink-100 p-4">
<View className="text-sm font-semibold text-red-600 mb-4"></View>
<View className="grid grid-cols-2 gap-4 text-sm">
<View className="bg-gray-50 rounded-lg p-3">
<Text className="text-gray-600 text-xs"></Text>
<Text className="text-lg font-semibold text-gray-800 mt-1">
{formatCurrency(paymentStats.totalAmount)}
</Text>
</View>
<View className="bg-gray-50 rounded-lg p-3">
<Text className="text-gray-600 text-xs"></Text>
<Text className="text-lg font-semibold text-gray-800 mt-1">
{formatCurrency(paymentStats.totalPrincipal)}
</Text>
</View>
<View className="bg-gray-50 rounded-lg p-3">
<Text className="text-gray-600 text-xs"></Text>
<Text className="text-lg font-semibold text-gray-800 mt-1">
{formatCurrency(paymentStats.totalInterest)}
</Text>
</View>
<View className="bg-green-50 rounded-lg p-3">
<Text className="text-green-600 text-xs"></Text>
<Text className="text-lg font-semibold text-green-800 mt-1">
{formatCurrency(paymentStats.paidPrincipal)}
</Text>
</View>
<View className="bg-green-50 rounded-lg p-3">
<Text className="text-green-600 text-xs"></Text>
<Text className="text-lg font-semibold text-green-800 mt-1">
{formatCurrency(paymentStats.paidInterest)}
</Text>
</View>
<View className="bg-red-50 rounded-lg p-3">
<Text className="text-red-600 text-xs"></Text>
<Text className="text-lg font-semibold text-red-800 mt-1">
{formatCurrency(paymentStats.unpaidPrincipal)}
</Text>
</View>
</View>
<View className="mt-4 bg-amber-50 rounded-lg p-3">
<Text className="text-amber-600 text-xs"></Text>
<Text className="text-lg font-semibold text-amber-800 mt-1">
{formatCurrency(paymentStats.unpaidInterest)}
</Text>
</View>
</View>
{/* 回款记录 */}
<View className="bg-white rounded-xl border border-pink-100 p-4">
<View className="text-sm font-semibold text-red-600 mb-4"></View>
{paymentRecords.length === 0 ? (
<View className="text-center py-8">
<Text className="text-gray-400 text-sm"></Text>
<Text className="text-gray-400 text-xs mt-2"></Text>
</View>
) : (
<View className="space-y-3">
{paymentRecords.map((record) => (
<View key={record.id} className="border border-gray-100 rounded-lg p-3">
<View className="flex justify-between items-start">
<View>
<Text className="text-gray-800 font-medium">
{formatCurrency(record.amount)}
</Text>
<Text className="text-gray-500 text-xs mt-1">
: {formatCurrency(record.principal)} | : {formatCurrency(record.interest)}
</Text>
<Text className="text-gray-400 text-xs mt-1">
{record.paymentTime}
</Text>
</View>
<View className={`px-2 py-1 rounded text-xs ${
record.status === 'completed'
? 'bg-green-100 text-green-600'
: 'bg-yellow-100 text-yellow-600'
}`}>
{record.status === 'completed' ? '已完成' : '待确认'}
</View>
</View>
</View>
))}
</View>
)}
</View>
{/* 预计回款 */}
<View className="bg-white rounded-xl border border-pink-100 p-4">
<View className="flex justify-between items-center mb-4">
<Text className="text-sm font-semibold text-red-600"></Text>
<Button size="small" type="primary" onClick={addExpectedPayment}>
</Button>
</View>
{expectedPayments.length === 0 ? (
<View className="text-center py-8">
<Text className="text-gray-400 text-sm"></Text>
</View>
) : (
<View className="space-y-3">
{expectedPayments.map((expected) => (
<View key={expected.id} className="border border-gray-100 rounded-lg p-3">
<View className="flex justify-between items-center">
<View>
<Text className="text-gray-800 font-medium">
{formatCurrency(expected.amount)}
</Text>
<Text className="text-gray-500 text-xs mt-1">
: {expected.expectedDate}
</Text>
<Text className="text-gray-400 text-xs mt-1">
: {expected.createdAt}
</Text>
</View>
<Button
size="small"
type="danger"
fill="outline"
onClick={() => deleteExpectedPayment(expected.id)}
>
</Button>
</View>
</View>
))}
</View>
)}
</View>
</View>
)}
</View>
{step5Done && (
<View className="fixed z-50 bottom-0 left-0 right-0 bg-pink-50 px-4 py-4 safe-area-bottom">
<Button
type="primary"
block
onClick={goStep7}
>
访
</Button>
</View>
)}
</ConfigProvider>
</View>
)
}