- 将公司相关页面重命名为客户管理页面 - 新增信用客户详情、编辑、跟进页面 - 实现客户状态跟踪和跟进流程 - 更新应用配置中的页面路由映射 - 优化订单详情页面的时间轴显示逻辑
428 lines
16 KiB
TypeScript
428 lines
16 KiB
TypeScript
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>
|
||
)
|
||
}
|