feat(credit): 重构小程序端客户管理功能

- 优化代码结构,使用函数组件和Hooks替代类组件
- 实现表单验证和错误处理机制
- 添加加载状态和提交防抖保护
- 增加城市选择和文件上传功能
- 实现推荐状态切换和长按删除功能
- 统一错误提示和用户反馈机制
- 优化页面标题和导航体验
This commit is contained in:
2026-03-16 22:12:16 +08:00
parent 694efb77ec
commit 138f28793f
4 changed files with 314 additions and 168 deletions

View File

@@ -1,4 +1,5 @@
import request from '@/utils/request'; import request from '@/utils/request';
import { withCreditUserScope } from '@/api/credit/utils/data-scope';
import type { ApiResult, PageResult } from '@/api/index'; import type { ApiResult, PageResult } from '@/api/index';
import type { CreditMpCustomer, CreditMpCustomerParam } from './model'; import type { CreditMpCustomer, CreditMpCustomerParam } from './model';
@@ -8,7 +9,7 @@ import type { CreditMpCustomer, CreditMpCustomerParam } from './model';
export async function pageCreditMpCustomer(params: CreditMpCustomerParam) { export async function pageCreditMpCustomer(params: CreditMpCustomerParam) {
const res = await request.get<ApiResult<PageResult<CreditMpCustomer>>>( const res = await request.get<ApiResult<PageResult<CreditMpCustomer>>>(
'/credit/credit-mp-customer/page', '/credit/credit-mp-customer/page',
params withCreditUserScope(params)
); );
if (res.code === 0) { if (res.code === 0) {
return res.data; return res.data;
@@ -22,7 +23,7 @@ export async function pageCreditMpCustomer(params: CreditMpCustomerParam) {
export async function listCreditMpCustomer(params?: CreditMpCustomerParam) { export async function listCreditMpCustomer(params?: CreditMpCustomerParam) {
const res = await request.get<ApiResult<CreditMpCustomer[]>>( const res = await request.get<ApiResult<CreditMpCustomer[]>>(
'/credit/credit-mp-customer', '/credit/credit-mp-customer',
params withCreditUserScope(params)
); );
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
return res.data; return res.data;
@@ -62,6 +63,9 @@ export async function updateCreditMpCustomer(data: CreditMpCustomer) {
* 删除小程序端客户 * 删除小程序端客户
*/ */
export async function removeCreditMpCustomer(id?: number) { export async function removeCreditMpCustomer(id?: number) {
if (id == null) {
return Promise.reject(new Error('缺少id'));
}
const res = await request.del<ApiResult<unknown>>( const res = await request.del<ApiResult<unknown>>(
'/credit/credit-mp-customer/' + id '/credit/credit-mp-customer/' + id
); );
@@ -75,11 +79,10 @@ export async function removeCreditMpCustomer(id?: number) {
* 批量删除小程序端客户 * 批量删除小程序端客户
*/ */
export async function removeBatchCreditMpCustomer(data: (number | undefined)[]) { export async function removeBatchCreditMpCustomer(data: (number | undefined)[]) {
const ids = (data || []).filter((v): v is number => typeof v === 'number');
const res = await request.del<ApiResult<unknown>>( const res = await request.del<ApiResult<unknown>>(
'/credit/credit-mp-customer/batch', '/credit/credit-mp-customer/batch',
{ ids
data
}
); );
if (res.code === 0) { if (res.code === 0) {
return res.message; return res.message;

View File

@@ -1,98 +1,136 @@
import {useEffect, useState, useRef} from "react"; import { useEffect, useMemo, useRef, useState } from 'react'
import {useRouter} from '@tarojs/taro' import Taro, { useRouter } from '@tarojs/taro'
import {Button, Loading, CellGroup, Input, TextArea, Form} from '@nutui/nutui-react-taro' import { View } from '@tarojs/components'
import Taro from '@tarojs/taro' import { Button, CellGroup, Form, Input, Loading, TextArea } from '@nutui/nutui-react-taro'
import {View} from '@tarojs/components'
import {CreditMpCustomer} from "@/api/credit/creditMpCustomer/model"; import type { CreditMpCustomer } from '@/api/credit/creditMpCustomer/model'
import {getCreditMpCustomer, listCreditMpCustomer, updateCreditMpCustomer, addCreditMpCustomer} from "@/api/credit/creditMpCustomer"; import { addCreditMpCustomer, getCreditMpCustomer, updateCreditMpCustomer } from '@/api/credit/creditMpCustomer'
export default function CreditMpCustomerAddPage() {
const { params } = useRouter()
const id = useMemo(() => {
const n = Number(params?.id)
return Number.isFinite(n) && n > 0 ? n : undefined
}, [params?.id])
const AddCreditMpCustomer = () => {
const {params} = useRouter();
const [loading, setLoading] = useState<boolean>(true)
const [FormData, setFormData] = useState<CreditMpCustomer>({})
const formRef = useRef<any>(null) const formRef = useRef<any>(null)
const [loading, setLoading] = useState(true)
const reload = async () => { const [initialValues, setInitialValues] = useState<Partial<CreditMpCustomer>>({})
if (params.id) { const [submitting, setSubmitting] = useState(false)
const data = await getCreditMpCustomer(Number(params.id))
setFormData(data)
} else {
setFormData({})
}
}
// 提交表单
const submitSucceed = async (values: any) => {
try {
if (params.id) {
// 编辑模式
await updateCreditMpCustomer({
...values,
id: Number(params.id)
})
} else {
// 新增模式
await addCreditMpCustomer(values)
}
Taro.showToast({
title: `操作成功`,
icon: 'success'
})
setTimeout(() => {
return Taro.navigateBack()
}, 1000)
} catch (error) {
Taro.showToast({
title: `操作失败`,
icon: 'error'
});
}
}
const submitFailed = (error: any) => {
console.log(error, 'err...')
}
useEffect(() => { useEffect(() => {
reload().then(() => { Taro.setNavigationBarTitle({ title: id ? '编辑小程序端客户' : '新增小程序端客户' })
}, [id])
useEffect(() => {
const run = async () => {
setLoading(true)
try {
if (id) {
const data = await getCreditMpCustomer(id)
setInitialValues(data || {})
} else {
setInitialValues({})
}
} catch (e) {
console.error('加载失败:', e)
Taro.showToast({ title: (e as any)?.message || '加载失败', icon: 'none' })
} finally {
setLoading(false) setLoading(false)
}) }
}, []); }
run()
}, [id])
const onFinish = async (values: any) => {
if (submitting) return
setSubmitting(true)
try {
if (id) {
await updateCreditMpCustomer({ ...values, id })
} else {
await addCreditMpCustomer(values)
}
Taro.showToast({ title: '操作成功', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 800)
} catch (e) {
console.error('保存失败:', e)
Taro.showToast({ title: (e as any)?.message || '操作失败', icon: 'none' })
} finally {
setSubmitting(false)
}
}
if (loading) { if (loading) {
return <Loading className={'px-2'}></Loading> return <Loading className="px-2"></Loading>
} }
return ( return (
<> <View className="bg-gray-50 min-h-screen px-3 pt-3 pb-6">
<Form <Form
ref={formRef} ref={formRef}
divider divider
initialValues={FormData} initialValues={initialValues}
labelPosition="left" labelPosition="left"
onFinish={(values) => submitSucceed(values)} onFinish={onFinish}
onFinishFailed={(errors) => submitFailed(errors)}
footer={ footer={
<div <Button nativeType="submit" type="primary" size="large" loading={submitting} block>
style={{ {id ? '更新' : '保存'}
display: 'flex',
justifyContent: 'center',
width: '100%'
}}
>
<Button
nativeType="submit"
type="success"
size="large"
className={'w-full'}
block
>
{params.id ? '更新' : '保存'}
</Button> </Button>
</div>
} }
> >
<CellGroup style={{padding: '4px 0'}}> <CellGroup>
<Form.Item name="toUser" label="拖欠方" initialValue={FormData.toUser} required> <Form.Item
name="toUser"
label="拖欠方"
required
rules={[{ required: true, message: '请输入拖欠方' }]}
>
<Input placeholder="请输入拖欠方/付款方名称" maxLength={50} />
</Form.Item>
<Form.Item
name="price"
label="拖欠金额"
required
rules={[{ required: true, message: '请输入拖欠金额' }]}
>
<Input placeholder="请输入拖欠金额" type="number" />
</Form.Item>
<Form.Item
name="years"
label="拖欠年数"
required
rules={[{ required: true, message: '请输入拖欠年数' }]}
>
<Input placeholder="请输入拖欠年数" type="number" />
</Form.Item>
<Form.Item name="province" label="省份">
<Input placeholder="可选" maxLength={20} />
</Form.Item>
<Form.Item name="city" label="城市">
<Input placeholder="可选" maxLength={20} />
</Form.Item>
<Form.Item name="region" label="辖区">
<Input placeholder="可选" maxLength={20} />
</Form.Item>
<Form.Item name="url" label="链接">
<Input placeholder="可选" maxLength={200} />
</Form.Item>
<Form.Item name="files" label="文件">
<TextArea placeholder="可选可填文件URL或JSON" maxLength={1000} rows={3} />
</Form.Item>
<Form.Item name="comments" label="备注">
<TextArea placeholder="可选" maxLength={200} showCount rows={3} />
</Form.Item>
</CellGroup>
</Form>
</View>
)
}

View File

@@ -1,64 +1,129 @@
import {useState} from "react"; import { useCallback, useState } from 'react'
import Taro, {useDidShow} from '@tarojs/taro' import Taro, { useDidShow } from '@tarojs/taro'
import {Button, Cell, CellGroup, Space, Empty, ConfigProvider, Divider} from '@nutui/nutui-react-taro' import { View, Text } from '@tarojs/components'
import {Dongdong, ArrowRight, CheckNormal, Checked} from '@nutui/icons-react-taro' import { Button, Cell, CellGroup, ConfigProvider, Empty, Space } from '@nutui/nutui-react-taro'
import {View} from '@tarojs/components' import { ArrowRight, CheckNormal, Checked } from '@nutui/icons-react-taro'
import {CreditMpCustomer} from "@/api/credit/creditMpCustomer/model";
import {listCreditMpCustomer, removeCreditMpCustomer, updateCreditMpCustomer} from "@/api/credit/creditMpCustomer";
const CreditMpCustomerList = () => { import type { CreditMpCustomer } from '@/api/credit/creditMpCustomer/model'
import { listCreditMpCustomer, removeCreditMpCustomer, updateCreditMpCustomer } from '@/api/credit/creditMpCustomer'
export default function CreditMpCustomerListPage() {
const [list, setList] = useState<CreditMpCustomer[]>([]) const [list, setList] = useState<CreditMpCustomer[]>([])
const [loading, setLoading] = useState(false)
const reload = () => { const reload = useCallback(async () => {
listCreditMpCustomer({ setLoading(true)
// 添加查询条件 try {
}) const data = await listCreditMpCustomer()
.then(data => {
setList(data || []) setList(data || [])
}) } catch (e) {
.catch(() => { console.error('获取数据失败:', e)
Taro.showToast({ Taro.showToast({ title: (e as any)?.message || '获取数据失败', icon: 'none' })
title: '获取数据失败', } finally {
icon: 'error' setLoading(false)
});
})
}
const onDel = async (id?: number) => {
await removeCreditMpCustomer(id)
Taro.showToast({
title: '删除成功',
icon: 'success'
});
reload();
} }
}, [])
useDidShow(() => { useDidShow(() => {
reload() reload()
}); })
if (list.length == 0) { const goAdd = () => Taro.navigateTo({ url: '/credit/creditMpCustomer/add' })
return (
<ConfigProvider> const goEdit = (id?: number) => {
<div className={'h-full flex flex-col justify-center items-center'} style={{ if (!id) return
height: 'calc(100vh - 300px)', Taro.navigateTo({ url: `/credit/creditMpCustomer/add?id=${id}` })
}}> }
<Empty
style={{ const onToggleRecommend = async (row: CreditMpCustomer) => {
backgroundColor: 'transparent' if (!row?.id) return
}} const next = row.recommend === 1 ? 0 : 1
description="暂无数据" try {
/> await updateCreditMpCustomer({ ...row, recommend: next })
<Space> Taro.showToast({ title: next === 1 ? '已设为推荐' : '已取消推荐', icon: 'success' })
<Button onClick={() => Taro.navigateTo({url: '/credit/creditMpCustomer/add'})}></Button> reload()
</Space> } catch (e) {
</div> console.error('更新失败:', e)
</ConfigProvider> Taro.showToast({ title: (e as any)?.message || '更新失败', icon: 'none' })
) }
}
const onDel = async (id?: number) => {
if (!id) return
const res = await Taro.showModal({ title: '提示', content: '确认删除该记录?' })
if (!res.confirm) return
try {
await removeCreditMpCustomer(id)
Taro.showToast({ title: '删除成功', icon: 'success' })
reload()
} catch (e) {
console.error('删除失败:', e)
Taro.showToast({ title: (e as any)?.message || '删除失败', icon: 'none' })
}
} }
return ( return (
<> <View className="bg-gray-50 min-h-screen">
{list.map((item, _) => ( <ConfigProvider>
<Cell.Group key={item. <View className="px-3 pt-3">
<Space>
<Button type="primary" size="small" onClick={goAdd}>
</Button>
<Button size="small" loading={loading} onClick={reload}>
</Button>
</Space>
</View>
{!list.length ? (
<View className="px-3 pt-10">
<Empty description={loading ? '加载中...' : '暂无数据'} />
</View>
) : (
<View className="px-3 pt-3 pb-6">
<CellGroup>
{list.map(row => {
const recommended = row.recommend === 1
const title = row.toUser || '-'
const price = row.price ? `${row.price}` : '-'
const years = row.years ? `${row.years}` : '-'
const location = [row.province, row.city, row.region].filter(Boolean).join(' ')
const desc = `${price} · ${years}${location ? ` · ${location}` : ''}`
return (
<View key={row.id} onLongPress={() => onDel(row.id)}>
<Cell
title={title}
description={desc}
extra={
<View className="flex items-center gap-3">
<View
className="flex items-center"
onClick={e => {
e.stopPropagation()
onToggleRecommend(row)
}}
>
{recommended ? <Checked size={16} /> : <CheckNormal size={16} />}
<Text className="ml-1 text-xs text-gray-500">{recommended ? '推荐' : '未推荐'}</Text>
</View>
<ArrowRight color="#cccccc" />
</View>
}
onClick={() => goEdit(row.id)}
/>
</View>
)
})}
</CellGroup>
<View className="mt-2 text-xs text-gray-400">
</View>
</View>
)}
</ConfigProvider>
</View>
)
}

View File

@@ -7,6 +7,7 @@ import { ArrowRight, Close } from '@nutui/icons-react-taro'
import FixedButton from '@/components/FixedButton' import FixedButton from '@/components/FixedButton'
import RegionData from '@/api/json/regions-data.json' import RegionData from '@/api/json/regions-data.json'
import { uploadFileByPath } from '@/api/system/file' import { uploadFileByPath } from '@/api/system/file'
import { addCreditMpCustomer } from '@/api/credit/creditMpCustomer'
import './add.scss' import './add.scss'
@@ -28,6 +29,7 @@ export default function CreditOrderAddPage() {
const [cityVisible, setCityVisible] = useState(false) const [cityVisible, setCityVisible] = useState(false)
const [cityText, setCityText] = useState('') const [cityText, setCityText] = useState('')
const [cityValue, setCityValue] = useState<string[]>([])
const [attachments, setAttachments] = useState<Attachment[]>([]) const [attachments, setAttachments] = useState<Attachment[]>([])
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
@@ -67,11 +69,21 @@ export default function CreditOrderAddPage() {
} }
const chooseAndUploadImages = async () => { const chooseAndUploadImages = async () => {
const res = await Taro.chooseImage({ let res: any
try {
res = await Taro.chooseImage({
count: 9, count: 9,
sizeType: ['compressed'], sizeType: ['compressed'],
sourceType: ['album', 'camera'] sourceType: ['album', 'camera']
}) })
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
console.error('选择图片失败:', e)
Taro.showToast({ title: '选择图片失败', icon: 'none' })
return
}
const paths = (res?.tempFilePaths || []).filter(Boolean) const paths = (res?.tempFilePaths || []).filter(Boolean)
if (!paths.length) return if (!paths.length) return
@@ -95,11 +107,21 @@ export default function CreditOrderAddPage() {
const chooseAndUploadDocs = async () => { const chooseAndUploadDocs = async () => {
// 微信小程序:从会话/聊天等入口选择文件 // 微信小程序:从会话/聊天等入口选择文件
// H5 等环境可能不支持,走 try/catch 提示即可 // H5 等环境可能不支持,走 try/catch 提示即可
let res: any
try {
// @ts-ignore // @ts-ignore
const res = await Taro.chooseMessageFile({ res = await Taro.chooseMessageFile({
count: 9, count: 9,
type: 'file' type: 'file'
}) })
} catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return
console.error('选择文件失败:', e)
Taro.showToast({ title: '当前环境不支持选取文件', icon: 'none' })
return
}
// @ts-ignore // @ts-ignore
const tempFiles = (res?.tempFiles || []) as Array<{ path?: string; name?: string }> const tempFiles = (res?.tempFiles || []) as Array<{ path?: string; name?: string }>
const paths = tempFiles.map(f => f?.path).filter(Boolean) as string[] const paths = tempFiles.map(f => f?.path).filter(Boolean) as string[]
@@ -187,27 +209,44 @@ export default function CreditOrderAddPage() {
return return
} }
const payload = { const [province, city, region] = cityValue
payer, const files = attachments.map(a => ({
amount, name: a.name,
years, url: a.url,
remark, thumbnail: a.thumbnail,
city: cityText, isImage: a.isImage
files: attachments.map(a => ({ name: a.name, url: a.url, isImage: a.isImage })) }))
}
setSubmitting(true) setSubmitting(true)
try { try {
// TODO: 这里可替换为真实接口提交(如后端提供 `/credit/order` await addCreditMpCustomer({
console.log('发需求提交:', payload) toUser: payer,
price: String(amount),
years: String(years),
comments: remark || undefined,
province: province || undefined,
city: city || undefined,
region: region || undefined,
files: files.length ? JSON.stringify(files) : undefined
})
Taro.showToast({ title: '提交成功', icon: 'success' }) Taro.showToast({ title: '提交成功', icon: 'success' })
Taro.eventCenter.trigger('credit:order:created', payload) Taro.eventCenter.trigger('credit:order:created', {
toUser: payer,
price: String(amount),
years: String(years),
comments: remark || undefined,
province: province || undefined,
city: city || undefined,
region: region || undefined,
files
})
setTimeout(() => { setTimeout(() => {
Taro.navigateBack() Taro.navigateBack()
}, 800) }, 800)
} catch (e) { } catch (e) {
console.error('提交失败:', e) console.error('提交失败:', e)
Taro.showToast({ title: '提交失败,请重试', icon: 'none' }) Taro.showToast({ title: (e as any)?.message || '提交失败,请重试', icon: 'none' })
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@@ -217,7 +256,7 @@ export default function CreditOrderAddPage() {
console.log('表单校验失败:', err) console.log('表单校验失败:', err)
} }
const canSubmit = !uploading && !submitting const canSubmit = !uploading && !submitting && agree
return ( return (
<View className="credit-order-add-page"> <View className="credit-order-add-page">
@@ -292,7 +331,7 @@ export default function CreditOrderAddPage() {
))} ))}
</View> </View>
) : ( ) : (
<Text className="attachments__empty"></Text> <Text className="attachments__empty"></Text>
)} )}
<Button <Button
@@ -324,8 +363,10 @@ export default function CreditOrderAddPage() {
options={cityOptions as any} options={cityOptions as any}
title="选择城市" title="选择城市"
onChange={(value: any[]) => { onChange={(value: any[]) => {
const txt = value.filter(Boolean).slice(0, 2).join(' ') const list = (value || []).filter(Boolean).slice(0, 3).map(v => String(v))
setCityText(txt) setCityValue(list)
const txt = list.slice(0, 2).join(' ')
setCityText(txt || '')
setCityVisible(false) setCityVisible(false)
}} }}
onClose={() => setCityVisible(false)} onClose={() => setCityVisible(false)}
@@ -339,4 +380,3 @@ export default function CreditOrderAddPage() {
</View> </View>
) )
} }