- 将公司相关页面重命名为客户管理页面 - 新增信用客户详情、编辑、跟进页面 - 实现客户状态跟踪和跟进流程 - 更新应用配置中的页面路由映射 - 优化订单详情页面的时间轴显示逻辑
349 lines
13 KiB
TypeScript
349 lines
13 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 } 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 CustomerStatus = '保护期内' | '已签约' | '已完成' | '保护期外'
|
||
|
||
type FollowPerson = {
|
||
name: string
|
||
time?: string
|
||
isCurrent?: 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 getCompanyIndustry = (c?: CreditCompany) => {
|
||
if (!c) return ''
|
||
return String(
|
||
c.nationalStandardIndustryCategories6 ||
|
||
c.nationalStandardIndustryCategories2 ||
|
||
c.nationalStandardIndustryCategories ||
|
||
c.institutionType ||
|
||
''
|
||
).trim()
|
||
}
|
||
|
||
const parseContactFromComments = (comments?: string) => {
|
||
const txt = String(comments || '').trim()
|
||
if (!txt) return ''
|
||
const m = txt.match(/联系人:([^;;]+)/)
|
||
return String(m?.[1] || '').trim()
|
||
}
|
||
|
||
const getStatusTextClass = (s: CustomerStatus) => {
|
||
if (s === '保护期内') return 'text-green-600'
|
||
if (s === '已签约') return 'text-orange-500'
|
||
if (s === '已完成') return 'text-blue-600'
|
||
return 'text-gray-500'
|
||
}
|
||
|
||
const normalizeFollowPeople = (company: CreditCompany): FollowPerson[] => {
|
||
// 兼容后端可能下发多种字段;未下发时给出静态示例,保证 UI/逻辑可演示。
|
||
const anyCompany = company as any
|
||
|
||
const incoming: FollowPerson[] = []
|
||
|
||
const arr1 = anyCompany?.followPeople as Array<any> | undefined
|
||
if (Array.isArray(arr1) && arr1.length) {
|
||
for (const it of arr1) {
|
||
const name = String(it?.name || it?.realName || it?.userRealName || '').trim()
|
||
if (!name) continue
|
||
incoming.push({ name, time: it?.time || it?.date, isCurrent: !!it?.isCurrent })
|
||
}
|
||
}
|
||
|
||
const arr2 = anyCompany?.followHistory as Array<any> | undefined
|
||
if (Array.isArray(arr2) && arr2.length) {
|
||
for (const it of arr2) {
|
||
const name = String(it?.name || it?.realName || it?.userRealName || it || '').trim()
|
||
if (!name) continue
|
||
incoming.push({ name, time: it?.time || it?.date, isCurrent: !!it?.isCurrent })
|
||
}
|
||
}
|
||
|
||
const currentName = String(
|
||
anyCompany?.followRealName || anyCompany?.userRealName || anyCompany?.realName || ''
|
||
).trim()
|
||
if (currentName) incoming.push({ name: currentName, isCurrent: true })
|
||
|
||
const cleaned = incoming.filter(x => x?.name).map(x => ({ ...x, name: String(x.name).trim() }))
|
||
const deduped: FollowPerson[] = []
|
||
const seen = new Set<string>()
|
||
for (const p of cleaned) {
|
||
const k = p.name
|
||
if (seen.has(k)) continue
|
||
seen.add(k)
|
||
deduped.push(p)
|
||
}
|
||
|
||
if (deduped.length) {
|
||
// 规则:往期跟进人(灰色)按时间顺序在前;当前跟进人(蓝色)放在最后。
|
||
const current = deduped.filter(p => p.isCurrent)
|
||
const history = deduped.filter(p => !p.isCurrent)
|
||
history.sort((a, b) => {
|
||
const at = a.time ? dayjs(a.time).valueOf() : 0
|
||
const bt = b.time ? dayjs(b.time).valueOf() : 0
|
||
return at - bt
|
||
})
|
||
// 多个 current 时取最后一个为“当前”
|
||
const currentOne = current.length ? current[current.length - 1] : undefined
|
||
return currentOne ? history.concat([{ ...currentOne, isCurrent: true }]) : history
|
||
}
|
||
|
||
// 无真实跟进人信息:尽量展示一个可识别的占位,避免“凭空造历史数据”造成误导。
|
||
const uid = Number(company?.userId)
|
||
if (Number.isFinite(uid) && uid > 0) return [{ name: `员工${uid}`, isCurrent: true }]
|
||
return []
|
||
}
|
||
|
||
const getAssignDateText = (company?: CreditCompany) => {
|
||
const anyCompany = company as any
|
||
const raw = anyCompany?.assignDate || anyCompany?.assignTime || anyCompany?.updateTime || anyCompany?.createTime
|
||
const t = String(raw || '').trim()
|
||
if (!t) return ''
|
||
const d = dayjs(t)
|
||
if (!d.isValid()) return t
|
||
return d.format('YYYY-MM-DD')
|
||
}
|
||
|
||
export default function CreditCompanyDetailPage() {
|
||
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 reload = 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(() => {
|
||
reload().then()
|
||
})
|
||
|
||
const name = useMemo(() => {
|
||
const c = company || ({} as CreditCompany)
|
||
return String(c.matchName || c.name || '').trim()
|
||
}, [company])
|
||
|
||
const code = useMemo(() => String(company?.code || '').trim(), [company?.code])
|
||
const industry = useMemo(() => getCompanyIndustry(company || undefined), [company])
|
||
const contact = useMemo(() => parseContactFromComments(company?.comments), [company?.comments])
|
||
|
||
const phones = useMemo(() => {
|
||
const arr = [...splitPhones(company?.tel), ...splitPhones(company?.moreTel)]
|
||
return uniq(arr)
|
||
}, [company?.moreTel, company?.tel])
|
||
|
||
const address = useMemo(() => {
|
||
const c = company || ({} as CreditCompany)
|
||
const region = [c.province, c.city, c.region].filter(Boolean).join('')
|
||
const addr = String(c.address || '').trim()
|
||
return (region + addr).trim() || region || addr
|
||
}, [company])
|
||
|
||
const followPeople = useMemo(() => (company ? normalizeFollowPeople(company) : []), [company])
|
||
const assignDate = useMemo(() => getAssignDateText(company || undefined), [company])
|
||
|
||
const customerStatus = useMemo(() => {
|
||
const anyCompany = company as any
|
||
const raw = String(anyCompany?.customerStatus || anyCompany?.statusText || '').trim()
|
||
const allowed: CustomerStatus[] = ['保护期内', '已签约', '已完成', '保护期外']
|
||
if (allowed.includes(raw as any)) return raw as CustomerStatus
|
||
return '保护期内'
|
||
}, [company])
|
||
|
||
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 && companyId) Taro.navigateTo({ url: `/credit/my-customer/edit?id=${companyId}` })
|
||
if (res.tapIndex === 1) reload()
|
||
} 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"
|
||
onClick={() => Taro.showToast({ title: '设置(示意)', icon: 'none' })}
|
||
>
|
||
<Setting size={14} />
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
|
||
<View style={{ paddingTop: `${headerOffset}px` }} className="max-w-md mx-auto px-4 pb-24">
|
||
{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={reload}>
|
||
重试
|
||
</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-base font-semibold text-gray-900">{name || '—'}</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">{code || '—'}</Text>
|
||
</View>
|
||
|
||
<View className="flex items-start justify-between gap-3">
|
||
<Text className="text-gray-500">所属行业</Text>
|
||
<Text className="text-red-500 text-right break-all">{industry || '—'}</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">{contact || '—'}</Text>
|
||
</View>
|
||
|
||
<View className="flex items-start justify-between gap-3">
|
||
<Text className="text-gray-500">客户联系方式</Text>
|
||
<View className="text-right">
|
||
{phones.length ? (
|
||
<Text className="text-gray-400 break-all">{phones.join(',')}</Text>
|
||
) : (
|
||
<Text className="text-gray-400">—</Text>
|
||
)}
|
||
</View>
|
||
</View>
|
||
|
||
<View className="flex items-start justify-between gap-3">
|
||
<Text className="text-gray-500">地址</Text>
|
||
<Text className="text-gray-700 text-right break-all">{address || '—'}</Text>
|
||
</View>
|
||
|
||
<View className="flex items-start justify-between gap-3">
|
||
<Text className="text-gray-500">跟进人</Text>
|
||
<View className="text-right">
|
||
{followPeople.length ? (
|
||
followPeople.map((p, idx) => (
|
||
<Text
|
||
key={`${p.name}-${idx}`}
|
||
className={p.isCurrent ? 'text-blue-600 break-all' : 'text-gray-400 break-all'}
|
||
>
|
||
{idx === followPeople.length - 1 ? p.name : `${p.name},`}
|
||
</Text>
|
||
))
|
||
) : (
|
||
<Text className="text-gray-400">—</Text>
|
||
)}
|
||
</View>
|
||
</View>
|
||
|
||
<View className="flex items-start justify-between gap-3">
|
||
<Text className="text-gray-500">分配日期</Text>
|
||
<Text className="text-gray-400 text-right">{assignDate || '—'}</Text>
|
||
</View>
|
||
|
||
<View className="flex items-start justify-between gap-3">
|
||
<Text className="text-gray-500">客户状态</Text>
|
||
<Text className={`${getStatusTextClass(customerStatus)} text-right`}>{customerStatus}</Text>
|
||
</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
|
||
style={{ background: '#ef4444', borderColor: '#ef4444' }}
|
||
onClick={() => {
|
||
if (!companyId) {
|
||
Taro.showToast({ title: '缺少客户ID', icon: 'none' })
|
||
return
|
||
}
|
||
Taro.navigateTo({ url: `/credit/my-customer/follow-step1?id=${companyId}` })
|
||
}}
|
||
>
|
||
跟进
|
||
</Button>
|
||
</View>
|
||
</ConfigProvider>
|
||
</View>
|
||
)
|
||
}
|