feat(credit): 重构客户管理模块并新增信用客户功能

- 将公司相关页面重命名为客户管理页面
- 新增信用客户详情、编辑、跟进页面
- 实现客户状态跟踪和跟进流程
- 更新应用配置中的页面路由映射
- 优化订单详情页面的时间轴显示逻辑
This commit is contained in:
2026-03-19 23:06:30 +08:00
parent addb53f9c5
commit f306e7a9f7
22 changed files with 1350 additions and 60 deletions

View File

@@ -42,15 +42,18 @@ export default {
"customer/index", "customer/index",
"order/index", "order/index",
"order/add", "order/add",
"company/index",
"company/add",
"company/detail",
"company/follow-step1",
"company/edit",
"my-order/index", "my-order/index",
"my-order/detail", "my-order/detail",
'creditMpCustomer/index', 'creditMpCustomer/index',
'creditMpCustomer/add', 'creditMpCustomer/add',
"creditMpCustomer/detail",
"creditMpCustomer/follow-step1",
"creditMpCustomer/edit",
"mp-customer/index",
"mp-customer/add",
"mp-customer/detail",
"mp-customer/follow-step1",
"mp-customer/edit"
] ]
} }
], ],

View File

@@ -219,7 +219,7 @@ export default function CreditCompanyDetailPage() {
onClick={async () => { onClick={async () => {
try { try {
const res = await Taro.showActionSheet({ itemList: ['编辑客户', '刷新'] }) const res = await Taro.showActionSheet({ itemList: ['编辑客户', '刷新'] })
if (res.tapIndex === 0 && companyId) Taro.navigateTo({ url: `/credit/company/edit?id=${companyId}` }) if (res.tapIndex === 0 && companyId) Taro.navigateTo({ url: `/credit/my-customer/edit?id=${companyId}` })
if (res.tapIndex === 1) reload() if (res.tapIndex === 1) reload()
} catch (e) { } catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '') const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
@@ -336,7 +336,7 @@ export default function CreditCompanyDetailPage() {
Taro.showToast({ title: '缺少客户ID', icon: 'none' }) Taro.showToast({ title: '缺少客户ID', icon: 'none' })
return return
} }
Taro.navigateTo({ url: `/credit/company/follow-step1?id=${companyId}` }) Taro.navigateTo({ url: `/credit/my-customer/follow-step1?id=${companyId}` })
}} }}
> >

View File

@@ -1,28 +1,53 @@
import { useCallback, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro' import Taro, { useDidShow } from '@tarojs/taro'
import { View, Text } from '@tarojs/components' import { View, Text } from '@tarojs/components'
import { Button, Cell, CellGroup, ConfigProvider, Empty, Space } from '@nutui/nutui-react-taro' import { Button, Cell, CellGroup, ConfigProvider, Empty, Space } from '@nutui/nutui-react-taro'
import { ArrowRight, CheckNormal, Checked } from '@nutui/icons-react-taro' import { ArrowRight, CheckNormal, Checked } from '@nutui/icons-react-taro'
import type { CreditMpCustomer } from '@/api/credit/creditMpCustomer/model' import type { CreditMpCustomer } from '@/api/credit/creditMpCustomer/model'
import { listCreditMpCustomer, removeCreditMpCustomer, updateCreditMpCustomer } from '@/api/credit/creditMpCustomer' import { pageCreditMpCustomer, removeCreditMpCustomer, updateCreditMpCustomer } from '@/api/credit/creditMpCustomer'
export default function CreditMpCustomerListPage() { export default function CreditMpCustomerListPage() {
const [list, setList] = useState<CreditMpCustomer[]>([]) const [list, setList] = useState<CreditMpCustomer[]>([])
const [count, setCount] = useState(0)
const [page, setPage] = useState(1)
const limit = 20
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const hasMore = useMemo(() => list.length < count, [count, list.length])
const fetchPage = useCallback(
async (opts: { nextPage: number; replace: boolean }) => {
try {
if (opts.replace) setLoading(true)
else setLoadingMore(true)
const res = await pageCreditMpCustomer({ page: opts.nextPage, limit } as any)
const incoming = (res?.list || []) as CreditMpCustomer[]
const total = Number(res?.count || 0)
setCount(Number.isFinite(total) ? total : 0)
setPage(opts.nextPage)
setList(prev => (opts.replace ? incoming : prev.concat(incoming)))
} catch (e) {
console.error('获取数据失败:', e)
Taro.showToast({ title: (e as any)?.message || '获取数据失败', icon: 'none' })
} finally {
setLoading(false)
setLoadingMore(false)
}
},
[limit]
)
const reload = useCallback(async () => { const reload = useCallback(async () => {
setLoading(true) await fetchPage({ nextPage: 1, replace: true })
try { }, [fetchPage])
const data = await listCreditMpCustomer()
setList(data || []) const loadMore = useCallback(async () => {
} catch (e) { if (loading || loadingMore || !hasMore) return
console.error('获取数据失败:', e) await fetchPage({ nextPage: page + 1, replace: false })
Taro.showToast({ title: (e as any)?.message || '获取数据失败', icon: 'none' }) }, [fetchPage, hasMore, loading, loadingMore, page])
} finally {
setLoading(false)
}
}, [])
useDidShow(() => { useDidShow(() => {
reload() reload()
@@ -83,6 +108,12 @@ export default function CreditMpCustomerListPage() {
</View> </View>
) : ( ) : (
<View className="px-3 pt-3 pb-6"> <View className="px-3 pt-3 pb-6">
<View className="mb-2 text-xs text-gray-500 flex items-center justify-between">
<Text>{count}</Text>
<Text>
{list.length}
</Text>
</View>
<CellGroup> <CellGroup>
{list.map(row => { {list.map(row => {
const recommended = row.recommend === 1 const recommended = row.recommend === 1
@@ -118,6 +149,11 @@ export default function CreditMpCustomerListPage() {
) )
})} })}
</CellGroup> </CellGroup>
<View className="mt-3 flex justify-center">
<Button fill="none" size="small" style={{ color: '#bdbdbd' }} disabled={!hasMore || loadingMore} onClick={loadMore}>
{hasMore ? (loadingMore ? '加载中...' : '加载更多') : '没有更多了'}
</Button>
</View>
<View className="mt-2 text-xs text-gray-400"> <View className="mt-2 text-xs text-gray-400">
</View> </View>

View File

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

View File

@@ -0,0 +1,348 @@
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/my-customer/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={() => {
if (!companyId) {
Taro.showToast({ title: '缺少客户ID', icon: 'none' })
return
}
Taro.navigateTo({ url: `/credit/my-customer/follow-step1?id=${companyId}` })
}}
>
</Button>
</View>
</ConfigProvider>
</View>
)
}

View File

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

View File

@@ -0,0 +1,364 @@
import { useCallback, useMemo, useState } from 'react'
import Taro, { useDidShow, useRouter } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import { Cell, CellGroup, ConfigProvider, Popup, Tag, TextArea } from '@nutui/nutui-react-taro'
import { getCreditCompany } 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 FixedButton from '@/components/FixedButton'
type CustomerStatus = '保护期内' | '已签约' | '已完成' | '保护期外'
const STATUS_OPTIONS: CustomerStatus[] = ['保护期内', '已签约', '已完成', '保护期外']
const PENDING_EDIT_STORAGE_KEY = 'credit_company_pending_edit_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
}
}
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 | null) => {
if (!c) return []
const arr = [...splitPhones(c.tel), ...splitPhones(c.moreTel)]
return Array.from(new Set(arr))
}
const getCompanyIndustry = (c?: CreditCompany | null) => {
if (!c) return ''
return String(
c.nationalStandardIndustryCategories6 ||
c.nationalStandardIndustryCategories2 ||
c.nationalStandardIndustryCategories ||
c.institutionType ||
''
).trim()
}
type PendingEdit = {
status: CustomerStatus
remark: string
submittedAt: string
}
const loadPendingMap = (): Record<string, PendingEdit> => {
try {
const raw = Taro.getStorageSync(PENDING_EDIT_STORAGE_KEY)
return safeParseJSON<Record<string, PendingEdit>>(raw) || {}
} catch (_e) {
return {}
}
}
const savePendingMap = (map: Record<string, PendingEdit>) => {
try {
Taro.setStorageSync(PENDING_EDIT_STORAGE_KEY, map)
} catch (_e) {
// ignore
}
}
export default function CreditCompanyEditPage() {
const router = useRouter()
const companyId = Number(router?.params?.id)
const statusBarHeight = useMemo(() => {
try {
const info = Taro.getSystemInfoSync()
return Number(info?.statusBarHeight || 0)
} catch (_e) {
return 0
}
}, [])
const [loading, setLoading] = useState(false)
const [company, setCompany] = useState<CreditCompany | null>(null)
const [staffList, setStaffList] = useState<User[]>([])
const [statusPickerVisible, setStatusPickerVisible] = useState(false)
const [customerStatus, setCustomerStatus] = useState<CustomerStatus>('保护期内')
const [remark, setRemark] = useState('')
const [pending, setPending] = useState(false)
const [pendingAt, setPendingAt] = useState<string | undefined>(undefined)
const [submitting, setSubmitting] = useState(false)
const ensureStaffLoaded = useCallback(async () => {
if (staffList.length) return
try {
const res = await listUsers({ isStaff: true } as any)
setStaffList((res || []) as User[])
} catch (_e) {
// ignore (只影响“跟进人”展示)
}
}, [staffList.length])
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 followRealName = useMemo(() => {
if (!company?.userId) return ''
return staffNameMap.get(Number(company.userId)) || ''
}, [company?.userId, staffNameMap])
const assignDate = useMemo(() => {
// 兼容:接口未提供“分配日期”字段时,用 updateTime/createTime 兜底展示
return String(company?.updateTime || company?.createTime || '')
}, [company?.createTime, company?.updateTime])
const pendingKey = useMemo(() => (Number.isFinite(companyId) && companyId > 0 ? String(companyId) : ''), [companyId])
const loadCompany = useCallback(async () => {
if (!Number.isFinite(companyId) || companyId <= 0) {
Taro.showToast({ title: '参数错误', icon: 'none' })
return
}
setLoading(true)
try {
const data = await getCreditCompany(companyId)
setCompany(data)
// 从本地 pending map 恢复“待审核”状态与内容(纯前端模拟)
const map = loadPendingMap()
const pendingEdit = pendingKey ? map[pendingKey] : undefined
if (pendingEdit) {
setCustomerStatus(pendingEdit.status)
setRemark(pendingEdit.remark)
setPending(true)
setPendingAt(pendingEdit.submittedAt)
} else {
setCustomerStatus('保护期内')
setRemark('')
setPending(false)
setPendingAt(undefined)
}
} catch (e) {
console.error('加载客户信息失败:', e)
Taro.showToast({ title: '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}, [companyId, pendingKey])
useDidShow(() => {
loadCompany().then()
ensureStaffLoaded().then()
})
const submitOrWithdraw = async () => {
if (submitting) return
if (!pendingKey) return
// 撤回修改
if (pending) {
setSubmitting(true)
try {
const map = loadPendingMap()
delete map[pendingKey]
savePendingMap(map)
setPending(false)
setPendingAt(undefined)
Taro.showToast({ title: '已撤回,可重新修改', icon: 'success' })
} finally {
setSubmitting(false)
}
return
}
// 提交修改(仅备注 + 状态),进入待审核(纯前端模拟)
const r = remark.trim()
if (!r) {
Taro.showToast({ title: '请填写备注信息', icon: 'none' })
return
}
setSubmitting(true)
try {
const map = loadPendingMap()
const now = new Date().toISOString()
map[pendingKey] = { status: customerStatus, remark: r, submittedAt: now }
savePendingMap(map)
setPending(true)
setPendingAt(now)
Taro.showToast({ title: '提交成功,等待审核', icon: 'success' })
} finally {
setSubmitting(false)
}
}
const phones = useMemo(() => getCompanyPhones(company), [company])
const industry = useMemo(() => getCompanyIndustry(company), [company])
const companyName = String(company?.matchName || company?.name || '')
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">
<Text>...</Text>
</View>
<View className="w-7 h-7 rounded-full border border-gray-300" />
</View>
</View>
</View>
<View style={{ paddingTop: `${statusBarHeight + 80}px` }} className="max-w-md mx-auto px-3">
{loading ? (
<View className="py-12 text-center text-gray-500">
<Text>...</Text>
</View>
) : (
<>
<CellGroup>
<Cell title="客户名称" description={companyName || '-'} />
<Cell title="统一代码" description={String(company?.code || '-')} />
<Cell
title="所属行业"
description={
<Text className="text-red-500">
{industry || '-'}
</Text>
}
/>
<Cell title="客户联系人" description={String((company as any)?.contactName || '-')} />
<Cell
title="客户联系方式"
description={
phones.length ? (
<View className="flex flex-col">
{phones.map(p => (
<Text key={p} className="text-gray-600">
{p}
</Text>
))}
</View>
) : (
'-'
)
}
/>
<Cell title="地址" description={String(company?.address || '-')} />
<Cell
title="跟进人"
description={
<Text className="text-blue-600">
{followRealName || '未分配'}
</Text>
}
/>
<Cell title="分配日期" description={assignDate || '-'} />
<Cell
title="客户状态"
description={
<View className="flex items-center gap-2">
<Text className={customerStatus === '保护期内' ? 'text-green-600' : 'text-gray-700'}>
{customerStatus}
</Text>
{pending && <Tag type="warning"></Tag>}
</View>
}
onClick={() => {
if (pending) return
setStatusPickerVisible(true)
}}
/>
</CellGroup>
<View className="mt-3 px-1">
<View className="text-sm text-gray-700 mb-2"></View>
<TextArea
placeholder="请输入客户最新信息情况,管理员审核通过后,客户信息才可变更!"
value={remark}
onChange={(v) => setRemark(v)}
rows={4}
maxLength={500}
showCount
autoHeight
disabled={pending}
/>
{pending && (
<View className="mt-2 text-xs text-gray-500">
{pendingAt ? `${pendingAt}` : ''}
</View>
)}
</View>
</>
)}
</View>
<Popup
visible={statusPickerVisible}
position="bottom"
style={{ height: '45vh' }}
onClose={() => setStatusPickerVisible(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={() => setStatusPickerVisible(false)}>
</Text>
</View>
<CellGroup>
{STATUS_OPTIONS.map(s => (
<Cell
key={s}
title={<Text className={s === customerStatus ? 'text-blue-600' : ''}>{s}</Text>}
onClick={() => {
setCustomerStatus(s)
setStatusPickerVisible(false)
}}
/>
))}
</CellGroup>
</View>
</Popup>
<FixedButton
text={submitting ? '处理中...' : pending ? '撤回修改' : '确定修改'}
background="#ef4444"
disabled={loading || submitting}
onClick={submitOrWithdraw}
/>
</ConfigProvider>
</View>
)
}

View File

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

View File

@@ -0,0 +1,427 @@
import { useCallback, useMemo, useState } from 'react'
import Taro, { useDidShow, useRouter } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import { Button, ConfigProvider, Empty, Loading, TextArea } 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 Intention = '无意向' | '有意向'
type FollowStep1Draft = {
submitted: boolean
submittedAt: string
companyId: number
contact: string
phone: string
audioSelected: boolean
smsShots: number
callShots: number
remark: string
intention: Intention
// 业务规则模拟:
// 多次沟通 + 有意向 => 无需审核(可直接进入下一步,下一步不在本页实现)
// 多次沟通 + 无意向 => 需管理员审核
communicationCount: number
needApproval: boolean
isApproved: 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 parseContactFromComments = (comments?: string) => {
const txt = String(comments || '').trim()
if (!txt) return ''
const m = txt.match(/联系人:([^;]+)/)
return String(m?.[1] || '').trim()
}
const makeThumb = () => ({
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`
})
const getDraftKey = (companyId: number) => `credit_company_follow_step1:${companyId}`
const getCountKey = (companyId: number) => `credit_company_follow_comm_count:${companyId}`
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
}
}
export default function CreditCompanyFollowStep1Page() {
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 [submitted, setSubmitted] = useState(false)
const [audioSelected, setAudioSelected] = useState(false)
const [smsShots, setSmsShots] = useState<Array<{ id: string }>>([])
const [callShots, setCallShots] = useState<Array<{ id: string }>>([])
const [remark, setRemark] = useState('')
const [intention, setIntention] = useState<Intention | undefined>(undefined)
const loadDraft = useCallback(() => {
if (!companyId) return
const raw = Taro.getStorageSync(getDraftKey(companyId))
const saved = safeParseJSON<FollowStep1Draft>(raw)
if (!saved?.submitted) return
setSubmitted(true)
setAudioSelected(!!saved.audioSelected)
setSmsShots(Array.from({ length: Math.max(0, Math.min(6, Number(saved.smsShots || 0))) }, makeThumb))
setCallShots(Array.from({ length: Math.max(0, Math.min(6, Number(saved.callShots || 0))) }, makeThumb))
setRemark(String(saved.remark || ''))
setIntention(saved.intention)
}, [companyId])
const reloadCompany = 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(() => {
reloadCompany().then()
loadDraft()
})
const contact = useMemo(() => parseContactFromComments(company?.comments), [company?.comments])
const phone = useMemo(() => {
const arr = uniq([...splitPhones(company?.tel), ...splitPhones(company?.moreTel)])
return String(arr[0] || '').trim()
}, [company?.moreTel, company?.tel])
const canEdit = !submitted
const addSmsShot = () => {
if (!canEdit) return
if (smsShots.length >= 6) {
Taro.showToast({ title: '短信截图已达上限(6张)', icon: 'none' })
return
}
setSmsShots(prev => prev.concat(makeThumb()))
}
const addCallShot = () => {
if (!canEdit) return
if (callShots.length >= 6) {
Taro.showToast({ title: '电话沟通截图已达上限(6张)', icon: 'none' })
return
}
setCallShots(prev => prev.concat(makeThumb()))
}
const chooseAudio = async () => {
if (!canEdit) return
// 本步骤仅做“选择录音文件”的交互模拟
setAudioSelected(true)
Taro.showToast({ title: '已选择录音文件(模拟)', icon: 'none' })
}
const submit = async () => {
if (!companyId) {
Taro.showToast({ title: '缺少客户ID', icon: 'none' })
return
}
if (!intention) {
Taro.showToast({ title: '请选择意向(无意向/有意向)', icon: 'none' })
return
}
if (!remark.trim()) {
Taro.showToast({ title: '请填写沟通情况', icon: 'none' })
return
}
if (!smsShots.length && !callShots.length) {
Taro.showToast({ title: '建议至少上传1张截图非必填', icon: 'none' })
}
const prevCountRaw = Taro.getStorageSync(getCountKey(companyId))
const prevCount = Number(prevCountRaw || 0)
const communicationCount = (Number.isFinite(prevCount) ? prevCount : 0) + 1
Taro.setStorageSync(getCountKey(companyId), communicationCount)
const needApproval = communicationCount > 1 && intention === '无意向'
const isApproved = false // 模拟:默认未审核;后续步骤可检查该标志
const payload: FollowStep1Draft = {
submitted: true,
submittedAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
companyId,
contact,
phone,
audioSelected,
smsShots: smsShots.length,
callShots: callShots.length,
remark: remark.trim(),
intention,
communicationCount,
needApproval,
isApproved
}
Taro.setStorageSync(getDraftKey(companyId), payload)
setSubmitted(true)
await Taro.showModal({
title: '提示',
content: '跟进信息已提交\n请等待管理员审核',
showCancel: false
})
}
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) reloadCompany()
} 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">
<Setting size={14} />
</View>
</View>
</View>
</View>
<View style={{ paddingTop: `${headerOffset}px` }} className="max-w-md mx-auto px-4 pb-28">
{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={reloadCompany}>
</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-sm text-gray-900 font-semibold"></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">{contact || '—'}</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">{phone || '—'}</Text>
</View>
<View className="flex items-center justify-between gap-3">
<Text className="text-gray-500">
</Text>
<Button size="small" fill="outline" disabled={!canEdit} onClick={chooseAudio}>
{audioSelected ? '已选择' : '点击上传'}
</Button>
</View>
<View className="pt-2 border-t border-pink-100" />
<View>
<View className="flex items-center justify-between">
<Text className="text-gray-700"></Text>
<Text className="text-xs text-gray-400">{smsShots.length}/6</Text>
</View>
<View className="mt-2 flex flex-wrap gap-2">
{smsShots.map((x, idx) => (
<View
key={x.id}
className="w-16 h-16 rounded-lg bg-gray-200 flex items-center justify-center text-xs text-gray-500"
>
{idx + 1}
</View>
))}
<View
className={`w-16 h-16 rounded-lg border border-dashed flex items-center justify-center text-2xl ${
!canEdit || smsShots.length >= 6 ? 'border-gray-200 text-gray-200' : 'border-gray-300 text-gray-400'
}`}
onClick={addSmsShot}
>
+
</View>
</View>
</View>
<View>
<View className="flex items-center justify-between">
<Text className="text-gray-700"></Text>
<Text className="text-xs text-gray-400">{callShots.length}/6</Text>
</View>
<View className="mt-2 flex flex-wrap gap-2">
{callShots.map((x, idx) => (
<View
key={x.id}
className="w-16 h-16 rounded-lg bg-gray-200 flex items-center justify-center text-xs text-gray-500"
>
{idx + 1}
</View>
))}
<View
className={`w-16 h-16 rounded-lg border border-dashed flex items-center justify-center text-2xl ${
!canEdit || callShots.length >= 6 ? 'border-gray-200 text-gray-200' : 'border-gray-300 text-gray-400'
}`}
onClick={addCallShot}
>
+
</View>
</View>
</View>
<View>
<Text className="text-gray-700">
<Text className="text-red-500">*</Text>
</Text>
<View className="mt-2">
<TextArea
value={remark}
onChange={setRemark}
placeholder="请输入"
disabled={!canEdit}
maxLength={300}
style={{ background: '#fff' }}
/>
</View>
</View>
<View>
<Text className="text-gray-700">
<Text className="text-red-500">*</Text>
</Text>
<View className="mt-2 grid grid-cols-2 gap-3">
<View
className={`rounded-xl border px-3 py-2 text-center ${
intention === '无意向' ? 'border-red-400 bg-red-50 text-red-600' : 'border-gray-200 text-gray-700'
} ${!canEdit ? 'opacity-60' : ''}`}
onClick={() => canEdit && setIntention('无意向')}
>
</View>
<View
className={`rounded-xl border px-3 py-2 text-center ${
intention === '有意向' ? 'border-red-400 bg-red-50 text-red-600' : 'border-gray-200 text-gray-700'
} ${!canEdit ? 'opacity-60' : ''}`}
onClick={() => canEdit && setIntention('有意向')}
>
</View>
</View>
<View className="mt-2 text-xs text-gray-400">
</View>
</View>
{submitted && (
<View className="pt-2 text-xs text-gray-400">
</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
disabled={!canEdit}
style={{
background: submitted ? '#94a3b8' : '#ef4444',
borderColor: submitted ? '#94a3b8' : '#ef4444'
}}
onClick={submit}
>
</Button>
</View>
</ConfigProvider>
</View>
)
}

View File

@@ -19,8 +19,9 @@ import {
import { Copy, Phone } from '@nutui/icons-react-taro' import { Copy, Phone } from '@nutui/icons-react-taro'
import RegionData from '@/api/json/regions-data.json' import RegionData from '@/api/json/regions-data.json'
import { pageCreditCompany, updateCreditCompany } from '@/api/credit/creditCompany' import { updateCreditCompany } from '@/api/credit/creditCompany'
import type { CreditCompany } from '@/api/credit/creditCompany/model' import type { CreditCompany } from '@/api/credit/creditCompany/model'
import { pageCreditMpCustomer } from '@/api/credit/creditMpCustomer'
import { listUsers } from '@/api/system/user' import { listUsers } from '@/api/system/user'
import type { User } from '@/api/system/user/model' import type { User } from '@/api/system/user/model'
import { hasRole } from '@/utils/permission' import { hasRole } from '@/utils/permission'
@@ -239,7 +240,7 @@ export default function CreditCompanyPage() {
keywords: searchValue?.trim() || undefined keywords: searchValue?.trim() || undefined
} }
const res = await pageCreditCompany(params) const res = await pageCreditMpCustomer(params as any)
const incoming = (res?.list || []) as CreditCompany[] const incoming = (res?.list || []) as CreditCompany[]
const filtered = applyFilters(incoming) const filtered = applyFilters(incoming)
@@ -337,7 +338,7 @@ export default function CreditCompanyPage() {
} }
const openAddCustomer = () => { const openAddCustomer = () => {
Taro.navigateTo({ url: '/credit/company/add' }) Taro.navigateTo({ url: '/credit/my-customer/add' })
} }
const ensureStaffLoaded = async () => { const ensureStaffLoaded = async () => {
@@ -479,7 +480,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/detail?id=${c.id}` }) Taro.navigateTo({ url: `/credit/my-customer/detail?id=${c.id}` })
}} }}
> >
<View className="flex gap-3 items-start w-full"> <View className="flex gap-3 items-start w-full">

View File

@@ -18,10 +18,45 @@ type TimelineNode = {
actionText?: string actionText?: string
actionType?: 'detail' actionType?: 'detail'
variant?: 'normal' | 'returned' variant?: 'normal' | 'returned'
state?: 'done' | 'current' | 'todo'
} }
const safeText = (v: any) => String(v ?? '').trim() const safeText = (v: any) => String(v ?? '').trim()
const STEP_TITLES = ['未受理', '已受理', '材料提交', '合同签订', '执行回款', '完结'] as const
const clampStep = (n: number) => Math.max(0, Math.min(5, n))
const inferStepFromStatusText = (txt: string) => {
const t = safeText(txt)
if (!t) return undefined
if (t.includes('完结') || t.includes('已办结')) return 5
if (t.includes('执行回款')) return 4
if (t.includes('合同') || t.includes('签订')) return 3
if (t.includes('材料')) return 2
if (t.includes('已受理') || (t.includes('受理') && !t.includes('未受理'))) return 1
if (t.includes('未受理')) return 0
const n = Number(t)
if (Number.isInteger(n) && n >= 0 && n <= 5) return n
return undefined
}
const getDemandStep = (d?: CreditMpCustomer | null) => {
const anyDemand = (d || {}) as any
const raw =
anyDemand?.step ??
anyDemand?.stepStatus ??
anyDemand?.statusStep ??
anyDemand?.stepNum ??
anyDemand?.stepCode ??
anyDemand?.statusTxt
const txt = safeText(raw)
const n = Number(txt)
if (Number.isInteger(n) && n >= 0 && n <= 5) return n
return inferStepFromStatusText(txt)
}
const formatDate = (t?: string) => { const formatDate = (t?: string) => {
const txt = safeText(t) const txt = safeText(t)
if (!txt) return '' if (!txt) return ''
@@ -116,44 +151,53 @@ export default function CreditMyOrderDetailPage() {
const topStatus = useMemo(() => safeText((company as any)?.customerStatus || (company as any)?.statusText) || '保护期内', [company]) const topStatus = useMemo(() => safeText((company as any)?.customerStatus || (company as any)?.statusText) || '保护期内', [company])
const timeline = useMemo<TimelineNode[]>(() => { const timeline = useMemo<TimelineNode[]>(() => {
const returned = safeText(demand?.statusTxt).includes('退回') const statusTxt = safeText(demand?.statusTxt)
const base: TimelineNode[] = [ const returned = statusTxt.includes('退回')
{ const step = getDemandStep(demand)
title: '第一步:加微信前沟通', const currentStep = typeof step === 'number' ? clampStep(step) : 0
status: '审核通过', const isNumericStatusTxt = Number.isInteger(Number(statusTxt))
time: '2025-11-11', const currentStatusText = !isNumericStatusTxt && statusTxt ? statusTxt : '进行中'
actionText: '查看详情',
actionType: 'detail' const createTime = safeText(demand?.createTime)
}, const updateTime = safeText(demand?.updateTime)
{ title: '已受理', desc: `负责人:${topFollower || 'XXX'}`, time: '2025-01-01 11:30:30' }, const currentTime = updateTime || createTime
{ title: '材料提交与梳理(第二步节点完成)', desc: '审核评估', time: '2025-01-01 11:30:30', tag: '第二步完成' },
{ title: '合同已签订(第五步节点完成)', desc: '项目启动', time: '2025-01-01 11:30:30', tag: '第五步完成' }, const base: TimelineNode[] = STEP_TITLES.map((title, idx) => {
{ const isCurrent = !returned && idx === currentStep
title: '执行回款(第六步节点完成)', const isDone = returned ? idx <= currentStep : idx < currentStep
desc: '部分回款:金额\n全部回款金额', const state: TimelineNode['state'] = isCurrent ? 'current' : isDone ? 'done' : 'todo'
time: '2025-03-12 11:30:30', const node: TimelineNode = {
tag: '第六步完成' title,
}, state,
{ title: '完结', desc: '已办结/已终止', time: '2025-01-01 11:30:30' } status: isCurrent ? currentStatusText : isDone ? '已完成' : '待处理'
] }
if (idx === 0 && createTime) node.time = createTime
if (isCurrent && currentTime) node.time = currentTime
if (isCurrent) node.tag = '当前'
if (idx === 1 && topFollower) node.desc = `负责人:${topFollower}`
return node
})
if (returned) { if (returned) {
base.splice(2, 0, { const anyDemand = (demand || {}) as any
title: '已退回', const reason =
variant: 'returned', safeText(anyDemand?.returnReason || anyDemand?.reason || anyDemand?.rejectReason || anyDemand?.rejectMsg || anyDemand?.auditRemark) ||
desc: '原因:需求不清晰,请重新提交', '需求已退回,请根据提示重新提交'
time: '2025-01-01 11:30:30'
})
} else {
base.push({ base.push({
title: '已退回', title: '已退回',
variant: 'returned', variant: 'returned',
desc: '原因:需求不清晰,请重新提交', state: 'current',
time: '2025-01-01 11:30:30' status: '已退回',
desc: `原因:${reason}`,
time: currentTime || undefined,
tag: '退回'
}) })
} }
return base return base
}, [demand?.statusTxt, topFollower]) }, [demand, topFollower])
const onNodeAction = async (node: TimelineNode) => { const onNodeAction = async (node: TimelineNode) => {
if (node.actionType === 'detail') { if (node.actionType === 'detail') {
@@ -222,8 +266,21 @@ export default function CreditMyOrderDetailPage() {
{timeline.map((n, idx) => { {timeline.map((n, idx) => {
const isLast = idx === timeline.length - 1 const isLast = idx === timeline.length - 1
const isReturned = n.variant === 'returned' const isReturned = n.variant === 'returned'
const circleClass = isReturned ? 'bg-red-600' : 'bg-red-500' const state = n.state || 'todo'
const lineClass = isReturned ? 'bg-red-200' : 'bg-red-200' const circleClass = isReturned
? 'bg-red-600'
: state === 'done'
? 'bg-green-500'
: state === 'current'
? 'bg-orange-500'
: 'bg-gray-300'
const lineClass = isReturned
? 'bg-red-200'
: state === 'done'
? 'bg-green-200'
: state === 'current'
? 'bg-orange-200'
: 'bg-gray-200'
const tag = safeText(n.tag) const tag = safeText(n.tag)
const status = safeText(n.status) const status = safeText(n.status)
const time = safeText(n.time) const time = safeText(n.time)
@@ -239,7 +296,11 @@ export default function CreditMyOrderDetailPage() {
<View className="flex-1 pb-4"> <View className="flex-1 pb-4">
<View className="flex items-start justify-between gap-3"> <View className="flex items-start justify-between gap-3">
<View className="min-w-0"> <View className="min-w-0">
<Text className={`text-sm font-semibold ${isReturned ? 'text-red-600' : 'text-gray-900'}`}> <Text
className={`text-sm font-semibold ${
isReturned ? 'text-red-600' : state === 'done' ? 'text-gray-900' : state === 'current' ? 'text-orange-600' : 'text-gray-500'
}`}
>
{n.title} {n.title}
</Text> </Text>
{(status || time) && ( {(status || time) && (

View File

@@ -14,6 +14,15 @@ type ListQuery = {
userId?: number userId?: number
} }
const STEP_STATUS_TEXT: Record<number, string> = {
0: '未受理',
1: '已受理',
2: '材料提交',
3: '合同签订',
4: '执行回款',
5: '完结'
}
const getCurrentUserId = (): number | undefined => { const getCurrentUserId = (): number | undefined => {
try { try {
const raw = Taro.getStorageSync('UserId') const raw = Taro.getStorageSync('UserId')
@@ -31,11 +40,30 @@ const buildDesc = (row: CreditMpCustomer) => {
return [price, years, location].filter(Boolean).join(' · ') return [price, years, location].filter(Boolean).join(' · ')
} }
const getStepStatusText = (row: CreditMpCustomer) => {
const anyRow = row as any
const raw =
anyRow?.step ??
anyRow?.stepStatus ??
anyRow?.statusStep ??
anyRow?.stepNum ??
anyRow?.stepCode ??
row.statusTxt
const txt = String(raw ?? '').trim()
if (!txt) return '处理中'
const n = Number(txt)
if (Number.isInteger(n) && n in STEP_STATUS_TEXT) return STEP_STATUS_TEXT[n]
return txt
}
const getStatusBadgeClass = (s?: string) => { const getStatusBadgeClass = (s?: string) => {
const txt = String(s || '').trim() const txt = String(s ?? '').trim()
if (!txt) return 'bg-gray-400' if (!txt) return 'bg-gray-400'
if (txt.includes('退回')) return 'bg-red-500' if (txt.includes('退回')) return 'bg-red-500'
if (txt.includes('完结') || txt.includes('已办结')) return 'bg-green-600' if (txt.includes('完结') || txt.includes('已办结')) return 'bg-green-600'
if (txt.includes('未受理')) return 'bg-gray-400'
if (txt.includes('受理') || txt.includes('通过') || txt.includes('签订')) return 'bg-orange-500' if (txt.includes('受理') || txt.includes('通过') || txt.includes('签订')) return 'bg-orange-500'
return 'bg-blue-500' return 'bg-blue-500'
} }
@@ -155,7 +183,7 @@ export default function CreditMyOrderPage() {
list.map(row => { list.map(row => {
const title = String(row.toUser || '-').trim() const title = String(row.toUser || '-').trim()
const desc = buildDesc(row) const desc = buildDesc(row)
const statusTxt = String(row.statusTxt || '处理中').trim() const statusTxt = getStepStatusText(row)
const time = String(row.createTime || '').slice(0, 19).replace('T', ' ') const time = String(row.createTime || '').slice(0, 19).replace('T', ' ')
return ( return (

View File

@@ -157,7 +157,7 @@ function Home() {
{admin && ( {admin && (
<View className='ctaWrap gap-3'> <View className='ctaWrap gap-3'>
<View className='ctaBtn' onClick={() => navTo('/credit/company/index', true)}> <View className='ctaBtn' onClick={() => navTo('/credit/creditMpCustomer/index', true)}>
<Text className='ctaBtnText'></Text> <Text className='ctaBtnText'></Text>
</View> </View>
<View className='ctaBtn' onClick={() => navTo('/credit/order/index', true)}> <View className='ctaBtn' onClick={() => navTo('/credit/order/index', true)}>

View File

@@ -21,6 +21,7 @@ const UserCard = forwardRef<any, any>((_, ref) => {
const [IsLogin, setIsLogin] = useState<boolean>(false) const [IsLogin, setIsLogin] = useState<boolean>(false)
const [userInfo, setUserInfo] = useState<User>() const [userInfo, setUserInfo] = useState<User>()
// @ts-ignore
const themeStyles = useThemeStyles(); const themeStyles = useThemeStyles();
const canShowScanButton = (() => { const canShowScanButton = (() => {
const v: any = (userInfo as any)?.isAdmin const v: any = (userInfo as any)?.isAdmin