feat(credit): 新增信用订单功能模块

- 添加信用订单创建页面,支持填写拖欠方、金额、年数等信息
- 实现附件上传功能,支持图片和文档文件上传预览
- 集成城市选择组件,方便用户选择所在地区
- 添加服务协议勾选确认机制
- 在app配置中注册信用订单相关路由
- 重构文件上传API,新增uploadFileByPath方法支持路径上传
- 更新发现页面,集成店铺网点查询和定位功能
- 实现下拉刷新和无限滚动加载更多网点数据
- 添加地图导航和电话拨打功能
- 优化网点列表显示,按距离排序并显示距离信息
This commit is contained in:
2026-03-04 11:04:02 +08:00
parent c90b69140c
commit 1d199c8441
13 changed files with 1392 additions and 182 deletions

View File

@@ -1,68 +1,164 @@
import {useMemo, useState} from 'react'
import Taro from '@tarojs/taro'
import { useMemo, useRef, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import {Input, Text, View} from '@tarojs/components'
import { Empty, InfiniteLoading, Loading, PullToRefresh } from '@nutui/nutui-react-taro'
import {Search} from '@nutui/icons-react-taro'
import { pageShopStore } from '@/api/shop/shopStore'
import type { ShopStore } from '@/api/shop/shopStore/model'
import { getCurrentLngLat } from '@/utils/location'
import './find.scss'
type SiteItem = {
id: string
cityName: string
address: string
phone: string
contact: string
distanceMeter: number
const PAGE_SIZE = 10
type LngLat = { lng: number; lat: number }
type ShopStoreView = ShopStore & { __distanceMeter?: number }
const parseLngLat = (raw: string | undefined): LngLat | null => {
const text = (raw || '').trim()
if (!text) return null
const parts = text.split(/[,\s]+/).filter(Boolean)
if (parts.length < 2) return null
const a = Number(parts[0])
const b = Number(parts[1])
if (!Number.isFinite(a) || !Number.isFinite(b)) return null
// Accept both "lng,lat" and "lat,lng".
const looksLikeLngLat = Math.abs(a) <= 180 && Math.abs(b) <= 90
const looksLikeLatLng = Math.abs(a) <= 90 && Math.abs(b) <= 180
if (looksLikeLngLat) return { lng: a, lat: b }
if (looksLikeLatLng) return { lng: b, lat: a }
return null
}
const MOCK_SITES: SiteItem[] = [
{
id: '1',
cityName: '北京朝阳区网点',
address: '地安门西大街(南门)',
phone: '15878179339',
contact: '刘先生',
distanceMeter: 100
},
{
id: '2',
cityName: '兰州某某区网点',
address: '地安门西大街(南门)',
phone: '15878179339',
contact: '黄先生',
distanceMeter: 150
},
{
id: '3',
cityName: '合肥市某某区网点',
address: '地安门西大街(南门)',
phone: '15878179339',
contact: '黄先生',
distanceMeter: 250
},
{
id: '4',
cityName: '南宁市某某区网点',
address: '广西壮族自治区南宁市良庆区五象新区五象大道403号富雅国际金融中心G1栋高层6006',
phone: '15878179339',
contact: '柳先生',
distanceMeter: 1250
}
]
const distanceMeters = (a: LngLat, b: LngLat) => {
const toRad = (x: number) => (x * Math.PI) / 180
const R = 6371000
const dLat = toRad(b.lat - a.lat)
const dLng = toRad(b.lng - a.lng)
const lat1 = toRad(a.lat)
const lat2 = toRad(b.lat)
const sin1 = Math.sin(dLat / 2)
const sin2 = Math.sin(dLng / 2)
const h = sin1 * sin1 + Math.cos(lat1) * Math.cos(lat2) * sin2 * sin2
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)))
}
const formatDistance = (meter: number | undefined) => {
if (!Number.isFinite(meter as number)) return ''
const m = Math.max(0, Math.round(meter as number))
if (m < 1000) return `${m}`
const km = m / 1000
return `${km.toFixed(km >= 10 ? 0 : 1)}公里`
}
const Find = () => {
const [keyword, setKeyword] = useState<string>('')
const [storeList, setStoreList] = useState<ShopStoreView[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [total, setTotal] = useState(0)
const [userLngLat, setUserLngLat] = useState<LngLat | null>(null)
const filtered = useMemo(() => {
const key = keyword.trim()
if (!key) return MOCK_SITES
return MOCK_SITES.filter((it) => it.cityName.includes(key))
}, [keyword])
const pageRef = useRef(1)
const latestListRef = useRef<ShopStoreView[]>([])
const loadingRef = useRef(false)
const coordsRef = useRef<LngLat | null>(null)
const onNavigate = (item: SiteItem) => {
Taro.showToast({title: `导航至:${item.cityName}(示例)`, icon: 'none'})
const viewList = useMemo<ShopStoreView[]>(() => {
const me = userLngLat
if (!me) return storeList
// Keep backend order; only attach distance for display.
return storeList.map((s) => {
const coords = parseLngLat(s.lngAndLat || s.location)
if (!coords) return s
return { ...s, __distanceMeter: distanceMeters(me, coords) }
})
}, [storeList, userLngLat])
const loadStores = async (isRefresh = true, keywordsOverride?: string) => {
if (loadingRef.current) return
loadingRef.current = true
setLoading(true)
if (isRefresh) {
pageRef.current = 1
latestListRef.current = []
setStoreList([])
setHasMore(true)
setTotal(0)
}
try {
if (!coordsRef.current) {
const me = await getCurrentLngLat('为您展示附近网点,需要获取定位信息。')
const lng = me ? Number(me.lng) : NaN
const lat = me ? Number(me.lat) : NaN
coordsRef.current = Number.isFinite(lng) && Number.isFinite(lat) ? { lng, lat } : null
setUserLngLat(coordsRef.current)
}
const currentPage = pageRef.current
const kw = (keywordsOverride ?? keyword).trim()
const res = await pageShopStore({
page: currentPage,
limit: PAGE_SIZE,
keywords: kw || undefined
})
const resList = res?.list || []
const nextList = isRefresh ? resList : [...latestListRef.current, ...resList]
latestListRef.current = nextList
setStoreList(nextList)
const count = typeof res?.count === 'number' ? res.count : nextList.length
setTotal(count)
setHasMore(nextList.length < count)
if (resList.length > 0) {
pageRef.current = currentPage + 1
} else {
setHasMore(false)
}
} catch (e) {
console.error('获取网点列表失败:', e)
Taro.showToast({ title: '获取网点失败', icon: 'none' })
setHasMore(false)
} finally {
loadingRef.current = false
setLoading(false)
}
}
useDidShow(() => {
loadStores(true).then()
})
const onNavigate = (item: ShopStore) => {
const coords = parseLngLat(item.lngAndLat || item.location)
if (!coords) {
Taro.showToast({ title: '网点暂无坐标,无法导航', icon: 'none' })
return
}
Taro.openLocation({
latitude: coords.lat,
longitude: coords.lng,
name: item.name || item.city || '网点',
address: item.address || ''
})
}
const onCall = (phone: string | undefined) => {
const p = (phone || '').trim()
if (!p) {
Taro.showToast({ title: '暂无联系电话', icon: 'none' })
return
}
Taro.makePhoneCall({ phoneNumber: p })
}
const onSearch = () => {
Taro.showToast({title: '查询(示例)', icon: 'none'})
loadStores(true).then()
}
return (
@@ -84,44 +180,84 @@ const Find = () => {
</View>
</View>
<View className='siteList'>
{filtered.map((item) => (
<View key={item.id} className='siteCard'>
<View className='siteCardInner'>
<View className='siteInfo'>
<View className='siteRow siteRowTop'>
<Text className='siteLabel'></Text>
<Text className='siteValue siteValueStrong'>{item.cityName}</Text>
</View>
<View className='siteDivider' />
<View className='siteRow'>
<Text className='siteLabel'></Text>
<Text className='siteValue'>{item.address}</Text>
</View>
<View className='siteRow'>
<Text className='siteLabel'></Text>
<Text className='siteValue'>{item.phone}</Text>
</View>
<View className='siteRow'>
<Text className='siteLabel'></Text>
<Text className='siteValue'>{item.contact}</Text>
</View>
</View>
<View className='siteSide' onClick={() => onNavigate(item)}>
<View className='navArrow' />
<Text className='distanceText'>{item.distanceMeter}</Text>
</View>
<PullToRefresh onRefresh={() => loadStores(true)} headHeight={60}>
<View style={{ height: 'calc(100vh)', overflowY: 'auto' }} id='store-scroll'>
{viewList.length === 0 && !loading ? (
<View className='emptyWrap'>
<Empty description='暂无网点' style={{ backgroundColor: 'transparent' }} />
</View>
</View>
))}
) : (
<View className='siteList'>
<InfiniteLoading
target='store-scroll'
hasMore={hasMore}
onLoadMore={() => loadStores(false)}
loadingText={
<View className='emptyWrap'>
<Loading />
<Text className='emptyText' style={{ marginLeft: '8px' }}>
...
</Text>
</View>
}
loadMoreText={
<View className='emptyWrap'>
<Text className='emptyText'>
{viewList.length === 0 ? '暂无网点' : '没有更多了'}
</Text>
</View>
}
>
{viewList.map((item, idx) => {
const name = item?.name || item?.city || item?.province || '网点'
const contact = item?.managerName || '--'
const distanceText = formatDistance(item?.__distanceMeter)
return (
<View key={String(item?.id ?? `${name}-${idx}`)} className='siteCard'>
<View className='siteCardInner'>
<View className='siteInfo'>
<View className='siteRow siteRowTop'>
<Text className='siteLabel'></Text>
<Text className='siteValue siteValueStrong'>{name}</Text>
</View>
<View className='siteDivider' />
<View className='siteRow'>
<Text className='siteLabel'></Text>
<Text className='siteValue'>{item?.address || '--'}</Text>
</View>
<View className='siteRow'>
<Text className='siteLabel'></Text>
<Text className='siteValue' onClick={() => onCall(item?.phone)}>
{item?.phone || '--'}
</Text>
</View>
<View className='siteRow'>
<Text className='siteLabel'></Text>
<Text className='siteValue'>{contact}</Text>
</View>
</View>
{filtered.length === 0 && (
<View className='emptyWrap'>
<Text className='emptyText'></Text>
</View>
)}
</View>
<View className='siteSide' onClick={() => onNavigate(item)}>
<View className='navArrow' />
<Text className='distanceText'>
{distanceText ? `距离${distanceText}` : '查看导航'}
</Text>
</View>
</View>
</View>
)
})}
</InfiniteLoading>
{total > 0 && (
<View className='emptyWrap' style={{ paddingTop: '10rpx' }}>
<Text className='emptyText'> {total} </Text>
</View>
)}
</View>
)}
</View>
</PullToRefresh>
<View className='bottomSafe' />
</View>