feat(credit): 重构信用模块订单页面并添加详情功能

- 将信用模块我的订单页面从模拟数据改为真实API数据
- 添加订单详情页面及路由配置
- 集成信用客户管理API接口调用
- 实现订单列表的搜索、分页和加载更多功能
- 添加订单状态筛选和统计信息展示
- 新增订单详情的时间线展示功能
- 更新首页进度查询跳转逻辑
- 优化页面加载和错误处理机制
This commit is contained in:
2026-03-17 22:36:02 +08:00
parent 372275ef44
commit 66628dbebf
5 changed files with 496 additions and 309 deletions

View File

@@ -1,347 +1,209 @@
import { useMemo, useState } from 'react'
import Taro from '@tarojs/taro'
import { useCallback, useEffect, useMemo, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import { Button, ConfigProvider, DatePicker, Empty, Input, Popup } from '@nutui/nutui-react-taro'
import { Search } from '@nutui/icons-react-taro'
import dayjs from 'dayjs'
import { Button, ConfigProvider, Empty, Input, Loading } from '@nutui/nutui-react-taro'
import { ArrowRight, Search } from '@nutui/icons-react-taro'
type PayStatus = '全部回款' | '部分回款' | '未回款'
type AmountSort = '默认' | '从高到低' | '从低到高'
import { pageCreditMpCustomer } from '@/api/credit/creditMpCustomer'
import type { CreditMpCustomer } from '@/api/credit/creditMpCustomer/model'
type OrderItem = {
id: string
orderNo: string
companyName: string
unifiedCode: string
projectName: string
follower: string
payStatus: PayStatus
date: string // YYYY-MM-DD
principal: number
interest: number
type ListQuery = {
page: number
limit: number
keywords?: string
userId?: number
}
const formatYmd = (d?: Date | null) => {
if (!d) return ''
return dayjs(d).format('YYYY-MM-DD')
}
const getStatusStyle = (s: PayStatus) => {
if (s === '全部回款') return 'bg-green-500'
if (s === '部分回款') return 'bg-orange-500'
return 'bg-red-500'
}
const makeMockOrders = (page: number): OrderItem[] => {
const companies = [
{ name: '广西万宇工程建设有限公司', code: '91450100MA00000001' },
{ name: '广西远恒建筑有限公司', code: '91450100MA00000002' },
{ name: '南宁宏达工程有限公司', code: '91450100MA00000003' },
{ name: '桂林鑫盛建设有限公司', code: '91450100MA00000004' }
]
const followers = ['罗天东', '张三', '李四', '王五']
const suffix = ['一期', '二期', '改扩建', '配套', '市政', '装饰', '机电']
// page=1给出 16 条示例,统计更直观(项目=订单维度,而非公司维度)
const size = page === 1 ? 16 : 6
const baseNo = 9099009999 + (page - 1) * 100
const list: OrderItem[] = []
for (let i = 0; i < size; i++) {
const c = companies[(page + i) % companies.length]
const follower = followers[(i + page) % followers.length]
const payStatus: PayStatus =
page === 1 ? '全部回款' : (['全部回款', '部分回款', '未回款'][(i + page) % 3] as PayStatus)
const orderNo = String(baseNo + i)
const date = dayjs('2025-10-10').subtract((page - 1) * 7 + (i % 6), 'day').format('YYYY-MM-DD')
// page=1让本金/利息合计更规整(本金 2000利息 200
const principal = page === 1 ? (i < 8 ? 100 : 150) : 200 + ((i + page) % 5) * 50
const interest = page === 1 ? (i < 8 ? 10 : 15) : 20 + ((i + page) % 4) * 5
const projectName = `${c.name}${suffix[(i + page) % suffix.length]}项目`
list.push({
id: `${page}-${i}-${orderNo}`,
orderNo,
companyName: c.name,
unifiedCode: c.code,
projectName,
follower,
payStatus,
date,
principal,
interest
})
const getCurrentUserId = (): number | undefined => {
try {
const raw = Taro.getStorageSync('UserId')
const n = Number(raw)
return Number.isFinite(n) && n > 0 ? n : undefined
} catch {
return undefined
}
return list
}
export default function CreditOrderPage() {
const buildDesc = (row: CreditMpCustomer) => {
const price = row.price ? `${row.price}` : ''
const years = row.years ? `${row.years}` : ''
const location = [row.province, row.city, row.region].filter(Boolean).join(' ')
return [price, years, location].filter(Boolean).join(' · ')
}
const [rawList, setRawList] = useState<OrderItem[]>(() => makeMockOrders(1))
const [mockPage, setMockPage] = useState(1)
const getStatusBadgeClass = (s?: string) => {
const txt = String(s || '').trim()
if (!txt) return 'bg-gray-400'
if (txt.includes('退回')) return 'bg-red-500'
if (txt.includes('完结') || txt.includes('已办结')) return 'bg-green-600'
if (txt.includes('受理') || txt.includes('通过') || txt.includes('签订')) return 'bg-orange-500'
return 'bg-blue-500'
}
const [searchValue, setSearchValue] = useState('')
const [amountSort, setAmountSort] = useState<AmountSort>('默认')
const [payFilter, setPayFilter] = useState<PayStatus>('全部回款')
export default function CreditMyOrderPage() {
const [list, setList] = useState<CreditMpCustomer[]>([])
const [count, setCount] = useState(0)
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [datePopupVisible, setDatePopupVisible] = useState(false)
const [datePickerVisible, setDatePickerVisible] = useState(false)
const [picking, setPicking] = useState<'start' | 'end'>('start')
const [startDate, setStartDate] = useState<Date | null>(null)
const [endDate, setEndDate] = useState<Date | null>(null)
const [keywords, setKeywords] = useState('')
const [page, setPage] = useState(1)
const limit = 10
const filteredList = useMemo(() => {
let list = rawList.slice()
const userId = useMemo(() => getCurrentUserId(), [])
const q = searchValue.trim()
if (q) {
const qq = q.toLowerCase()
list = list.filter(o => {
return (
String(o.companyName || '').toLowerCase().includes(qq) ||
String(o.unifiedCode || '').toLowerCase().includes(qq) ||
String(o.orderNo || '').toLowerCase().includes(qq)
)
})
const hasMore = useMemo(() => list.length < count, [count, list.length])
const fetchPage = useCallback(
async (opts: { nextPage: number; replace: boolean }) => {
if (!userId) {
setList([])
setCount(0)
return
}
const query: ListQuery = {
page: opts.nextPage,
limit,
keywords: keywords.trim() || undefined,
userId
}
try {
if (opts.replace) setLoading(true)
else setLoadingMore(true)
const res = await pageCreditMpCustomer(query 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)
}
},
[keywords, limit, userId]
)
const reload = useCallback(async () => {
await fetchPage({ nextPage: 1, replace: true })
}, [fetchPage])
const loadMore = useCallback(async () => {
if (loading || loadingMore || !hasMore) return
await fetchPage({ nextPage: page + 1, replace: false })
}, [fetchPage, hasMore, loading, loadingMore, page])
useDidShow(() => {
reload().then()
})
useEffect(() => {
const handler = () => reload()
Taro.eventCenter.on('credit:order:created', handler)
return () => {
Taro.eventCenter.off('credit:order:created', handler)
}
}, [reload])
if (payFilter) {
list = list.filter(o => o.payStatus === payFilter)
}
if (startDate || endDate) {
const start = startDate ? dayjs(startDate).startOf('day') : null
const end = endDate ? dayjs(endDate).endOf('day') : null
list = list.filter(o => {
const t = dayjs(o.date, 'YYYY-MM-DD')
if (start && t.isBefore(start)) return false
if (end && t.isAfter(end)) return false
return true
})
}
if (amountSort !== '默认') {
list.sort((a, b) => {
const av = Number(a.principal || 0)
const bv = Number(b.principal || 0)
return amountSort === '从高到低' ? bv - av : av - bv
})
}
return list
}, [amountSort, endDate, payFilter, rawList, searchValue, startDate])
const stats = useMemo(() => {
// 业务说明:一个公司可能对应多个项目(多条订单),因此“订单数量”=项目/订单条数,而不是公司数。
const total = filteredList.length
const principal = filteredList.reduce((sum, o) => sum + Number(o.principal || 0), 0)
const interest = filteredList.reduce((sum, o) => sum + Number(o.interest || 0), 0)
return { total, principal, interest }
}, [filteredList])
const timeText = useMemo(() => {
const s = formatYmd(startDate)
const e = formatYmd(endDate)
if (!s && !e) return '时间筛选'
if (s && e) return `${s}~${e}`
return s ? `${s}~` : `~${e}`
}, [endDate, startDate])
const pickAmountSort = async () => {
try {
const options: AmountSort[] = ['默认', '从高到低', '从低到高']
const res = await Taro.showActionSheet({ itemList: options })
const next = options[res.tapIndex]
if (next) setAmountSort(next)
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
console.error('选择金额排序失败:', e)
}
}
const pickPayFilter = async () => {
try {
const options: PayStatus[] = ['全部回款', '部分回款', '未回款']
const res = await Taro.showActionSheet({ itemList: options })
const next = options[res.tapIndex]
if (next) setPayFilter(next)
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
console.error('选择回款筛选失败:', e)
}
}
const loadMore = () => {
const nextPage = mockPage + 1
const incoming = makeMockOrders(nextPage)
setMockPage(nextPage)
setRawList(prev => prev.concat(incoming))
console.log('加载更多:', { nextPage, appended: incoming.length })
Taro.showToast({ title: '已加载更多', icon: 'none' })
const goDetail = (id?: number) => {
if (!id) return
Taro.navigateTo({ url: `/credit/my-order/detail?id=${id}` })
}
return (
<View className="bg-pink-50 min-h-screen">
<ConfigProvider>
<View className="max-w-md mx-auto">
<View className="px-4 pt-2">
<View className="px-4 pt-3">
<View className="bg-white rounded-full border border-pink-100 px-3 py-2 flex items-center gap-2">
<Search size={16} className="text-gray-400" />
<View className="flex-1">
<Input
value={searchValue}
onChange={setSearchValue}
placeholder="请输入公司名称、统一代码查询、订单号查询"
value={keywords}
onChange={setKeywords}
placeholder="请输入拖欠方名称查询"
onConfirm={reload}
/>
</View>
<Button size="small" type="primary" onClick={reload}>
</Button>
</View>
<View className="mt-3 grid grid-cols-3 gap-2">
<View
className="bg-white rounded-xl border border-pink-100 px-2 py-2 text-xs text-gray-700 flex items-center justify-center"
onClick={pickAmountSort}
>
<Text className="mr-1"></Text>
<Text className="text-gray-400">{amountSort === '从高到低' ? '↓' : amountSort === '从低到高' ? '↑' : '↕'}</Text>
</View>
<View
className="bg-white rounded-xl border border-pink-100 px-2 py-2 text-xs text-gray-700 flex items-center justify-center"
onClick={pickPayFilter}
>
<Text>{payFilter}</Text>
</View>
<View
className="bg-white rounded-xl border border-pink-100 px-2 py-2 text-xs text-gray-700 flex items-center justify-center"
onClick={() => setDatePopupVisible(true)}
>
<Text className="truncate">{timeText}</Text>
</View>
</View>
<View className="mt-2 text-xs text-gray-400 flex items-center justify-between">
<Text>{stats.total}</Text>
<Text>{stats.principal}</Text>
<Text>{stats.interest}</Text>
<View className="mt-2 text-xs text-gray-500 flex items-center justify-between">
<Text></Text>
<Text>{count}</Text>
</View>
</View>
<View className="px-4 mt-3 pb-6">
{!filteredList.length ? (
{loading ? (
<View className="bg-white rounded-xl border border-pink-100 py-10 flex items-center justify-center">
<Loading>...</Loading>
</View>
) : !list.length ? (
<View className="bg-white rounded-xl border border-pink-100 py-10">
<Empty description="暂无订单" />
<Empty description={userId ? '暂无需求' : '未登录或缺少用户信息'} />
</View>
) : (
filteredList.map(o => (
<View key={o.id} className="bg-white rounded-xl border border-pink-100 p-3 mb-3">
<View className="flex items-center justify-between">
<Text className="text-xs text-gray-500">{o.orderNo}</Text>
<View className={`px-2 py-1 rounded-full ${getStatusStyle(o.payStatus)}`}>
<Text className="text-xs text-white">{o.payStatus}</Text>
list.map(row => {
const title = String(row.toUser || '-').trim()
const desc = buildDesc(row)
const statusTxt = String(row.statusTxt || '处理中').trim()
const time = String(row.createTime || '').slice(0, 19).replace('T', ' ')
return (
<View
key={String(row.id)}
className="bg-white rounded-xl border border-pink-100 p-3 mb-3"
onClick={() => goDetail(row.id)}
>
<View className="flex items-center justify-between">
<Text className="text-sm font-semibold text-gray-900">{title}</Text>
<View className="flex items-center gap-2">
<View className={`px-2 py-1 rounded-full ${getStatusBadgeClass(statusTxt)}`}>
<Text className="text-xs text-white">{statusTxt}</Text>
</View>
<ArrowRight color="#cccccc" />
</View>
</View>
{!!desc && (
<View className="mt-2 text-xs text-gray-600">
<Text>{desc}</Text>
</View>
)}
<View className="mt-2 text-xs text-gray-500 flex items-center justify-between">
<Text>ID{row.id ?? '-'}</Text>
<Text>{time || ''}</Text>
</View>
</View>
{/* 项目名称是核心分组/统计维度(一个公司可有多个项目=多条订单),因此需要突出显示 */}
<View className="mt-2">
<Text className="text-sm font-semibold text-rose-600">{o.projectName}</Text>
</View>
<View className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
<Text>{o.companyName}</Text>
<Text>{o.follower}</Text>
<Text>{o.date}</Text>
</View>
<View className="mt-2 flex items-center gap-6 text-xs text-gray-700">
<Text>
<Text className="text-gray-400"></Text>
{o.principal}
</Text>
<Text>
<Text className="text-gray-400"></Text>
{o.interest}
</Text>
</View>
</View>
))
)
})
)}
<View className="mt-2 flex justify-center">
<Button
fill="none"
size="small"
style={{ color: '#bdbdbd' }}
onClick={loadMore}
>
</Button>
</View>
{!!list.length && (
<View className="mt-2 flex justify-center">
<Button
fill="none"
size="small"
style={{ color: '#bdbdbd' }}
disabled={!hasMore || loadingMore}
onClick={loadMore}
>
{hasMore ? (loadingMore ? '加载中...' : '加载更多') : '没有更多了'}
</Button>
</View>
)}
</View>
</View>
<Popup
visible={datePopupVisible}
position="bottom"
onClose={() => setDatePopupVisible(false)}
style={{ borderTopLeftRadius: '12px', borderTopRightRadius: '12px' }}
>
<View className="px-4 py-4 bg-white">
<View className="text-base font-semibold text-gray-900"></View>
<View className="mt-3 text-sm text-gray-700">
<View className="flex items-center justify-between py-2" onClick={() => { setPicking('start'); setDatePickerVisible(true) }}>
<Text></Text>
<Text className="text-gray-500">{formatYmd(startDate) || '未选择'}</Text>
</View>
<View className="flex items-center justify-between py-2" onClick={() => { setPicking('end'); setDatePickerVisible(true) }}>
<Text></Text>
<Text className="text-gray-500">{formatYmd(endDate) || '未选择'}</Text>
</View>
</View>
<View className="mt-4 flex items-center justify-between gap-3">
<Button
fill="outline"
onClick={() => {
setStartDate(null)
setEndDate(null)
}}
>
</Button>
<Button type="primary" onClick={() => setDatePopupVisible(false)}>
</Button>
</View>
</View>
</Popup>
<DatePicker
visible={datePickerVisible}
title={picking === 'start' ? '选择开始日期' : '选择结束日期'}
type="date"
value={(picking === 'start' ? startDate : endDate) || new Date()}
startDate={dayjs('2020-01-01').toDate()}
endDate={dayjs('2035-12-31').toDate()}
onClose={() => setDatePickerVisible(false)}
onCancel={() => setDatePickerVisible(false)}
onConfirm={(_options, selectedValue) => {
const [y, m, d] = (selectedValue || []).map(v => Number(v))
const next = new Date(y, (m || 1) - 1, d || 1, 0, 0, 0)
if (picking === 'start') {
setStartDate(next)
if (endDate && dayjs(endDate).isBefore(next, 'day')) setEndDate(null)
} else {
setEndDate(next)
if (startDate && dayjs(next).isBefore(startDate, 'day')) setStartDate(null)
}
setDatePickerVisible(false)
}}
/>
</ConfigProvider>
</View>
)