feat(credit): 完善客户跟进流程功能
- 添加了客户跟进流程的第2至第7步页面路由配置 - 实现了完整的七步跟进流程,包括加微信、建群沟通、合同定稿等环节 - 添加了各步骤的状态管理、审批机制和进度跟踪功能 - 集成了录音功能,支持现场录音和从聊天记录选择音频 - 优化了图片上传组件,使用Image组件替代背景图方式显示 - 添加了步骤间的导航控制和前置条件检查逻辑 - 实现了审核接口和批量审批功能 - 增加了跟进统计和流程结束功能 - 完善了用户界面,提供更清晰的流程指引和状态展示
This commit is contained in:
23
.windsurf/workflows/review.md
Normal file
23
.windsurf/workflows/review.md
Normal 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.
|
||||
使用中文回复
|
||||
@@ -102,3 +102,109 @@ export async function getCreditMpCustomer(id: number) {
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -126,6 +126,94 @@ export interface CreditMpCustomer {
|
||||
followStep1ApprovedAt?: string;
|
||||
// 跟进 Step1:审核人
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,6 +54,9 @@ export default {
|
||||
"mp-customer/add",
|
||||
"mp-customer/detail",
|
||||
"mp-customer/follow-step1",
|
||||
"mp-customer/follow-step2",
|
||||
"mp-customer/follow-step3",
|
||||
"mp-customer/follow-step4",
|
||||
"mp-customer/edit"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -571,6 +571,126 @@ export default function CreditMpCustomerDetailPage() {
|
||||
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 (
|
||||
<View className="bg-gray-50 min-h-screen">
|
||||
<ConfigProvider>
|
||||
@@ -746,22 +866,105 @@ export default function CreditMpCustomerDetailPage() {
|
||||
)}
|
||||
</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">
|
||||
<CellGroup>
|
||||
<Cell title="客户ID" description={String(row.id ?? '—')} />
|
||||
<Cell title="跟进状态" description={stepText} />
|
||||
<Cell title="所在地区" description={buildLocation(row) || '—'} />
|
||||
<Cell title="创建时间" description={fmtTime(row.createTime) || '—'} />
|
||||
{/*<Cell title="更新时间" description={fmtTime(row.updateTime) || '—'} />*/}
|
||||
{!!row.url && <Cell title="链接" description={String(row.url)} />}
|
||||
{/*{!!row.files && <Cell title="文件" description={String(row.files)} />}*/}
|
||||
</CellGroup>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<FixedButton text="跟进" background="#ef4444" disabled={!rowId} onClick={goFollow} />
|
||||
<FixedButton text="开始跟进" background="#ef4444" disabled={!rowId} onClick={goFollow} />
|
||||
</ConfigProvider>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -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 { View, Text } from '@tarojs/components'
|
||||
import { Image, View, Text } from '@tarojs/components'
|
||||
import { Button, ConfigProvider, Empty, Loading, TextArea } from '@nutui/nutui-react-taro'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
@@ -46,6 +46,19 @@ type AudioAttachment = {
|
||||
|
||||
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) => {
|
||||
if (!url) return false
|
||||
return /^https?:\/\//i.test(url)
|
||||
@@ -175,6 +188,13 @@ export default function CreditMpCustomerFollowStep1Page() {
|
||||
const [remark, setRemark] = useState('')
|
||||
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 () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
@@ -220,6 +240,44 @@ export default function CreditMpCustomerFollowStep1Page() {
|
||||
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 phone = useMemo(() => String(getCustomerPhones(row)[0] || '').trim(), [row])
|
||||
|
||||
@@ -249,8 +307,11 @@ export default function CreditMpCustomerFollowStep1Page() {
|
||||
return
|
||||
}
|
||||
|
||||
const tempFilePaths = (res?.tempFilePaths || []) as string[]
|
||||
if (!tempFilePaths.length) return
|
||||
const tempFilePaths = pathsFromChooseImageResult(res)
|
||||
if (!tempFilePaths.length) {
|
||||
Taro.showToast({ title: '未获取到图片路径', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
Taro.showLoading({ title: '上传中...' })
|
||||
try {
|
||||
@@ -301,9 +362,7 @@ export default function CreditMpCustomerFollowStep1Page() {
|
||||
[callShots, canEdit, smsShots]
|
||||
)
|
||||
|
||||
const chooseAndUploadAudio = useCallback(async () => {
|
||||
if (!canEdit) return
|
||||
|
||||
const pickAudioFromChat = useCallback(async () => {
|
||||
let res: any
|
||||
try {
|
||||
// @ts-ignore
|
||||
@@ -312,7 +371,7 @@ export default function CreditMpCustomerFollowStep1Page() {
|
||||
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
|
||||
if (msg.includes('cancel')) return
|
||||
console.error('选择录音失败:', e)
|
||||
Taro.showToast({ title: '当前环境不支持选取文件', icon: 'none' })
|
||||
Taro.showToast({ title: '当前环境不支持从会话选文件,请使用微信小程序', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -338,7 +397,66 @@ export default function CreditMpCustomerFollowStep1Page() {
|
||||
} finally {
|
||||
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 () => {
|
||||
if (!audio) return
|
||||
@@ -456,7 +574,13 @@ export default function CreditMpCustomerFollowStep1Page() {
|
||||
showCancel: false
|
||||
})
|
||||
|
||||
Taro.navigateBack().catch(() => {})
|
||||
if (customerId) {
|
||||
Taro.redirectTo({
|
||||
url: `/credit/mp-customer/follow-step2?id=${customerId}`
|
||||
}).catch(() => {})
|
||||
} else {
|
||||
Taro.navigateBack().catch(() => {})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('提交跟进失败:', e)
|
||||
Taro.showToast({ title: (e as any)?.message || '提交失败', icon: 'none' })
|
||||
@@ -552,14 +676,7 @@ export default function CreditMpCustomerFollowStep1Page() {
|
||||
onClick={() => onImageAction('sms', x)}
|
||||
>
|
||||
{x.url ? (
|
||||
<View
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `url(${x.thumbnail || x.url})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center'
|
||||
}}
|
||||
/>
|
||||
<Image className="w-full h-full" src={x.thumbnail || x.url} mode="aspectFill" />
|
||||
) : (
|
||||
<View className="w-full h-full flex items-center justify-center text-xs text-gray-500">
|
||||
图{idx + 1}
|
||||
@@ -591,14 +708,7 @@ export default function CreditMpCustomerFollowStep1Page() {
|
||||
onClick={() => onImageAction('call', x)}
|
||||
>
|
||||
{x.url ? (
|
||||
<View
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `url(${x.thumbnail || x.url})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center'
|
||||
}}
|
||||
/>
|
||||
<Image className="w-full h-full" src={x.thumbnail || x.url} mode="aspectFill" />
|
||||
) : (
|
||||
<View className="w-full h-full flex items-center justify-center text-xs text-gray-500">
|
||||
图{idx + 1}
|
||||
@@ -670,6 +780,50 @@ export default function CreditMpCustomerFollowStep1Page() {
|
||||
)}
|
||||
</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">
|
||||
<Button
|
||||
type="primary"
|
||||
|
||||
5
src/credit/mp-customer/follow-step2.config.ts
Normal file
5
src/credit/mp-customer/follow-step2.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '客户跟进',
|
||||
navigationBarTextStyle: 'black',
|
||||
navigationBarBackgroundColor: '#ffffff'
|
||||
})
|
||||
445
src/credit/mp-customer/follow-step2.tsx
Normal file
445
src/credit/mp-customer/follow-step2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
src/credit/mp-customer/follow-step3.config.ts
Normal file
5
src/credit/mp-customer/follow-step3.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '客户跟进',
|
||||
navigationBarTextStyle: 'black',
|
||||
navigationBarBackgroundColor: '#ffffff'
|
||||
})
|
||||
1058
src/credit/mp-customer/follow-step3.tsx
Normal file
1058
src/credit/mp-customer/follow-step3.tsx
Normal file
File diff suppressed because it is too large
Load Diff
5
src/credit/mp-customer/follow-step4.config.ts
Normal file
5
src/credit/mp-customer/follow-step4.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '客户跟进',
|
||||
navigationBarTextStyle: 'black',
|
||||
navigationBarBackgroundColor: '#ffffff'
|
||||
})
|
||||
729
src/credit/mp-customer/follow-step4.tsx
Normal file
729
src/credit/mp-customer/follow-step4.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
src/credit/mp-customer/follow-step5.config.ts
Normal file
5
src/credit/mp-customer/follow-step5.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '客户跟进',
|
||||
navigationBarTextStyle: 'black',
|
||||
navigationBarBackgroundColor: '#ffffff'
|
||||
})
|
||||
621
src/credit/mp-customer/follow-step5.tsx
Normal file
621
src/credit/mp-customer/follow-step5.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
src/credit/mp-customer/follow-step6.config.ts
Normal file
5
src/credit/mp-customer/follow-step6.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '客户跟进',
|
||||
navigationBarTextStyle: 'black',
|
||||
navigationBarBackgroundColor: '#ffffff'
|
||||
})
|
||||
459
src/credit/mp-customer/follow-step6.tsx
Normal file
459
src/credit/mp-customer/follow-step6.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
src/credit/mp-customer/follow-step7.config.ts
Normal file
5
src/credit/mp-customer/follow-step7.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '客户跟进',
|
||||
navigationBarTextStyle: 'black',
|
||||
navigationBarBackgroundColor: '#ffffff'
|
||||
})
|
||||
603
src/credit/mp-customer/follow-step7.tsx
Normal file
603
src/credit/mp-customer/follow-step7.tsx
Normal 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
458
后端实现指南.md
Normal 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. **性能优化**:大量数据时考虑分页和索引优化
|
||||
|
||||
## 🔄 后续扩展
|
||||
|
||||
可以考虑的功能:
|
||||
- 跟进模板和标准化流程
|
||||
- 自动提醒和通知
|
||||
- 数据统计和报表
|
||||
- 跟进效率分析
|
||||
- 客户满意度评估
|
||||
Reference in New Issue
Block a user