@@ -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 headerOff set = statusBarHeight + 80
const title = String ( row ? . toUser || '' ) . trim ( ) || '客户详情'
const title = String ( row ? . toU ser || '' ) . 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-9 00" > { 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-1 00 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 . status Txt || '—' ) } / >
< Cell title = "分配人ID " description = { row . userId ? String ( row . userId ) : '未分配 ' } / >
< Cell title = "客户ID " description = { String ( row . id ? ? '—' ) } / >
< Cell title = "跟进 状态" description = { String ( ( row as any ) ? . step Txt ? ? 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 >
)
}