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,23 @@
---
auto_execution_mode: 0
description: Review code changes for bugs, security issues, and improvements
---
You are a senior software engineer performing a thorough code review to identify potential bugs.
Your task is to find all potential bugs and code improvements in the code changes. Focus on:
1. Logic errors and incorrect behavior
2. Edge cases that aren't handled
3. Null/undefined reference issues
4. Race conditions or concurrency issues
5. Security vulnerabilities
6. Improper resource management or resource leaks
7. API contract violations
8. Incorrect caching behavior, including cache staleness issues, cache key-related bugs, incorrect cache invalidation, and ineffective caching
9. Violations of existing code patterns or conventions
Make sure to:
1. If exploring the codebase, call multiple tools in parallel for increased efficiency. Do not spend too much time exploring.
2. If you find any pre-existing bugs in the code, you should also report those since it's important for us to maintain general code quality for the user.
3. Do NOT report issues that are speculative or low-confidence. All your conclusions should be based on a complete understanding of the codebase.
4. Remember that if you were given a specific git commit, it may not be checked out and local code states may be different.
使用中文回复

View File

@@ -102,3 +102,109 @@ export async function getCreditMpCustomer(id: number) {
} }
return Promise.reject(new Error(res.message)); return Promise.reject(new Error(res.message));
} }
/**
* 审核跟进步骤
*/
export async function approveFollowStep(customerId: number, step: number, approved: boolean, remark?: string) {
const res = await request.post<ApiResult<unknown>>(
'/credit/credit-mp-customer/approve-follow-step',
{
customerId,
step,
approved,
remark
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 批量审核跟进步骤
*/
export async function batchApproveFollowSteps(approvals: Array<{
customerId: number;
step: number;
approved: boolean;
remark?: string;
}>) {
const res = await request.post<ApiResult<unknown>>(
'/credit/credit-mp-customer/batch-approve-follow-steps',
{ approvals }
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 获取待审核的跟进步骤列表
*/
export async function getPendingApprovalSteps(params?: {
step?: number;
customerId?: number;
userId?: number;
}) {
const res = await request.get<ApiResult<Array<{
customerId: number;
customerName: string;
step: number;
stepTitle: string;
submittedAt: string;
submittedBy: string;
content: any;
}>>>(
'/credit/credit-mp-customer/pending-approval-steps',
params
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 获取客户跟进统计
*/
export async function getFollowStatistics(customerId: number) {
const res = await request.get<ApiResult<{
totalSteps: number;
completedSteps: number;
currentStep: number;
progress: number;
stepDetails: Array<{
step: number;
title: string;
status: 'pending' | 'submitted' | 'approved' | 'rejected';
submittedAt?: string;
approvedAt?: string;
}>;
}>>(
'/credit/credit-mp-customer/follow-statistics/' + customerId
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 结束客户跟进流程
*/
export async function endFollowProcess(customerId: number, reason?: string) {
const res = await request.post<ApiResult<unknown>>(
'/credit/credit-mp-customer/end-follow-process',
{
customerId,
reason
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -126,6 +126,94 @@ export interface CreditMpCustomer {
followStep1ApprovedAt?: string; followStep1ApprovedAt?: string;
// 跟进 Step1审核人 // 跟进 Step1审核人
followStep1ApprovedBy?: number; followStep1ApprovedBy?: number;
// 跟进 Step2是否已提交
followStep2Submitted?: number | boolean;
// 跟进 Step2提交时间
followStep2SubmittedAt?: string;
// 跟进 Step2微信号
followStep2WechatId?: string;
// 跟进 Step2添加微信截图JSON单张
followStep2WechatShot?: string;
// 跟进 Step2沟通情况
followStep2Remark?: string;
// 跟进 Step2是否需要管理员审核第二步起通常为 1
followStep2NeedApproval?: number | boolean;
// 跟进 Step2是否已审核通过
followStep2Approved?: number | boolean;
// 跟进 Step2审核时间
followStep2ApprovedAt?: string;
// 跟进 Step2审核人
followStep2ApprovedBy?: number;
// 跟进 Step3是否已提交
followStep3Submitted?: number | boolean;
followStep3SubmittedAt?: string;
/** 群名称 */
followStep3GroupName?: string;
/** 建群日期 YYYY-MM-DD */
followStep3GroupDate?: string;
/** 建群人 */
followStep3GroupCreator?: string;
/** 企业经办人 */
followStep3CorporateAgent?: string;
/** 是否老板 */
followStep3IsBoss?: string;
/** 微信群截图 JSON单张 */
followStep3GroupShot?: string;
/** 多组订单/对赌等内容 JSON 字符串 */
followStep3OrdersJson?: string;
followStep3NeedApproval?: number | boolean;
followStep3Approved?: number | boolean;
followStep3ApprovedAt?: string;
followStep3ApprovedBy?: number;
// 跟进 Step4合同定稿
followStep4Submitted?: number | boolean;
followStep4SubmittedAt?: string;
/** 合同定稿文件 JSON 数组 */
followStep4ContractFiles?: string;
/** 客户认可截图 JSON单张 */
followStep4CustomerShot?: string;
followStep4CustomerAudioUrl?: string;
followStep4CustomerAudioName?: string;
followStep4Remark?: string;
followStep4NeedApproval?: number | boolean;
followStep4Approved?: number | boolean;
followStep4ApprovedAt?: string;
followStep4ApprovedBy?: number;
// 跟进 Step5合同签订
followStep5Submitted?: number | boolean;
followStep5SubmittedAt?: string;
/** 合同信息 JSON 数组 */
followStep5Contracts?: string;
followStep5NeedApproval?: number | boolean;
followStep5Approved?: number | boolean;
followStep5ApprovedAt?: string;
followStep5ApprovedBy?: number;
// 跟进 Step6订单回款
followStep6Submitted?: number | boolean;
followStep6SubmittedAt?: string;
/** 财务录入的回款记录 JSON 数组 */
followStep6PaymentRecords?: string;
/** 预计回款 JSON 数组 */
followStep6ExpectedPayments?: string;
followStep6NeedApproval?: number | boolean;
followStep6Approved?: number | boolean;
followStep6ApprovedAt?: string;
followStep6ApprovedBy?: number;
// 跟进 Step7电话回访
followStep7Submitted?: number | boolean;
followStep7SubmittedAt?: string;
/** 回访记录 JSON 数组 */
followStep7VisitRecords?: string;
followStep7NeedApproval?: number | boolean;
followStep7Approved?: number | boolean;
followStep7ApprovedAt?: string;
followStep7ApprovedBy?: number;
} }
/** /**

View File

@@ -54,6 +54,9 @@ export default {
"mp-customer/add", "mp-customer/add",
"mp-customer/detail", "mp-customer/detail",
"mp-customer/follow-step1", "mp-customer/follow-step1",
"mp-customer/follow-step2",
"mp-customer/follow-step3",
"mp-customer/follow-step4",
"mp-customer/edit" "mp-customer/edit"
] ]
} }

View File

@@ -571,6 +571,126 @@ export default function CreditMpCustomerDetailPage() {
Taro.navigateTo({ url: `/credit/mp-customer/follow-step1?id=${rowId}` }) Taro.navigateTo({ url: `/credit/mp-customer/follow-step1?id=${rowId}` })
} }
const goFollowStep2 = () => {
if (!rowId) return
Taro.navigateTo({ url: `/credit/mp-customer/follow-step2?id=${rowId}` })
}
const goFollowStep3 = () => {
if (!rowId) return
Taro.navigateTo({ url: `/credit/mp-customer/follow-step3?id=${rowId}` })
}
const goFollowStep4 = () => {
if (!rowId) return
Taro.navigateTo({ url: `/credit/mp-customer/follow-step4?id=${rowId}` })
}
const goFollowStep5 = () => {
if (!rowId) return
Taro.navigateTo({ url: `/credit/mp-customer/follow-step5?id=${rowId}` })
}
const goFollowStep6 = () => {
if (!rowId) return
Taro.navigateTo({ url: `/credit/mp-customer/follow-step6?id=${rowId}` })
}
const goFollowStep7 = () => {
if (!rowId) return
Taro.navigateTo({ url: `/credit/mp-customer/follow-step7?id=${rowId}` })
}
// 获取每个步骤的状态
const getStepStatus = useCallback((stepNum: number) => {
const anyRow = row as any
switch (stepNum) {
case 1:
return {
submitted: Boolean(anyRow?.followStep1Submitted) || Boolean(anyRow?.followStep1SubmittedAt),
approved: Boolean(anyRow?.followStep1Approved),
needApproval: Boolean(anyRow?.followStep1NeedApproval),
submittedAt: anyRow?.followStep1SubmittedAt
}
case 2:
return {
submitted: Boolean(anyRow?.followStep2Submitted) || Boolean(anyRow?.followStep2SubmittedAt),
approved: Boolean(anyRow?.followStep2Approved),
needApproval: Boolean(anyRow?.followStep2NeedApproval),
submittedAt: anyRow?.followStep2SubmittedAt
}
case 3:
return {
submitted: Boolean(anyRow?.followStep3Submitted) || Boolean(anyRow?.followStep3SubmittedAt),
approved: Boolean(anyRow?.followStep3Approved),
needApproval: Boolean(anyRow?.followStep3NeedApproval),
submittedAt: anyRow?.followStep3SubmittedAt
}
case 4:
return {
submitted: Boolean(anyRow?.followStep4Submitted) || Boolean(anyRow?.followStep4SubmittedAt),
approved: Boolean(anyRow?.followStep4Approved),
needApproval: Boolean(anyRow?.followStep4NeedApproval),
submittedAt: anyRow?.followStep4SubmittedAt
}
case 5:
return {
submitted: Boolean(anyRow?.followStep5Submitted) || Boolean(anyRow?.followStep5SubmittedAt),
approved: Boolean(anyRow?.followStep5Approved),
needApproval: Boolean(anyRow?.followStep5NeedApproval),
submittedAt: anyRow?.followStep5SubmittedAt
}
case 6:
return {
submitted: Boolean(anyRow?.followStep6Submitted) || Boolean(anyRow?.followStep6SubmittedAt),
approved: Boolean(anyRow?.followStep6Approved),
needApproval: Boolean(anyRow?.followStep6NeedApproval),
submittedAt: anyRow?.followStep6SubmittedAt
}
case 7:
return {
submitted: Boolean(anyRow?.followStep7Submitted) || Boolean(anyRow?.followStep7SubmittedAt),
approved: Boolean(anyRow?.followStep7Approved),
needApproval: Boolean(anyRow?.followStep7NeedApproval),
submittedAt: anyRow?.followStep7SubmittedAt
}
default:
return { submitted: false, approved: false, needApproval: false, submittedAt: null }
}
}, [row])
// 获取步骤状态文本和颜色
const getStepStatusDisplay = useCallback((status: ReturnType<typeof getStepStatus>) => {
if (status.submitted) {
if (status.approved) {
return { text: '审核通过', color: 'green' }
} else if (status.needApproval) {
return { text: '待审核', color: 'orange' }
} else {
return { text: '未通过', color: 'red' }
}
}
return { text: '未开始', color: 'gray' }
}, [])
// 检查是否可以进入某个步骤
const canEnterStep = useCallback((stepNum: number) => {
if (stepNum === 1) return true
const prevStepStatus = getStepStatus(stepNum - 1)
return prevStepStatus.approved
}, [getStepStatus])
// 步骤配置
const stepConfigs = [
{ num: 1, title: '加微信前沟通', goFn: goFollow, statusFn: () => getStepStatus(1) },
{ num: 2, title: '加微信', goFn: goFollowStep2, statusFn: () => getStepStatus(2) },
{ num: 3, title: '建群沟通', goFn: goFollowStep3, statusFn: () => getStepStatus(3) },
{ num: 4, title: '合同定稿', goFn: goFollowStep4, statusFn: () => getStepStatus(4) },
{ num: 5, title: '合同签订', goFn: goFollowStep5, statusFn: () => getStepStatus(5) },
{ num: 6, title: '回款', goFn: goFollowStep6, statusFn: () => getStepStatus(6) },
{ num: 7, title: '电话回访', goFn: goFollowStep7, statusFn: () => getStepStatus(7) }
]
return ( return (
<View className="bg-gray-50 min-h-screen"> <View className="bg-gray-50 min-h-screen">
<ConfigProvider> <ConfigProvider>
@@ -746,22 +866,105 @@ export default function CreditMpCustomerDetailPage() {
)} )}
</View> </View>
<View className="mt-3 bg-white rounded-xl border border-gray-100 p-4">
<View className="text-sm font-medium text-gray-900 mb-3"></View>
<View className="space-y-3">
{stepConfigs.map((config) => {
const status = config.statusFn()
const statusDisplay = getStepStatusDisplay(status)
const canEnter = canEnterStep(config.num)
return (
<View key={config.num} className="border border-gray-200 rounded-lg p-3">
<View className="flex items-center justify-between">
<View className="flex items-center gap-3">
<View className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium ${
status.approved
? 'bg-green-100 text-green-600'
: status.submitted && status.needApproval
? 'bg-orange-100 text-orange-600'
: status.submitted
? 'bg-red-100 text-red-600'
: 'bg-gray-100 text-gray-600'
}`}>
{config.num}
</View>
<View>
<Text className="text-sm font-medium text-gray-900">{config.title}</Text>
{status.submittedAt && (
<Text className="text-xs text-gray-500">
{fmtTime(status.submittedAt)}
</Text>
)}
</View>
</View>
<View className="flex items-center gap-2">
<Tag
type={
statusDisplay.color === 'green' ? 'primary' :
statusDisplay.color === 'orange' ? 'warning' :
statusDisplay.color === 'red' ? 'danger' : 'default'
}
>
{statusDisplay.text}
</Tag>
</View>
</View>
<View className="mt-3 flex gap-2">
<Button
size="small"
type={canEnter ? 'primary' : 'default'}
disabled={!canEnter}
onClick={config.goFn}
>
{canEnter ? '进入' : '未解锁'}
</Button>
{status.submitted && (
<Button size="small" fill="outline">
</Button>
)}
{config.num >= 3 && status.approved && (
<Button size="small" fill="outline">
</Button>
)}
{config.num === 3 && status.approved && (
<Button size="small" fill="outline">
</Button>
)}
{(config.num === 6 || config.num === 7) && status.approved && (
<Button size="small" fill="outline">
</Button>
)}
</View>
</View>
)
})}
</View>
</View>
<View className="mt-3 bg-white rounded-xl border border-gray-100 p-2"> <View className="mt-3 bg-white rounded-xl border border-gray-100 p-2">
<CellGroup> <CellGroup>
<Cell title="客户ID" description={String(row.id ?? '—')} /> <Cell title="客户ID" description={String(row.id ?? '—')} />
<Cell title="跟进状态" description={stepText} /> <Cell title="跟进状态" description={stepText} />
<Cell title="所在地区" description={buildLocation(row) || '—'} /> <Cell title="所在地区" description={buildLocation(row) || '—'} />
<Cell title="创建时间" description={fmtTime(row.createTime) || '—'} /> <Cell title="创建时间" description={fmtTime(row.createTime) || '—'} />
{/*<Cell title="更新时间" description={fmtTime(row.updateTime) || '—'} />*/}
{!!row.url && <Cell title="链接" description={String(row.url)} />} {!!row.url && <Cell title="链接" description={String(row.url)} />}
{/*{!!row.files && <Cell title="文件" description={String(row.files)} />}*/}
</CellGroup> </CellGroup>
</View> </View>
</View> </View>
)} )}
</View> </View>
<FixedButton text="跟进" background="#ef4444" disabled={!rowId} onClick={goFollow} /> <FixedButton text="开始跟进" background="#ef4444" disabled={!rowId} onClick={goFollow} />
</ConfigProvider> </ConfigProvider>
</View> </View>
) )

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Taro, { useDidShow, useRouter } from '@tarojs/taro' import Taro, { useDidShow, useRouter } from '@tarojs/taro'
import { View, Text } from '@tarojs/components' import { Image, View, Text } from '@tarojs/components'
import { Button, ConfigProvider, Empty, Loading, TextArea } from '@nutui/nutui-react-taro' import { Button, ConfigProvider, Empty, Loading, TextArea } from '@nutui/nutui-react-taro'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@@ -46,6 +46,19 @@ type AudioAttachment = {
const makeId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}` const makeId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`
/** 兼容不同基础库tempFilePaths 或 tempFiles[].path */
const pathsFromChooseImageResult = (res: any): string[] => {
const a = res?.tempFilePaths
if (Array.isArray(a) && a.length) {
return a.map((p: any) => String(p || '').trim()).filter(Boolean)
}
const files = res?.tempFiles
if (Array.isArray(files) && files.length) {
return files.map((f: any) => String(f?.path || '').trim()).filter(Boolean)
}
return []
}
const isHttpUrl = (url?: string) => { const isHttpUrl = (url?: string) => {
if (!url) return false if (!url) return false
return /^https?:\/\//i.test(url) return /^https?:\/\//i.test(url)
@@ -175,6 +188,13 @@ export default function CreditMpCustomerFollowStep1Page() {
const [remark, setRemark] = useState('') const [remark, setRemark] = useState('')
const [intention, setIntention] = useState<Intention | undefined>(undefined) const [intention, setIntention] = useState<Intention | undefined>(undefined)
const [recorderPanelOpen, setRecorderPanelOpen] = useState(false)
const [isRecording, setIsRecording] = useState(false)
const recorderPendingRef = useRef(false)
const recorderRef = useRef<any>(null)
const isWeapp = useMemo(() => Taro.getEnv() === Taro.ENV_TYPE.WEAPP, [])
const reload = useCallback(async () => { const reload = useCallback(async () => {
setError(null) setError(null)
setLoading(true) setLoading(true)
@@ -220,6 +240,44 @@ export default function CreditMpCustomerFollowStep1Page() {
reload().then() reload().then()
}) })
useEffect(() => {
if (typeof Taro.getRecorderManager !== 'function') return
const rm = Taro.getRecorderManager()
recorderRef.current = rm
rm.onStop(async (res: any) => {
if (!recorderPendingRef.current) return
recorderPendingRef.current = false
setIsRecording(false)
setRecorderPanelOpen(false)
const path = String(res?.tempFilePath || '').trim()
if (!path) return
Taro.showLoading({ title: '上传中...' })
try {
const record: any = await uploadFileByPath(path)
const url = String(record?.url || record?.downloadUrl || record?.thumbnail || record?.path || '').trim()
if (!url) throw new Error('上传失败缺少url')
const name = `录音_${dayjs().format('MMDD_HHmmss')}.aac`
setAudio({
url,
name: String(record?.name || name).trim()
})
Taro.showToast({ title: '已上传', icon: 'success' })
} catch (e) {
console.error('上传录音失败:', e)
Taro.showToast({ title: (e as any)?.message || '上传失败', icon: 'none' })
} finally {
Taro.hideLoading()
}
})
rm.onError((err: any) => {
if (!recorderPendingRef.current) return
recorderPendingRef.current = false
setIsRecording(false)
console.error('录音失败:', err)
Taro.showToast({ title: '录音失败', icon: 'none' })
})
}, [])
const contact = useMemo(() => getCustomerContact(row), [row]) const contact = useMemo(() => getCustomerContact(row), [row])
const phone = useMemo(() => String(getCustomerPhones(row)[0] || '').trim(), [row]) const phone = useMemo(() => String(getCustomerPhones(row)[0] || '').trim(), [row])
@@ -249,8 +307,11 @@ export default function CreditMpCustomerFollowStep1Page() {
return return
} }
const tempFilePaths = (res?.tempFilePaths || []) as string[] const tempFilePaths = pathsFromChooseImageResult(res)
if (!tempFilePaths.length) return if (!tempFilePaths.length) {
Taro.showToast({ title: '未获取到图片路径', icon: 'none' })
return
}
Taro.showLoading({ title: '上传中...' }) Taro.showLoading({ title: '上传中...' })
try { try {
@@ -301,9 +362,7 @@ export default function CreditMpCustomerFollowStep1Page() {
[callShots, canEdit, smsShots] [callShots, canEdit, smsShots]
) )
const chooseAndUploadAudio = useCallback(async () => { const pickAudioFromChat = useCallback(async () => {
if (!canEdit) return
let res: any let res: any
try { try {
// @ts-ignore // @ts-ignore
@@ -312,7 +371,7 @@ export default function CreditMpCustomerFollowStep1Page() {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '') const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return if (msg.includes('cancel')) return
console.error('选择录音失败:', e) console.error('选择录音失败:', e)
Taro.showToast({ title: '当前环境不支持选取文件', icon: 'none' }) Taro.showToast({ title: '当前环境不支持从会话选文件,请使用微信小程序', icon: 'none' })
return return
} }
@@ -338,7 +397,66 @@ export default function CreditMpCustomerFollowStep1Page() {
} finally { } finally {
Taro.hideLoading() Taro.hideLoading()
} }
}, [canEdit]) }, [])
const startInAppRecording = useCallback(async () => {
const rm = recorderRef.current
if (!rm) {
Taro.showToast({ title: '当前环境不支持现场录音', icon: 'none' })
return
}
try {
await Taro.authorize({ scope: 'scope.record' })
} catch (_e) {
try {
const setting = await Taro.getSetting()
const auth = (setting as any)?.authSetting?.['scope.record']
if (auth === false) {
await Taro.openSetting()
}
} catch (_e2) {
// ignore
}
}
recorderPendingRef.current = true
try {
// aac 在 iOS 小程序上兼容性更好
rm.start({
duration: 600000,
format: 'aac',
sampleRate: 44100,
encodeBitRate: 96000
} as any)
setIsRecording(true)
} catch (e) {
recorderPendingRef.current = false
console.error('开始录音失败:', e)
Taro.showToast({ title: '无法开始录音', icon: 'none' })
}
}, [])
const stopInAppRecording = useCallback(() => {
recorderRef.current?.stop()
}, [])
const chooseAndUploadAudio = useCallback(async () => {
if (!canEdit) return
const items = isWeapp ? ['从聊天记录选择', '现场录音'] : ['从聊天记录选择']
try {
const sheet = await Taro.showActionSheet({ itemList: items })
if (sheet.tapIndex === 0) {
await pickAudioFromChat()
return
}
if (sheet.tapIndex === 1 && isWeapp) {
setRecorderPanelOpen(true)
}
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
}
}, [canEdit, isWeapp, pickAudioFromChat])
const onAudioAction = useCallback(async () => { const onAudioAction = useCallback(async () => {
if (!audio) return if (!audio) return
@@ -456,7 +574,13 @@ export default function CreditMpCustomerFollowStep1Page() {
showCancel: false showCancel: false
}) })
if (customerId) {
Taro.redirectTo({
url: `/credit/mp-customer/follow-step2?id=${customerId}`
}).catch(() => {})
} else {
Taro.navigateBack().catch(() => {}) Taro.navigateBack().catch(() => {})
}
} catch (e) { } catch (e) {
console.error('提交跟进失败:', e) console.error('提交跟进失败:', e)
Taro.showToast({ title: (e as any)?.message || '提交失败', icon: 'none' }) Taro.showToast({ title: (e as any)?.message || '提交失败', icon: 'none' })
@@ -552,14 +676,7 @@ export default function CreditMpCustomerFollowStep1Page() {
onClick={() => onImageAction('sms', x)} onClick={() => onImageAction('sms', x)}
> >
{x.url ? ( {x.url ? (
<View <Image className="w-full h-full" src={x.thumbnail || x.url} mode="aspectFill" />
className="w-full h-full"
style={{
backgroundImage: `url(${x.thumbnail || x.url})`,
backgroundSize: 'cover',
backgroundPosition: 'center'
}}
/>
) : ( ) : (
<View className="w-full h-full flex items-center justify-center text-xs text-gray-500"> <View className="w-full h-full flex items-center justify-center text-xs text-gray-500">
{idx + 1} {idx + 1}
@@ -591,14 +708,7 @@ export default function CreditMpCustomerFollowStep1Page() {
onClick={() => onImageAction('call', x)} onClick={() => onImageAction('call', x)}
> >
{x.url ? ( {x.url ? (
<View <Image className="w-full h-full" src={x.thumbnail || x.url} mode="aspectFill" />
className="w-full h-full"
style={{
backgroundImage: `url(${x.thumbnail || x.url})`,
backgroundSize: 'cover',
backgroundPosition: 'center'
}}
/>
) : ( ) : (
<View className="w-full h-full flex items-center justify-center text-xs text-gray-500"> <View className="w-full h-full flex items-center justify-center text-xs text-gray-500">
{idx + 1} {idx + 1}
@@ -670,6 +780,50 @@ export default function CreditMpCustomerFollowStep1Page() {
)} )}
</View> </View>
{recorderPanelOpen && (
<View
className="fixed inset-0 flex flex-col justify-end"
style={{ zIndex: 100, backgroundColor: 'rgba(0,0,0,0.45)' }}
onClick={() => {
if (!isRecording) setRecorderPanelOpen(false)
}}
>
<View
className="bg-white rounded-t-2xl px-4 pt-4 pb-6 safe-area-bottom max-w-md mx-auto w-full"
onClick={e => e.stopPropagation()}
>
<View className="text-center text-sm text-gray-800 font-medium"></View>
<View className="mt-2 text-xs text-gray-500 text-center">
</View>
<View className="mt-4">
{!isRecording ? (
<Button type="primary" block onClick={startInAppRecording}>
</Button>
) : (
<Button type="primary" block onClick={stopInAppRecording}>
</Button>
)}
</View>
<View className="mt-3">
<Button
fill="outline"
block
disabled={isRecording}
onClick={() => {
if (isRecording) return
setRecorderPanelOpen(false)
}}
>
</Button>
</View>
</View>
</View>
)}
<View className="fixed z-50 bottom-0 left-0 right-0 bg-pink-50 px-4 py-4 safe-area-bottom"> <View className="fixed z-50 bottom-0 left-0 right-0 bg-pink-50 px-4 py-4 safe-area-bottom">
<Button <Button
type="primary" type="primary"

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '客户跟进',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
})

View File

@@ -0,0 +1,445 @@
import { useCallback, useMemo, useState } from 'react'
import Taro, { useDidShow, useRouter } from '@tarojs/taro'
import { Image, Text, View } from '@tarojs/components'
import { Button, ConfigProvider, Empty, Input, Loading, TextArea } from '@nutui/nutui-react-taro'
import dayjs from 'dayjs'
import { getCreditMpCustomer, updateCreditMpCustomer } from '@/api/credit/creditMpCustomer'
import type { CreditMpCustomer } from '@/api/credit/creditMpCustomer/model'
import { uploadFileByPath } from '@/api/system/file'
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 Attachment = {
id: string
name: string
url: string
thumbnail?: string
}
const makeId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`
const guessNameFromUrl = (url: string, fallback: string) => {
const clean = String(url || '').split('?')[0].split('#')[0]
const lastSlash = clean.lastIndexOf('/')
const base = lastSlash >= 0 ? clean.slice(lastSlash + 1) : clean
return String(base || fallback).trim()
}
/** 兼容不同基础库tempFilePaths 或 tempFiles[].path */
const pathsFromChooseImageResult = (res: any): string[] => {
const a = res?.tempFilePaths
if (Array.isArray(a) && a.length) {
return a.map((p: any) => String(p || '').trim()).filter(Boolean)
}
const files = res?.tempFiles
if (Array.isArray(files) && files.length) {
return files.map((f: any) => String(f?.path || '').trim()).filter(Boolean)
}
return []
}
const normalizeAttachmentsFromJson = (raw?: string): Attachment[] => {
const parsed = safeParseJSON<any>(raw)
if (!parsed) return []
if (!Array.isArray(parsed)) return []
const out: Attachment[] = []
for (const item of parsed) {
if (typeof item === 'string') {
const url = String(item).trim()
if (!url) continue
out.push({ id: makeId(), url, name: guessNameFromUrl(url, '图片') })
continue
}
const url = String(item?.url || item?.downloadUrl || item?.path || '').trim()
if (!url) continue
out.push({
id: makeId(),
url,
name: String(item?.name || guessNameFromUrl(url, '图片')).trim(),
thumbnail: item?.thumbnail ? String(item.thumbnail) : undefined
})
}
return out
}
const getCustomerContact = (row?: CreditMpCustomer | null) => {
if (!row) return ''
const anyRow = row as any
const fromField = String(anyRow?.contact || anyRow?.contactName || anyRow?.linkman || anyRow?.contacts || '').trim()
if (fromField) return fromField
const txt = String(row.comments || '').trim()
if (!txt) return ''
const m = txt.match(/联系人:([^;]+)/)
return String(m?.[1] || '').trim()
}
export default function CreditMpCustomerFollowStep2Page() {
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 [submitted, setSubmitted] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [wechatId, setWechatId] = useState('')
const [screenshot, setScreenshot] = useState<Attachment | null>(null)
const [remark, setRemark] = useState('')
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 hasSubmitted = Boolean(anyRow?.followStep2Submitted) || Boolean(anyRow?.followStep2SubmittedAt)
setSubmitted(hasSubmitted)
setWechatId(String(anyRow?.followStep2WechatId || '').trim())
const shots = normalizeAttachmentsFromJson(anyRow?.followStep2WechatShot)
setScreenshot(shots[0] || null)
setRemark(String(anyRow?.followStep2Remark || ''))
} catch (e) {
console.error('加载客户信息失败:', e)
setRow(null)
setError(String((e as any)?.message || '加载失败'))
} finally {
setLoading(false)
}
}, [customerId])
useDidShow(() => {
reload().then()
})
const contact = useMemo(() => getCustomerContact(row), [row])
const step1Done = useMemo(() => {
const anyRow = row as any
return Boolean(anyRow?.followStep1Submitted) || Boolean(anyRow?.followStep1SubmittedAt)
}, [row])
const canEdit = !submitted && step1Done
const chooseAndUploadScreenshot = useCallback(async () => {
if (!canEdit) return
let res: any
try {
res = await Taro.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera']
})
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
Taro.showToast({ title: '选择图片失败', icon: 'none' })
return
}
const paths = pathsFromChooseImageResult(res)
const p = paths[0]
if (!p) {
Taro.showToast({ title: '未获取到图片路径', icon: 'none' })
return
}
Taro.showLoading({ title: '上传中...' })
try {
const record: any = await uploadFileByPath(p)
const url = String(record?.url || record?.downloadUrl || record?.thumbnail || record?.path || '').trim()
if (!url) return
setScreenshot({
id: makeId(),
name: String(record?.name || guessNameFromUrl(url, '截图')).trim(),
url,
thumbnail: record?.thumbnail ? String(record.thumbnail) : undefined
})
Taro.showToast({ title: '已上传', icon: 'success' })
} catch (e) {
console.error('上传图片失败:', e)
Taro.showToast({ title: (e as any)?.message || '上传失败', icon: 'none' })
} finally {
Taro.hideLoading()
}
}, [canEdit])
const onScreenshotAction = useCallback(async () => {
if (!screenshot) return
try {
const res = await Taro.showActionSheet({ itemList: ['预览', '重新上传', '删除'] })
if (res.tapIndex === 0) {
await Taro.previewImage({ urls: [screenshot.url], current: screenshot.url })
}
if (res.tapIndex === 1) {
if (!canEdit) return
await chooseAndUploadScreenshot()
}
if (res.tapIndex === 2) {
if (!canEdit) return
setScreenshot(null)
}
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
}
}, [canEdit, chooseAndUploadScreenshot, screenshot])
const goStep1 = () => {
if (!customerId) return
Taro.navigateTo({ url: `/credit/mp-customer/follow-step1?id=${customerId}` }).catch(() => {})
}
const submit = async () => {
if (!customerId) {
Taro.showToast({ title: '缺少客户ID', icon: 'none' })
return
}
if (!step1Done) {
Taro.showToast({ title: '请先完成第一步跟进', icon: 'none' })
return
}
if (!wechatId.trim()) {
Taro.showToast({ title: '请填写微信号', icon: 'none' })
return
}
if (!screenshot?.url) {
Taro.showToast({ title: '请上传添加微信的截图', icon: 'none' })
return
}
if (!remark.trim()) {
Taro.showToast({ title: '请填写沟通情况', icon: 'none' })
return
}
if (submitting) return
if (!row) return
setSubmitting(true)
try {
const anyRow = row as any
const shotPayload = [
{
name: screenshot.name,
url: screenshot.url,
thumbnail: screenshot.thumbnail,
isImage: true
}
]
const nextStep = (() => {
const raw = anyRow?.step ?? anyRow?.stepStatus ?? anyRow?.stepNum ?? anyRow?.stepCode ?? undefined
const n = Number(raw)
if (Number.isInteger(n) && n >= 0) return Math.max(2, n)
return 2
})()
await updateCreditMpCustomer({
...(row as any),
id: customerId,
step: nextStep,
followStep2Submitted: 1,
followStep2SubmittedAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
followStep2WechatId: wechatId.trim(),
followStep2WechatShot: JSON.stringify(shotPayload),
followStep2Remark: remark.trim(),
followStep2NeedApproval: 1,
followStep2Approved: 0
} as any)
setSubmitted(true)
setRow(prev =>
prev
? ({
...(prev as any),
step: nextStep,
followStep2Submitted: 1,
followStep2SubmittedAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
followStep2WechatId: wechatId.trim(),
followStep2WechatShot: JSON.stringify(shotPayload),
followStep2Remark: remark.trim(),
followStep2NeedApproval: 1,
followStep2Approved: 0
} as any)
: prev
)
await Taro.showModal({
title: '提示',
content: '跟进信息已提交\n请等待管理员审核',
showCancel: false
})
if (customerId) {
Taro.redirectTo({
url: `/credit/mp-customer/follow-step3?id=${customerId}`
}).catch(() => {})
} else {
Taro.navigateBack().catch(() => {})
}
} catch (e) {
console.error('提交跟进失败:', e)
Taro.showToast({ title: (e as any)?.message || '提交失败', icon: 'none' })
} finally {
setSubmitting(false)
}
}
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-28">
{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>
</View>
) : !row ? (
<View className="bg-white rounded-xl border border-pink-100 py-10">
<Empty description="暂无客户信息" />
</View>
) : (
<View className="bg-white rounded-xl border border-pink-100 p-4">
<View className="text-sm font-semibold text-red-600"></View>
{!step1Done && (
<View className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<Text></Text>
<View className="mt-2">
<Button size="small" type="primary" onClick={goStep1}>
</Button>
</View>
</View>
)}
<View className="mt-3 text-xs text-gray-500 leading-relaxed">
</View>
<View className="mt-4 pt-3 border-t border-pink-100 space-y-4 text-sm">
<View>
<Text className="text-gray-700">
<Text className="text-red-500">*</Text>
</Text>
<View className="mt-2">
<Input
value={wechatId}
placeholder="请输入微信号"
disabled={!canEdit}
onChange={(v: string) => setWechatId(v)}
/>
</View>
</View>
<View className="flex items-start justify-between gap-3">
<Text className="text-gray-500 shrink-0"></Text>
<Text className="text-gray-900 text-right break-all">{contact || '—'}</Text>
</View>
<View>
<Text className="text-gray-700">
<Text className="text-red-500">*</Text>
</Text>
<View className="mt-2 flex flex-wrap gap-2">
{screenshot?.url ? (
<View
className="w-20 h-20 rounded-lg bg-gray-200 overflow-hidden"
onClick={onScreenshotAction}
>
<Image className="w-full h-full" src={screenshot.thumbnail || screenshot.url} mode="aspectFill" />
</View>
) : null}
<View
className={`w-20 h-20 rounded-lg border border-dashed flex items-center justify-center text-2xl ${
!canEdit ? 'border-gray-200 text-gray-200' : 'border-gray-300 text-gray-400'
}`}
onClick={() => {
if (!canEdit) return
if (screenshot) {
onScreenshotAction()
} else {
chooseAndUploadScreenshot()
}
}}
>
+
</View>
</View>
</View>
<View>
<Text className="text-gray-700">
<Text className="text-red-500">*</Text>
</Text>
<View className="mt-2">
<TextArea
value={remark}
onChange={setRemark}
placeholder="请输入"
disabled={!canEdit}
maxLength={300}
style={{ background: '#fff' }}
/>
</View>
</View>
{submitted && (
<View className="pt-2 text-xs text-gray-400"></View>
)}
</View>
</View>
)}
</View>
<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
disabled={!canEdit || submitting}
style={{
background: submitted || !step1Done ? '#94a3b8' : '#ef4444',
borderColor: submitted || !step1Done ? '#94a3b8' : '#ef4444'
}}
onClick={submit}
>
{submitting ? '提交中...' : '确定'}
</Button>
</View>
</ConfigProvider>
</View>
)
}

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '客户跟进',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '客户跟进',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
})

View File

@@ -0,0 +1,729 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Taro, { useDidShow, useRouter } from '@tarojs/taro'
import { Image, Text, View } from '@tarojs/components'
import { Button, ConfigProvider, Empty, Loading, TextArea } from '@nutui/nutui-react-taro'
import dayjs from 'dayjs'
import { getCreditMpCustomer, updateCreditMpCustomer } from '@/api/credit/creditMpCustomer'
import type { CreditMpCustomer } from '@/api/credit/creditMpCustomer/model'
import { uploadFileByPath } from '@/api/system/file'
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 FileItem = { id: string; name: string; url: string }
type Attachment = { id: string; name: string; url: string; thumbnail?: string }
type AudioAttachment = { name: string; url: string }
const makeId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`
const MAX_CONTRACT_FILES = 20
const guessNameFromUrl = (url: string, fallback: string) => {
const clean = String(url || '').split('?')[0].split('#')[0]
const lastSlash = clean.lastIndexOf('/')
const base = lastSlash >= 0 ? clean.slice(lastSlash + 1) : clean
return String(base || fallback).trim()
}
const pathsFromChooseImageResult = (res: any): string[] => {
const a = res?.tempFilePaths
if (Array.isArray(a) && a.length) {
return a.map((p: any) => String(p || '').trim()).filter(Boolean)
}
const files = res?.tempFiles
if (Array.isArray(files) && files.length) {
return files.map((f: any) => String(f?.path || '').trim()).filter(Boolean)
}
return []
}
const isHttpUrl = (url?: string) => {
if (!url) return false
return /^https?:\/\//i.test(url)
}
const normalizeShotFromJson = (raw?: string): Attachment | null => {
if (!raw) return null
const parsed = safeParseJSON<any>(raw)
if (!parsed) return null
const arr = Array.isArray(parsed) ? parsed : [parsed]
const first = arr[0]
if (!first) return null
const url = String(first?.url || first?.downloadUrl || first?.path || (typeof first === 'string' ? first : '')).trim()
if (!url) return null
return {
id: makeId(),
url,
name: String(first?.name || guessNameFromUrl(url, '截图')).trim(),
thumbnail: first?.thumbnail ? String(first.thumbnail) : undefined
}
}
const parseContractFiles = (raw?: string): FileItem[] => {
const parsed = safeParseJSON<any[]>(raw)
if (!Array.isArray(parsed)) return []
const out: FileItem[] = []
for (const item of parsed) {
const url = String(item?.url || item?.downloadUrl || item?.path || '').trim()
if (!url) continue
out.push({
id: makeId(),
name: String(item?.name || guessNameFromUrl(url, '文件')).trim(),
url
})
}
return out
}
export default function CreditMpCustomerFollowStep4Page() {
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 [submitted, setSubmitted] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [contractFiles, setContractFiles] = useState<FileItem[]>([])
const [screenshot, setScreenshot] = useState<Attachment | null>(null)
const [audio, setAudio] = useState<AudioAttachment | null>(null)
const [remark, setRemark] = useState('')
const [recorderPanelOpen, setRecorderPanelOpen] = useState(false)
const [isRecording, setIsRecording] = useState(false)
const recorderPendingRef = useRef(false)
const recorderRef = useRef<any>(null)
const isWeapp = useMemo(() => Taro.getEnv() === Taro.ENV_TYPE.WEAPP, [])
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
setSubmitted(Boolean(anyRow?.followStep4Submitted) || Boolean(anyRow?.followStep4SubmittedAt))
setContractFiles(parseContractFiles(anyRow?.followStep4ContractFiles))
setScreenshot(normalizeShotFromJson(anyRow?.followStep4CustomerShot))
const audioUrl = String(anyRow?.followStep4CustomerAudioUrl || '').trim()
if (audioUrl) {
setAudio({
url: audioUrl,
name: String(anyRow?.followStep4CustomerAudioName || guessNameFromUrl(audioUrl, '录音')).trim()
})
} else {
setAudio(null)
}
setRemark(String(anyRow?.followStep4Remark || ''))
} catch (e) {
console.error('加载失败:', e)
setRow(null)
setError(String((e as any)?.message || '加载失败'))
} finally {
setLoading(false)
}
}, [customerId])
useDidShow(() => {
reload().then()
})
useEffect(() => {
if (typeof Taro.getRecorderManager !== 'function') return
const rm = Taro.getRecorderManager()
recorderRef.current = rm
rm.onStop(async (res: any) => {
if (!recorderPendingRef.current) return
recorderPendingRef.current = false
setIsRecording(false)
setRecorderPanelOpen(false)
const path = String(res?.tempFilePath || '').trim()
if (!path) return
Taro.showLoading({ title: '上传中...' })
try {
const record: any = await uploadFileByPath(path)
const url = String(record?.url || record?.downloadUrl || record?.thumbnail || record?.path || '').trim()
if (!url) throw new Error('上传失败缺少url')
const name = `录音_${dayjs().format('MMDD_HHmmss')}.aac`
setAudio({
url,
name: String(record?.name || name).trim()
})
Taro.showToast({ title: '已上传', icon: 'success' })
} catch (e) {
Taro.showToast({ title: (e as any)?.message || '上传失败', icon: 'none' })
} finally {
Taro.hideLoading()
}
})
rm.onError(() => {
if (!recorderPendingRef.current) return
recorderPendingRef.current = false
setIsRecording(false)
Taro.showToast({ title: '录音失败', icon: 'none' })
})
}, [])
const step3Done = useMemo(() => {
const anyRow = row as any
return Boolean(anyRow?.followStep3Submitted) || Boolean(anyRow?.followStep3SubmittedAt)
}, [row])
const canEdit = !submitted && step3Done
const pickContractFile = useCallback(async () => {
if (!canEdit) return
if (contractFiles.length >= MAX_CONTRACT_FILES) {
Taro.showToast({ title: `最多${MAX_CONTRACT_FILES}个文件`, icon: 'none' })
return
}
let res: any
try {
// @ts-ignore
res = await Taro.chooseMessageFile({ count: 1, type: 'file' })
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
Taro.showToast({ title: '请使用微信小程序从会话选择文件', icon: 'none' })
return
}
// @ts-ignore
const picked = (res?.tempFiles || []) as Array<{ path?: string; name?: string }>
const path = String(picked?.[0]?.path || '').trim()
const name = String(picked?.[0]?.name || '').trim()
if (!path) return
Taro.showLoading({ title: '上传中...' })
try {
const record: any = await uploadFileByPath(path)
const url = String(record?.url || record?.downloadUrl || record?.thumbnail || record?.path || '').trim()
if (!url) throw new Error('上传失败')
setContractFiles(prev =>
prev.concat({
id: makeId(),
name: String(name || record?.name || guessNameFromUrl(url, '合同')).trim(),
url
})
)
Taro.showToast({ title: '已上传', icon: 'success' })
} catch (e) {
Taro.showToast({ title: (e as any)?.message || '上传失败', icon: 'none' })
} finally {
Taro.hideLoading()
}
}, [canEdit, contractFiles.length])
const onContractFileAction = useCallback(
async (item: FileItem) => {
try {
const res = await Taro.showActionSheet({ itemList: ['打开', '删除'] })
if (res.tapIndex === 0) {
let filePath = item.url
if (isHttpUrl(item.url)) {
const dl = await Taro.downloadFile({ url: item.url })
// @ts-ignore
filePath = dl?.tempFilePath
}
await Taro.openDocument({ filePath, showMenu: true } as any)
}
if (res.tapIndex === 1) {
if (!canEdit) return
setContractFiles(prev => prev.filter(x => x.id !== item.id))
}
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
}
},
[canEdit]
)
const chooseScreenshot = useCallback(async () => {
if (!canEdit) return
let res: any
try {
res = await Taro.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera']
})
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
Taro.showToast({ title: '选择图片失败', icon: 'none' })
return
}
const paths = pathsFromChooseImageResult(res)
const p = paths[0]
if (!p) return
Taro.showLoading({ title: '上传中...' })
try {
const record: any = await uploadFileByPath(p)
const url = String(record?.url || record?.downloadUrl || record?.thumbnail || record?.path || '').trim()
if (!url) return
setScreenshot({
id: makeId(),
name: String(record?.name || guessNameFromUrl(url, '截图')).trim(),
url,
thumbnail: record?.thumbnail ? String(record.thumbnail) : undefined
})
Taro.showToast({ title: '已上传', icon: 'success' })
} catch (e) {
Taro.showToast({ title: (e as any)?.message || '上传失败', icon: 'none' })
} finally {
Taro.hideLoading()
}
}, [canEdit])
const onScreenshotAction = useCallback(async () => {
if (!screenshot) return
try {
const res = await Taro.showActionSheet({ itemList: ['预览', '重新上传', '删除'] })
if (res.tapIndex === 0) {
await Taro.previewImage({ urls: [screenshot.url], current: screenshot.url })
}
if (res.tapIndex === 1) {
if (!canEdit) return
await chooseScreenshot()
}
if (res.tapIndex === 2) {
if (!canEdit) return
setScreenshot(null)
}
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
}
}, [canEdit, chooseScreenshot, screenshot])
const pickAudioFromChat = useCallback(async () => {
let res: any
try {
// @ts-ignore
res = await Taro.chooseMessageFile({ count: 1, type: 'file' })
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
Taro.showToast({ title: '请使用微信小程序选择文件', icon: 'none' })
return
}
// @ts-ignore
const picked = (res?.tempFiles || []) as Array<{ path?: string; name?: string }>
const path = String(picked?.[0]?.path || '').trim()
const name = String(picked?.[0]?.name || '').trim()
if (!path) return
Taro.showLoading({ title: '上传中...' })
try {
const record: any = await uploadFileByPath(path)
const url = String(record?.url || record?.downloadUrl || record?.thumbnail || record?.path || '').trim()
if (!url) throw new Error('上传失败')
setAudio({
url,
name: String(name || record?.name || guessNameFromUrl(url, '录音')).trim()
})
Taro.showToast({ title: '已上传', icon: 'success' })
} catch (e) {
Taro.showToast({ title: (e as any)?.message || '上传失败', icon: 'none' })
} finally {
Taro.hideLoading()
}
}, [])
const startInAppRecording = useCallback(async () => {
const rm = recorderRef.current
if (!rm) {
Taro.showToast({ title: '当前环境不支持现场录音', icon: 'none' })
return
}
try {
await Taro.authorize({ scope: 'scope.record' })
} catch (_e) {
try {
const setting = await Taro.getSetting()
const auth = (setting as any)?.authSetting?.['scope.record']
if (auth === false) await Taro.openSetting()
} catch (_e2) {
// ignore
}
}
recorderPendingRef.current = true
try {
rm.start({ duration: 600000, format: 'aac', sampleRate: 44100, encodeBitRate: 96000 } as any)
setIsRecording(true)
} catch (e) {
recorderPendingRef.current = false
Taro.showToast({ title: '无法开始录音', icon: 'none' })
}
}, [])
const stopInAppRecording = useCallback(() => {
recorderRef.current?.stop()
}, [])
const chooseAndUploadAudio = useCallback(async () => {
if (!canEdit) return
const items = isWeapp ? ['从聊天记录选择', '现场录音'] : ['从聊天记录选择']
try {
const sheet = await Taro.showActionSheet({ itemList: items })
if (sheet.tapIndex === 0) {
await pickAudioFromChat()
return
}
if (sheet.tapIndex === 1 && isWeapp) {
setRecorderPanelOpen(true)
}
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
}
}, [canEdit, isWeapp, pickAudioFromChat])
const onAudioAction = useCallback(async () => {
if (!audio) return
try {
const res = await Taro.showActionSheet({
itemList: ['打开', canEdit ? '重新上传' : '重新上传(不可编辑)', canEdit ? '删除' : '删除(不可编辑)']
})
if (res.tapIndex === 0) {
let filePath = audio.url
if (isHttpUrl(audio.url)) {
const dl = await Taro.downloadFile({ url: audio.url })
// @ts-ignore
filePath = dl?.tempFilePath
}
await Taro.openDocument({ filePath, showMenu: true } as any)
}
if (res.tapIndex === 1) {
if (!canEdit) return
await chooseAndUploadAudio()
}
if (res.tapIndex === 2) {
if (!canEdit) return
setAudio(null)
}
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
}
}, [audio, canEdit, chooseAndUploadAudio])
const goStep3 = () => {
if (!customerId) return
Taro.navigateTo({ url: `/credit/mp-customer/follow-step3?id=${customerId}` }).catch(() => {})
}
const submit = async () => {
if (!customerId) {
Taro.showToast({ title: '缺少客户ID', icon: 'none' })
return
}
if (!step3Done) {
Taro.showToast({ title: '请先完成第三步跟进', icon: 'none' })
return
}
if (!contractFiles.length) {
Taro.showToast({ title: '请上传合同定稿文件', icon: 'none' })
return
}
if (!screenshot?.url) {
Taro.showToast({ title: '请上传客户认可的截图', icon: 'none' })
return
}
if (submitting || !row) return
setSubmitting(true)
try {
const anyRow = row as any
const contractPayload = contractFiles.map(f => ({
name: f.name,
url: f.url,
isFile: true
}))
const shotPayload = [
{
name: screenshot.name,
url: screenshot.url,
thumbnail: screenshot.thumbnail,
isImage: true
}
]
const nextStep = (() => {
const raw = anyRow?.step ?? anyRow?.stepStatus ?? anyRow?.stepNum ?? anyRow?.stepCode ?? undefined
const n = Number(raw)
if (Number.isInteger(n) && n >= 0) return Math.max(4, n)
return 4
})()
await updateCreditMpCustomer({
...(row as any),
id: customerId,
step: nextStep,
followStep4Submitted: 1,
followStep4SubmittedAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
followStep4ContractFiles: JSON.stringify(contractPayload),
followStep4CustomerShot: JSON.stringify(shotPayload),
followStep4CustomerAudioUrl: audio?.url || undefined,
followStep4CustomerAudioName: audio?.name || undefined,
followStep4Remark: remark.trim() || undefined,
followStep4NeedApproval: 1,
followStep4Approved: 0
} as any)
setSubmitted(true)
setRow(prev =>
prev
? ({
...(prev as any),
step: nextStep,
followStep4Submitted: 1,
followStep4SubmittedAt: dayjs().format('YYYY-MM-DD HH:mm:ss')
} as any)
: prev
)
await Taro.showModal({
title: '提示',
content: '跟进信息已提交\n请等待管理员审核',
showCancel: false
})
Taro.navigateBack().catch(() => {})
} catch (e) {
Taro.showToast({ title: (e as any)?.message || '提交失败', icon: 'none' })
} finally {
setSubmitting(false)
}
}
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>
</View>
) : !row ? (
<View className="bg-white rounded-xl border border-pink-100 py-10">
<Empty description="暂无客户信息" />
</View>
) : (
<View className="bg-white rounded-xl border border-pink-100 p-4">
<View className="text-sm font-semibold text-red-600">稿</View>
{!step3Done && (
<View className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<Text></Text>
<View className="mt-2">
<Button size="small" type="primary" onClick={goStep3}>
</Button>
</View>
</View>
)}
<View className="mt-4 pt-3 border-t border-pink-100 text-sm">
<View className="flex flex-row items-center justify-between gap-2">
<Text className="text-gray-700 shrink-0">
稿<Text className="text-red-500">*</Text>
</Text>
<Button size="small" fill="outline" disabled={!canEdit} onClick={pickContractFile}>
</Button>
</View>
<View className="mt-2 text-xs text-gray-400"></View>
{contractFiles.length > 0 && (
<View className="mt-2">
{contractFiles.map((f, idx) => (
<View
key={f.id}
className={`flex flex-row items-center justify-between gap-2 border border-gray-100 rounded-lg px-2 py-2 ${
idx > 0 ? 'mt-2' : ''
}`}
onClick={() => onContractFileAction(f)}
>
<Text className="text-xs text-gray-800 flex-1 truncate">{f.name || '文件'}</Text>
<Text className="text-xs text-blue-600 shrink-0"></Text>
</View>
))}
</View>
)}
{canEdit && (
<View className="mt-3">
<Text className="text-sm text-red-600" onClick={pickContractFile}>
+稿
</Text>
</View>
)}
<View className="mt-6">
<Text className="text-gray-700">
<Text className="text-red-500">*</Text>
</Text>
<View className="mt-2 flex flex-wrap gap-2">
{screenshot?.url ? (
<View
className="w-20 h-20 rounded-lg bg-gray-200 overflow-hidden"
onClick={onScreenshotAction}
>
<Image
className="w-full h-full"
src={screenshot.thumbnail || screenshot.url}
mode="aspectFill"
/>
</View>
) : null}
<View
className={`w-20 h-20 rounded-lg border border-dashed flex items-center justify-center text-2xl ${
!canEdit ? 'border-gray-200 text-gray-200' : 'border-gray-300 text-gray-400'
}`}
onClick={() => {
if (!canEdit) return
if (screenshot) onScreenshotAction()
else chooseScreenshot()
}}
>
+
</View>
</View>
</View>
<View className="mt-6">
<Text className="text-gray-700"></Text>
<View className="mt-2 flex flex-row items-center justify-between gap-2">
{audio ? (
<View className="flex flex-row items-center gap-2 flex-1 min-w-0">
<Text
className="text-xs text-blue-600 truncate flex-1"
onClick={onAudioAction}
>
{audio.name || '录音'}
</Text>
<Button size="small" fill="outline" disabled={!canEdit} onClick={chooseAndUploadAudio}>
</Button>
</View>
) : (
<Button size="small" fill="outline" disabled={!canEdit} onClick={chooseAndUploadAudio}>
</Button>
)}
</View>
</View>
<View className="mt-6">
<Text className="text-gray-700"></Text>
<View className="mt-2">
<TextArea
value={remark}
onChange={setRemark}
placeholder="如客户在XX时间已同意合同内容"
disabled={!canEdit}
maxLength={500}
style={{ background: '#fff' }}
/>
</View>
</View>
{submitted && (
<View className="mt-4 text-xs text-gray-400"></View>
)}
</View>
</View>
)}
</View>
{recorderPanelOpen && (
<View
className="fixed inset-0 flex flex-col justify-end"
style={{ zIndex: 100, backgroundColor: 'rgba(0,0,0,0.45)' }}
onClick={() => {
if (!isRecording) setRecorderPanelOpen(false)
}}
>
<View
className="bg-white rounded-t-2xl px-4 pt-4 pb-6 safe-area-bottom max-w-md mx-auto w-full"
onClick={e => e.stopPropagation()}
>
<View className="text-center text-sm text-gray-800 font-medium"></View>
<View className="mt-2 text-xs text-gray-500 text-center"></View>
<View className="mt-4">
{!isRecording ? (
<Button type="primary" block onClick={startInAppRecording}>
</Button>
) : (
<Button type="primary" block onClick={stopInAppRecording}>
</Button>
)}
</View>
<View className="mt-3">
<Button
fill="outline"
block
disabled={isRecording}
onClick={() => {
if (!isRecording) setRecorderPanelOpen(false)
}}
>
</Button>
</View>
</View>
</View>
)}
<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
disabled={!canEdit || submitting}
style={{
background: submitted || !step3Done ? '#94a3b8' : '#ef4444',
borderColor: submitted || !step3Done ? '#94a3b8' : '#ef4444'
}}
onClick={submit}
>
{submitting ? '提交中...' : '确定'}
</Button>
</View>
</ConfigProvider>
</View>
)
}

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '客户跟进',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
})

View File

@@ -0,0 +1,621 @@
import { useCallback, useMemo, useState } from 'react'
import Taro, { useDidShow, useRouter } from '@tarojs/taro'
import { Text, View } from '@tarojs/components'
import { Button, ConfigProvider, Empty, Loading, Input, TextArea } from '@nutui/nutui-react-taro'
import dayjs from 'dayjs'
import { getCreditMpCustomer, updateCreditMpCustomer } from '@/api/credit/creditMpCustomer'
import type { CreditMpCustomer } from '@/api/credit/creditMpCustomer/model'
import { uploadFileByPath } from '@/api/system/file'
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 FileItem = { id: string; name: string; url: string }
type ContractItem = {
id: string
guarantor: string
guarantorPhone: string
signatory: string
signatoryPhone: string
signingTime: string
contractAmount: string
principal: string
interest: string
communicationStatus: string
scannedFiles: FileItem[]
}
const makeId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`
const MAX_CONTRACT_FILES = 20
const guessNameFromUrl = (url: string, fallback: string) => {
const clean = String(url || '').split('?')[0].split('#')[0]
const lastSlash = clean.lastIndexOf('/')
const base = lastSlash >= 0 ? clean.slice(lastSlash + 1) : clean
return String(base || fallback).trim()
}
const parseContractFiles = (raw?: string): FileItem[] => {
const parsed = safeParseJSON<any[]>(raw)
if (!Array.isArray(parsed)) return []
const out: FileItem[] = []
for (const item of parsed) {
const url = String(item?.url || item?.downloadUrl || item?.path || '').trim()
if (!url) continue
out.push({
id: makeId(),
name: String(item?.name || guessNameFromUrl(url, '文件')).trim(),
url
})
}
return out
}
const parseContracts = (raw?: string): ContractItem[] => {
const parsed = safeParseJSON<any[]>(raw)
if (!Array.isArray(parsed)) return []
return parsed.map(item => ({
id: makeId(),
guarantor: String(item?.guarantor || '').trim(),
guarantorPhone: String(item?.guarantorPhone || '').trim(),
signatory: String(item?.signatory || '').trim(),
signatoryPhone: String(item?.signatoryPhone || '').trim(),
signingTime: String(item?.signingTime || '').trim(),
contractAmount: String(item?.contractAmount || '').trim(),
principal: String(item?.principal || '').trim(),
interest: String(item?.interest || '').trim(),
communicationStatus: String(item?.communicationStatus || '').trim(),
scannedFiles: parseContractFiles(item?.scannedFiles)
}))
}
export default function CreditMpCustomerFollowStep5Page() {
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 [submitted, setSubmitted] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [contracts, setContracts] = useState<ContractItem[]>([])
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
setSubmitted(Boolean(anyRow?.followStep5Submitted) || Boolean(anyRow?.followStep5SubmittedAt))
const parsedContracts = parseContracts(anyRow?.followStep5Contracts)
setContracts(parsedContracts.length > 0 ? parsedContracts : [{
id: makeId(),
guarantor: '',
guarantorPhone: '',
signatory: '',
signatoryPhone: '',
signingTime: '',
contractAmount: '',
principal: '',
interest: '',
communicationStatus: '',
scannedFiles: []
}])
} catch (e) {
console.error('加载失败:', e)
setRow(null)
setError(String((e as any)?.message || '加载失败'))
} finally {
setLoading(false)
}
}, [customerId])
useDidShow(() => {
reload().then()
})
const step4Done = useMemo(() => {
const anyRow = row as any
return Boolean(anyRow?.followStep4Submitted) || Boolean(anyRow?.followStep4SubmittedAt)
}, [row])
const canEdit = !submitted && step4Done
const addNewContract = useCallback(() => {
if (!canEdit) return
setContracts(prev => [...prev, {
id: makeId(),
guarantor: '',
guarantorPhone: '',
signatory: '',
signatoryPhone: '',
signingTime: '',
contractAmount: '',
principal: '',
interest: '',
communicationStatus: '',
scannedFiles: []
}])
}, [canEdit])
const updateContract = useCallback((contractId: string, field: keyof ContractItem, value: any) => {
if (!canEdit) return
setContracts(prev => prev.map(contract =>
contract.id === contractId ? { ...contract, [field]: value } : contract
))
}, [canEdit])
const deleteContract = useCallback((contractId: string) => {
if (!canEdit) return
if (contracts.length <= 1) {
Taro.showToast({ title: '至少保留一组合同', icon: 'none' })
return
}
setContracts(prev => prev.filter(contract => contract.id !== contractId))
}, [canEdit, contracts.length])
const pickScannedFile = useCallback(async (contractId: string) => {
if (!canEdit) return
const contract = contracts.find(c => c.id === contractId)
if (!contract || contract.scannedFiles.length >= MAX_CONTRACT_FILES) {
Taro.showToast({ title: `最多${MAX_CONTRACT_FILES}个文件`, icon: 'none' })
return
}
let res: any
try {
// @ts-ignore
res = await Taro.chooseMessageFile({ count: 1, type: 'file' })
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
Taro.showToast({ title: '请使用微信小程序从会话选择文件', icon: 'none' })
return
}
// @ts-ignore
const picked = (res?.tempFiles || []) as Array<{ path?: string; name?: string }>
const path = String(picked?.[0]?.path || '').trim()
const name = String(picked?.[0]?.name || '').trim()
if (!path) return
Taro.showLoading({ title: '上传中...' })
try {
const record: any = await uploadFileByPath(path)
const url = String(record?.url || record?.downloadUrl || record?.thumbnail || record?.path || '').trim()
if (!url) throw new Error('上传失败')
const newFile = {
id: makeId(),
name: String(name || record?.name || guessNameFromUrl(url, '扫描件')).trim(),
url
}
updateContract(contractId, 'scannedFiles', [...contract.scannedFiles, newFile])
Taro.showToast({ title: '已上传', icon: 'success' })
} catch (e) {
Taro.showToast({ title: (e as any)?.message || '上传失败', icon: 'none' })
} finally {
Taro.hideLoading()
}
}, [canEdit, contracts, updateContract])
const onScannedFileAction = useCallback(async (contractId: string, file: FileItem) => {
try {
const res = await Taro.showActionSheet({ itemList: ['打开', '删除'] })
if (res.tapIndex === 0) {
let filePath = file.url
if (file.url.startsWith('http')) {
const dl = await Taro.downloadFile({ url: file.url })
// @ts-ignore
filePath = dl?.tempFilePath
}
await Taro.openDocument({ filePath, showMenu: true } as any)
}
if (res.tapIndex === 1) {
if (!canEdit) return
const contract = contracts.find(c => c.id === contractId)
if (contract) {
updateContract(contractId, 'scannedFiles', contract.scannedFiles.filter(f => f.id !== file.id))
}
}
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
}
}, [canEdit, contracts, updateContract])
const selectSigningTime = useCallback(async (contractId: string) => {
if (!canEdit) return
try {
const res = await Taro.showActionSheet({
itemList: ['今天', '昨天', '选择日期']
})
let date = dayjs()
if (res.tapIndex === 1) {
date = date.subtract(1, 'day')
} else if (res.tapIndex === 2) {
// For simplicity, use today's date when "选择日期" is selected
// In a real implementation, you might want to use a proper date picker
Taro.showToast({ title: '已选择今天日期', icon: 'none' })
}
updateContract(contractId, 'signingTime', date.format('YYYY-MM-DD'))
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
}
}, [canEdit, updateContract])
const goStep4 = () => {
if (!customerId) return
Taro.navigateTo({ url: `/credit/mp-customer/follow-step4?id=${customerId}` }).catch(() => {})
}
const submit = async () => {
if (!customerId) {
Taro.showToast({ title: '缺少客户ID', icon: 'none' })
return
}
if (!step4Done) {
Taro.showToast({ title: '请先完成第四步跟进', icon: 'none' })
return
}
// Validate all contracts
for (let i = 0; i < contracts.length; i++) {
const contract = contracts[i]
if (!contract.signatory.trim()) {
Taro.showToast({ title: `${i + 1}组合同请填写签订人`, icon: 'none' })
return
}
if (!contract.signatoryPhone.trim()) {
Taro.showToast({ title: `${i + 1}组合同请填写联系电话`, icon: 'none' })
return
}
if (!contract.signingTime.trim()) {
Taro.showToast({ title: `${i + 1}组合同请选择签订时间`, icon: 'none' })
return
}
if (!contract.contractAmount.trim()) {
Taro.showToast({ title: `${i + 1}组合同请填写合同金额`, icon: 'none' })
return
}
if (!contract.principal.trim()) {
Taro.showToast({ title: `${i + 1}组合同请填写本金`, icon: 'none' })
return
}
if (!contract.communicationStatus.trim()) {
Taro.showToast({ title: `${i + 1}组合同请填写沟通情况`, icon: 'none' })
return
}
if (contract.scannedFiles.length === 0) {
Taro.showToast({ title: `${i + 1}组合同请上传扫描件`, icon: 'none' })
return
}
}
if (submitting || !row) return
setSubmitting(true)
try {
const anyRow = row as any
const contractsPayload = contracts.map(contract => ({
guarantor: contract.guarantor,
guarantorPhone: contract.guarantorPhone,
signatory: contract.signatory,
signatoryPhone: contract.signatoryPhone,
signingTime: contract.signingTime,
contractAmount: contract.contractAmount,
principal: contract.principal,
interest: contract.interest,
communicationStatus: contract.communicationStatus,
scannedFiles: contract.scannedFiles.map(f => ({
name: f.name,
url: f.url,
isFile: true
}))
}))
const nextStep = (() => {
const raw = anyRow?.step ?? anyRow?.stepStatus ?? anyRow?.stepNum ?? anyRow?.stepCode ?? undefined
const n = Number(raw)
if (Number.isInteger(n) && n >= 0) return Math.max(5, n)
return 5
})()
await updateCreditMpCustomer({
...(row as any),
id: customerId,
step: nextStep,
followStep5Submitted: 1,
followStep5SubmittedAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
followStep5Contracts: JSON.stringify(contractsPayload),
followStep5NeedApproval: 1,
followStep5Approved: 0
} as any)
setSubmitted(true)
setRow(prev =>
prev
? ({
...(prev as any),
step: nextStep,
followStep5Submitted: 1,
followStep5SubmittedAt: dayjs().format('YYYY-MM-DD HH:mm:ss')
} as any)
: prev
)
await Taro.showModal({
title: '提示',
content: '跟进信息已提交\n请等待管理员审核',
showCancel: false
})
// 跳转到第六步
Taro.navigateTo({ url: `/credit/mp-customer/follow-step6?id=${customerId}` }).catch(() => {})
} catch (e) {
Taro.showToast({ title: (e as any)?.message || '提交失败', icon: 'none' })
} finally {
setSubmitting(false)
}
}
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>
</View>
) : !row ? (
<View className="bg-white rounded-xl border border-pink-100 py-10">
<Empty description="暂无客户信息" />
</View>
) : (
<View className="bg-white rounded-xl border border-pink-100 p-4">
<View className="text-sm font-semibold text-red-600"></View>
{!step4Done && (
<View className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<Text>稿</Text>
<View className="mt-2">
<Button size="small" type="primary" onClick={goStep4}>
</Button>
</View>
</View>
)}
<View className="mt-4 pt-3 border-t border-pink-100 text-sm">
{contracts.map((contract, index) => (
<View
key={contract.id}
className={`mb-6 pb-6 ${index === contracts.length - 1 ? '' : 'border-b border-gray-100'}`}
>
{contracts.length > 1 && (
<View className="flex justify-between items-center mb-3">
<Text className="text-gray-700 font-medium"> {index + 1}</Text>
{canEdit && (
<Button size="small" type="danger" fill="outline" onClick={() => deleteContract(contract.id)}>
</Button>
)}
</View>
)}
<View className="space-y-4">
<View>
<Text className="text-gray-700"></Text>
<Input
className="mt-2"
value={contract.guarantor}
onChange={(value) => updateContract(contract.id, 'guarantor', value)}
placeholder="请输入保证人姓名"
disabled={!canEdit}
/>
</View>
<View>
<Text className="text-gray-700"></Text>
<Input
className="mt-2"
value={contract.guarantorPhone}
onChange={(value) => updateContract(contract.id, 'guarantorPhone', value)}
placeholder="请输入保证电话"
disabled={!canEdit}
/>
</View>
<View>
<Text className="text-gray-700">
<Text className="text-red-500">*</Text>
</Text>
<Input
className="mt-2"
value={contract.signatory}
onChange={(value) => updateContract(contract.id, 'signatory', value)}
placeholder="请输入签订人姓名"
disabled={!canEdit}
/>
</View>
<View>
<Text className="text-gray-700">
<Text className="text-red-500">*</Text>
</Text>
<Input
className="mt-2"
value={contract.signatoryPhone}
onChange={(value) => updateContract(contract.id, 'signatoryPhone', value)}
placeholder="请输入联系电话"
disabled={!canEdit}
/>
</View>
<View>
<Text className="text-gray-700">
<Text className="text-red-500">*</Text>
</Text>
<View className="mt-2">
<Button
fill="outline"
size="small"
disabled={!canEdit}
onClick={() => selectSigningTime(contract.id)}
>
{contract.signingTime || '请选择'}
</Button>
</View>
</View>
<View>
<Text className="text-gray-700">
<Text className="text-red-500">*</Text>
</Text>
<Input
className="mt-2"
value={contract.contractAmount}
onChange={(value) => updateContract(contract.id, 'contractAmount', value)}
placeholder="请输入合同金额"
disabled={!canEdit}
/>
</View>
<View>
<Text className="text-gray-700">
<Text className="text-red-500">*</Text>
</Text>
<Input
className="mt-2"
value={contract.principal}
onChange={(value) => updateContract(contract.id, 'principal', value)}
placeholder="请输入本金"
disabled={!canEdit}
/>
</View>
<View>
<Text className="text-gray-700"></Text>
<Input
className="mt-2"
value={contract.interest}
onChange={(value) => updateContract(contract.id, 'interest', value)}
placeholder="请输入利息"
disabled={!canEdit}
/>
</View>
<View>
<Text className="text-gray-700">
<Text className="text-red-500">*</Text>
</Text>
<TextArea
className="mt-2"
value={contract.communicationStatus}
onChange={(value) => updateContract(contract.id, 'communicationStatus', value)}
placeholder="请输入沟通情况"
disabled={!canEdit}
maxLength={500}
style={{ background: '#fff' }}
/>
</View>
<View>
<Text className="text-gray-700">
<Text className="text-red-500">*</Text>
</Text>
<View className="mt-2">
<Button
size="small"
fill="outline"
disabled={!canEdit}
onClick={() => pickScannedFile(contract.id)}
>
+
</Button>
</View>
{contract.scannedFiles.length > 0 && (
<View className="mt-2">
{contract.scannedFiles.map((file, fileIdx) => (
<View
key={file.id}
className={`flex flex-row items-center justify-between gap-2 border border-gray-100 rounded-lg px-2 py-2 ${
fileIdx > 0 ? 'mt-2' : ''
}`}
onClick={() => onScannedFileAction(contract.id, file)}
>
<Text className="text-xs text-gray-800 flex-1 truncate">{file.name || '文件'}</Text>
<Text className="text-xs text-blue-600 shrink-0"></Text>
</View>
))}
</View>
)}
</View>
</View>
</View>
))}
{canEdit && (
<View className="mt-4">
<Text className="text-sm text-red-600" onClick={addNewContract}>
+
</Text>
</View>
)}
{submitted && (
<View className="mt-4 text-xs text-gray-400"></View>
)}
</View>
</View>
)}
</View>
<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
disabled={!canEdit || submitting}
style={{
background: submitted || !step4Done ? '#94a3b8' : '#ef4444',
borderColor: submitted || !step4Done ? '#94a3b8' : '#ef4444'
}}
onClick={submit}
>
{submitting ? '提交中...' : '确定'}
</Button>
</View>
</ConfigProvider>
</View>
)
}

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '客户跟进',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
})

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>
)
}

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '客户跟进',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
})

View File

@@ -0,0 +1,603 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Taro, { useDidShow, useRouter } from '@tarojs/taro'
import { Image, Text, View } from '@tarojs/components'
import { Button, ConfigProvider, Empty, Loading, Input, TextArea, Radio } from '@nutui/nutui-react-taro'
import dayjs from 'dayjs'
import { getCreditMpCustomer, updateCreditMpCustomer } from '@/api/credit/creditMpCustomer'
import type { CreditMpCustomer } from '@/api/credit/creditMpCustomer/model'
import { uploadFileByPath } from '@/api/system/file'
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 Attachment = { id: string; name: string; url: string; thumbnail?: string }
type AudioAttachment = { name: string; url: string }
type VisitRecord = {
id: string
phoneRecording: AudioAttachment | null
interviewee: string
intervieweePhone: string
visitSituation: string
wechatScreenshot: Attachment | null
satisfaction: 'satisfied' | 'normal' | 'dissatisfied' | 'no_reply' | 'blacklisted'
}
const makeId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`
const guessNameFromUrl = (url: string, fallback: string) => {
const clean = String(url || '').split('?')[0].split('#')[0]
const lastSlash = clean.lastIndexOf('/')
const base = lastSlash >= 0 ? clean.slice(lastSlash + 1) : clean
return String(base || fallback).trim()
}
const pathsFromChooseImageResult = (res: any): string[] => {
const a = res?.tempFilePaths
if (Array.isArray(a) && a.length) {
return a.map((p: any) => String(p || '').trim()).filter(Boolean)
}
const files = res?.tempFiles
if (Array.isArray(files) && files.length) {
return files.map((f: any) => String(f?.path || '').trim()).filter(Boolean)
}
return []
}
const normalizeShotFromJson = (raw?: string): Attachment | null => {
if (!raw) return null
const parsed = safeParseJSON<any>(raw)
if (!parsed) return null
const arr = Array.isArray(parsed) ? parsed : [parsed]
const first = arr[0]
if (!first) return null
const url = String(first?.url || first?.downloadUrl || first?.path || (typeof first === 'string' ? first : '')).trim()
if (!url) return null
return {
id: makeId(),
url,
name: String(first?.name || guessNameFromUrl(url, '截图')).trim(),
thumbnail: first?.thumbnail ? String(first.thumbnail) : undefined
}
}
const parseVisitRecords = (raw?: string): VisitRecord[] => {
const parsed = safeParseJSON<any[]>(raw)
if (!Array.isArray(parsed)) return []
return parsed.map(item => ({
id: makeId(),
phoneRecording: item?.phoneRecording ? {
name: String(item.phoneRecording.name || guessNameFromUrl(item.phoneRecording.url, '录音')).trim(),
url: String(item.phoneRecording.url || '').trim()
} : null,
interviewee: String(item?.interviewee || '').trim(),
intervieweePhone: String(item?.intervieweePhone || '').trim(),
visitSituation: String(item?.visitSituation || '').trim(),
wechatScreenshot: normalizeShotFromJson(item?.wechatScreenshot),
satisfaction: item?.satisfaction || 'satisfied'
}))
}
export default function CreditMpCustomerFollowStep7Page() {
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 [submitted, setSubmitted] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [visitRecords, setVisitRecords] = useState<VisitRecord[]>([])
const recorderPendingRef = useRef(false)
const recorderRef = useRef<any>(null)
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
setSubmitted(Boolean(anyRow?.followStep7Submitted) || Boolean(anyRow?.followStep7SubmittedAt))
const parsedRecords = parseVisitRecords(anyRow?.followStep7VisitRecords)
setVisitRecords(parsedRecords.length > 0 ? parsedRecords : [{
id: makeId(),
phoneRecording: null,
interviewee: '',
intervieweePhone: '',
visitSituation: '',
wechatScreenshot: null,
satisfaction: 'satisfied'
}])
} catch (e) {
console.error('加载失败:', e)
setRow(null)
setError(String((e as any)?.message || '加载失败'))
} finally {
setLoading(false)
}
}, [customerId])
useDidShow(() => {
reload().then()
})
useEffect(() => {
if (typeof Taro.getRecorderManager !== 'function') return
const rm = Taro.getRecorderManager()
recorderRef.current = rm
rm.onStop(async (res: any) => {
if (!recorderPendingRef.current) return
recorderPendingRef.current = false
const path = String(res?.tempFilePath || '').trim()
if (!path) return
Taro.showLoading({ title: '上传中...' })
try {
const record: any = await uploadFileByPath(path)
const url = String(record?.url || record?.downloadUrl || record?.thumbnail || record?.path || '').trim()
if (!url) throw new Error('上传失败缺少url')
// 这里需要更新对应记录的录音
// 由于录音是针对特定记录的需要在调用时传入recordId
} catch (e) {
Taro.showToast({ title: (e as any)?.message || '上传失败', icon: 'none' })
} finally {
Taro.hideLoading()
}
})
rm.onError(() => {
if (!recorderPendingRef.current) return
recorderPendingRef.current = false
Taro.showToast({ title: '录音失败', icon: 'none' })
})
}, [])
const step6Done = useMemo(() => {
const anyRow = row as any
return Boolean(anyRow?.followStep6Submitted) || Boolean(anyRow?.followStep6SubmittedAt)
}, [row])
const canEdit = !submitted && step6Done
const addNewVisitRecord = useCallback(() => {
if (!canEdit) return
setVisitRecords(prev => [...prev, {
id: makeId(),
phoneRecording: null,
interviewee: '',
intervieweePhone: '',
visitSituation: '',
wechatScreenshot: null,
satisfaction: 'satisfied'
}])
}, [canEdit])
const updateVisitRecord = useCallback((recordId: string, field: keyof VisitRecord, value: any) => {
if (!canEdit) return
setVisitRecords(prev => prev.map(record =>
record.id === recordId ? { ...record, [field]: value } : record
))
}, [canEdit])
const deleteVisitRecord = useCallback((recordId: string) => {
if (!canEdit) return
if (visitRecords.length <= 1) {
Taro.showToast({ title: '至少保留一组回访记录', icon: 'none' })
return
}
setVisitRecords(prev => prev.filter(record => record.id !== recordId))
}, [canEdit, visitRecords.length])
const pickPhoneRecording = useCallback(async (recordId: string) => {
if (!canEdit) return
let res: any
try {
// @ts-ignore
res = await Taro.chooseMessageFile({ count: 1, type: 'file' })
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
Taro.showToast({ title: '请使用微信小程序选择文件', icon: 'none' })
return
}
// @ts-ignore
const picked = (res?.tempFiles || []) as Array<{ path?: string; name?: string }>
const path = String(picked?.[0]?.path || '').trim()
const name = String(picked?.[0]?.name || '').trim()
if (!path) return
Taro.showLoading({ title: '上传中...' })
try {
const record: any = await uploadFileByPath(path)
const url = String(record?.url || record?.downloadUrl || record?.thumbnail || record?.path || '').trim()
if (!url) throw new Error('上传失败')
updateVisitRecord(recordId, 'phoneRecording', {
name: String(name || record?.name || guessNameFromUrl(url, '录音')).trim(),
url
})
Taro.showToast({ title: '已上传', icon: 'success' })
} catch (e) {
Taro.showToast({ title: (e as any)?.message || '上传失败', icon: 'none' })
} finally {
Taro.hideLoading()
}
}, [canEdit, updateVisitRecord])
const chooseWechatScreenshot = useCallback(async (recordId: string) => {
if (!canEdit) return
let res: any
try {
res = await Taro.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera']
})
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
Taro.showToast({ title: '选择图片失败', icon: 'none' })
return
}
const paths = pathsFromChooseImageResult(res)
const p = paths[0]
if (!p) return
Taro.showLoading({ title: '上传中...' })
try {
const record: any = await uploadFileByPath(p)
const url = String(record?.url || record?.downloadUrl || record?.thumbnail || record?.path || '').trim()
if (!url) return
updateVisitRecord(recordId, 'wechatScreenshot', {
id: makeId(),
name: String(record?.name || guessNameFromUrl(url, '截图')).trim(),
url,
thumbnail: record?.thumbnail ? String(record.thumbnail) : undefined
})
Taro.showToast({ title: '已上传', icon: 'success' })
} catch (e) {
Taro.showToast({ title: (e as any)?.message || '上传失败', icon: 'none' })
} finally {
Taro.hideLoading()
}
}, [canEdit, updateVisitRecord])
const goStep6 = () => {
if (!customerId) return
Taro.navigateTo({ url: `/credit/mp-customer/follow-step6?id=${customerId}` }).catch(() => {})
}
const submit = async () => {
if (!customerId) {
Taro.showToast({ title: '缺少客户ID', icon: 'none' })
return
}
if (!step6Done) {
Taro.showToast({ title: '请先完成第六步跟进', icon: 'none' })
return
}
// 验证所有回访记录
for (let i = 0; i < visitRecords.length; i++) {
const record = visitRecords[i]
if (!record.intervieweePhone.trim()) {
Taro.showToast({ title: `${i + 1}组回访请填写被回访电话`, icon: 'none' })
return
}
if (!record.visitSituation.trim()) {
Taro.showToast({ title: `${i + 1}组回访请填写回访情况`, icon: 'none' })
return
}
if (!record.wechatScreenshot?.url) {
Taro.showToast({ title: `${i + 1}组回访请上传微信截图`, icon: 'none' })
return
}
}
if (submitting || !row) return
setSubmitting(true)
try {
const anyRow = row as any
const recordsPayload = visitRecords.map(record => ({
phoneRecording: record.phoneRecording ? {
name: record.phoneRecording.name,
url: record.phoneRecording.url
} : null,
interviewee: record.interviewee,
intervieweePhone: record.intervieweePhone,
visitSituation: record.visitSituation,
wechatScreenshot: record.wechatScreenshot ? {
name: record.wechatScreenshot.name,
url: record.wechatScreenshot.url,
thumbnail: record.wechatScreenshot.thumbnail
} : null,
satisfaction: record.satisfaction
}))
const nextStep = (() => {
const raw = anyRow?.step ?? anyRow?.stepStatus ?? anyRow?.stepNum ?? anyRow?.stepCode ?? undefined
const n = Number(raw)
if (Number.isInteger(n) && n >= 0) return Math.max(7, n)
return 7
})()
await updateCreditMpCustomer({
...(row as any),
id: customerId,
step: nextStep,
followStep7Submitted: 1,
followStep7SubmittedAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
followStep7VisitRecords: JSON.stringify(recordsPayload),
followStep7NeedApproval: 1,
followStep7Approved: 0
} as any)
setSubmitted(true)
setRow(prev =>
prev
? ({
...(prev as any),
step: nextStep,
followStep7Submitted: 1,
followStep7SubmittedAt: dayjs().format('YYYY-MM-DD HH:mm:ss')
} as any)
: prev
)
await Taro.showModal({
title: '提示',
content: '跟进信息已提交\n请等待管理员审核',
showCancel: false
})
Taro.navigateBack().catch(() => {})
} catch (e) {
Taro.showToast({ title: (e as any)?.message || '提交失败', icon: 'none' })
} finally {
setSubmitting(false)
}
}
const satisfactionOptions = [
{ value: 'satisfied', label: '满意' },
{ value: 'normal', label: '一般' },
{ value: 'dissatisfied', label: '不满意' },
{ value: 'no_reply', label: '无回复' },
{ value: 'blacklisted', label: '拉黑' }
]
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>
{!step6Done && (
<View className="mt-4">
<Button type="primary" onClick={goStep6}>
</Button>
</View>
)}
</View>
) : !row ? (
<View className="bg-white rounded-xl border border-pink-100 py-10">
<Empty description="暂无客户信息" />
</View>
) : (
<View className="bg-white rounded-xl border border-pink-100 p-4">
<View className="text-sm font-semibold text-red-600">访</View>
{!step6Done && (
<View className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<Text></Text>
<View className="mt-2">
<Button size="small" type="primary" onClick={goStep6}>
</Button>
</View>
</View>
)}
<View className="mt-4 pt-3 border-t border-pink-100 text-sm">
{visitRecords.map((record, index) => (
<View key={record.id} className={`mb-6 pb-6 ${index === visitRecords.length - 1 ? '' : 'border-b border-gray-100'}`}>
{visitRecords.length > 1 && (
<View className="flex justify-between items-center mb-3">
<Text className="text-gray-700 font-medium">访 {index + 1}</Text>
{canEdit && (
<Button size="small" type="danger" fill="outline" onClick={() => deleteVisitRecord(record.id)}>
</Button>
)}
</View>
)}
<View className="space-y-4">
<View>
<Text className="text-gray-700"></Text>
<View className="mt-2">
{record.phoneRecording ? (
<View className="flex flex-row items-center justify-between gap-2">
<Text className="text-xs text-blue-600 truncate flex-1">
{record.phoneRecording.name || '录音'}
</Text>
<Button size="small" fill="outline" disabled={!canEdit} onClick={() => pickPhoneRecording(record.id)}>
</Button>
</View>
) : (
<Button size="small" fill="outline" disabled={!canEdit} onClick={() => pickPhoneRecording(record.id)}>
</Button>
)}
</View>
</View>
<View>
<Text className="text-gray-700">访</Text>
<Input
className="mt-2"
value={record.interviewee}
onChange={(value) => updateVisitRecord(record.id, 'interviewee', value)}
placeholder="请输入被回访人姓名"
disabled={!canEdit}
/>
</View>
<View>
<Text className="text-gray-700">
访<Text className="text-red-500">*</Text>
</Text>
<Input
className="mt-2"
value={record.intervieweePhone}
onChange={(value) => updateVisitRecord(record.id, 'intervieweePhone', value)}
placeholder="请输入被回访电话"
disabled={!canEdit}
/>
</View>
<View>
<Text className="text-gray-700">
访<Text className="text-red-500">*</Text>
</Text>
<TextArea
className="mt-2"
value={record.visitSituation}
onChange={(value) => updateVisitRecord(record.id, 'visitSituation', value)}
placeholder="请输入回访情况"
disabled={!canEdit}
maxLength={500}
style={{ background: '#fff' }}
/>
</View>
<View>
<Text className="text-gray-700">
<Text className="text-red-500">*</Text>
</Text>
<View className="mt-2 flex flex-wrap gap-2">
{record.wechatScreenshot?.url ? (
<View
className="w-20 h-20 rounded-lg bg-gray-200 overflow-hidden"
onClick={() => {
if (canEdit) {
chooseWechatScreenshot(record.id)
}
}}
>
<Image
className="w-full h-full"
src={record.wechatScreenshot.thumbnail || record.wechatScreenshot.url}
mode="aspectFill"
/>
</View>
) : null}
<View
className={`w-20 h-20 rounded-lg border border-dashed flex items-center justify-center text-2xl ${
!canEdit ? 'border-gray-200 text-gray-200' : 'border-gray-300 text-gray-400'
}`}
onClick={() => {
if (!canEdit) return
if (record.wechatScreenshot) {
chooseWechatScreenshot(record.id)
} else {
chooseWechatScreenshot(record.id)
}
}}
>
+
</View>
</View>
</View>
<View>
<Text className="text-gray-700">
<Text className="text-red-500">*</Text>
</Text>
<View className="mt-2">
<Radio.Group
value={record.satisfaction}
onChange={(value) => updateVisitRecord(record.id, 'satisfaction', value)}
disabled={!canEdit}
>
<View className="space-y-2">
{satisfactionOptions.map((option) => (
<View key={option.value} className="flex items-center">
<Radio value={option.value} />
<Text className="ml-2">{option.label}</Text>
</View>
))}
</View>
</Radio.Group>
</View>
</View>
</View>
</View>
))}
{canEdit && (
<View className="mt-4">
<Text className="text-sm text-red-600" onClick={addNewVisitRecord}>
+访
</Text>
</View>
)}
{submitted && (
<View className="mt-4 text-xs text-gray-400"></View>
)}
</View>
</View>
)}
</View>
<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
disabled={!canEdit || submitting}
style={{
background: submitted || !step6Done ? '#94a3b8' : '#ef4444',
borderColor: submitted || !step6Done ? '#94a3b8' : '#ef4444'
}}
onClick={submit}
>
{submitting ? '提交中...' : '确定'}
</Button>
</View>
</ConfigProvider>
</View>
)
}

458
后端实现指南.md Normal file
View File

@@ -0,0 +1,458 @@
# 客户跟进7步骤后端实现指南
## 📋 概述
本指南详细说明如何实现客户跟进7个步骤功能的后端代码包括数据库设计、Java后端实现和API接口。
## 🗄️ 数据库设计
### 1. 修改 credit_mp_customer 表结构
```sql
-- 为第5-7步添加字段第1-4步字段已存在
-- 第5步合同签订
ALTER TABLE credit_mp_customer ADD COLUMN follow_step5_submitted TINYINT DEFAULT 0 COMMENT '是否已提交';
ALTER TABLE credit_mp_customer ADD COLUMN follow_step5_submitted_at VARCHAR(255) COMMENT '提交时间';
ALTER TABLE credit_mp_customer ADD COLUMN follow_step5_contracts TEXT COMMENT '合同信息JSON数组';
ALTER TABLE credit_mp_customer ADD COLUMN follow_step5_need_approval TINYINT DEFAULT 1 COMMENT '是否需要审核';
ALTER TABLE credit_mp_customer ADD COLUMN follow_step5_approved TINYINT DEFAULT 0 COMMENT '是否审核通过';
ALTER TABLE credit_mp_customer ADD COLUMN follow_step5_approved_at VARCHAR(255) COMMENT '审核时间';
ALTER TABLE credit_mp_customer ADD COLUMN follow_step5_approved_by BIGINT COMMENT '审核人ID';
-- 第6步订单回款
ALTER TABLE credit_mp_customer ADD COLUMN follow_step6_submitted TINYINT DEFAULT 0 COMMENT '是否已提交';
ALTER TABLE credit_mp_customer ADD COLUMN follow_step6_submitted_at VARCHAR(255) COMMENT '提交时间';
ALTER TABLE credit_mp_customer ADD COLUMN follow_step6_payment_records TEXT COMMENT '财务录入的回款记录JSON数组';
ALTER TABLE credit_mp_customer ADD COLUMN follow_step6_expected_payments TEXT COMMENT '预计回款JSON数组';
ALTER TABLE credit_mp_customer ADD COLUMN follow_step6_need_approval TINYINT DEFAULT 1 COMMENT '是否需要审核';
ALTER TABLE credit_mp_customer ADD COLUMN follow_step6_approved TINYINT DEFAULT 0 COMMENT '是否审核通过';
ALTER TABLE credit_mp_customer ADD COLUMN follow_step6_approved_at VARCHAR(255) COMMENT '审核时间';
ALTER TABLE credit_mp_customer ADD COLUMN follow_step6_approved_by BIGINT COMMENT '审核人ID';
-- 第7步电话回访
ALTER TABLE credit_mp_customer ADD COLUMN follow_step7_submitted TINYINT DEFAULT 0 COMMENT '是否已提交';
ALTER TABLE credit_mp_customer ADD COLUMN follow_step7_submitted_at VARCHAR(255) COMMENT '提交时间';
ALTER TABLE credit_mp_customer ADD COLUMN follow_step7_visit_records TEXT COMMENT '回访记录JSON数组';
ALTER TABLE credit_mp_customer ADD COLUMN follow_step7_need_approval TINYINT DEFAULT 1 COMMENT '是否需要审核';
ALTER TABLE credit_mp_customer ADD COLUMN follow_step7_approved TINYINT DEFAULT 0 COMMENT '是否审核通过';
ALTER TABLE credit_mp_customer ADD COLUMN follow_step7_approved_at VARCHAR(255) COMMENT '审核时间';
ALTER TABLE credit_mp_customer ADD COLUMN follow_step7_approved_by BIGINT COMMENT '审核人ID';
-- 添加流程结束相关字段
ALTER TABLE credit_mp_customer ADD COLUMN follow_process_ended TINYINT DEFAULT 0 COMMENT '流程是否已结束';
ALTER TABLE credit_mp_customer ADD COLUMN follow_process_end_time VARCHAR(255) COMMENT '流程结束时间';
ALTER TABLE credit_mp_customer ADD COLUMN follow_process_end_reason TEXT COMMENT '流程结束原因';
```
### 2. 创建审核记录表(可选)
```sql
CREATE TABLE credit_follow_approval (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
customer_id BIGINT NOT NULL COMMENT '客户ID',
step TINYINT NOT NULL COMMENT '步骤号',
approved TINYINT NOT NULL COMMENT '是否通过',
remark TEXT COMMENT '审核备注',
approved_by BIGINT COMMENT '审核人ID',
approved_at VARCHAR(255) COMMENT '审核时间',
created_at VARCHAR(255) COMMENT '创建时间',
INDEX idx_customer_step (customer_id, step),
INDEX idx_approved_by (approved_by)
) COMMENT='跟进步骤审核记录';
```
## ☕ Java后端实现
### 1. 实体类修改
```java
// CreditMpCustomer.java 添加字段
public class CreditMpCustomer {
// ... 现有字段
// 第5步字段
private Integer followStep5Submitted;
private String followStep5SubmittedAt;
private String followStep5Contracts;
private Integer followStep5NeedApproval;
private Integer followStep5Approved;
private String followStep5ApprovedAt;
private Long followStep5ApprovedBy;
// 第6步字段
private Integer followStep6Submitted;
private String followStep6SubmittedAt;
private String followStep6PaymentRecords;
private String followStep6ExpectedPayments;
private Integer followStep6NeedApproval;
private Integer followStep6Approved;
private String followStep6ApprovedAt;
private Long followStep6ApprovedBy;
// 第7步字段
private Integer followStep7Submitted;
private String followStep7SubmittedAt;
private String followStep7VisitRecords;
private Integer followStep7NeedApproval;
private Integer followStep7Approved;
private String followStep7ApprovedAt;
private Long followStep7ApprovedBy;
// 流程结束字段
private Integer followProcessEnded;
private String followProcessEndTime;
private String followProcessEndReason;
// getter/setter 方法...
}
```
### 2. DTO类创建
```java
// FollowStepApprovalDTO.java
@Data
public class FollowStepApprovalDTO {
private Long customerId;
private Integer step;
private Boolean approved;
private String remark;
}
// BatchFollowStepApprovalDTO.java
@Data
public class BatchFollowStepApprovalDTO {
private List<FollowStepApprovalDTO> approvals;
}
// FollowStatisticsDTO.java
@Data
public class FollowStatisticsDTO {
private Integer totalSteps;
private Integer completedSteps;
private Integer currentStep;
private Double progress;
private List<FollowStepDetailDTO> stepDetails;
}
// FollowStepDetailDTO.java
@Data
public class FollowStepDetailDTO {
private Integer step;
private String title;
private String status; // pending, submitted, approved, rejected
private String submittedAt;
private String approvedAt;
}
```
### 3. Service层实现
```java
// CreditMpCustomerServiceImpl.java 添加方法
@Service
public class CreditMpCustomerServiceImpl implements CreditMpCustomerService {
/**
* 审核跟进步骤
*/
@Override
@Transactional
public void approveFollowStep(FollowStepApprovalDTO dto) {
CreditMpCustomer customer = getById(dto.getCustomerId());
if (customer == null) {
throw new ServiceException("客户不存在");
}
// 验证步骤是否已提交
if (!isStepSubmitted(customer, dto.getStep())) {
throw new ServiceException("该步骤尚未提交,无法审核");
}
// 更新审核状态
updateStepApproval(customer, dto);
// 记录审核日志
saveApprovalLog(dto);
// 如果审核通过,更新客户步骤状态
if (dto.getApproved()) {
updateCustomerStep(customer, dto.getStep());
}
}
/**
* 批量审核跟进步骤
*/
@Override
@Transactional
public void batchApproveFollowSteps(BatchFollowStepApprovalDTO dto) {
for (FollowStepApprovalDTO approval : dto.getApprovals()) {
approveFollowStep(approval);
}
}
/**
* 获取待审核的跟进步骤列表
*/
@Override
public List<PendingApprovalStepVO> getPendingApprovalSteps(FollowStepQueryDTO query) {
return baseMapper.selectPendingApprovalSteps(query);
}
/**
* 获取客户跟进统计
*/
@Override
public FollowStatisticsDTO getFollowStatistics(Long customerId) {
CreditMpCustomer customer = getById(customerId);
if (customer == null) {
throw new ServiceException("客户不存在");
}
FollowStatisticsDTO statistics = new FollowStatisticsDTO();
statistics.setTotalSteps(7);
List<FollowStepDetailDTO> stepDetails = new ArrayList<>();
int completedSteps = 0;
int currentStep = 1;
for (int i = 1; i <= 7; i++) {
FollowStepDetailDTO detail = getStepDetail(customer, i);
stepDetails.add(detail);
if ("approved".equals(detail.getStatus())) {
completedSteps++;
currentStep = i + 1;
} else if ("submitted".equals(detail.getStatus()) && currentStep == 1) {
currentStep = i;
}
}
statistics.setCompletedSteps(completedSteps);
statistics.setCurrentStep(Math.min(currentStep, 7));
statistics.setProgress((double) completedSteps / 7 * 100);
statistics.setStepDetails(stepDetails);
return statistics;
}
/**
* 结束客户跟进流程
*/
@Override
@Transactional
public void endFollowProcess(Long customerId, String reason) {
CreditMpCustomer customer = getById(customerId);
if (customer == null) {
throw new ServiceException("客户不存在");
}
customer.setFollowProcessEnded(1);
customer.setFollowProcessEndTime(DateUtil.formatDateTime(new Date()));
customer.setFollowProcessEndReason(reason);
updateById(customer);
}
// 私有辅助方法...
private boolean isStepSubmitted(CreditMpCustomer customer, Integer step) {
switch (step) {
case 1: return customer.getFollowStep1Submitted() == 1;
case 2: return customer.getFollowStep2Submitted() == 1;
// ... 其他步骤
case 7: return customer.getFollowStep7Submitted() == 1;
default: return false;
}
}
private void updateStepApproval(CreditMpCustomer customer, FollowStepApprovalDTO dto) {
String currentTime = DateUtil.formatDateTime(new Date());
Long currentUserId = SecurityFrameworkUtils.getLoginUserId();
switch (dto.getStep()) {
case 5:
customer.setFollowStep5Approved(dto.getApproved() ? 1 : 0);
customer.setFollowStep5ApprovedAt(currentTime);
customer.setFollowStep5ApprovedBy(currentUserId);
break;
case 6:
customer.setFollowStep6Approved(dto.getApproved() ? 1 : 0);
customer.setFollowStep6ApprovedAt(currentTime);
customer.setFollowStep6ApprovedBy(currentUserId);
break;
case 7:
customer.setFollowStep7Approved(dto.getApproved() ? 1 : 0);
customer.setFollowStep7ApprovedAt(currentTime);
customer.setFollowStep7ApprovedBy(currentUserId);
break;
}
updateById(customer);
}
}
```
### 4. Controller层实现
```java
// CreditMpCustomerController.java 添加接口
@RestController
@RequestMapping("/credit/credit-mp-customer")
public class CreditMpCustomerController {
@PostMapping("/approve-follow-step")
@OperLog(title = "审核跟进步骤", businessType = BusinessType.UPDATE)
public R<Void> approveFollowStep(@RequestBody FollowStepApprovalDTO dto) {
creditMpCustomerService.approveFollowStep(dto);
return R.ok();
}
@PostMapping("/batch-approve-follow-steps")
@OperLog(title = "批量审核跟进步骤", businessType = BusinessType.UPDATE)
public R<Void> batchApproveFollowSteps(@RequestBody BatchFollowStepApprovalDTO dto) {
creditMpCustomerService.batchApproveFollowSteps(dto);
return R.ok();
}
@GetMapping("/pending-approval-steps")
@OperLog(title = "获取待审核跟进步骤", businessType = BusinessType.SELECT)
public R<List<PendingApprovalStepVO>> getPendingApprovalSteps(FollowStepQueryDTO query) {
List<PendingApprovalStepVO> list = creditMpCustomerService.getPendingApprovalSteps(query);
return R.ok(list);
}
@GetMapping("/follow-statistics/{customerId}")
@OperLog(title = "获取客户跟进统计", businessType = BusinessType.SELECT)
public R<FollowStatisticsDTO> getFollowStatistics(@PathVariable Long customerId) {
FollowStatisticsDTO statistics = creditMpCustomerService.getFollowStatistics(customerId);
return R.ok(statistics);
}
@PostMapping("/end-follow-process")
@OperLog(title = "结束客户跟进流程", businessType = BusinessType.UPDATE)
public R<Void> endFollowProcess(@RequestBody EndFollowProcessDTO dto) {
creditMpCustomerService.endFollowProcess(dto.getCustomerId(), dto.getReason());
return R.ok();
}
}
```
### 5. Mapper层SQL
```xml
<!-- CreditMpCustomerMapper.xml 添加查询方法 -->
<select id="selectPendingApprovalSteps" resultType="com.your.package.PendingApprovalStepVO">
SELECT
c.id as customerId,
c.to_user as customerName,
5 as step,
'合同签订' as stepTitle,
c.follow_step5_submitted_at as submittedAt,
u.real_name as submittedBy,
c.follow_step5_contracts as content
FROM credit_mp_customer c
LEFT JOIN sys_user u ON c.user_id = u.user_id
WHERE c.follow_step5_submitted = 1
AND c.follow_step5_approved = 0
AND c.deleted = 0
UNION ALL
SELECT
c.id as customerId,
c.to_user as customerName,
6 as step,
'订单回款' as stepTitle,
c.follow_step6_submitted_at as submittedAt,
u.real_name as submittedBy,
c.follow_step6_expected_payments as content
FROM credit_mp_customer c
LEFT JOIN sys_user u ON c.user_id = u.user_id
WHERE c.follow_step6_submitted = 1
AND c.follow_step6_approved = 0
AND c.deleted = 0
UNION ALL
SELECT
c.id as customerId,
c.to_user as customerName,
7 as step,
'电话回访' as stepTitle,
c.follow_step7_submitted_at as submittedAt,
u.real_name as submittedBy,
c.follow_step7_visit_records as content
FROM credit_mp_customer c
LEFT JOIN sys_user u ON c.user_id = u.user_id
WHERE c.follow_step7_submitted = 1
AND c.follow_step7_approved = 0
AND c.deleted = 0
<if test="step != null">
HAVING step = #{step}
</if>
<if test="customerId != null">
HAVING customerId = #{customerId}
</if>
ORDER BY submittedAt DESC
</select>
```
## 🔧 业务逻辑说明
### 1. 步骤解锁机制
- 第一步始终可用
- 后续步骤需要前一步审核通过才能进行
- 前端通过 `canEnterStep` 逻辑控制
### 2. 审核流程
- 步骤提交后设置 `needApproval = 1`
- 管理员在后台审核
- 审核通过后设置 `approved = 1` 并更新时间
### 3. 数据格式
- 所有复杂数据使用JSON格式存储
- 文件上传返回URL存储在JSON数组中
- 时间统一使用 `YYYY-MM-DD HH:mm:ss` 格式
### 4. 权限控制
- 销售只能提交和查看自己的客户
- 管理员可以审核所有步骤
- 财务人员可以录入第6步回款数据
## 📱 前端集成
前端代码已经完成,包括:
- 7个步骤的完整页面
- 步骤状态显示和跳转逻辑
- 数据提交和验证
- 客户详情页面的汇总显示
## 🚀 部署步骤
1. 执行数据库迁移脚本
2. 部署Java后端代码
3. 更新前端API调用
4. 测试完整流程
5. 配置权限和审核流程
## 📝 注意事项
1. **数据备份**:执行数据库变更前请备份
2. **权限配置**:确保各角色权限正确配置
3. **文件上传**:确认文件上传服务正常
4. **审核流程**:测试审核流程的完整性
5. **性能优化**:大量数据时考虑分页和索引优化
## 🔄 后续扩展
可以考虑的功能:
- 跟进模板和标准化流程
- 自动提醒和通知
- 数据统计和报表
- 跟进效率分析
- 客户满意度评估