feat(credit): 新增客户详情页面并调整导航链接

- 在应用配置中添加了公司详情页面路由权限
- 将公司列表页面的点击事件从编辑页面改为详情页面
- 新增客户详情页面配置文件,设置导航栏标题和样式
- 实现完整的客户详情页面,包含客户基本信息展示
- 添加跟进人信息解析和展示功能
- 实现电话号码分割和去重逻辑
- 集成客户状态显示和分配日期计算功能
- 添加页面加载、错误处理和重试机制
- 实现顶部导航栏和底部操作按钮布局
This commit is contained in:
2026-03-05 13:04:43 +08:00
parent 9cbfade6b5
commit 704fdf14cd
4 changed files with 354 additions and 1 deletions

View File

@@ -124,6 +124,7 @@ export default {
"order/add", "order/add",
"company/index", "company/index",
"company/add", "company/add",
"company/detail",
"company/edit" "company/edit"
] ]
} }

View File

@@ -0,0 +1,7 @@
export default definePageConfig({
navigationBarTitleText: '客户详情',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff',
navigationStyle: 'custom'
})

View File

@@ -0,0 +1,345 @@
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>
)
}

View File

@@ -479,7 +479,7 @@ export default function CreditCompanyPage() {
onClick={() => { onClick={() => {
if (selectMode) return if (selectMode) return
if (!c?.id) return if (!c?.id) return
Taro.navigateTo({ url: `/credit/company/edit?id=${c.id}` }) Taro.navigateTo({ url: `/credit/company/detail?id=${c.id}` })
}} }}
> >
<View className="flex gap-3 items-start w-full"> <View className="flex gap-3 items-start w-full">