Files
template-10579/src/credit/company/detail.tsx
赵忠林 704fdf14cd feat(credit): 新增客户详情页面并调整导航链接
- 在应用配置中添加了公司详情页面路由权限
- 将公司列表页面的点击事件从编辑页面改为详情页面
- 新增客户详情页面配置文件,设置导航栏标题和样式
- 实现完整的客户详情页面,包含客户基本信息展示
- 添加跟进人信息解析和展示功能
- 实现电话号码分割和去重逻辑
- 集成客户状态显示和分配日期计算功能
- 添加页面加载、错误处理和重试机制
- 实现顶部导航栏和底部操作按钮布局
2026-03-05 13:04:43 +08:00

346 lines
13 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 } 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/company/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={() => {
console.log('follow company:', companyId)
Taro.showToast({ title: '开始跟进该客户', icon: 'none' })
}}
>
</Button>
</View>
</ConfigProvider>
</View>
)
}