Files
template-10579/src/credit/creditMpCustomer/follow-step1.tsx
赵忠林 f306e7a9f7 feat(credit): 重构客户管理模块并新增信用客户功能
- 将公司相关页面重命名为客户管理页面
- 新增信用客户详情、编辑、跟进页面
- 实现客户状态跟踪和跟进流程
- 更新应用配置中的页面路由映射
- 优化订单详情页面的时间轴显示逻辑
2026-03-19 23:06:30 +08:00

428 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}