diff --git a/.windsurf/workflows/review.md b/.windsurf/workflows/review.md new file mode 100644 index 0000000..529dff9 --- /dev/null +++ b/.windsurf/workflows/review.md @@ -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. +使用中文回复 \ No newline at end of file diff --git a/src/api/credit/creditMpCustomer/index.ts b/src/api/credit/creditMpCustomer/index.ts index 70691db..aebf32a 100644 --- a/src/api/credit/creditMpCustomer/index.ts +++ b/src/api/credit/creditMpCustomer/index.ts @@ -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>( + '/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>( + '/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>>( + '/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; + }>>( + '/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>( + '/credit/credit-mp-customer/end-follow-process', + { + customerId, + reason + } + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} diff --git a/src/api/credit/creditMpCustomer/model/index.ts b/src/api/credit/creditMpCustomer/model/index.ts index 138f31d..1206c5d 100644 --- a/src/api/credit/creditMpCustomer/model/index.ts +++ b/src/api/credit/creditMpCustomer/model/index.ts @@ -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; } /** diff --git a/src/app.config.ts b/src/app.config.ts index 7bd5180..5d836f1 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -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" ] } diff --git a/src/credit/mp-customer/detail.tsx b/src/credit/mp-customer/detail.tsx index ae804d4..b1a6a71 100644 --- a/src/credit/mp-customer/detail.tsx +++ b/src/credit/mp-customer/detail.tsx @@ -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) => { + 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 ( @@ -746,22 +866,105 @@ export default function CreditMpCustomerDetailPage() { )} + + 跟进流程 + + {stepConfigs.map((config) => { + const status = config.statusFn() + const statusDisplay = getStepStatusDisplay(status) + const canEnter = canEnterStep(config.num) + + return ( + + + + + {config.num} + + + {config.title} + {status.submittedAt && ( + + {fmtTime(status.submittedAt)} + + )} + + + + + {statusDisplay.text} + + + + + + + + {status.submitted && ( + + )} + + {config.num >= 3 && status.approved && ( + + )} + + {config.num === 3 && status.approved && ( + + )} + + {(config.num === 6 || config.num === 7) && status.approved && ( + + )} + + + ) + })} + + + - {/**/} {!!row.url && } - {/*{!!row.files && }*/} )} - + ) diff --git a/src/credit/mp-customer/follow-step1.tsx b/src/credit/mp-customer/follow-step1.tsx index 57e1e17..6b9b4e7 100644 --- a/src/credit/mp-customer/follow-step1.tsx +++ b/src/credit/mp-customer/follow-step1.tsx @@ -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(undefined) + const [recorderPanelOpen, setRecorderPanelOpen] = useState(false) + const [isRecording, setIsRecording] = useState(false) + const recorderPendingRef = useRef(false) + const recorderRef = useRef(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 ? ( - + ) : ( 图{idx + 1} @@ -591,14 +708,7 @@ export default function CreditMpCustomerFollowStep1Page() { onClick={() => onImageAction('call', x)} > {x.url ? ( - + ) : ( 图{idx + 1} @@ -670,6 +780,50 @@ export default function CreditMpCustomerFollowStep1Page() { )} + {recorderPanelOpen && ( + { + if (!isRecording) setRecorderPanelOpen(false) + }} + > + e.stopPropagation()} + > + 现场录音 + + 请授予麦克风权限;停止后将自动上传 + + + {!isRecording ? ( + + ) : ( + + )} + + + + + + + )} + + + + ) : !row ? ( + + + + ) : ( + + 第二步:加微信 + + {!step1Done && ( + + 需先完成「第一步:加微信前沟通」后,才可填写本页。 + + + + + )} + + + 从第二步开始,每一步的内容,都要管理员审核通过。 + + + + + + 微信号* + + + setWechatId(v)} + /> + + + + + 联系人 + {contact || '—'} + + + + + 添加微信的截图* + + + {screenshot?.url ? ( + + + + ) : null} + { + if (!canEdit) return + if (screenshot) { + onScreenshotAction() + } else { + chooseAndUploadScreenshot() + } + }} + > + + + + + + + + + 沟通情况* + + +