feat(company): 添加客户跟进功能
- 在路由配置中注册新的跟进页面路径 - 修改客户详情页面的跟进按钮逻辑,跳转到新页面并验证参数 - 新增客户跟进步骤一页面,包含完整的表单功能 - 实现联系人信息解析、电话号码提取等业务逻辑 - 添加录音上传、截图上传等功能模块 - 实现意向选择、沟通情况填写等交互组件 - 添加本地存储草稿功能和提交验证逻辑 - 实现业务规则:多次沟通后的审核流程判断
This commit is contained in:
@@ -125,6 +125,7 @@ export default {
|
|||||||
"company/index",
|
"company/index",
|
||||||
"company/add",
|
"company/add",
|
||||||
"company/detail",
|
"company/detail",
|
||||||
|
"company/follow-step1",
|
||||||
"company/edit"
|
"company/edit"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,8 +332,11 @@ export default function CreditCompanyDetailPage() {
|
|||||||
block
|
block
|
||||||
style={{ background: '#ef4444', borderColor: '#ef4444' }}
|
style={{ background: '#ef4444', borderColor: '#ef4444' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log('follow company:', companyId)
|
if (!companyId) {
|
||||||
Taro.showToast({ title: '开始跟进该客户', icon: 'none' })
|
Taro.showToast({ title: '缺少客户ID', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Taro.navigateTo({ url: `/credit/company/follow-step1?id=${companyId}` })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
跟进
|
跟进
|
||||||
|
|||||||
7
src/credit/company/follow-step1.config.ts
Normal file
7
src/credit/company/follow-step1.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '客户跟进',
|
||||||
|
navigationBarTextStyle: 'black',
|
||||||
|
navigationBarBackgroundColor: '#ffffff',
|
||||||
|
navigationStyle: 'custom'
|
||||||
|
})
|
||||||
|
|
||||||
427
src/credit/company/follow-step1.tsx
Normal file
427
src/credit/company/follow-step1.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user