feat(company): 添加客户跟进功能

- 在路由配置中注册新的跟进页面路径
- 修改客户详情页面的跟进按钮逻辑,跳转到新页面并验证参数
- 新增客户跟进步骤一页面,包含完整的表单功能
- 实现联系人信息解析、电话号码提取等业务逻辑
- 添加录音上传、截图上传等功能模块
- 实现意向选择、沟通情况填写等交互组件
- 添加本地存储草稿功能和提交验证逻辑
- 实现业务规则:多次沟通后的审核流程判断
This commit is contained in:
2026-03-05 13:25:24 +08:00
parent 704fdf14cd
commit 02e6f2a0ab
4 changed files with 440 additions and 2 deletions

View File

@@ -125,6 +125,7 @@ export default {
"company/index",
"company/add",
"company/detail",
"company/follow-step1",
"company/edit"
]
}

View File

@@ -332,8 +332,11 @@ export default function CreditCompanyDetailPage() {
block
style={{ background: '#ef4444', borderColor: '#ef4444' }}
onClick={() => {
console.log('follow company:', companyId)
Taro.showToast({ title: '开始跟进该客户', icon: 'none' })
if (!companyId) {
Taro.showToast({ title: '缺少客户ID', icon: 'none' })
return
}
Taro.navigateTo({ url: `/credit/company/follow-step1?id=${companyId}` })
}}
>

View File

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

View File

@@ -0,0 +1,427 @@
import { useCallback, useMemo, useState } from 'react'
import Taro, { useDidShow, useRouter } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import { Button, ConfigProvider, Empty, Loading, TextArea } from '@nutui/nutui-react-taro'
import { Setting } from '@nutui/icons-react-taro'
import dayjs from 'dayjs'
import { getCreditCompany } from '@/api/credit/creditCompany'
import type { CreditCompany } from '@/api/credit/creditCompany/model'
type Intention = '无意向' | '有意向'
type FollowStep1Draft = {
submitted: boolean
submittedAt: string
companyId: number
contact: string
phone: string
audioSelected: boolean
smsShots: number
callShots: number
remark: string
intention: Intention
// 业务规则模拟:
// 多次沟通 + 有意向 => 无需审核(可直接进入下一步,下一步不在本页实现)
// 多次沟通 + 无意向 => 需管理员审核
communicationCount: number
needApproval: boolean
isApproved: boolean
}
const splitPhones = (raw?: string) => {
const text = String(raw || '').trim()
if (!text) return []
return text
.split(/[\s,;;、\n\r]+/g)
.map(s => s.trim())
.filter(Boolean)
}
const uniq = <T,>(arr: T[]) => Array.from(new Set(arr))
const parseContactFromComments = (comments?: string) => {
const txt = String(comments || '').trim()
if (!txt) return ''
const m = txt.match(/联系人:([^;]+)/)
return String(m?.[1] || '').trim()
}
const makeThumb = () => ({
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`
})
const getDraftKey = (companyId: number) => `credit_company_follow_step1:${companyId}`
const getCountKey = (companyId: number) => `credit_company_follow_comm_count:${companyId}`
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
}
}
export default function CreditCompanyFollowStep1Page() {
const router = useRouter()
const companyId = useMemo(() => {
const id = Number((router?.params as any)?.id)
return Number.isFinite(id) && id > 0 ? id : undefined
}, [router?.params])
const statusBarHeight = useMemo(() => {
try {
const info = Taro.getSystemInfoSync()
return Number(info?.statusBarHeight || 0)
} catch (_e) {
return 0
}
}, [])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [company, setCompany] = useState<CreditCompany | null>(null)
const [submitted, setSubmitted] = useState(false)
const [audioSelected, setAudioSelected] = useState(false)
const [smsShots, setSmsShots] = useState<Array<{ id: string }>>([])
const [callShots, setCallShots] = useState<Array<{ id: string }>>([])
const [remark, setRemark] = useState('')
const [intention, setIntention] = useState<Intention | undefined>(undefined)
const loadDraft = useCallback(() => {
if (!companyId) return
const raw = Taro.getStorageSync(getDraftKey(companyId))
const saved = safeParseJSON<FollowStep1Draft>(raw)
if (!saved?.submitted) return
setSubmitted(true)
setAudioSelected(!!saved.audioSelected)
setSmsShots(Array.from({ length: Math.max(0, Math.min(6, Number(saved.smsShots || 0))) }, makeThumb))
setCallShots(Array.from({ length: Math.max(0, Math.min(6, Number(saved.callShots || 0))) }, makeThumb))
setRemark(String(saved.remark || ''))
setIntention(saved.intention)
}, [companyId])
const reloadCompany = useCallback(async () => {
setError(null)
setLoading(true)
try {
if (!companyId) throw new Error('缺少客户ID')
const res = await getCreditCompany(companyId)
setCompany(res as CreditCompany)
} catch (e) {
console.error('加载客户信息失败:', e)
setCompany(null)
setError(String((e as any)?.message || '加载失败'))
} finally {
setLoading(false)
}
}, [companyId])
useDidShow(() => {
reloadCompany().then()
loadDraft()
})
const contact = useMemo(() => parseContactFromComments(company?.comments), [company?.comments])
const phone = useMemo(() => {
const arr = uniq([...splitPhones(company?.tel), ...splitPhones(company?.moreTel)])
return String(arr[0] || '').trim()
}, [company?.moreTel, company?.tel])
const canEdit = !submitted
const addSmsShot = () => {
if (!canEdit) return
if (smsShots.length >= 6) {
Taro.showToast({ title: '短信截图已达上限(6张)', icon: 'none' })
return
}
setSmsShots(prev => prev.concat(makeThumb()))
}
const addCallShot = () => {
if (!canEdit) return
if (callShots.length >= 6) {
Taro.showToast({ title: '电话沟通截图已达上限(6张)', icon: 'none' })
return
}
setCallShots(prev => prev.concat(makeThumb()))
}
const chooseAudio = async () => {
if (!canEdit) return
// 本步骤仅做“选择录音文件”的交互模拟
setAudioSelected(true)
Taro.showToast({ title: '已选择录音文件(模拟)', icon: 'none' })
}
const submit = async () => {
if (!companyId) {
Taro.showToast({ title: '缺少客户ID', icon: 'none' })
return
}
if (!intention) {
Taro.showToast({ title: '请选择意向(无意向/有意向)', icon: 'none' })
return
}
if (!remark.trim()) {
Taro.showToast({ title: '请填写沟通情况', icon: 'none' })
return
}
if (!smsShots.length && !callShots.length) {
Taro.showToast({ title: '建议至少上传1张截图非必填', icon: 'none' })
}
const prevCountRaw = Taro.getStorageSync(getCountKey(companyId))
const prevCount = Number(prevCountRaw || 0)
const communicationCount = (Number.isFinite(prevCount) ? prevCount : 0) + 1
Taro.setStorageSync(getCountKey(companyId), communicationCount)
const needApproval = communicationCount > 1 && intention === '无意向'
const isApproved = false // 模拟:默认未审核;后续步骤可检查该标志
const payload: FollowStep1Draft = {
submitted: true,
submittedAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
companyId,
contact,
phone,
audioSelected,
smsShots: smsShots.length,
callShots: callShots.length,
remark: remark.trim(),
intention,
communicationCount,
needApproval,
isApproved
}
Taro.setStorageSync(getDraftKey(companyId), payload)
setSubmitted(true)
await Taro.showModal({
title: '提示',
content: '跟进信息已提交\n请等待管理员审核',
showCancel: false
})
}
const headerOffset = statusBarHeight + 80
return (
<View className="bg-pink-50 min-h-screen">
<ConfigProvider>
<View className="fixed z-50 top-0 left-0 right-0 bg-pink-50" style={{ paddingTop: `${statusBarHeight}px` }}>
<View className="px-4 h-10 flex items-center justify-between text-sm text-gray-900">
<Text className="font-medium">12:00</Text>
<View className="flex items-center gap-2 text-xs text-gray-600">
<Text></Text>
<Text>Wi-Fi</Text>
<Text></Text>
</View>
</View>
<View className="px-4 pb-2 flex items-center justify-between">
<Text className="text-sm text-gray-700" onClick={() => Taro.navigateBack()}>
</Text>
<Text className="text-base font-semibold text-gray-900"></Text>
<View className="flex items-center gap-3">
<View
className="w-7 h-7 rounded-full border border-gray-300 flex items-center justify-center text-gray-700"
onClick={async () => {
try {
const res = await Taro.showActionSheet({ itemList: ['刷新'] })
if (res.tapIndex === 0) reloadCompany()
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
}
}}
>
<Text>...</Text>
</View>
<View className="w-7 h-7 rounded-full border border-gray-300 flex items-center justify-center text-gray-700">
<Setting size={14} />
</View>
</View>
</View>
</View>
<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={reloadCompany}>
</Button>
</View>
</View>
) : !company ? (
<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 text-gray-900 font-semibold"></View>
<View className="mt-3 space-y-3 text-sm">
<View className="flex items-start justify-between gap-3">
<Text className="text-gray-500">
</Text>
<Text className="text-gray-400 text-right break-all">{contact || '—'}</Text>
</View>
<View className="flex items-start justify-between gap-3">
<Text className="text-gray-500">
</Text>
<Text className="text-gray-400 text-right break-all">{phone || '—'}</Text>
</View>
<View className="flex items-center justify-between gap-3">
<Text className="text-gray-500">
</Text>
<Button size="small" fill="outline" disabled={!canEdit} onClick={chooseAudio}>
{audioSelected ? '已选择' : '点击上传'}
</Button>
</View>
<View className="pt-2 border-t border-pink-100" />
<View>
<View className="flex items-center justify-between">
<Text className="text-gray-700"></Text>
<Text className="text-xs text-gray-400">{smsShots.length}/6</Text>
</View>
<View className="mt-2 flex flex-wrap gap-2">
{smsShots.map((x, idx) => (
<View
key={x.id}
className="w-16 h-16 rounded-lg bg-gray-200 flex items-center justify-center text-xs text-gray-500"
>
{idx + 1}
</View>
))}
<View
className={`w-16 h-16 rounded-lg border border-dashed flex items-center justify-center text-2xl ${
!canEdit || smsShots.length >= 6 ? 'border-gray-200 text-gray-200' : 'border-gray-300 text-gray-400'
}`}
onClick={addSmsShot}
>
+
</View>
</View>
</View>
<View>
<View className="flex items-center justify-between">
<Text className="text-gray-700"></Text>
<Text className="text-xs text-gray-400">{callShots.length}/6</Text>
</View>
<View className="mt-2 flex flex-wrap gap-2">
{callShots.map((x, idx) => (
<View
key={x.id}
className="w-16 h-16 rounded-lg bg-gray-200 flex items-center justify-center text-xs text-gray-500"
>
{idx + 1}
</View>
))}
<View
className={`w-16 h-16 rounded-lg border border-dashed flex items-center justify-center text-2xl ${
!canEdit || callShots.length >= 6 ? 'border-gray-200 text-gray-200' : 'border-gray-300 text-gray-400'
}`}
onClick={addCallShot}
>
+
</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>
<View>
<Text className="text-gray-700">
<Text className="text-red-500">*</Text>
</Text>
<View className="mt-2 grid grid-cols-2 gap-3">
<View
className={`rounded-xl border px-3 py-2 text-center ${
intention === '无意向' ? 'border-red-400 bg-red-50 text-red-600' : 'border-gray-200 text-gray-700'
} ${!canEdit ? 'opacity-60' : ''}`}
onClick={() => canEdit && setIntention('无意向')}
>
</View>
<View
className={`rounded-xl border px-3 py-2 text-center ${
intention === '有意向' ? 'border-red-400 bg-red-50 text-red-600' : 'border-gray-200 text-gray-700'
} ${!canEdit ? 'opacity-60' : ''}`}
onClick={() => canEdit && setIntention('有意向')}
>
</View>
</View>
<View className="mt-2 text-xs text-gray-400">
</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}
style={{
background: submitted ? '#94a3b8' : '#ef4444',
borderColor: submitted ? '#94a3b8' : '#ef4444'
}}
onClick={submit}
>
</Button>
</View>
</ConfigProvider>
</View>
)
}