feat(user): 新增收货地址管理及售后申请页面

- 新增地址类型定义,增强前端地址数据结构
- 新增地址编辑页面,支持地址智能识别和定位选点功能
- 地址编辑支持省市区选择及默认地址设置
- 新增地址列表页面,支持地址展示、删除、编辑和选择功能
- 实现售后申请页面,支持选择售后类型和退款原因
- 售后申请支持商品选择、退款金额计算和凭证上传
- 新增售后详情页面,支持售后状态展示及申请取消
- 优化页面加载和用户交互体验,增加错误提示和权限处理
This commit is contained in:
2026-07-01 12:11:56 +08:00
parent bf6ed504cc
commit 1fa58040f3
636 changed files with 58878 additions and 716 deletions

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '消息详情',
}

View File

@@ -0,0 +1,169 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { getShopMessage, deleteShopMessage } from '@/api/shop/shopMessage'
import type { ShopMessage, MessageType } from '@/api/shop/shopMessage/model'
definePageConfig({
navigationBarTitleText: '消息详情',
})
const MessageDetailPage: React.FC = () => {
const { id } = Taro.getCurrentInstance().router?.params || {}
const [message, setMessage] = useState<ShopMessage | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (id) {
fetchMessage(id)
}
}, [id])
const fetchMessage = async (msgId: string) => {
try {
const res = await getShopMessage(msgId)
if (res?.code === 0 && res.data) {
setMessage(res.data)
}
} catch (err: any) {
Taro.showToast({ title: err.message || '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
// 获取类型图标
const getTypeIcon = (type: MessageType) => {
const iconMap: Record<MessageType, string> = {
system: '🔔',
order: '📦',
activity: '🎁',
}
return iconMap[type] || '📋'
}
// 获取类型名称
const getTypeName = (type: MessageType) => {
const nameMap: Record<MessageType, string> = {
system: '系统通知',
order: '订单通知',
activity: '活动通知',
}
return nameMap[type] || '其他'
}
// 格式化时间
const formatTime = (timeStr: string) => {
const date = new Date(timeStr)
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hour = date.getHours().toString().padStart(2, '0')
const minute = date.getMinutes().toString().padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
// 删除消息
const handleDelete = () => {
if (!message) return
Taro.showModal({
title: '提示',
content: '确定删除该消息吗?',
confirmColor: '#0e932e',
success: async (res) => {
if (res.confirm) {
try {
await deleteShopMessage(message.id)
Taro.showToast({ title: '删除成功', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 1500)
} catch (err: any) {
Taro.showToast({ title: err.message || '删除失败', icon: 'none' })
}
}
}
})
}
if (loading) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-sm text-gray-400'>...</Text>
</View>
)
}
if (!message) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-sm text-gray-400'></Text>
</View>
)
}
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
<ScrollView scrollY className='flex-1'>
{/* 消息头部 */}
<View className='bg-white p-4 mb-3'>
<View className='flex items-center gap-2 mb-3'>
<Text className='text-2xl'>{getTypeIcon(message.type)}</Text>
<View className='flex-1'>
<Text className='text-lg font-bold text-gray-800 block mb-1'>
{message.title}
</Text>
<View className='flex items-center gap-2'>
<Text className='text-xs text-gray-400'>
{formatTime(message.createdAt)}
</Text>
<View className='bg-gray-100 px-2 py-1 rounded'>
<Text className='text-xs text-gray-500'>
{getTypeName(message.type)}
</Text>
</View>
</View>
</View>
</View>
</View>
{/* 消息内容 */}
<View className='bg-white p-4 mb-3'>
<Text className='text-sm text-gray-700 leading-7 block whitespace-pre-wrap'>
{message.content}
</Text>
</View>
{/* 相关操作 */}
<View className='bg-white p-4'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='flex flex-col gap-3'>
<View
className='flex items-center justify-between py-2 border-b border-gray-50'
onClick={() => Taro.navigateTo({ url: '/pages/user/user' })}
>
<Text className='text-sm text-gray-600'></Text>
<Text className='text-gray-400'></Text>
</View>
<View
className='flex items-center justify-between py-2 border-b border-gray-50'
onClick={() => Taro.switchTab({ url: '/pages/index/index' })}
>
<Text className='text-sm text-gray-600'></Text>
<Text className='text-gray-400'></Text>
</View>
<View
className='flex items-center justify-between py-2'
onClick={handleDelete}
>
<Text className='text-sm text-red-500'></Text>
<Text className='text-gray-400'></Text>
</View>
</View>
</View>
<View className='h-6' />
</ScrollView>
</View>
)
}
export default MessageDetailPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '消息通知',
}

View File

@@ -0,0 +1,262 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { listShopMessage, readShopMessage, deleteShopMessage, readAllShopMessage } from '@/api/shop/shopMessage'
import type { ShopMessage, MessageType } from '@/api/shop/shopMessage'
definePageConfig({
navigationBarTitleText: '消息通知',
})
// 消息类型
const messageTypes = [
{ label: '全部', value: '' },
{ label: '系统通知', value: 'system' },
{ label: '订单通知', value: 'order' },
{ label: '活动通知', value: 'activity' },
]
const MessageListPage: React.FC = () => {
const [activeType, setActiveType] = useState<string>('')
const [messages, setMessages] = useState<ShopMessage[]>([])
const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false)
const [page, setPage] = useState(1)
useEffect(() => {
setMessages([])
setPage(1)
setFinished(false)
loadList(1)
}, [activeType])
const loadList = async (p: number) => {
if (loading) return
setLoading(true)
try {
const params: any = {
page: p,
limit: 10,
}
if (activeType) {
params.type = activeType
}
const res = await listShopMessage(params)
if (res?.list) {
if (p === 1) {
setMessages(res.list)
} else {
setMessages(prev => [...prev, ...res.list])
}
setFinished(res.list.length < 10)
setPage(p)
}
} catch (err) {
console.error('加载消息失败', err)
Taro.showToast({ title: '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
// 加载更多
const handleLoadMore = () => {
if (!finished && !loading) {
loadList(page + 1)
}
}
// 标记已读
const markAsRead = async (id: string) => {
try {
await readShopMessage(id)
setMessages(prev =>
prev.map(msg =>
msg.id === id ? { ...msg, isRead: true } : msg
)
)
Taro.navigateTo({ url: `/pages/message/detail/index?id=${id}` })
} catch (err) {
console.error('标记已读失败', err)
}
}
// 删除消息
const deleteMessage = (id: string) => {
Taro.showModal({
title: '提示',
content: '确定删除该消息吗?',
success: async (res) => {
if (res.confirm) {
try {
await deleteShopMessage(id)
setMessages(prev => prev.filter(msg => msg.id !== id))
Taro.showToast({ title: '删除成功', icon: 'success' })
} catch (err) {
console.error('删除消息失败', err)
Taro.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
// 全部标记为已读
const markAllAsRead = () => {
Taro.showModal({
title: '提示',
content: '确定将所有消息标记为已读吗?',
success: async (res) => {
if (res.confirm) {
try {
await readAllShopMessage()
setMessages(prev =>
prev.map(msg => ({ ...msg, isRead: true }))
)
Taro.showToast({ title: '已全部标为已读', icon: 'success' })
} catch (err) {
console.error('全部标记已读失败', err)
Taro.showToast({ title: '操作失败', icon: 'none' })
}
}
}
})
}
// 获取类型图标
const getTypeIcon = (type: MessageType) => {
const iconMap: Record<MessageType, string> = {
'system': '🔔',
'order': '📦',
'activity': '🎁',
}
return iconMap[type] || '📋'
}
// 获取类型名称
const getTypeName = (type: MessageType) => {
const nameMap: Record<MessageType, string> = {
'system': '系统',
'order': '订单',
'activity': '活动',
}
return nameMap[type] || '其他'
}
// 格式化时间
const formatTime = (timeStr: string) => {
const date = new Date(timeStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) {
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `今天 ${hours}:${minutes}`
} else if (days === 1) {
return '昨天'
} else if (days < 7) {
return `${days}天前`
} else {
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
return `${month}-${day}`
}
}
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
{/* 顶部操作栏 */}
<View className='bg-white px-3 py-2 flex justify-between items-center border-b border-gray-100'>
<Text className='text-sm text-gray-500'>
{messages.length}
</Text>
<View onClick={markAllAsRead}>
<Text className='text-sm text-orange-500'></Text>
</View>
</View>
{/* 分类标签 */}
<View className='bg-white flex overflow-x-auto'>
{messageTypes.map(type => (
<View
key={type.value}
className={`flex-shrink-0 px-4 py-3 relative ${
activeType === type.value ? 'text-orange-500 font-medium' : 'text-gray-600'
}`}
onClick={() => setActiveType(type.value)}
>
<Text className='text-sm'>{type.label}</Text>
{activeType === type.value && (
<View className='absolute bottom-0 w-8 h-px bg-orange-500 rounded' style={{ left: '50%', transform: 'translateX(-50%)' }} />
)}
</View>
))}
</View>
{/* 消息列表 */}
<ScrollView scrollY className='flex-1'>
{messages.length === 0 ? (
<View className='text-center py-16'>
<Text className='text-4xl mb-3 block'>📭</Text>
<Text className='text-sm text-gray-400'></Text>
</View>
) : (
<View className='p-3'>
{messages.map(message => (
<View
key={message.id}
className={`bg-white rounded-lg p-4 mb-3 relative ${
!message.isRead ? 'border-l-4 border-orange-500' : ''
}`}
onClick={() => markAsRead(message.id)}
>
{/* 未读标识 */}
{!message.isRead && (
<View className='absolute top-4 right-4 w-2 h-2 bg-red-500 rounded-full' />
)}
{/* 消息头部 */}
<View className='flex items-center gap-2 mb-2'>
<Text className='text-lg'>{getTypeIcon(message.type)}</Text>
<Text className='text-sm font-medium text-gray-800 flex-1'>
{message.title}
</Text>
<Text className='text-xs text-gray-400'>
{formatTime(message.createdAt)}
</Text>
</View>
{/* 消息内容 */}
<Text className='text-sm text-gray-600 mb-2 block ml-7'>
{message.content}
</Text>
{/* 消息底部 */}
<View className='flex items-center justify-between ml-7'>
<View className='bg-gray-100 px-2 py-1 rounded'>
<Text className='text-xs text-gray-500'>
{getTypeName(message.type)}
</Text>
</View>
<View onClick={(e) => {
e.stopPropagation()
deleteMessage(message.id)
}}>
<Text className='text-xs text-gray-400'></Text>
</View>
</View>
</View>
))}
</View>
)}
</ScrollView>
</View>
)
}
export default MessageListPage