feat(credit): 完善客户详情页面功能
- 移除自定义导航栏样式配置 - 添加员工列表加载和缓存机制 - 实现客户状态显示和状态选项功能 - 添加客户电话号码提取和展示功能 - 集成跟进人员标签显示功能 - 实现客户联系方式拨打功能 - 添加跟进历史记录存储机制 - 更新页面背景色和样式 - 添加固定底部跟进按钮 - 修改API基础URL配置
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '客户详情',
|
||||
navigationBarTextStyle: 'black',
|
||||
navigationBarBackgroundColor: '#ffffff',
|
||||
navigationStyle: 'custom'
|
||||
navigationBarBackgroundColor: '#ffffff'
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Button, Cell, CellGroup, ConfigProvider, Empty, Loading } from '@nutui/nutui-react-taro'
|
||||
import { Setting } from '@nutui/icons-react-taro'
|
||||
import { Cell, CellGroup, ConfigProvider, Empty, Loading, Tag } from '@nutui/nutui-react-taro'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { getCreditMpCustomer } from '@/api/credit/creditMpCustomer'
|
||||
import type { CreditMpCustomer } from '@/api/credit/creditMpCustomer/model'
|
||||
import { listUsers } from '@/api/system/user'
|
||||
import type { User } from '@/api/system/user/model'
|
||||
import FixedButton from '@/components/FixedButton'
|
||||
|
||||
const fmtTime = (t?: string) => {
|
||||
const txt = String(t || '').trim()
|
||||
@@ -28,6 +30,77 @@ const buildDesc = (row?: CreditMpCustomer | null) => {
|
||||
return [price, years, loc].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
type CustomerStatus = '保护期内' | '已签约' | '已完成' | '保护期外'
|
||||
const STATUS_OPTIONS: CustomerStatus[] = ['保护期内', '已签约', '已完成', '保护期外']
|
||||
const FOLLOW_HISTORY_KEY_PREFIX = 'credit_mp_customer_follow_user_history:'
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 pickPhoneLike = (raw?: string) => {
|
||||
const txt = String(raw || '').trim()
|
||||
if (!txt) return []
|
||||
const picked = txt.match(/1\d{10}/g) || []
|
||||
const bySplit = splitPhones(txt)
|
||||
return uniq([...picked, ...bySplit])
|
||||
}
|
||||
|
||||
const getCustomerPhones = (row?: CreditMpCustomer | null) => {
|
||||
if (!row) return []
|
||||
const anyRow = row as any
|
||||
const pool = [
|
||||
anyRow?.tel,
|
||||
anyRow?.moreTel,
|
||||
anyRow?.phone,
|
||||
anyRow?.mobile,
|
||||
anyRow?.contactPhone,
|
||||
anyRow?.otherPhone,
|
||||
anyRow?.otherPhones,
|
||||
row.comments
|
||||
]
|
||||
const arr = pool.flatMap(v => pickPhoneLike(String(v || '')))
|
||||
return uniq(arr)
|
||||
.map(s => String(s).trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const getCustomerStatusText = (row?: CreditMpCustomer | null): string => {
|
||||
if (!row) return ''
|
||||
const anyRow = row as any
|
||||
return String(anyRow?.customerStatus || anyRow?.statusTxt || anyRow?.statusText || '').trim()
|
||||
}
|
||||
|
||||
const loadFollowHistoryIds = (customerId: number): number[] => {
|
||||
try {
|
||||
const raw = Taro.getStorageSync(`${FOLLOW_HISTORY_KEY_PREFIX}${customerId}`)
|
||||
const arr = safeParseJSON<number[]>(raw) || []
|
||||
return arr
|
||||
.map(v => Number(v))
|
||||
.filter(v => Number.isFinite(v) && v > 0)
|
||||
} catch (_e) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export default function CreditMpCustomerDetailPage() {
|
||||
const router = useRouter()
|
||||
const rowId = useMemo(() => {
|
||||
@@ -35,18 +108,11 @@ export default function CreditMpCustomerDetailPage() {
|
||||
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 staffLoadingPromiseRef = useRef<Promise<User[]> | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [row, setRow] = useState<CreditMpCustomer | null>(null)
|
||||
const [staffList, setStaffList] = useState<User[]>([])
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setError(null)
|
||||
@@ -64,103 +130,207 @@ export default function CreditMpCustomerDetailPage() {
|
||||
}
|
||||
}, [rowId])
|
||||
|
||||
const ensureStaffLoaded = useCallback(async (): Promise<User[]> => {
|
||||
if (staffList.length) return staffList
|
||||
if (staffLoadingPromiseRef.current) return staffLoadingPromiseRef.current
|
||||
|
||||
const p = (async () => {
|
||||
try {
|
||||
const res = await listUsers({ isStaff: true } as any)
|
||||
const arr = (res || []) as User[]
|
||||
setStaffList(arr)
|
||||
return arr
|
||||
} catch (_e) {
|
||||
return []
|
||||
} finally {
|
||||
staffLoadingPromiseRef.current = null
|
||||
}
|
||||
})()
|
||||
staffLoadingPromiseRef.current = p
|
||||
return p
|
||||
}, [staffList])
|
||||
|
||||
useDidShow(() => {
|
||||
Taro.setNavigationBarTitle({ title: '客户详情' })
|
||||
reload().then()
|
||||
ensureStaffLoaded().then()
|
||||
})
|
||||
|
||||
const headerOffset = statusBarHeight + 80
|
||||
const title = String(row?.toUser || '').trim() || '客户详情'
|
||||
const title = String(row?.toUser || '').trim() || '—'
|
||||
const desc = buildDesc(row)
|
||||
const statusText = useMemo(() => getCustomerStatusText(row), [row])
|
||||
|
||||
const staffNameMap = useMemo(() => {
|
||||
const map = new Map<number, string>()
|
||||
for (const u of staffList) {
|
||||
if (!u?.userId) continue
|
||||
map.set(u.userId, String(u.realName || u.nickname || u.username || `员工${u.userId}`))
|
||||
}
|
||||
return map
|
||||
}, [staffList])
|
||||
|
||||
const phones = useMemo(() => getCustomerPhones(row), [row])
|
||||
|
||||
const followerIds = useMemo(() => {
|
||||
if (!rowId) return []
|
||||
const anyRow = row as any
|
||||
const fromRow: number[] = []
|
||||
const fromStorage = loadFollowHistoryIds(rowId)
|
||||
|
||||
const ids1 = anyRow?.followUserIds
|
||||
if (Array.isArray(ids1)) fromRow.push(...ids1.map((v: any) => Number(v)))
|
||||
|
||||
const ids2 = anyRow?.followUsers
|
||||
if (Array.isArray(ids2)) fromRow.push(...ids2.map((v: any) => Number(v?.userId)))
|
||||
|
||||
return uniq([...fromRow, ...fromStorage])
|
||||
.map(v => Number(v))
|
||||
.filter(v => Number.isFinite(v) && v > 0)
|
||||
}, [row, rowId])
|
||||
|
||||
const followerTags = useMemo(() => {
|
||||
const currentId = Number(row?.userId)
|
||||
const currentName =
|
||||
String((row as any)?.realName || (row as any)?.userRealName || (row as any)?.followRealName || '').trim() ||
|
||||
(Number.isFinite(currentId) && currentId > 0 ? staffNameMap.get(currentId) : '') ||
|
||||
''
|
||||
|
||||
const others = followerIds.filter(id => id !== currentId)
|
||||
const otherNames = uniq(others.map(id => staffNameMap.get(id) || `员工${id}`))
|
||||
.filter(Boolean)
|
||||
.filter(n => String(n) !== currentName)
|
||||
|
||||
const tags = otherNames.map(n => ({ text: n, current: false }))
|
||||
if (currentName) tags.push({ text: currentName, current: true })
|
||||
return tags
|
||||
}, [followerIds, row, staffNameMap])
|
||||
|
||||
const statusSelected = useMemo(() => {
|
||||
const t = String(statusText || '').trim()
|
||||
return STATUS_OPTIONS.includes(t as any) ? (t as CustomerStatus) : undefined
|
||||
}, [statusText])
|
||||
|
||||
const goFollow = () => {
|
||||
if (!rowId) return
|
||||
Taro.navigateTo({ url: `/credit/mp-customer/follow-step1?id=${rowId}` })
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="bg-pink-50 min-h-screen">
|
||||
<View className="bg-gray-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 && rowId) Taro.navigateTo({ url: `/credit/creditMpCustomer/add?id=${rowId}` })
|
||||
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">
|
||||
<View className="max-w-md mx-auto px-4 pt-4 pb-4">
|
||||
{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="bg-white rounded-xl border border-gray-100 p-6">
|
||||
<View className="text-red-500 text-sm">{error}</View>
|
||||
<View className="mt-4">
|
||||
<Button type="primary" onClick={reload}>
|
||||
重试
|
||||
</Button>
|
||||
</View>
|
||||
<View className="mt-4 text-sm text-blue-600" onClick={reload}>点击重试</View>
|
||||
</View>
|
||||
) : !row ? (
|
||||
<View className="bg-white rounded-xl border border-pink-100 py-10">
|
||||
<View className="bg-white rounded-xl border border-gray-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">{title}</View>
|
||||
{!!desc && (
|
||||
<View className="mt-2 text-xs text-gray-500">
|
||||
<Text>{desc}</Text>
|
||||
<View>
|
||||
<View className="bg-white rounded-xl border border-gray-100 p-4">
|
||||
<View className="flex items-start justify-between gap-3">
|
||||
<View className="min-w-0 flex-1">
|
||||
<View className="text-base font-semibold text-gray-900 truncate">{title}</View>
|
||||
{!!desc && <View className="mt-2 text-xs text-gray-500 truncate">{desc}</View>}
|
||||
</View>
|
||||
<Tag type={statusSelected ? 'primary' : 'default'}>
|
||||
{statusText || '—'}
|
||||
</Tag>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="mt-3">
|
||||
<View className="mt-3">
|
||||
<View className="text-sm font-medium text-gray-900 mb-2">客户状态</View>
|
||||
<View className="flex flex-wrap gap-2">
|
||||
{STATUS_OPTIONS.map(s => {
|
||||
const active = s === statusSelected
|
||||
return (
|
||||
<View
|
||||
key={s}
|
||||
className={
|
||||
active
|
||||
? 'px-3 py-1 rounded-full text-xs border border-blue-200 bg-blue-50 text-blue-600'
|
||||
: 'px-3 py-1 rounded-full text-xs border border-gray-200 bg-gray-50 text-gray-500'
|
||||
}
|
||||
>
|
||||
{s}
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mt-3 bg-white rounded-xl border border-gray-100 p-4">
|
||||
<View className="text-sm font-medium text-gray-900 mb-2">客户联系方式</View>
|
||||
{phones.length ? (
|
||||
<View className="flex flex-wrap gap-2">
|
||||
{phones.map(p => (
|
||||
<View
|
||||
key={p}
|
||||
className="px-3 py-1 rounded-full text-xs border border-gray-200 bg-gray-50 text-gray-700"
|
||||
onClick={() => Taro.makePhoneCall({ phoneNumber: p }).catch(() => {})}
|
||||
>
|
||||
{p}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View className="text-xs text-gray-500">暂无电话</View>
|
||||
)}
|
||||
{!!row.comments && (
|
||||
<View className="mt-3 text-xs text-gray-500 break-words">
|
||||
<Text>备注:{String(row.comments)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="mt-3 bg-white rounded-xl border border-gray-100 p-4">
|
||||
<View className="text-sm font-medium text-gray-900 mb-2">跟进人</View>
|
||||
{followerTags.length ? (
|
||||
<View className="flex flex-wrap gap-2">
|
||||
{followerTags.map(t => (
|
||||
<View
|
||||
key={`${t.text}_${t.current ? 'c' : 'h'}`}
|
||||
className={
|
||||
t.current
|
||||
? 'px-3 py-1 rounded-full text-xs border border-blue-200 bg-blue-50 text-blue-600'
|
||||
: 'px-3 py-1 rounded-full text-xs border border-gray-200 bg-gray-50 text-gray-500'
|
||||
}
|
||||
>
|
||||
{t.text}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View className="text-xs text-gray-500">未分配</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="mt-3 bg-white rounded-xl border border-gray-100 p-2">
|
||||
<CellGroup>
|
||||
<Cell title="订单号" description={String(row.id ?? '—')} />
|
||||
<Cell title="状态" description={String(row.statusTxt || '—')} />
|
||||
<Cell title="分配人ID" description={row.userId ? String(row.userId) : '未分配'} />
|
||||
<Cell title="客户ID" description={String(row.id ?? '—')} />
|
||||
<Cell title="跟进状态" description={String((row as any)?.stepTxt ?? row.step ?? '—')} />
|
||||
<Cell title="所在地区" description={buildLocation(row) || '—'} />
|
||||
<Cell title="创建时间" description={fmtTime(row.createTime) || '—'} />
|
||||
<Cell title="更新时间" description={fmtTime(row.updateTime) || '—'} />
|
||||
{!!row.url && <Cell title="链接" description={String(row.url)} />}
|
||||
{!!row.files && <Cell title="文件" description={String(row.files)} />}
|
||||
{!!row.comments && <Cell title="备注" description={String(row.comments)} />}
|
||||
</CellGroup>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<FixedButton text="跟进" background="#ef4444" disabled={!rowId} onClick={goFollow} />
|
||||
</ConfigProvider>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ const STEP_STATUS_TEXT: Record<number, string> = {
|
||||
|
||||
const STEP_OPTIONS = [0, 1, 2, 3, 4, 5].map(code => ({ code, text: STEP_STATUS_TEXT[code] }))
|
||||
|
||||
const FOLLOW_HISTORY_KEY_PREFIX = 'credit_mp_customer_follow_user_history:'
|
||||
|
||||
const safeParseJSON = <T,>(v: any): T | null => {
|
||||
try {
|
||||
if (!v) return null
|
||||
@@ -140,6 +142,29 @@ const filterCustomers = (incoming: CreditMpCustomer[], filters: FilterValues) =>
|
||||
})
|
||||
}
|
||||
|
||||
const loadFollowHistoryIds = (customerId: number): number[] => {
|
||||
try {
|
||||
const raw = Taro.getStorageSync(`${FOLLOW_HISTORY_KEY_PREFIX}${customerId}`)
|
||||
const arr = safeParseJSON<number[]>(raw) || []
|
||||
return arr
|
||||
.map(v => Number(v))
|
||||
.filter(v => Number.isFinite(v) && v > 0)
|
||||
} catch (_e) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const saveFollowHistoryIds = (customerId: number, ids: number[]) => {
|
||||
const uniq = Array.from(new Set(ids))
|
||||
.map(v => Number(v))
|
||||
.filter(v => Number.isFinite(v) && v > 0)
|
||||
try {
|
||||
Taro.setStorageSync(`${FOLLOW_HISTORY_KEY_PREFIX}${customerId}`, JSON.stringify(uniq))
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export default function CreditCompanyPage() {
|
||||
const serverPageRef = useRef(1)
|
||||
const staffLoadingPromiseRef = useRef<Promise<User[]> | null>(null)
|
||||
@@ -449,6 +474,13 @@ export default function CreditCompanyPage() {
|
||||
|
||||
for (const id of ids) {
|
||||
const detail = await getCreditMpCustomer(Number(id))
|
||||
const prevOwner = Number((detail as any)?.userId)
|
||||
if (Number.isFinite(prevOwner) || Number.isFinite(userId)) {
|
||||
const history = loadFollowHistoryIds(Number(id))
|
||||
history.push(prevOwner)
|
||||
history.push(userId)
|
||||
saveFollowHistoryIds(Number(id), history)
|
||||
}
|
||||
await updateCreditMpCustomer({ ...(detail || {}), id, userId } as any)
|
||||
}
|
||||
Taro.showToast({ title: '分配成功', icon: 'success' })
|
||||
|
||||
Reference in New Issue
Block a user