feat(credit): 重构客户管理页面为订单管理功能

- 在app.config.ts中添加creditMpCustomer相关页面配置
- 将src/credit/customer/index.config.ts中的标题从'客户联系人管理'改为'企业经办人订单管理'
- 重写src/credit/customer/index.tsx文件,将原有客户列表功能替换为企业经办人订单列表
- 实现订单搜索、筛选和详情查看功能
- 添加mock数据用于展示企业经办人、企业名称和跟进人信息
- 创建新的API模型和接口文件用于小程序端客户管理功能
This commit is contained in:
2026-03-16 21:52:46 +08:00
parent 4a23e2e68c
commit 338edaac13
6 changed files with 420 additions and 621 deletions

View File

@@ -1,654 +1,115 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { useMemo, useState } from 'react'
import { View, Text } from '@tarojs/components'
import {
Address,
Button,
Cell,
CellGroup,
Checkbox,
ConfigProvider,
Empty,
InfiniteLoading,
Loading,
Popup,
PullToRefresh,
SearchBar,
Tag
} from '@nutui/nutui-react-taro'
import { Copy, Phone } from '@nutui/icons-react-taro'
import { ConfigProvider, Empty, Popup, SearchBar } from '@nutui/nutui-react-taro'
import RegionData from '@/api/json/regions-data.json'
import { pageCreditCompany, updateCreditCompany } from '@/api/credit/creditCompany'
import type { CreditCompany } from '@/api/credit/creditCompany/model'
import { listUsers } from '@/api/system/user'
import type { User } from '@/api/system/user/model'
import { hasRole } from '@/utils/permission'
const PAGE_SIZE = 10
type FollowStatus = '全部' | '未联系' | '加微前沟通' | '跟进中' | '已成交' | '无意向'
const FOLLOW_STATUS_OPTIONS: FollowStatus[] = ['全部', '未联系', '加微前沟通', '跟进中', '已成交', '无意向']
const FOLLOW_MAP_STORAGE_KEY = 'credit_company_follow_status_map'
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
}
type AgentOrderRow = {
id: string
agentName: string
companyName: string
follower: string
// 预留:若经办人为老板,可用于后续扩展“老板名下多企业订单”
isBoss?: boolean
}
const getCompanyIdKey = (c: CreditCompany) => String(c?.id || '')
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 getCompanyPhones = (c: CreditCompany) => {
const arr = [...splitPhones(c.tel), ...splitPhones(c.moreTel)]
return Array.from(new Set(arr))
}
const getCompanyIndustry = (c: CreditCompany) => {
// 兼容:不同数据源字段可能不一致,优先取更具体的大类
return String(
c.nationalStandardIndustryCategories6 ||
c.nationalStandardIndustryCategories2 ||
c.nationalStandardIndustryCategories ||
c.institutionType ||
''
).trim()
}
export default function CreditCompanyPage() {
const serverPageRef = useRef(1)
const [list, setList] = useState<CreditCompany[]>([])
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const MOCK_LIST: AgentOrderRow[] = [
{ id: '1', agentName: '邓莉莉', companyName: '网宿公司', follower: '章鱼' },
{ id: '2', agentName: '邓莉莉', companyName: '飞数公司', follower: '章鱼' }
]
export default function CreditCustomerPage() {
const [searchValue, setSearchValue] = useState('')
const [detailVisible, setDetailVisible] = useState(false)
const [activeRow, setActiveRow] = useState<AgentOrderRow | null>(null)
const [cityVisible, setCityVisible] = useState(false)
const [cityText, setCityText] = useState<string>('全部')
const filteredList = useMemo(() => {
const q = searchValue.trim()
if (!q) return MOCK_LIST
return MOCK_LIST.filter(r => String(r.agentName || '').includes(q))
}, [searchValue])
const [followVisible, setFollowVisible] = useState(false)
const [followStatus, setFollowStatus] = useState<FollowStatus>('全部')
const [industryVisible, setIndustryVisible] = useState(false)
const [industryText, setIndustryText] = useState<string>('全部')
const [selectMode, setSelectMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [staffPopupVisible, setStaffPopupVisible] = useState(false)
const [staffLoading, setStaffLoading] = useState(false)
const [staffList, setStaffList] = useState<User[]>([])
const [staffSelectedId, setStaffSelectedId] = useState<number | undefined>(undefined)
const [assigning, setAssigning] = useState(false)
const [followMap, setFollowMap] = useState<Record<string, FollowStatus>>(() => {
const raw = Taro.getStorageSync(FOLLOW_MAP_STORAGE_KEY)
return safeParseJSON<Record<string, FollowStatus>>(raw) || {}
})
const currentUser = useMemo(() => {
return safeParseJSON<User>(Taro.getStorageSync('User')) || ({} as User)
}, [])
const canAssign = useMemo(() => {
// 超级管理员允许分配并更改客户归属userId
if (currentUser?.isSuperAdmin) return true
if (hasRole('superAdmin')) return true
return false
}, [currentUser?.isSuperAdmin])
const cityOptions = useMemo(() => {
// NutUI Address options: [{ text, value, children }]
// @ts-ignore
return (RegionData || []).map(a => ({
value: a.label,
text: a.label,
children: (a.children || []).map(b => ({
value: b.label,
text: b.label,
children: (b.children || []).map(c => ({
value: c.label,
text: c.label
}))
}))
}))
}, [])
const staffNameMap = useMemo(() => {
const map = new Map<number, string>()
for (const u of staffList) {
if (!u?.userId) continue
const name = String(u.realName || u.nickname || u.username || `员工${u.userId}`)
map.set(u.userId, name)
}
return map
}, [staffList])
const getFollowStatus = useCallback(
(c: CreditCompany): FollowStatus => {
const k = getCompanyIdKey(c)
const stored = k ? followMap[k] : undefined
if (stored) return stored
return '未联系'
},
[followMap]
)
const setFollowStatusFor = async (c: CreditCompany) => {
if (!c?.id) return
try {
const res = await Taro.showActionSheet({
itemList: FOLLOW_STATUS_OPTIONS.filter(s => s !== '全部')
})
const next = FOLLOW_STATUS_OPTIONS.filter(s => s !== '全部')[res.tapIndex] as FollowStatus | undefined
if (!next) return
setFollowMap(prev => {
const k = getCompanyIdKey(c)
const merged = { ...prev, [k]: next }
Taro.setStorageSync(FOLLOW_MAP_STORAGE_KEY, merged)
return merged
})
// 若当前正在按状态筛选,则即时移除不匹配项,避免“改完状态但列表不变”的割裂感。
if (followStatus !== '全部' && next !== followStatus && c.id) {
const cid = Number(c.id)
setList(prev => prev.filter(x => Number(x.id) !== cid))
setSelectedIds(prev => prev.filter(id => id !== cid))
}
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
}
}
const applyFilters = useCallback(
(incoming: CreditCompany[]) => {
const city = cityText === '全部' ? '' : cityText
const industry = industryText === '全部' ? '' : industryText
const follow = followStatus === '全部' ? '' : followStatus
return incoming.filter(c => {
if (c?.deleted === 1) return false
if (city) {
const full = [c.province, c.city, c.region].filter(Boolean).join(' ')
if (!full.includes(city) && String(c.city || '') !== city) return false
}
if (industry) {
const ind = getCompanyIndustry(c)
if (!ind.includes(industry)) return false
}
if (follow) {
if (getFollowStatus(c) !== follow) return false
}
return true
})
},
[cityText, followStatus, getFollowStatus, industryText]
)
const reload = useCallback(
async (resetPage = false) => {
if (loading) return
setLoading(true)
setError(null)
if (resetPage) {
serverPageRef.current = 1
setHasMore(true)
setSelectedIds([])
setSelectMode(false)
}
let nextList = resetPage ? [] : list
let page = serverPageRef.current
let serverHasMore = true
// 当筛选条件较严格时,可能需要多拉几页才能拿到“至少一条匹配数据”
const MAX_PAGE_TRIES = 8
let tries = 0
try {
while (tries < MAX_PAGE_TRIES) {
tries += 1
const params: any = {
page,
limit: PAGE_SIZE,
keywords: searchValue?.trim() || undefined
}
const res = await pageCreditCompany(params)
const incoming = (res?.list || []) as CreditCompany[]
const filtered = applyFilters(incoming)
if (resetPage) {
nextList = filtered
resetPage = false
} else {
nextList = nextList.concat(filtered)
}
page += 1
// 服务端无更多页:终止
if (incoming.length < PAGE_SIZE) {
serverHasMore = false
break
}
// 已拿到可展示数据:先返回,让用户继续滚动触发下一次加载
if (filtered.length > 0) break
}
serverPageRef.current = page
setList(nextList)
setHasMore(serverHasMore)
} catch (e) {
console.error('加载客户列表失败:', e)
setError('加载失败,请重试')
setHasMore(false)
} finally {
setLoading(false)
}
},
[applyFilters, list, loading, searchValue]
)
const handleRefresh = useCallback(async () => {
await reload(true)
}, [reload])
const loadMore = useCallback(async () => {
if (loading || !hasMore) return
await reload(false)
}, [hasMore, loading, reload])
useDidShow(() => {
reload(true).then()
// 预加载员工列表,保证“跟进人(realName)”可展示
ensureStaffLoaded().then()
})
const visibleIndustryOptions = useMemo(() => {
const uniq = new Set<string>()
for (const m of list) {
const ind = getCompanyIndustry(m)
if (ind) uniq.add(ind)
}
const arr = Array.from(uniq)
arr.sort()
return ['全部'].concat(arr)
}, [list])
const toggleSelectId = (companyId?: number, checked?: boolean) => {
if (!companyId) return
setSelectedIds(prev => {
if (checked) return prev.includes(companyId) ? prev : prev.concat(companyId)
return prev.filter(id => id !== companyId)
})
}
const copyPhones = async () => {
const pool = selectMode && selectedIds.length ? list.filter(c => selectedIds.includes(Number(c.id))) : list
const phones = pool.flatMap(c => getCompanyPhones(c))
const unique = Array.from(new Set(phones)).filter(Boolean)
if (!unique.length) {
Taro.showToast({ title: '暂无可复制的电话', icon: 'none' })
return
}
await Taro.setClipboardData({ data: unique.join('\n') })
Taro.showToast({ title: `已复制${unique.length}个电话`, icon: 'success' })
}
const callPhone = async (phone?: string) => {
const num = String(phone || '').trim()
if (!num) {
Taro.showToast({ title: '暂无电话', icon: 'none' })
return
}
try {
await Taro.makePhoneCall({ phoneNumber: num })
} catch (e) {
console.error('拨号失败:', e)
Taro.showToast({ title: '拨号失败', icon: 'none' })
}
}
const openAddCustomer = () => {
Taro.navigateTo({ url: '/credit/company/add' })
}
const ensureStaffLoaded = async () => {
if (staffLoading) return
if (staffList.length) return
setStaffLoading(true)
try {
const res = await listUsers({ isAdmin: true } as any)
setStaffList((res || []) as User[])
} catch (e) {
console.error('加载员工列表失败:', e)
Taro.showToast({ title: '加载员工失败', icon: 'none' })
} finally {
setStaffLoading(false)
}
}
const openAssign = async () => {
if (!canAssign) {
Taro.showToast({ title: '当前角色无分配权限', icon: 'none' })
return
}
if (!selectMode) {
setSelectMode(true)
setSelectedIds([])
Taro.showToast({ title: '请选择要分配的客户', icon: 'none' })
return
}
if (!selectedIds.length) {
Taro.showToast({ title: '请先勾选客户', icon: 'none' })
return
}
await ensureStaffLoaded()
setStaffPopupVisible(true)
}
const submitAssign = async () => {
if (!staffSelectedId) {
Taro.showToast({ title: '请选择分配对象', icon: 'none' })
return
}
if (!selectedIds.length) {
Taro.showToast({ title: '请先勾选客户', icon: 'none' })
return
}
setAssigning(true)
try {
for (const id of selectedIds) {
await updateCreditCompany({ id, userId: staffSelectedId } as any)
}
Taro.showToast({ title: '分配成功', icon: 'success' })
setStaffPopupVisible(false)
setSelectMode(false)
setSelectedIds([])
await reload(true)
} catch (e) {
console.error('分配失败:', e)
Taro.showToast({ title: '分配失败,请重试', icon: 'none' })
} finally {
setAssigning(false)
}
}
const totalText = useMemo(() => `${filteredList.length}个订单`, [filteredList.length])
return (
<View className="bg-gray-50 min-h-screen">
<ConfigProvider>
<View className="py-2">
<SearchBar
placeholder="公司名称 / 统一代码"
value={searchValue}
onChange={setSearchValue}
onSearch={() => reload(true)}
/>
<View className="py-2 px-3">
<SearchBar placeholder="请输入经办人姓名" value={searchValue} onChange={setSearchValue} />
</View>
<View className="px-3 pb-2 flex items-center gap-2">
<Button size="small" fill="outline" onClick={() => setCityVisible(true)}>
{cityText === '全部' ? '地区' : cityText}
</Button>
<Button size="small" fill="outline" onClick={() => setFollowVisible(true)}>
{followStatus === '全部' ? '跟进状态' : followStatus}
</Button>
<Button size="small" fill="outline" onClick={() => setIndustryVisible(true)}>
{industryText === '全部' ? '行业' : industryText}
</Button>
<View className="flex-1" />
<Button size="small" fill="outline" icon={<Copy />} onClick={copyPhones}>
</Button>
</View>
{!!error && (
<View className="px-4 pb-2 text-sm text-red-500">
{error}
<View className="px-3">
<View className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-900">
<View className="font-medium mb-1"></View>
<View></View>
<View></View>
<View></View>
<View></View>
<View className="mt-2 text-xs text-yellow-700">zzl</View>
</View>
)}
</View>
<PullToRefresh onRefresh={handleRefresh} headHeight={60}>
<View className="px-3" style={{ height: 'calc(100vh - 190px)', overflowY: 'auto' }} id="credit-company-scroll">
{list.length === 0 && !loading ? (
<View className="flex flex-col justify-center items-center" style={{ height: 'calc(100vh - 240px)' }}>
<Empty description="暂无客户数据" style={{ backgroundColor: 'transparent' }} />
<View className="px-3 mt-3 flex items-center justify-between">
<Text className="text-sm text-gray-700"></Text>
<Text className="text-sm text-gray-600">{totalText}</Text>
</View>
<View className="px-3 mt-2">
<View className="bg-white rounded-lg overflow-hidden border border-gray-100">
<View className="flex bg-gray-50 text-xs text-gray-600 px-3 py-2">
<View className="flex-1"></View>
<View className="flex-1"></View>
<View className="flex-1"></View>
</View>
{filteredList.length === 0 ? (
<View className="py-10">
<Empty description="暂无数据" />
</View>
) : (
<InfiniteLoading
target="credit-company-scroll"
hasMore={hasMore}
onLoadMore={loadMore}
loadingText={
<View className="flex justify-center items-center py-4">
<Loading />
<View className="ml-2">...</View>
</View>
}
loadMoreText={
<View className="text-center py-4 text-gray-500">
{list.length === 0 ? '暂无数据' : '没有更多了'}
</View>
}
>
{list.map((c, idx) => {
const id = Number(c.id)
const selected = !!id && selectedIds.includes(id)
const follow = getFollowStatus(c)
const ownerName =
// 兼容后端可能直接下发跟进人字段
(c as any)?.realName ||
(c as any)?.userRealName ||
(c as any)?.followRealName ||
(c.userId ? staffNameMap.get(Number(c.userId)) : undefined)
const name = c.matchName || c.name || `企业${c.id || ''}`
const industry = getCompanyIndustry(c)
const phones = getCompanyPhones(c)
const primaryPhone = phones[0]
return (
<CellGroup key={c.id || idx} className="mb-3">
<Cell
onClick={() => {
if (selectMode) return
if (!c?.id) return
Taro.navigateTo({ url: `/credit/company/detail?id=${c.id}` })
}}
>
<View className="flex gap-3 items-start w-full">
{selectMode && (
<View
className="pt-1"
onClick={(e) => {
e.stopPropagation()
}}
>
<Checkbox
checked={selected}
onChange={(checked) => toggleSelectId(id, checked)}
/>
</View>
)}
<View className="flex-1 min-w-0">
<View className="flex items-start justify-between gap-2">
<View className="min-w-0 flex-1">
<View className="text-base font-bold text-gray-900 truncate">
{name}
</View>
{!!industry && (
<View className="text-xs text-gray-500 truncate mt-1">
{industry}
</View>
)}
</View>
<Tag
type={follow === '无意向' ? 'danger' : follow === '已成交' ? 'success' : 'primary'}
onClick={(e: any) => {
e?.stopPropagation?.()
setFollowStatusFor(c)
}}
>
{follow}
</Tag>
</View>
<View className="mt-2 flex items-center justify-between gap-2">
<View className="text-xs text-gray-600 truncate">
<Text className="mr-2">
{primaryPhone ? primaryPhone : '暂无电话'}
</Text>
<Text className="text-gray-500">
{ownerName || '未分配'}
</Text>
</View>
<View className="flex items-center gap-2">
<Button
size="small"
fill="outline"
icon={<Phone />}
onClick={(e) => {
e.stopPropagation()
callPhone(primaryPhone)
}}
>
</Button>
</View>
</View>
{!!(c.province || c.city || c.region) && (
<View className="mt-2 text-xs text-gray-500 truncate">
{[c.province, c.city, c.region].filter(Boolean).join(' ')}
</View>
)}
</View>
</View>
</Cell>
</CellGroup>
)
})}
</InfiniteLoading>
)}
</View>
</PullToRefresh>
<View className="h-20 w-full" />
<View className="fixed z-50 bottom-0 left-0 right-0 bg-white border-t border-gray-200 px-3 py-3 safe-area-bottom">
<View className="flex items-center gap-3">
<Button
type="primary"
style={{ background: '#ef4444' }}
className="flex-1"
onClick={openAddCustomer}
>
</Button>
<Button
type="primary"
style={{ background: canAssign ? '#3b82f6' : '#94a3b8' }}
disabled={!canAssign || assigning}
className="flex-1"
onClick={openAssign}
>
{selectMode ? '分配所选' : '分配客户'}
</Button>
</View>
{selectMode && (
<View className="mt-2 flex items-center justify-between text-xs text-gray-500">
<Text> {selectedIds.length} </Text>
<View className="flex items-center justify-end gap-3">
<Text
className="text-blue-600"
onClick={() => copyPhones()}
>
</Text>
<Text
className="text-gray-500"
filteredList.map(r => (
<View
key={r.id}
className="flex px-3 py-3 text-sm text-gray-900 border-t border-gray-100"
onClick={() => {
setSelectMode(false)
setSelectedIds([])
setActiveRow(r)
setDetailVisible(true)
}}
>
</Text>
</View>
</View>
)}
<View className="flex-1 truncate">{r.agentName}</View>
<View className="flex-1 truncate">{r.companyName}</View>
<View className="flex-1 truncate">{r.follower}</View>
</View>
))
)}
</View>
</View>
<Address
visible={cityVisible}
options={cityOptions as any}
title="选择地区(到城市)"
onChange={(value: any[]) => {
const txt = value.filter(Boolean).slice(0, 2).join(' ')
setCityText(txt || '全部')
setCityVisible(false)
reload(true).then()
}}
onClose={() => setCityVisible(false)}
/>
<Popup
visible={followVisible}
visible={detailVisible}
position="bottom"
style={{ height: '45vh' }}
onClose={() => setFollowVisible(false)}
onClose={() => setDetailVisible(false)}
>
<View className="p-4">
<View className="flex items-center justify-between mb-3">
<Text className="text-base font-medium"></Text>
<Text className="text-sm text-gray-500" onClick={() => setFollowVisible(false)}>
<Text className="text-base font-medium"></Text>
<Text className="text-sm text-gray-500" onClick={() => setDetailVisible(false)}>
</Text>
</View>
<CellGroup>
{FOLLOW_STATUS_OPTIONS.map(s => (
<Cell
key={s}
title={<Text className={s === followStatus ? 'text-blue-600' : ''}>{s}</Text>}
onClick={() => {
setFollowStatus(s)
setFollowVisible(false)
reload(true).then()
}}
/>
))}
</CellGroup>
{activeRow ? (
<View className="text-sm text-gray-700 leading-6">
<View>{activeRow.agentName}</View>
<View>{activeRow.companyName}</View>
<View>{activeRow.follower}</View>
<View className="mt-2 text-xs text-gray-500"></View>
</View>
) : (
<View className="text-sm text-gray-500"></View>
)}
</View>
</Popup>
</ConfigProvider>
</View>
)
}