feat(credit): 新增客户详情页面并调整导航链接
- 在应用配置中添加了公司详情页面路由权限 - 将公司列表页面的点击事件从编辑页面改为详情页面 - 新增客户详情页面配置文件,设置导航栏标题和样式 - 实现完整的客户详情页面,包含客户基本信息展示 - 添加跟进人信息解析和展示功能 - 实现电话号码分割和去重逻辑 - 集成客户状态显示和分配日期计算功能 - 添加页面加载、错误处理和重试机制 - 实现顶部导航栏和底部操作按钮布局
This commit is contained in:
@@ -124,6 +124,7 @@ export default {
|
||||
"order/add",
|
||||
"company/index",
|
||||
"company/add",
|
||||
"company/detail",
|
||||
"company/edit"
|
||||
]
|
||||
}
|
||||
|
||||
7
src/credit/company/detail.config.ts
Normal file
7
src/credit/company/detail.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '客户详情',
|
||||
navigationBarTextStyle: 'black',
|
||||
navigationBarBackgroundColor: '#ffffff',
|
||||
navigationStyle: 'custom'
|
||||
})
|
||||
|
||||
345
src/credit/company/detail.tsx
Normal file
345
src/credit/company/detail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -479,7 +479,7 @@ export default function CreditCompanyPage() {
|
||||
onClick={() => {
|
||||
if (selectMode) 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">
|
||||
|
||||
Reference in New Issue
Block a user