feat(company): 添加客户跟进功能
- 在路由配置中注册新的跟进页面路径 - 修改客户详情页面的跟进按钮逻辑,跳转到新页面并验证参数 - 新增客户跟进步骤一页面,包含完整的表单功能 - 实现联系人信息解析、电话号码提取等业务逻辑 - 添加录音上传、截图上传等功能模块 - 实现意向选择、沟通情况填写等交互组件 - 添加本地存储草稿功能和提交验证逻辑 - 实现业务规则:多次沟通后的审核流程判断
This commit is contained in:
@@ -125,6 +125,7 @@ export default {
|
||||
"company/index",
|
||||
"company/add",
|
||||
"company/detail",
|
||||
"company/follow-step1",
|
||||
"company/edit"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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}` })
|
||||
}}
|
||||
>
|
||||
跟进
|
||||
|
||||
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