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,561 @@
import React, {useState, useEffect, useRef} from 'react'
import {View, Text, Input, Textarea, ScrollView} from '@tarojs/components'
import Taro, {useRouter} from '@tarojs/taro'
import {Address} from '@nutui/nutui-react-taro'
import {getShopUserAddress, addShopUserAddress, updateShopUserAddress} from '@/api/shop/shopUserAddress'
import type {ShopUserAddress} from '@/api/shop/shopUserAddress/model'
import {parseLngLatFromText} from '@/utils/geofence'
import RegionData from '@/api/json/regions-data.json'
definePageConfig({
navigationBarTitleText: '新增收货地址',
})
type SelectedLocation = { lng: string; lat: string; name?: string; address?: string }
/** 检查是否为定位权限拒绝错误 */
const isLocationDenied = (e: any) => {
const msg = String(e?.errMsg || e?.message || e || '')
return msg.includes('auth deny') || msg.includes('authorize') || msg.includes('permission') || msg.includes('denied') || msg.includes('scope.userLocation')
}
const isUserCancel = (e: any) => {
const msg = String(e?.errMsg || e?.message || e || '')
return msg.includes('cancel')
}
/** 检查经纬度是否有效 */
const hasValidLngLat = (addr?: Partial<ShopUserAddress> | null) => {
if (!addr) return false
const p = parseLngLatFromText(`${(addr as any)?.lng ?? ''},${(addr as any)?.lat ?? ''}`)
if (!p) return false
if (p.lng === 0 && p.lat === 0) return false
return true
}
const AddressEditPage: React.FC = () => {
const {id, fromWx} = useRouter().params
const isEditMode = !!id
const addressId = id ? Number(id) : undefined
const fromWxMode = fromWx === '1' || fromWx === 'true'
const [loading, setLoading] = useState(true)
const [formData, setFormData] = useState<Partial<ShopUserAddress>>({
name: '',
phone: '',
province: '',
city: '',
region: '',
address: '',
isDefault: false,
})
const [regionText, setRegionText] = useState('')
const [inputText, setInputText] = useState('')
const [selectedLocation, setSelectedLocation] = useState<SelectedLocation | null>(null)
const [regionLocked, setRegionLocked] = useState(false)
const [regionPickerVisible, setRegionPickerVisible] = useState(false)
const [regionOptions, setRegionOptions] = useState<any[]>([])
const wxDraftRef = useRef<Partial<ShopUserAddress> | null>(null)
const wxDraftPatchedRef = useRef(false)
/** 初始化省市区选择器数据 */
useEffect(() => {
const options = (RegionData as any[]).map((province) => ({
value: province.label,
text: province.label,
children: province.children?.map((city: any) => ({
value: city.label,
text: city.label,
children: city.children?.map((region: any) => ({
value: region.label,
text: region.label,
})) || [],
})) || [],
}))
setRegionOptions(options)
}, [])
/** 解析省市区 */
const parseRegion = (text: string) => {
for (const province of RegionData as any[]) {
if (text.includes(province.label)) {
const result: any = {province: province.label}
if (province.children) {
for (const city of province.children) {
if (text.includes(city.label)) {
result.city = city.label
if (city.children) {
for (const region of city.children) {
if (text.includes(region.label)) {
result.region = region.label
return result
}
}
}
return result
}
}
}
return result
}
}
return null
}
/** 解析地址文本(智能识别) */
const parseAddressText = (text: string) => {
const result: any = {}
// 手机号正则
const phoneRegex = /1[3-9]\d{9}/
const phoneMatch = text.match(phoneRegex)
if (phoneMatch) result.phone = phoneMatch[0]
// 姓名正则2-4个中文字符通常在开头
const nameRegex = /^[\u4e00-\u9fa5]{2,4}/
const nameMatch = text.match(nameRegex)
if (nameMatch) result.name = nameMatch[0]
// 省市区识别
const regionResult = parseRegion(text)
if (regionResult) {
result.province = regionResult.province
result.city = regionResult.city
result.region = regionResult.region
}
// 详细地址提取
let addressText = text
if (result.name) addressText = addressText.replace(result.name, '')
if (result.phone) addressText = addressText.replace(result.phone, '')
if (result.province) addressText = addressText.replace(result.province, '')
if (result.city) addressText = addressText.replace(result.city, '')
if (result.region) addressText = addressText.replace(result.region, '')
result.address = addressText.replace(/[,。\s]+/g, '').trim()
return result
}
/** 地址智能识别 */
const recognizeAddress = () => {
if (!inputText.trim()) {
Taro.showToast({title: '请输入要识别的文本', icon: 'none'})
return
}
try {
const result = parseAddressText(inputText)
const newFormData: any = {
...formData,
name: result.name || formData.name,
phone: result.phone || formData.phone,
address: result.address || formData.address,
}
if (!regionLocked) {
newFormData.province = result.province || formData.province
newFormData.city = result.city || formData.city
newFormData.region = result.region || formData.region
}
setFormData(newFormData)
if (!regionLocked && result.province && result.city && result.region) {
setRegionText(`${result.province} ${result.city} ${result.region}`)
}
Taro.showToast({title: regionLocked ? '识别成功(所在地区以定位为准)' : '识别成功', icon: 'success'})
setInputText('')
} catch {
Taro.showToast({title: '识别失败,请检查文本格式', icon: 'none'})
}
}
/** 选择定位 */
const chooseGeoLocation = async () => {
const applyChosenLocation = (res: any) => {
if (!res) return
if (res.latitude === undefined || res.longitude === undefined) {
Taro.showToast({title: '定位信息获取失败', icon: 'none'})
return
}
const next: SelectedLocation = {
lng: String(res.longitude),
lat: String(res.latitude),
name: res.name,
address: res.address,
}
// 尝试从地图返回的地址文本解析省市区
const regionResult = res?.provinceName || res?.cityName || res?.adName
? {
province: String(res.provinceName || ''),
city: String(res.cityName || ''),
region: String(res.adName || '')
}
: parseRegion(String(res.address || ''))
const province = String(regionResult?.province || '').trim()
const city = String(regionResult?.city || '').trim()
const region = String(regionResult?.region || '').trim()
if (!province || !city || !region) {
Taro.showToast({title: '定位未识别到所在地区,请重新选择定位', icon: 'none'})
return
}
setSelectedLocation(next)
setRegionLocked(true)
// 将地图选点的地址同步到"收货地址"(剥离省市区,避免重复)
const rawAddr = String(res.address || '').trim()
const name = String(res.name || '').trim()
let detail = rawAddr
for (const part of [province, city, region]) {
if (part) detail = detail.replace(part, '')
}
detail = detail.replace(/[,]+/g, ' ').replace(/\s+/g, ' ').trim()
const base = detail || rawAddr
const nextDetailAddress = (() => {
if (!base && !name) return ''
if (!base) return name
if (!name) return base
return base.includes(name) ? base : `${base} ${name}`
})()
setFormData(prev => ({
...prev,
lng: next.lng,
lat: next.lat,
address: nextDetailAddress || prev.address,
province,
city,
region,
}))
setRegionText(`${province} ${city} ${region}`)
}
try {
const initLat = selectedLocation?.lat ? Number(selectedLocation.lat) : undefined
const initLng = selectedLocation?.lng ? Number(selectedLocation.lng) : undefined
const latitude = typeof initLat === 'number' && Number.isFinite(initLat) ? initLat : undefined
const longitude = typeof initLng === 'number' && Number.isFinite(initLng) ? initLng : undefined
const res = await Taro.chooseLocation({latitude, longitude})
applyChosenLocation(res)
} catch (e: any) {
if (isUserCancel(e)) return
if (isLocationDenied(e)) {
try {
const modal = await Taro.showModal({
title: '需要定位权限',
content: '选择定位需要开启定位权限,请在设置中开启后重试。',
confirmText: '去设置',
})
if (modal.confirm) {
await Taro.openSetting()
const res = await Taro.chooseLocation({})
applyChosenLocation(res)
}
} catch (_e) { /* ignore */
}
return
}
Taro.showToast({title: '打开地图失败,请重试', icon: 'none'})
}
}
/** 打开地区选择器 */
const openRegionPicker = () => {
if (regionLocked) {
Taro.showToast({title: '所在地区已由定位确定,修改请重新选择定位', icon: 'none'})
return
}
setRegionPickerVisible(true)
}
/** 地区选择器确认 */
const handleRegionChange = (value: string[]) => {
if (regionLocked) {
Taro.showToast({title: '所在地区已由定位确定,修改请重新选择定位', icon: 'none'})
return
}
const [province, city, region] = value
setFormData(prev => ({...prev, province, city, region}))
setRegionText(value.join(' '))
}
/** 保存地址 */
const handleSave = async () => {
// 表单校验
if (!formData.name?.trim()) {
Taro.showToast({title: '请输入收货人姓名', icon: 'none'})
return
}
if (!formData.phone?.trim()) {
Taro.showToast({title: '请输入手机号', icon: 'none'})
return
}
if (!/^1[3-9]\d{9}$/.test(formData.phone)) {
Taro.showToast({title: '手机号格式不正确', icon: 'none'})
return
}
if (!formData.province || !formData.city || !formData.region) {
Taro.showToast({title: '请选择所在地区', icon: 'none'})
return
}
if (!formData.address?.trim()) {
Taro.showToast({title: '请输入详细地址', icon: 'none'})
return
}
// 经纬度检查(可选,不阻断保存流程)
const loc = selectedLocation || (hasValidLngLat(formData) ? {
lng: String(formData.lng),
lat: String(formData.lat)
} : null)
try {
const submitData: any = {
name: formData.name,
phone: formData.phone,
country: formData.country || '中国',
province: formData.province,
city: formData.city,
region: formData.region,
address: formData.address,
isDefault: formData.isDefault ?? true,
...(loc ? {lng: loc.lng, lat: loc.lat} : {}),
}
if (isEditMode && addressId) {
submitData.id = addressId
}
// 执行新增或更新
if (isEditMode) {
await updateShopUserAddress(submitData)
} else {
await addShopUserAddress(submitData)
}
Taro.showToast({title: `${isEditMode ? '更新' : '保存'}成功`, icon: 'success'})
setTimeout(() => Taro.navigateBack(), 1000)
} catch (error) {
console.error('保存失败:', error)
Taro.showToast({title: `${isEditMode ? '更新' : '保存'}失败`, icon: 'none'})
}
}
const updateField = (field: string, value: any) => {
setFormData(prev => ({...prev, [field]: value}))
}
/** 初始化加载 */
useEffect(() => {
const init = async () => {
// 动态设置页面标题
Taro.setNavigationBarTitle({
title: isEditMode ? '编辑收货地址' : (fromWxMode ? '完善收货地址' : '新增收货地址'),
})
// 微信地址导入
if (!isEditMode && fromWxMode && !wxDraftPatchedRef.current) {
try {
const draft = Taro.getStorageSync('WxAddressDraft')
if (draft) {
wxDraftPatchedRef.current = true
wxDraftRef.current = draft as any
Taro.removeStorageSync('WxAddressDraft')
setFormData(prev => ({...prev, ...(draft as any)}))
const p = String((draft as any)?.province || '').trim()
const c = String((draft as any)?.city || '').trim()
const r = String((draft as any)?.region || '').trim()
const rText = [p, c, r].filter(Boolean).join(' ')
if (rText) setRegionText(rText)
}
} catch (_e) { /* ignore */
}
}
// 编辑模式:加载已有地址数据
if (isEditMode && addressId) {
try {
const addr = await getShopUserAddress(addressId)
setFormData(addr)
setRegionText(`${addr.province || ''} ${addr.city || ''} ${addr.region || ''}`)
if (hasValidLngLat(addr)) {
setSelectedLocation({lng: String(addr.lng), lat: String(addr.lat)})
setRegionLocked(true)
}
} catch (error) {
console.error('加载地址失败:', error)
Taro.showToast({title: '加载地址失败', icon: 'none'})
}
}
setLoading(false)
}
init()
}, [])
if (loading) {
return (
<View className="flex items-center justify-center min-h-screen">
<Text className="text-gray-400 text-sm">...</Text>
</View>
)
}
return (
<View className="min-h-screen bg-gray-50 flex flex-col">
<ScrollView scrollY className="flex-1">
{/* 地址智能识别区 */}
<View className="bg-white px-3 pt-3 pb-2">
<View
className="rounded-lg p-2 relative"
style={{border: '1px dashed #0e932e'}}
>
<Textarea
style={{height: '80px', width: '100%', fontSize: '13px'}}
value={inputText}
onInput={e => setInputText(e.detail.value)}
placeholder="粘贴文本,点击「识别」自动识别收货人、地址、电话"
maxlength={200}
/>
<View
className="absolute right-2 bottom-2"
style={{
backgroundColor: '#0e932e',
borderRadius: '12px',
paddingLeft: '12px',
paddingRight: '12px',
paddingTop: '4px',
paddingBottom: '4px'
}}
onClick={recognizeAddress}
>
<Text className="text-white text-xs"></Text>
</View>
</View>
</View>
<View className="bg-gray-100" style={{height: '8px'}}/>
{/* 选择定位 */}
<View className="bg-white px-4 mb-2">
<View className="flex items-center py-3" onClick={chooseGeoLocation}>
<View className="flex-1">
<View className="flex items-center gap-1 mb-1">
<Text className="text-sm text-gray-700"></Text>
<Text className="text-gray-300" style={{fontSize: '10px'}}></Text>
</View>
<Text className="text-xs text-gray-400">
{selectedLocation?.address || (selectedLocation ? `经度:${selectedLocation.lng}, 纬度:${selectedLocation.lat}` : '用于判断是否超出配送范围')}
</Text>
{selectedLocation?.name && (
<Text className="text-xs text-green-600 mt-1">{selectedLocation.name}</Text>
)}
</View>
<Text className="text-gray-300 text-xs"></Text>
</View>
</View>
{/* 表单区 */}
<View className="bg-white px-4 py-2">
{/* 所在地区 */}
<View className="flex items-center py-3 border-b border-gray-50" onClick={openRegionPicker}>
<Text className="text-sm text-gray-700" style={{minWidth: '60px'}}></Text>
<View className="flex-1 flex items-center justify-between">
<Text className={`text-sm ${regionText ? 'text-gray-800' : 'text-gray-400'}`}>
{regionText || '请选择省市区'}
</Text>
<Text className="text-gray-300 text-xs"></Text>
</View>
</View>
{/* 详细地址 */}
<View className="flex items-center py-3 border-b border-gray-50">
<Text className="text-sm text-gray-700" style={{minWidth: '60px'}}></Text>
<Input
className="flex-1 text-sm"
placeholder="请输入详细收货地址"
maxLength={50}
value={formData.address || ''}
onInput={e => updateField('address', e.detail.value)}
/>
</View>
{/* 收货人 */}
<View className="flex items-center py-3 border-b border-gray-50">
<Text className="text-sm text-gray-700" style={{minWidth: '60px'}}></Text>
<Input
className="flex-1 text-sm"
placeholder="请输入收货人姓名"
maxLength={10}
value={formData.name || ''}
onInput={e => updateField('name', e.detail.value)}
/>
</View>
{/* 手机号 */}
<View className="flex items-center py-3 border-b border-gray-50">
<Text className="text-sm text-gray-700" style={{minWidth: '60px'}}></Text>
<Input
className="flex-1 text-sm"
placeholder="请输入手机号"
type="number"
maxlength={11}
value={formData.phone || ''}
onInput={e => updateField('phone', e.detail.value)}
/>
</View>
</View>
{/* 默认地址开关 */}
<View className="bg-white px-4 mt-2">
<View className="flex items-center justify-between py-3">
<Text className="text-sm text-gray-700"></Text>
<View
className={`rounded-full relative ${formData.isDefault ? 'bg-green-500' : 'bg-gray-300'}`}
style={{width: '40px', height: '22px'}}
onClick={() => updateField('isDefault', !formData.isDefault)}
>
<View
className={`absolute top-1 rounded-full bg-white ${formData.isDefault ? 'right-1' : 'left-1'}`}
style={{width: '16px', height: '16px'}}
/>
</View>
</View>
</View>
{/* 底部占位 */}
<View style={{height: '100px'}}/>
</ScrollView>
{/* 底部保存按钮 - 使用 flex 布局而非 fixed */}
<View className="bg-white border-t border-gray-200 px-4 py-3" style={{paddingBottom: '20px'}}>
<View
className="py-3 rounded-full text-center"
style={{backgroundColor: '#0e932e'}}
onClick={handleSave}
>
<Text className="text-white font-medium text-sm">{isEditMode ? '更新地址' : '保存并使用'}</Text>
</View>
</View>
{/* 省市区选择器 */}
<Address
visible={regionPickerVisible}
options={regionOptions}
title="选择所在地区"
onChange={(value) => handleRegionChange(value as string[])}
onClose={() => setRegionPickerVisible(false)}
/>
</View>
)
}
export default AddressEditPage

View File

@@ -0,0 +1,116 @@
import React from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro, { useDidShow } from '@tarojs/taro'
import { useAddress } from '@/hooks/useAddress'
import AddressCard from '@/components/business/AddressCard'
import EmptyState from '@/components/common/EmptyState'
definePageConfig({
navigationBarTitleText: '收货地址',
})
const AddressListPage: React.FC = () => {
const { addresses, loading, loadAddresses, deleteAddress, setDefault } = useAddress()
// 判断是否从结算页进来(选择地址模式)
const { from, select } = Taro.getCurrentInstance().router?.params || {}
const isSelectMode = select === '1' || from === 'checkout'
useDidShow(() => {
loadAddresses()
})
const handleDelete = (id: number) => {
Taro.showModal({
title: '提示',
content: '确定要删除该地址吗?',
confirmColor: '#0e932e',
success: async (res) => {
if (res.confirm) {
const success = await deleteAddress(id)
if (success) {
Taro.showToast({ title: '删除成功', icon: 'success' })
} else {
Taro.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
const handleEdit = (id: number) => {
Taro.navigateTo({ url: `/pages/user/address-edit?id=${id}` })
}
const handleAdd = () => {
Taro.navigateTo({ url: '/pages/user/address-edit' })
}
/** 选择地址:设为默认后返回上一页(结算页) */
const handleSelect = async (id: number) => {
if (!isSelectMode) return
// 选择模式:设为默认地址后返回
const addr = addresses.find(a => a.id === id)
if (!addr) return
if (!addr.isDefault) {
await setDefault(id)
}
// 通知结算页刷新地址
setTimeout(() => {
Taro.navigateBack()
}, 300)
}
return (
<View className="min-h-screen bg-gray-50 flex flex-col">
{/* 地址列表区域 */}
<ScrollView scrollY className="flex-1">
<View className="p-3 pb-20">
{loading ? (
<View className="flex items-center justify-center py-20">
<Text className="text-gray-400 text-sm">...</Text>
</View>
) : addresses.length === 0 ? (
<View className="pt-20">
<EmptyState
text="暂无收货地址"
actionText="添加地址"
onAction={handleAdd}
/>
</View>
) : (
addresses.map(addr => (
<AddressCard
key={addr.id}
address={addr}
selectMode={isSelectMode}
selected={!!addr.isDefault}
onClick={() => isSelectMode ? handleSelect(addr.id!) : handleEdit(addr.id!)}
showActions
onEdit={() => handleEdit(addr.id!)}
onDelete={() => handleDelete(addr.id!)}
onDefault={() => setDefault(addr.id!)}
/>
))
)}
</View>
</ScrollView>
{/* 底部固定按钮 */}
<View className="bg-white border-t border-gray-200 px-4 py-3" style={{ paddingBottom: '20px' }}>
<View
className="py-3 rounded-full text-center"
style={{ backgroundColor: '#0e932e' }}
onClick={handleAdd}
>
<Text className="text-white font-medium text-sm"></Text>
</View>
</View>
</View>
)
}
export default AddressListPage

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 { useRequest } from '@/hooks/useRequest'
import { pageUserBalanceLog, type UserBalanceLog } from '@/api/system/user/balance-log'
definePageConfig({
navigationBarTitleText: '余额明细',
})
// 场景类型映射
const SCENE_MAP: Record<number, string> = {
0: '充值',
1: '消费',
2: '退款',
3: '提现',
4: '收入',
5: '支出',
6: '转账',
7: '收款',
}
const tabs = [
{ label: '全部', value: -1 },
{ label: '收入', value: 1 },
{ label: '支出', value: 0 },
]
const BalanceLogPage: React.FC = () => {
const [activeTab, setActiveTab] = useState(-1)
const [page, setPage] = useState(1)
const [logs, setLogs] = useState<UserBalanceLog[]>([])
const [hasMore, setHasMore] = useState(true)
// 获取余额日志
const { run: fetchLogs, loading } = useRequest(pageUserBalanceLog, {
manual: true,
onSuccess: (data) => {
if (data?.list) {
if (page === 1) {
setLogs(data.list)
} else {
setLogs(prev => [...prev, ...data.list])
}
setHasMore(data.list.length >= 20)
}
},
onError: (err) => {
Taro.showToast({ title: err.message || '加载失败', icon: 'none' })
}
})
// 加载数据
const loadData = (pageNum: number = 1) => {
const params: any = { page: pageNum, limit: 20 }
if (activeTab === 1) {
params.moneyGt = 0 // 收入
} else if (activeTab === 0) {
params.moneyLt = 0 // 支出
}
fetchLogs(params)
}
// 初始化加载
useEffect(() => {
setPage(1)
loadData(1)
}, [activeTab])
// 加载更多
const loadMore = () => {
if (loading || !hasMore) return
const nextPage = page + 1
setPage(nextPage)
loadData(nextPage)
}
// 格式化金额
const formatMoney = (money?: string) => {
if (!money) return '0.00'
const num = parseFloat(money)
return num > 0 ? `+${num.toFixed(2)}` : num.toFixed(2)
}
// 获取场景描述
const getSceneText = (scene?: number) => {
return SCENE_MAP[scene || 0] || '其他'
}
return (
<View className='min-h-screen bg-gray-50'>
{/* Tab 栏 */}
<View className='bg-white flex'>
{tabs.map(tab => (
<View
key={tab.value}
className={`flex-1 text-center py-3 relative ${
activeTab === tab.value ? 'text-orange-500 font-medium' : 'text-gray-600'
}`}
onClick={() => setActiveTab(tab.value)}
>
<Text className='text-sm'>{tab.label}</Text>
{activeTab === tab.value && (
<View className='absolute bottom-0 left-0 right-0 flex justify-center'>
<View className='w-8 h-px bg-orange-500 rounded' />
</View>
)}
</View>
))}
</View>
{/* 日志列表 */}
<ScrollView
scrollY
className='flex-1'
onScrollToLower={loadMore}
>
{logs.length === 0 && !loading ? (
<View className='text-center py-16'>
<Text className='text-sm text-gray-400'></Text>
</View>
) : (
<View className='p-3'>
{logs.map(log => (
<View key={log.logId} className='bg-white rounded-lg p-4 mb-2'>
<View className='flex justify-between items-center'>
<View className='flex-1'>
<Text className='text-sm text-gray-800 block'>
{log.describe || getSceneText(log.scene)}
</Text>
<Text className='text-xs text-gray-400 mt-0 block'>{log.createTime}</Text>
</View>
<Text className={`text-base font-bold ${
parseFloat(log.money || '0') > 0 ? 'text-green-600' : 'text-red-500'
}`}>
{formatMoney(log.money)}
</Text>
</View>
{log.balance !== undefined && (
<View className='mt-2 pt-2 border-t border-gray-50'>
<Text className='text-xs text-gray-400'>
: ¥{parseFloat(String(log.balance)).toFixed(2)}
</Text>
</View>
)}
</View>
))}
{/* 加载更多 */}
{hasMore && (
<View className='text-center py-4'>
<Text className='text-xs text-gray-400'>
{loading ? '加载中...' : '上拉加载更多'}
</Text>
</View>
)}
{!hasMore && logs.length > 0 && (
<View className='text-center py-4'>
<Text className='text-xs text-gray-400'></Text>
</View>
)}
</View>
)}
</ScrollView>
</View>
)
}
export default BalanceLogPage

View File

@@ -0,0 +1,200 @@
.confirm-page {
display: flex;
flex-direction: column;
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 160rpx;
}
/* 地址卡片 */
.address-card {
position: relative;
background: #fff;
margin: 24rpx 24rpx 0;
border-radius: 16rpx;
padding: 24rpx 48rpx 24rpx 24rpx;
}
.addr-info {
display: flex;
align-items: center;
gap: 24rpx;
margin-bottom: 8rpx;
}
.addr-name {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.addr-phone {
font-size: 26rpx;
color: #666;
}
.addr-detail {
display: block;
font-size: 24rpx;
color: #666;
}
.addr-empty {
display: block;
font-size: 28rpx;
color: #ff6b35;
text-align: center;
padding: 8rpx 0;
}
.addr-arrow {
position: absolute;
right: 24rpx;
top: 50%;
transform: translateY(-50%);
font-size: 28rpx;
color: #ccc;
}
/* 权益包信息 */
.pkg-info-card {
background: #fff;
margin: 16rpx 24rpx 0;
border-radius: 16rpx;
overflow: hidden;
display: flex;
}
.pkg-img {
width: 200rpx;
height: 180rpx;
flex-shrink: 0;
}
.pkg-detail {
flex: 1;
padding: 20rpx 24rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.pkg-name {
display: block;
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 8rpx;
}
.pkg-desc {
display: block;
font-size: 22rpx;
color: #999;
line-height: 1.5;
margin-bottom: 8rpx;
}
.pkg-cost-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.cost-item {
font-size: 28rpx;
font-weight: bold;
}
.cost-item.points { color: #ff6b35; }
.cost-item.balance { color: #ff6b35; }
.cost-item.free { color: #07c160; }
/* 须知 */
.notice-card {
background: #fff;
margin: 16rpx 24rpx 0;
border-radius: 16rpx;
padding: 24rpx;
}
.notice-title {
display: block;
font-size: 26rpx;
font-weight: bold;
color: #333;
margin-bottom: 12rpx;
}
.notice-item {
display: block;
font-size: 22rpx;
color: #999;
line-height: 1.8;
}
/* 我的资产 */
.assets-card {
background: #fff;
margin: 16rpx 24rpx 0;
border-radius: 16rpx;
padding: 24rpx;
}
.assets-title {
display: block;
font-size: 26rpx;
font-weight: bold;
color: #333;
margin-bottom: 16rpx;
}
.assets-row {
display: flex;
gap: 48rpx;
}
.asset-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.asset-value {
font-size: 36rpx;
font-weight: bold;
color: #ff6b35;
line-height: 1.2;
}
.asset-label {
font-size: 22rpx;
color: #999;
margin-top: 6rpx;
}
/* 底部按钮 */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
padding: 24rpx;
box-shadow: 0 -2rpx 12rpx rgba(0,0,0,0.08);
}
.confirm-btn {
padding: 28rpx 0;
text-align: center;
background: linear-gradient(135deg, #ff6b35 0%, #ff9a56 100%);
color: #fff;
font-size: 32rpx;
font-weight: bold;
border-radius: 48rpx;
}
.confirm-btn.disabled {
opacity: 0.5;
}

View File

@@ -0,0 +1,178 @@
import { View, Text, Image } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { getBenefitPackageDetail, exchangeBenefitPackage } from '@/api/shop/shopMemberBenefit';
import { getDefaultAddress } from '@/api/shop/shopUserAddress';
import { getUserBalance } from '@/api/system/user/balance';
import './index.scss';
definePageConfig({
navigationBarTitleText: '确认兑换',
});
export default function BenefitExchangeConfirmPage() {
const router = useRouter();
const pkgId = Number(router.params.id);
const [pkg, setPkg] = useState<any>(null);
const [address, setAddress] = useState<any>(null);
const [userBalance, setUserBalance] = useState<string>('--');
const [userPoints, setUserPoints] = useState<string>('--');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
loadPackage();
loadAddress();
loadUserBalance();
}, []);
const loadPackage = async () => {
const res = await getBenefitPackageDetail(pkgId);
if (res.code === 200) setPkg(res.data);
};
const loadAddress = async () => {
const res = await getDefaultAddress();
if (res.code === 200 && res.data) setAddress(res.data);
};
const loadUserBalance = async () => {
try {
const data = await getUserBalance();
setUserBalance(data.balance || '0');
setUserPoints(String(data.points || 0));
} catch {
// ignore
}
};
const handleSelectAddress = () => {
Taro.navigateTo({ url: '/pages/user/address-list?select=1' });
};
const handleExchange = async () => {
if (!pkg) return;
if (pkg.needShip && !address) {
Taro.showToast({ title: '请先选择收货地址', icon: 'none' });
return;
}
Taro.showModal({
title: '确认兑换',
content: `确认兑换「${pkg.name}」?`,
success: async (res) => {
if (!res.confirm) return;
setSubmitting(true);
try {
const res2 = await exchangeBenefitPackage({
packageId: pkgId,
addressId: address?.id,
});
if (res2.code === 200) {
Taro.showToast({ title: '兑换申请成功', icon: 'success' });
setTimeout(() => {
Taro.redirectTo({ url: '/pages/user/benefit-exchange-list/index' });
}, 1500);
} else {
Taro.showToast({ title: res2.message || '兑换失败', icon: 'none' });
}
} catch (e) {
Taro.showToast({ title: '网络错误', icon: 'none' });
} finally {
setSubmitting(false);
}
},
});
};
if (!pkg) return null;
return (
<View className="confirm-page">
{/* 收货地址 */}
{pkg.needShip === 1 && (
<View className="address-card" onClick={handleSelectAddress}>
{address ? (
<>
<View className="addr-info">
<Text className="addr-name">{address.name}</Text>
<Text className="addr-phone">{address.phone}</Text>
</View>
<Text className="addr-detail">
{address.province}{address.city}{address.district}{address.detail}
</Text>
</>
) : (
<Text className="addr-empty">+ </Text>
)}
<Text className="addr-arrow">&gt;</Text>
</View>
)}
{/* 权益包信息 */}
<View className="pkg-info-card">
{pkg.coverImage && (
<Image className="pkg-img" src={pkg.coverImage} mode="aspectFill" />
)}
<View className="pkg-detail">
<Text className="pkg-name">{pkg.name}</Text>
{pkg.description && (
<Text className="pkg-desc">{pkg.description}</Text>
)}
<View className="pkg-cost-row">
{pkg.pointsCost > 0 && (
<Text className="cost-item points">{pkg.pointsCost} </Text>
)}
{pkg.balanceCost > 0 && (
<Text className="cost-item balance">¥{pkg.balanceCost}</Text>
)}
{!pkg.pointsCost && !pkg.balanceCost && (
<Text className="cost-item free"></Text>
)}
</View>
</View>
</View>
{/* 我的资产 */}
<View className="assets-card">
<Text className="assets-title"></Text>
<View className="assets-row">
{pkg.pointsCost > 0 && (
<View className="asset-item">
<Text className="asset-value">{userPoints}</Text>
<Text className="asset-label"></Text>
</View>
)}
{pkg.balanceCost > 0 && (
<View className="asset-item">
<Text className="asset-value">{userBalance}</Text>
<Text className="asset-label"></Text>
</View>
)}
</View>
</View>
{/* 须知 */}
<View className="notice-card">
<Text className="notice-title"></Text>
<Text className="notice-item">· </Text>
<Text className="notice-item">· </Text>
<Text className="notice-item">· </Text>
{pkg.validDays && (
<Text className="notice-item">· {pkg.validDays}</Text>
)}
</View>
{/* 底部按钮 */}
<View className="bottom-bar">
<View
className={`confirm-btn ${submitting ? 'disabled' : ''}`}
onClick={!submitting ? handleExchange : undefined}
>
{submitting ? '提交中...' : '确认兑换'}
</View>
</View>
</View>
);
}

View File

@@ -0,0 +1,158 @@
.exchange-list-page {
display: flex;
flex-direction: column;
height: 100vh;
background: #f5f5f5;
}
.tab-bar {
display: flex;
background: #fff;
border-bottom: 1rpx solid #eee;
}
.tab-item {
flex: 1;
text-align: center;
padding: 24rpx 0;
font-size: 26rpx;
color: #666;
position: relative;
}
.tab-item.active {
color: #ff6b35;
font-weight: bold;
}
.tab-item.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 4rpx;
background: #ff6b35;
border-radius: 2rpx;
}
.list-scroll {
flex: 1;
padding: 24rpx;
}
.exchange-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.pkg-name {
font-size: 28rpx;
font-weight: bold;
color: #333;
flex: 1;
}
.status-tag {
font-size: 22rpx;
padding: 4rpx 12rpx;
border-radius: 4rpx;
}
.pending { color: #fa8c16; background: rgba(250,140,22,.1); }
.shipped { color: #1890ff; background: rgba(24,144,255,.1); }
.done { color: #07c160; background: rgba(7,193,96,.1); }
.cancelled { color: #999; background: rgba(153,153,153,.1); }
.cost-row {
display: flex;
gap: 16rpx;
margin-bottom: 10rpx;
}
.cost-text {
font-size: 24rpx;
color: #ff6b35;
}
.address-row {
display: flex;
margin-bottom: 10rpx;
font-size: 22rpx;
color: #666;
}
.addr-label {
flex-shrink: 0;
color: #999;
}
.addr-val {
flex: 1;
line-height: 1.5;
}
.express-row {
display: flex;
align-items: center;
margin-bottom: 10rpx;
font-size: 22rpx;
color: #666;
}
.express-label {
flex-shrink: 0;
color: #999;
}
.express-val {
flex: 1;
color: #333;
}
.copy-btn {
color: #1890ff;
font-size: 22rpx;
padding: 4rpx 12rpx;
border: 1rpx solid #1890ff;
border-radius: 4rpx;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12rpx;
padding-top: 12rpx;
border-top: 1rpx solid #f5f5f5;
}
.create-time {
font-size: 22rpx;
color: #bbb;
}
.cancel-btn {
font-size: 24rpx;
color: #999;
padding: 6rpx 20rpx;
border: 1rpx solid #ddd;
border-radius: 24rpx;
}
.loading-tip {
text-align: center;
padding: 24rpx;
font-size: 24rpx;
color: #bbb;
}

View File

@@ -0,0 +1,170 @@
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { getMyExchangeList, cancelExchange } from '@/api/shop/shopMemberBenefit';
import EmptyState from '@/components/common/EmptyState';
import './index.scss';
definePageConfig({
navigationBarTitleText: '兑换记录',
});
const TABS = [
{ label: '全部', value: -1 },
{ label: '待处理', value: 0 },
{ label: '已发货', value: 1 },
{ label: '已完成', value: 2 },
];
const STATUS_TEXT: Record<number, string> = {
0: '待处理',
1: '已发货',
2: '已完成',
3: '已取消',
};
const STATUS_CLASS: Record<number, string> = {
0: 'pending',
1: 'shipped',
2: 'done',
3: 'cancelled',
};
export default function BenefitExchangeListPage() {
const [activeTab, setActiveTab] = useState(-1);
const [list, setList] = useState<any[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
useEffect(() => {
loadList(true);
}, [activeTab]);
const loadList = async (isRefresh = false) => {
if (loading) return;
setLoading(true);
const currentPage = isRefresh ? 1 : page;
try {
const params: any = { page: currentPage, limit: 20 };
if (activeTab >= 0) params.status = activeTab;
const res = await getMyExchangeList(params);
if (res.code === 200) {
const newList = res.data.list || [];
setList(isRefresh ? newList : [...list, ...newList]);
setPage(currentPage + 1);
setHasMore(newList.length >= 20);
}
} finally {
setLoading(false);
}
};
const handleCancel = (item: any) => {
Taro.showModal({
title: '取消兑换',
content: '确认取消该兑换申请吗?',
success: async (res) => {
if (!res.confirm) return;
const res2 = await cancelExchange(item.id);
if (res2.code === 200) {
Taro.showToast({ title: '已取消', icon: 'success' });
loadList(true);
}
},
});
};
const handleTrack = (item: any) => {
if (item.expressNo) {
Taro.setClipboardData({ data: item.expressNo });
Taro.showToast({ title: '快递单号已复制', icon: 'none' });
}
};
return (
<View className="exchange-list-page">
{/* 标签栏 */}
<View className="tab-bar">
{TABS.map((tab) => (
<View
key={tab.value}
className={`tab-item ${activeTab === tab.value ? 'active' : ''}`}
onClick={() => setActiveTab(tab.value)}
>
{tab.label}
</View>
))}
</View>
<ScrollView
className="list-scroll"
scrollY
onScrollToLower={() => hasMore && loadList()}
>
{!loading && list.length === 0 && (
<EmptyState description="暂无兑换记录" />
)}
{list.map((item) => (
<View key={item.id} className="exchange-card">
<View className="card-header">
<Text className="pkg-name">{item.packageName}</Text>
<Text className={`status-tag ${STATUS_CLASS[item.status]}`}>
{STATUS_TEXT[item.status]}
</Text>
</View>
<View className="cost-row">
{item.pointsCost > 0 && (
<Text className="cost-text"> {item.pointsCost} </Text>
)}
{item.balanceCost > 0 && (
<Text className="cost-text"> ¥{item.balanceCost}</Text>
)}
</View>
{item.addressSnapshot && (
<View className="address-row">
<Text className="addr-label"></Text>
<Text className="addr-val">
{(() => {
try {
const addr = JSON.parse(item.addressSnapshot);
return `${addr.name} ${addr.phone} ${addr.province}${addr.city}${addr.district}${addr.detail}`;
} catch {
return item.addressSnapshot;
}
})()}
</Text>
</View>
)}
{item.status === 1 && item.expressNo && (
<View className="express-row" onClick={() => handleTrack(item)}>
<Text className="express-label"></Text>
<Text className="express-val">
{item.expressCompany} {item.expressNo}
</Text>
<Text className="copy-btn"></Text>
</View>
)}
<View className="card-footer">
<Text className="create-time">{item.createTime}</Text>
{item.status === 0 && (
<View className="cancel-btn" onClick={() => handleCancel(item)}>
</View>
)}
</View>
</View>
))}
{loading && (
<View className="loading-tip">...</View>
)}
</ScrollView>
</View>
);
}

View File

@@ -0,0 +1,136 @@
.benefit-page {
display: flex;
flex-direction: column;
height: 100vh;
background: #f5f5f5;
}
.top-banner {
position: relative;
background: linear-gradient(135deg, #ff6b35 0%, #ff9a56 100%);
padding: 32rpx 32rpx 40rpx;
}
.banner-title {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #fff;
margin-bottom: 8rpx;
}
.banner-sub {
display: block;
font-size: 24rpx;
color: rgba(255,255,255,0.85);
}
.history-link {
position: absolute;
right: 32rpx;
top: 50%;
transform: translateY(-50%);
font-size: 24rpx;
color: rgba(255,255,255,0.9);
}
.pkg-list {
flex: 1;
padding: 24rpx;
}
.pkg-card {
background: #fff;
border-radius: 16rpx;
margin-bottom: 24rpx;
overflow: hidden;
}
.pkg-cover {
width: 100%;
height: 280rpx;
display: block;
}
.pkg-body {
padding: 24rpx;
}
.pkg-header {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 12rpx;
}
.pkg-name {
font-size: 30rpx;
font-weight: bold;
color: #333;
flex: 1;
}
.pkg-tag {
font-size: 20rpx;
color: #ff6b35;
background: rgba(255, 107, 53, 0.1);
padding: 4rpx 12rpx;
border-radius: 20rpx;
}
.pkg-desc {
display: block;
font-size: 24rpx;
color: #666;
line-height: 1.6;
margin-bottom: 16rpx;
}
.pkg-footer {
display: flex;
align-items: center;
gap: 16rpx;
}
.pkg-cost {
flex: 1;
display: flex;
align-items: baseline;
gap: 8rpx;
}
.cost-points {
font-size: 30rpx;
font-weight: bold;
color: #ff6b35;
}
.cost-balance {
font-size: 28rpx;
font-weight: bold;
color: #ff6b35;
}
.cost-free {
font-size: 30rpx;
font-weight: bold;
color: #07c160;
}
.pkg-stock {
font-size: 22rpx;
color: #999;
}
.exchange-btn {
padding: 16rpx 32rpx;
background: linear-gradient(135deg, #ff6b35 0%, #ff9a56 100%);
color: #fff;
font-size: 26rpx;
font-weight: bold;
border-radius: 32rpx;
}
.exchange-btn.disabled {
background: #ccc;
}

View File

@@ -0,0 +1,97 @@
import { View, Text, Image, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { getBenefitPackageList } from '@/api/shop/shopMemberBenefit';
import EmptyState from '@/components/common/EmptyState';
import './index.scss';
definePageConfig({
navigationBarTitleText: '会员权益包',
});
export default function BenefitPackagesPage() {
const [list, setList] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadList();
}, []);
const loadList = async () => {
try {
const res = await getBenefitPackageList();
if (res.code === 200) setList(res.data || []);
} finally {
setLoading(false);
}
};
const handleExchange = (pkg: any) => {
Taro.navigateTo({
url: `/pages/user/benefit-exchange-confirm/index?id=${pkg.id}`,
});
};
const handleHistory = () => {
Taro.navigateTo({ url: '/pages/user/benefit-exchange-list/index' });
};
return (
<View className="benefit-page">
{/* 顶部提示 */}
<View className="top-banner">
<Text className="banner-title"></Text>
<Text className="banner-sub"></Text>
<Text className="history-link" onClick={handleHistory}>
&gt;
</Text>
</View>
<ScrollView className="pkg-list" scrollY>
{!loading && list.length === 0 && (
<EmptyState description="暂无可用权益包" />
)}
{list.map((pkg) => (
<View key={pkg.id} className="pkg-card">
{pkg.coverImage && (
<Image className="pkg-cover" src={pkg.coverImage} mode="aspectFill" />
)}
<View className="pkg-body">
<View className="pkg-header">
<Text className="pkg-name">{pkg.name}</Text>
{pkg.tag && <Text className="pkg-tag">{pkg.tag}</Text>}
</View>
{pkg.description && (
<Text className="pkg-desc">{pkg.description}</Text>
)}
<View className="pkg-footer">
<View className="pkg-cost">
{pkg.pointsCost > 0 && (
<Text className="cost-points">{pkg.pointsCost}</Text>
)}
{pkg.balanceCost > 0 && (
<Text className="cost-balance">¥{pkg.balanceCost}</Text>
)}
{pkg.pointsCost === 0 && pkg.balanceCost === 0 && (
<Text className="cost-free"></Text>
)}
</View>
<View className="pkg-stock">
{pkg.stock === -1
? '不限库存'
: `剩余 ${pkg.stock}`}
</View>
<View
className={`exchange-btn ${pkg.stock === 0 ? 'disabled' : ''}`}
onClick={() => pkg.stock !== 0 && handleExchange(pkg)}
>
{pkg.stock === 0 ? '已兑完' : '立即兑换'}
</View>
</View>
</View>
</View>
))}
</ScrollView>
</View>
);
}

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '佣金明细',
}

View File

@@ -0,0 +1,159 @@
.commission-page {
display: flex;
flex-direction: column;
height: 100vh;
background: #f5f5f5;
}
.stats-card {
margin: 24rpx 24rpx 0;
padding: 32rpx;
background: linear-gradient(135deg, #ff6b35 0%, #ff9a56 100%);
border-radius: 16rpx;
position: relative;
}
.stats-row {
display: flex;
justify-content: space-between;
margin-bottom: 24rpx;
}
.stats-item {
display: flex;
flex-direction: column;
}
.stats-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 8rpx;
}
.stats-value {
font-size: 40rpx;
font-weight: bold;
color: #fff;
}
.stats-value.pending {
color: #fff;
}
.stats-value.settled {
color: rgba(255, 255, 255, 0.9);
}
.withdraw-btn {
position: absolute;
right: 32rpx;
top: 32rpx;
padding: 12rpx 32rpx;
background: #fff;
color: #ff6b35;
font-size: 26rpx;
font-weight: bold;
border-radius: 32rpx;
}
.tab-bar {
display: flex;
background: #fff;
margin-top: 24rpx;
border-bottom: 1rpx solid #eee;
}
.tab-item {
flex: 1;
text-align: center;
padding: 24rpx 0;
font-size: 28rpx;
color: #666;
position: relative;
}
.tab-item.active {
color: #ff6b35;
font-weight: bold;
}
.tab-item.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 48rpx;
height: 4rpx;
background: #ff6b35;
border-radius: 2rpx;
}
.commission-list {
flex: 1;
padding: 24rpx;
}
.commission-item {
background: #fff;
border-radius: 12rpx;
padding: 24rpx;
margin-bottom: 16rpx;
}
.commission-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.commission-order {
font-size: 26rpx;
color: #666;
}
.commission-amount {
font-size: 32rpx;
font-weight: bold;
color: #ff6b35;
}
.commission-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.meta-text {
font-size: 24rpx;
color: #999;
}
.status-tag {
font-size: 22rpx;
padding: 4rpx 12rpx;
border-radius: 4rpx;
}
.status-0 {
color: #ff9a56;
background: rgba(255, 154, 86, 0.1);
}
.status-1 {
color: #07c160;
background: rgba(7, 193, 96, 0.1);
}
.status-2 {
color: #999;
background: rgba(153, 153, 153, 0.1);
}
.settle-time {
font-size: 22rpx;
color: #999;
margin-top: 8rpx;
display: block;
}

View File

@@ -0,0 +1,151 @@
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { getCommissionStats, getMyCommissionList } from '@/api/shop/shopCommissionRecord';
import EmptyState from '@/components/common/EmptyState';
import { Loading } from '@nutui/nutui-react-taro';
import './index.scss';
definePageConfig({
navigationBarTitleText: '我的佣金',
});
export default function CommissionPage() {
const [stats, setStats] = useState<any>({});
const [list, setList] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [activeTab, setActiveTab] = useState(0); // 0待结算/1已结算
useEffect(() => {
loadStats();
loadList(true);
}, []);
const loadStats = async () => {
try {
const res = await getCommissionStats();
if (res.code === 200) {
setStats(res.data);
}
} catch (error) {
console.error('加载统计失败', error);
}
};
const loadList = async (isRefresh = false) => {
if (loading) return;
setLoading(true);
try {
const currentPage = isRefresh ? 1 : page;
const res = await getMyCommissionList({
status: activeTab,
page: currentPage,
limit: 20
});
if (res.code === 200) {
const newList = res.data.list || [];
setList(isRefresh ? newList : [...list, ...newList]);
setPage(currentPage + 1);
setHasMore(newList.length >= 20);
}
} catch (error) {
console.error('加载列表失败', error);
} finally {
setLoading(false);
}
};
const handleTabChange = (tab: number) => {
setActiveTab(tab);
setPage(1);
setHasMore(true);
loadList(true);
};
const handleWithdraw = () => {
Taro.navigateTo({ url: '/pages/user/withdraw/index' });
};
const getStatusText = (status: number) => {
const map: Record<number, string> = {
0: '待结算',
1: '已结算',
2: '已失效'
};
return map[status] || '未知';
};
return (
<View className="commission-page">
{/* 统计区域 */}
<View className="stats-card">
<View className="stats-row">
<View className="stats-item">
<Text className="stats-label">()</Text>
<Text className="stats-value pending">{stats.pendingTotal || '0.00'}</Text>
</View>
<View className="stats-item">
<Text className="stats-label">()</Text>
<Text className="stats-value settled">{stats.settledTotal || '0.00'}</Text>
</View>
</View>
<View className="withdraw-btn" onClick={handleWithdraw}>
</View>
</View>
{/* 标签页 */}
<View className="tab-bar">
<View
className={`tab-item ${activeTab === 0 ? 'active' : ''}`}
onClick={() => handleTabChange(0)}
>
({stats.pendingCount || 0})
</View>
<View
className={`tab-item ${activeTab === 1 ? 'active' : ''}`}
onClick={() => handleTabChange(1)}
>
({stats.settledCount || 0})
</View>
</View>
{/* 列表 */}
<ScrollView
className="commission-list"
scrollY
onScrollToLower={() => hasMore && loadList()}
>
{list.length === 0 && !loading ? (
<EmptyState description="暂无佣金记录" />
) : (
list.map((item) => (
<View key={item.id} className="commission-item">
<View className="commission-info">
<Text className="commission-order">: {item.orderNo}</Text>
<Text className="commission-amount">+{item.commissionAmount}</Text>
</View>
<View className="commission-meta">
<Text className="meta-text">: ¥{item.orderAmount}</Text>
<Text className={`status-tag status-${item.status}`}>
{getStatusText(item.status)}
</Text>
</View>
{item.settleTime && (
<Text className="settle-time">
: {item.settleTime}
</Text>
)}
</View>
))
)}
{loading && <Loading>...</Loading>}
</ScrollView>
</View>
);
}

View File

@@ -0,0 +1,259 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { pageShopUserCoupon, removeShopUserCoupon } from '@/api/shop/shopUserCoupon'
import type { ShopUserCoupon, ShopUserCouponParam } from '@/api/shop/shopUserCoupon/model'
import CouponCard from '@/components/business/CouponCard'
import EmptyState from '@/components/common/EmptyState'
import LoadMore from '@/components/common/LoadMore'
definePageConfig({
navigationBarTitleText: '我的优惠券',
})
// Tab 配置
type CouponTab = 'available' | 'used' | 'expired'
const tabList: { title: string; key: CouponTab }[] = [
{ title: '未使用', key: 'available' },
{ title: '已使用', key: 'used' },
{ title: '已过期', key: 'expired' },
]
interface TabState {
coupons: ShopUserCoupon[]
page: number
finished: boolean
loading: boolean
initialized: boolean
}
const CouponListPage: React.FC = () => {
// 根据 URL 参数初始化 tab
const { tab: initTab } = Taro.getCurrentInstance().router?.params || {}
const getInitTabIndex = () => {
switch (initTab) {
case 'used': return 1
case 'expired': return 2
default: return 0
}
}
const [tabIndex, setTabIndex] = useState(getInitTabIndex())
// 每个 tab 独立维护状态
const [tabStates, setTabStates] = useState<TabState[]>(
tabList.map(() => ({
coupons: [],
page: 1,
finished: false,
loading: false,
initialized: false,
}))
)
const currentState = tabStates[tabIndex]
const updateTabState = useCallback(
(index: number, partial: Partial<TabState>) => {
setTabStates(prev => {
const next = [...prev]
next[index] = { ...next[index], ...partial }
return next
})
},
[]
)
const loadCoupons = useCallback(
async (targetIndex: number, p: number) => {
const state = tabStates[targetIndex]
if (state.loading) return
updateTabState(targetIndex, { loading: true })
try {
const tabKey = tabList[targetIndex].key
const params: ShopUserCouponParam = { page: p, limit: 10 }
// 根据 tab 设置筛选条件
// status: 0=未使用, 1=已使用, 2=已过期
switch (tabKey) {
case 'available':
params.status = 0
break
case 'used':
params.status = 1
break
case 'expired':
params.status = 2
break
}
const res = await pageShopUserCoupon(params)
const list = res?.list || []
updateTabState(targetIndex, {
coupons: p === 1 ? list : [...state.coupons, ...list],
page: p,
finished: list.length < 10,
loading: false,
initialized: true,
})
} catch {
updateTabState(targetIndex, { loading: false, initialized: true })
}
},
[tabStates, updateTabState]
)
// 切换 tab 时加载数据
useEffect(() => {
if (!tabStates[tabIndex].initialized) {
loadCoupons(tabIndex, 1)
}
}, [tabIndex])
// 加载更多
const handleLoadMore = () => {
if (!currentState.finished && !currentState.loading) {
loadCoupons(tabIndex, currentState.page + 1)
}
}
// 切换 tab
const handleTabChange = (index: number) => {
if (index !== tabIndex) {
setTabIndex(index)
}
}
// 删除优惠券
const handleDelete = (id: string) => {
Taro.showModal({
title: '提示',
content: '确定要删除该优惠券吗?',
confirmColor: '#0e932e',
success: async (res) => {
if (res.confirm) {
try {
await removeShopUserCoupon(Number(id))
// 从当前 tab 移除
updateTabState(tabIndex, {
coupons: tabStates[tabIndex].coupons.filter(c => c.id !== id)
})
Taro.showToast({ title: '删除成功', icon: 'success' })
} catch {
Taro.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
// 获取空状态文本
const getEmptyText = () => {
switch (tabList[tabIndex].key) {
case 'available': return '暂无可用优惠券'
case 'used': return '暂无已使用优惠券'
case 'expired': return '暂无已过期优惠券'
}
}
// 获取空状态操作
const getEmptyAction = () => {
if (tabList[tabIndex].key === 'available') {
return {
text: '去领取',
action: () => Taro.switchTab({ url: '/pages/index/index' })
}
}
return null
}
// 判断是否禁用
const isDisabled = () => {
const key = tabList[tabIndex].key
return key === 'used' || key === 'expired'
}
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
{/* 自定义 Tabs 标题栏 */}
<View className='bg-white flex flex-row items-center border-b border-gray-100'>
{tabList.map((tab, index) => (
<View
key={tab.key}
className='flex-1 flex flex-col items-center justify-center py-3'
onClick={() => handleTabChange(index)}
>
<Text
className='text-sm font-medium'
style={{
color: index === tabIndex ? '#ee0a24' : '#666',
}}
>
{tab.title}
</Text>
{index === tabIndex && (
<View
className='mt-1 rounded-full'
style={{
width: '20px',
height: '3px',
backgroundColor: '#ee0a24',
}}
/>
)}
</View>
))}
</View>
{/* 内容区域 - 每个 tab 独立渲染 */}
<View className='flex-1 relative'>
{tabList.map((tab, idx) => (
<View
key={tab.key}
className='absolute inset-0'
style={{
display: idx === tabIndex ? 'flex' : 'none',
flexDirection: 'column',
}}
>
<ScrollView
scrollY
className='flex-1'
onScrollToLower={handleLoadMore}
lowerThreshold={100}
>
<View className='p-3'>
{tabStates[idx].coupons.length > 0 ? (
tabStates[idx].coupons.map(coupon => (
<CouponCard
key={coupon.id}
coupon={coupon}
disabled={isDisabled()}
showDelete={isDisabled()}
onDelete={() => handleDelete(coupon.id!)}
/>
))
) : tabStates[idx].initialized && !tabStates[idx].loading ? (
<EmptyState
text={getEmptyText()}
actionText={getEmptyAction()?.text}
onAction={getEmptyAction()?.action}
/>
) : null}
<LoadMore
loading={tabStates[idx].loading}
finished={tabStates[idx].finished}
/>
</View>
</ScrollView>
</View>
))}
</View>
</View>
)
}
export default CouponListPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '在线客服',
}

View File

@@ -0,0 +1,406 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'
import { View, Text, Button, Input, ScrollView } from '@tarojs/components'
import Taro, { useReachBottom } from '@tarojs/taro'
import NavBar from '@/components/NavBar'
import LoadMore from '@/components/common/LoadMore'
import EmptyState from '@/components/common/EmptyState'
import { pageShopChatConversation, addShopChatConversation } from '@/api/shop/shopChatConversation'
import { pageShopChatMessage, addShopChatMessage } from '@/api/shop/shopChatMessage'
import type { ShopChatConversation, ShopChatConversationParam } from '@/api/shop/shopChatConversation/model'
import type { ShopChatMessage } from '@/api/shop/shopChatMessage/model'
import type { PageResult } from '@/api'
definePageConfig({
navigationBarTitleText: '在线客服',
})
const SERVICE_ONLINE_HOURS = '9:00-21:00'
const SERVICE_HOTLINE = '400-888-8888'
const SERVICE_WECHAT = 'paopao_service'
const CustomerServicePage: React.FC = () => {
const [conversations, setConversations] = useState<ShopChatConversation[]>([])
const [currentConversation, setCurrentConversation] = useState<ShopChatConversation | null>(null)
const [messages, setMessages] = useState<ShopChatMessage[]>([])
const [inputMessage, setInputMessage] = useState('')
const [loading, setLoading] = useState(true)
const [sending, setSending] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [msgPage, setMsgPage] = useState(1)
const [showHistory, setShowHistory] = useState(false)
const scrollToBottom = useRef(false)
const isOnline = () => {
const now = new Date()
const h = now.getHours()
return h >= 9 && h < 21
}
// 加载会话列表
const loadConversations = useCallback(async () => {
setLoading(true)
try {
const params: ShopChatConversationParam = {
page: 1,
limit: 20,
order: 'desc',
sort: 'updateTime',
}
const result: PageResult<ShopChatConversation> = await pageShopChatConversation(params)
if (result?.list) {
setConversations(result.list)
if (result.list.length > 0) {
const latest = result.list[0]
setCurrentConversation(latest)
loadMessages(latest.id!, 1, false)
}
}
} catch (e) {
console.error('获取会话列表失败:', e)
setConversations([])
} finally {
setLoading(false)
}
}, [])
// 加载消息列表
const loadMessages = useCallback(async (conversationId: number, pageNum: number = 1, isLoadMore = false) => {
if (isLoadMore) setLoadingMore(true)
try {
const result = await pageShopChatMessage({
conversationId,
page: pageNum,
limit: 20,
order: 'asc',
sort: 'createTime',
} as any)
if (result?.list) {
if (isLoadMore) {
setMessages(prev => [...result.list!, ...prev])
} else {
setMessages(result.list)
scrollToBottom.current = true
}
setHasMore((result.list.length ?? 0) >= 20)
setMsgPage(pageNum)
}
} catch (e) {
console.error('获取消息列表失败:', e)
if (!isLoadMore) setMessages([])
} finally {
setLoadingMore(false)
}
}, [])
useEffect(() => {
loadConversations()
}, [loadConversations])
useReachBottom(() => {
if (hasMore && currentConversation && !loadingMore) {
loadMessages(currentConversation.id!, msgPage + 1, true)
}
})
// 创建新会话
const ensureConversation = async (): Promise<ShopChatConversation | null> => {
if (currentConversation) return currentConversation
try {
await addShopChatConversation({ type: 0 })
// 重新拉取会话列表获取新会话
const result = await pageShopChatConversation({ page: 1, limit: 20, order: 'desc', sort: 'updateTime' })
if (result?.list && result.list.length > 0) {
const conv = result.list[0]
setConversations(result.list)
setCurrentConversation(conv)
return conv
}
} catch (e) {
console.error('创建会话失败:', e)
}
return null
}
// 发送消息
const handleSendMessage = async () => {
const text = inputMessage.trim()
if (!text) {
Taro.showToast({ title: '请输入消息内容', icon: 'none' })
return
}
setSending(true)
setInputMessage('')
try {
const conv = await ensureConversation()
if (!conv?.id) {
Taro.showToast({ title: '无法创建会话,请稍后重试', icon: 'none' })
setInputMessage(text)
return
}
await addShopChatMessage({
content: text,
type: 'text',
toUserId: 0, // 发送给客服(服务端路由)
} as any)
// 消息发送成功后刷新消息列表
await loadMessages(conv.id, 1, false)
// 同步更新会话列表 lastMessage
setConversations(prev => prev.map(c =>
c.id === conv.id ? { ...c, lastMessage: text, updateTime: new Date().toISOString() } : c
))
} catch (e: any) {
console.error('发送消息失败:', e)
setInputMessage(text)
Taro.showToast({ title: e?.message || '发送失败,请重试', icon: 'none' })
} finally {
setSending(false)
}
}
// 拨打热线
const handleCallHotline = () => {
Taro.makePhoneCall({ phoneNumber: SERVICE_HOTLINE })
}
// 复制微信号
const handleCopyWechat = () => {
Taro.setClipboardData({
data: SERVICE_WECHAT,
success: () => Taro.showToast({ title: '已复制微信号', icon: 'success' }),
})
}
// 跳转帮助中心
const handleGoToHelp = () => {
Taro.navigateTo({ url: '/pages/user/help-center/index' })
}
// 选择会话
const handleSelectConversation = (conv: ShopChatConversation) => {
setCurrentConversation(conv)
setMessages([])
setMsgPage(1)
setHasMore(true)
loadMessages(conv.id!, 1, false)
}
const formatTime = (timeStr?: string) => {
if (!timeStr) return ''
const date = new Date(timeStr)
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
return `${m}-${d} ${h}:${min}`
}
const online = isOnline()
return (
<View className="flex flex-col min-h-screen bg-gray-100">
<NavBar title="在线客服" />
<ScrollView scrollY className="flex-1">
{/* 客服状态栏 */}
<View className="bg-white px-4 py-3 flex items-center justify-between">
<View className="flex items-center gap-2">
<View
className="w-2 h-2 rounded-full"
style={{ backgroundColor: online ? '#52c41a' : '#d1d5db' }}
/>
<Text className="text-sm text-gray-700">{online ? '客服在线' : '客服离线'}</Text>
</View>
<Text className="text-xs text-gray-400">: {SERVICE_ONLINE_HOURS}</Text>
</View>
{/* 微信在线客服按钮 */}
<View className="px-4 py-3 bg-white border-t border-gray-50">
<Button
openType="contact"
className="w-full h-12 rounded-lg text-white font-medium text-base border-0"
style={{ backgroundColor: '#07c160', lineHeight: '48px' }}
>
线
</Button>
</View>
{/* 消息记录入口 */}
{!showHistory && (
<View className="bg-white mx-4 mt-4 p-4 rounded-lg">
<View
className="flex items-center justify-between"
onClick={() => setShowHistory(true)}
>
<View className="flex items-center gap-2">
<Text className="text-lg">💬</Text>
<Text className="text-sm text-gray-700">
{conversations.length > 0 ? `${conversations.length}条会话)` : ''}
</Text>
</View>
<Text className="text-gray-400 text-sm"></Text>
</View>
</View>
)}
{/* 历史会话与消息列表 */}
{showHistory && (
<View className="mx-4 mt-4">
<View className="flex items-center justify-between mb-2">
<Text className="text-sm font-medium text-gray-700"></Text>
<Text
className="text-sm"
style={{ color: '#3b82f6' }}
onClick={() => setShowHistory(false)}
>
</Text>
</View>
{loading ? (
<LoadMore loading />
) : conversations.length === 0 ? (
<EmptyState message="暂无历史会话" small />
) : (
<View>
{conversations.map(conv => (
<View
key={conv.id}
className="bg-white p-3 rounded-lg mb-2"
style={{ border: currentConversation?.id === conv.id ? '2px solid #10b981' : 'none' }}
onClick={() => handleSelectConversation(conv)}
>
<View className="flex justify-between items-center">
<Text className="text-sm text-gray-700" style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{conv.content || conv.lastMessage || '暂无消息'}
</Text>
<Text className="text-xs text-gray-400 ml-2">{formatTime(conv.updateTime)}</Text>
</View>
</View>
))}
</View>
)}
{/* 当前会话的消息 */}
{currentConversation && messages.length > 0 && (
<View className="bg-white rounded-lg p-3 mb-4">
<Text className="text-xs text-gray-400 mb-3 block"></Text>
{loadingMore && <LoadMore loading />}
{messages.map(msg => (
<View
key={msg.id}
className={`flex mb-3 ${msg.formUserId === currentConversation.userId ? 'justify-end' : 'justify-start'}`}
>
<View
className="px-3 py-2 rounded-lg max-w-xs"
style={{
backgroundColor: msg.formUserId === currentConversation.userId ? '#10b981' : '#f3f4f6',
maxWidth: '70%',
}}
>
<Text
className="text-sm"
style={{ color: msg.formUserId === currentConversation.userId ? '#fff' : '#374151' }}
>
{msg.content}
</Text>
<Text
className="text-xs block mt-1"
style={{ color: msg.formUserId === currentConversation.userId ? 'rgba(255,255,255,0.7)' : '#9ca3af' }}
>
{formatTime(msg.createTime)}
</Text>
</View>
</View>
))}
</View>
)}
</View>
)}
{/* 其他联系方式 */}
<View className="bg-white mx-4 mt-4 p-4 rounded-lg">
<Text className="text-base font-medium text-gray-800 mb-3 block"></Text>
<View
className="flex items-center justify-between py-3 border-b border-gray-50"
onClick={handleCallHotline}
>
<View className="flex items-center gap-2">
<Text className="text-lg">📞</Text>
<View>
<Text className="text-sm text-gray-700 block">线</Text>
<Text className="text-xs text-gray-400">{SERVICE_HOTLINE}</Text>
</View>
</View>
<Text className="text-xs" style={{ color: '#3b82f6' }}></Text>
</View>
<View
className="flex items-center justify-between py-3"
onClick={handleCopyWechat}
>
<View className="flex items-center gap-2">
<Text className="text-lg">💬</Text>
<View>
<Text className="text-sm text-gray-700 block"></Text>
<Text className="text-xs text-gray-400">{SERVICE_WECHAT}</Text>
</View>
</View>
<Text className="text-xs" style={{ color: '#3b82f6' }}></Text>
</View>
</View>
{/* 帮助中心入口 */}
<View className="bg-white mx-4 mt-4 p-4 rounded-lg mb-4">
<Text className="text-base font-medium text-gray-800 mb-3 block"></Text>
<View
className="flex items-center justify-between py-2"
onClick={handleGoToHelp}
>
<Text className="text-sm text-gray-700"></Text>
<Text className="text-gray-400 text-sm"></Text>
</View>
</View>
{/* 留言(离线时) */}
{!online && (
<View className="bg-white mx-4 mb-4 p-4 rounded-lg">
<Text className="text-base font-medium text-gray-800 mb-3 block">线</Text>
<Text className="text-sm text-gray-400 block">
线线
</Text>
</View>
)}
</ScrollView>
{/* 消息输入区 - 始终显示,支持发送留言 */}
<View className="bg-white border-t border-gray-200 px-4 py-3 flex items-center gap-2">
<Input
className="flex-1 border border-gray-200 rounded-full text-sm"
style={{ paddingLeft: '16px', paddingRight: '16px', height: '40px', lineHeight: '40px' }}
placeholder={online ? '输入消息...' : '留言给客服...'}
value={inputMessage}
onInput={(e: any) => setInputMessage(e.detail.value)}
onConfirm={handleSendMessage}
disabled={sending}
/>
<View
className="rounded-full flex items-center justify-center"
style={{
width: '72px',
height: '40px',
backgroundColor: sending ? '#d1d5db' : '#10b981',
}}
onClick={sending ? undefined : handleSendMessage}
>
<Text className="text-sm text-white">{sending ? '发送中' : '发送'}</Text>
</View>
</View>
</View>
)
}
export default CustomerServicePage

View File

@@ -0,0 +1,97 @@
import React, { useState, useEffect } from 'react'
import { View, Text, Image, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { listShopGoodsFavorite } from '@/api/shop/shopGoodsFavorite'
import type { ShopGoodsFavorite } from '@/api/shop/shopGoodsFavorite/model'
import EmptyState from '@/components/common/EmptyState'
import LoadMore from '@/components/common/LoadMore'
import Price from '@/components/common/Price'
definePageConfig({
navigationBarTitleText: '我的收藏',
})
const FavoriteListPage: React.FC = () => {
const [list, setList] = useState<ShopGoodsFavorite[]>([])
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [finished, setFinished] = useState(false)
useEffect(() => {
loadFavorites(1)
}, [])
const loadFavorites = async (p: number) => {
if (loading) return
setLoading(true)
try {
const res = await listShopGoodsFavorite({ page: p, limit: 20 })
const newList = res || []
if (p === 1) {
setList(newList)
} else {
setList(prev => [...prev, ...newList])
}
setFinished(newList.length < 20)
setPage(p)
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' })
}
setLoading(false)
}
const handleItemClick = (goodsId: number) => {
Taro.navigateTo({ url: `/pages/shop/product-detail?id=${goodsId}` })
}
const handleLoadMore = () => {
if (!finished && !loading) {
loadFavorites(page + 1)
}
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView
scrollY
className='h-screen'
onScrollToLower={handleLoadMore}
lowerThreshold={100}
>
<View className='p-3'>
{list.length > 0 ? (
<View className='grid grid-cols-2 gap-3'>
{list.map(item => (
<View
key={item.favoriteId}
className='bg-white rounded-lg overflow-hidden'
onClick={() => handleItemClick(item.goodsId!)}
>
<Image
className='w-full'
style={{ height: '160px' }}
src={item.goodsImage}
mode='aspectFill'
/>
<View className='p-2'>
<Text className='text-sm text-gray-800 line-clamp-2 block'>
{item.goodsName}
</Text>
<View className='mt-1'>
<Price price={item.salePrice || '0'} size='small' loginMask />
</View>
</View>
</View>
))}
</View>
) : (
!loading && <EmptyState text='暂无收藏' />
)}
<LoadMore loading={loading} finished={finished} />
</View>
</ScrollView>
</View>
)
}
export default FavoriteListPage

View File

@@ -0,0 +1,146 @@
import React, { useState } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import EmptyState from '@/components/common/EmptyState'
definePageConfig({
navigationBarTitleText: '帮助中心',
})
interface HelpCategory {
id: number
title: string
icon: string
questions: HelpQuestion[]
}
interface HelpQuestion {
id: number
title: string
content: string
}
const helpData: HelpCategory[] = [
{
id: 1,
title: '购物指南',
icon: '🛍️',
questions: [
{ id: 101, title: '如何下单购买商品?', content: '您可以在商品详情页点击"立即购买"或"加入购物车"进行购买。支持微信支付、余额支付等多种支付方式。' },
{ id: 102, title: '购物车有什么用?', content: '购物车可以临时存放您想要购买的商品,您可以随时调整商品数量、删除商品或去结算。' },
{ id: 103, title: '如何查看我的订单?', content: '在"我的"页面点击"我的订单"即可查看所有订单,包括待付款、待发货、待收货等不同状态的订单。' },
]
},
{
id: 2,
title: '支付与退款',
icon: '💰',
questions: [
{ id: 201, title: '支持哪些支付方式?', content: '我们支持微信支付、支付宝支付(如有)、货到付款、积分抵扣等多种支付方式。' },
{ id: 202, title: '如何申请退款?', content: '在订单详情页点击"申请退款"按钮,填写退款原因和说明,提交后等待商家审核。' },
{ id: 203, title: '退款多久到账?', content: '退款审核通过后原路退回您的支付账户。微信支付一般1-3个工作日到账余额支付即时到账。' },
]
},
{
id: 3,
title: '配送与物流',
icon: '🚚',
questions: [
{ id: 301, title: '订单多久发货?', content: '一般情况下订单会在24小时内发货。特殊情况下可能会延迟我们会及时通知您。' },
{ id: 302, title: '如何查看物流信息?', content: '在订单详情页可以看到"查看物流"按钮,点击即可查看详细的物流配送信息。' },
{ id: 303, title: '可以指定送货时间吗?', content: '目前暂不支持指定送货时间,但您可以在订单备注中说明您的配送偏好,我们会尽量安排。' },
]
},
{
id: 4,
title: '会员与积分',
icon: '⭐',
questions: [
{ id: 401, title: '如何获得积分?', content: '您可以通过每日签到、购物消费、邀请好友等方式获得积分。积分可以在积分商城兑换商品或抵扣现金。' },
{ id: 402, title: '会员有什么特权?', content: '不同等级的会员享受不同的折扣优惠、专属客服、生日礼物等特权。会员等级越高,享受的特权越多。' },
{ id: 403, title: '积分会过期吗?', content: '积分有效期为一年,到期后未使用的积分将自动清零。请及时使用您的积分。' },
]
},
{
id: 5,
title: '账户与安全',
icon: '🔒',
questions: [
{ id: 501, title: '如何修改密码?', content: '在"我的"->"设置"->"修改密码"中,输入原密码和新密码即可完成修改。' },
{ id: 502, title: '忘记密码怎么办?', content: '在登录页面点击"忘记密码",通过手机号验证后可以重置密码。' },
{ id: 503, title: '如何保护账户安全?', content: '建议您设置复杂的密码、开启登录验证、不要将账户信息透露给他人,定期修改密码。' },
]
}
]
const HelpCenterPage: React.FC = () => {
const [expandedId, setExpandedId] = useState<number | null>(null)
const handleQuestionClick = (question: HelpQuestion) => {
Taro.navigateTo({
url: `/pages/user/help-detail/index?id=${question.id}&title=${encodeURIComponent(question.title)}&content=${encodeURIComponent(question.content)}`
})
}
const toggleCategory = (categoryId: number) => {
setExpandedId(expandedId === categoryId ? null : categoryId)
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY className='h-screen'>
<View className='p-3'>
{/* 搜索框(预留) */}
<View className='bg-white rounded-lg p-3 mb-3 flex items-center'>
<Text className='text-gray-400 text-sm'>🔍 ...</Text>
</View>
{/* 分类列表 */}
{helpData.map(category => (
<View key={category.id} className='bg-white rounded-lg mb-3 overflow-hidden'>
<View
className='p-4 flex items-center justify-between'
onClick={() => toggleCategory(category.id)}
>
<View className='flex items-center gap-2'>
<Text className='text-xl'>{category.icon}</Text>
<Text className='text-base font-medium text-gray-800'>{category.title}</Text>
</View>
<Text className='text-gray-400'>
{expandedId === category.id ? '▲' : '▼'}
</Text>
</View>
{expandedId === category.id && (
<View className='border-t border-gray-100'>
{category.questions.map((question, index) => (
<View
key={question.id}
className={`p-4 ${index < category.questions.length - 1 ? 'border-b border-gray-50' : ''}`}
onClick={() => handleQuestionClick(question)}
>
<Text className='text-sm text-gray-600'>{question.title}</Text>
</View>
))}
</View>
)}
</View>
))}
{/* 联系客服入口 */}
<View
className='bg-white rounded-lg p-4 flex items-center justify-center gap-2 mt-3'
onClick={() => Taro.navigateTo({ url: '/pages/user/customer-service/index' })}
>
<Text className='text-xl'>📞</Text>
<Text className='text-sm text-blue-500'>线</Text>
</View>
<View className='h-4'></View>
</View>
</ScrollView>
</View>
)
}
export default HelpCenterPage

View File

@@ -0,0 +1,50 @@
import React, { useEffect, useState } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro, { useRouter } from '@tarojs/taro'
definePageConfig({
navigationBarTitleText: '问题详情',
})
const HelpDetailPage: React.FC = () => {
const router = useRouter()
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
useEffect(() => {
const { title: t, content: c } = router.params
if (t) setTitle(decodeURIComponent(t))
if (c) setContent(decodeURIComponent(c))
}, [router.params])
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY className='h-screen'>
<View className='p-4'>
<View className='bg-white rounded-lg p-4'>
<Text className='text-lg font-medium text-gray-800 block mb-4'>
{title}
</Text>
<View className='border-t border-gray-100 pt-4'>
<Text className='text-sm text-gray-600 leading-7'>
{content}
</Text>
</View>
</View>
{/* 相关问题推荐(预留) */}
<View className='mt-4 bg-white rounded-lg p-4'>
<Text className='text-base font-medium text-gray-800 mb-3 block'>
</Text>
<Text className='text-sm text-gray-400'>
</Text>
</View>
</View>
</ScrollView>
</View>
)
}
export default HelpDetailPage

View File

@@ -0,0 +1,113 @@
import React, { useState, useEffect } from 'react'
import { View, Text, Image, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import EmptyState from '@/components/common/EmptyState'
import LoadMore from '@/components/common/LoadMore'
definePageConfig({
navigationBarTitleText: '浏览历史',
})
interface HistoryItem {
goodsId: number
name?: string
image?: string
price?: string
timestamp: number
}
const BrowseHistoryPage: React.FC = () => {
const [list, setList] = useState<HistoryItem[]>([])
useEffect(() => {
loadHistory()
}, [])
const loadHistory = () => {
try {
const history = Taro.getStorageSync('browse_history') || []
setList(history)
} catch {
setList([])
}
}
const handleItemClick = (goodsId: number) => {
Taro.navigateTo({ url: `/pages/shop/product-detail?id=${goodsId}` })
}
const handleClearHistory = () => {
Taro.showModal({
title: '提示',
content: '确定要清空浏览历史吗?',
success: (res) => {
if (res.confirm) {
Taro.removeStorageSync('browse_history')
setList([])
Taro.showToast({ title: '已清空', icon: 'success' })
}
}
})
}
const formatTime = (timestamp: number) => {
const date = new Date(timestamp)
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
}
return (
<View className='min-h-screen bg-gray-50'>
{/* 顶部操作栏 */}
{list.length > 0 && (
<View className='bg-white px-4 py-2 flex justify-end border-b border-gray-100'>
<Text
className='text-sm text-red-500'
onClick={handleClearHistory}
>
</Text>
</View>
)}
<ScrollView scrollY className='h-screen'>
<View className='p-3'>
{list.length > 0 ? (
<View className='grid grid-cols-2 gap-3'>
{list.map(item => (
<View
key={`${item.goodsId}-${item.timestamp}`}
className='bg-white rounded-lg overflow-hidden'
onClick={() => handleItemClick(item.goodsId)}
>
<Image
className='w-full'
style={{ height: '160px' }}
src={item.image}
mode='aspectFill'
/>
<View className='p-2'>
<Text className='text-sm text-gray-800 line-clamp-2 block'>
{item.name}
</Text>
<View className='flex items-center justify-between mt-1'>
<Text className='text-red-500 text-sm font-medium'>
¥{item.price || '0'}
</Text>
<Text className='text-xs text-gray-400'>
{formatTime(item.timestamp)}
</Text>
</View>
</View>
</View>
))}
</View>
) : (
<EmptyState text='暂无浏览历史' />
)}
</View>
</ScrollView>
</View>
)
}
export default BrowseHistoryPage

View File

@@ -0,0 +1,233 @@
import React from 'react'
import { View, Text, Image, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import MemberBadge from '@/components/business/MemberBadge'
const UserPage: React.FC = () => {
const { user, isLoggedIn } = useUser()
const menuItems = [
{ icon: '📦', label: '我的订单', url: '/pages/order/list' },
{ icon: '📅', label: '预约订单', url: '/pages/booking/list' },
{ icon: '🏆', label: '赛事活动', url: '/pages/event/my/index' },
{ icon: '🎫', label: '优惠券', url: '/pages/user/coupon-list' },
{ icon: '💰', label: '我的钱包', url: '/pages/user/wallet' },
{ icon: '📍', label: '收货地址', url: '/pages/user/address-list' },
{ icon: '⭐', label: '积分明细', url: '/pages/user/points-record' },
{ icon: '🔔', label: '消息通知', url: '/pages/index/notification' },
{ icon: '⚙️', label: '设置', url: '/pages/user/setting' },
]
const handleLogin = () => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
}
}
return (
<View className='min-h-screen bg-gray-100 flex flex-col'>
<ScrollView scrollY className='flex-1'>
{/* 顶部渐变背景区域 */}
<View className='relative'>
{/* 渐变背景 — 绿色系 */}
<View
className='absolute inset-0'
style={{
background: 'linear-gradient(135deg, #0d9488 0%, #059669 50%, #10b981 100%)',
}}
/>
{/* 装饰圆形 */}
<View
className='absolute'
style={{
width: '240px',
height: '240px',
borderRadius: '120px',
background: 'rgba(255, 255, 255, 0.08)',
top: '-100px',
right: '-80px',
}}
/>
<View
className='absolute'
style={{
width: '180px',
height: '180px',
borderRadius: '90px',
background: 'rgba(255, 255, 255, 0.06)',
top: '-60px',
left: '-60px',
}}
/>
<View
className='absolute'
style={{
width: '120px',
height: '120px',
borderRadius: '60px',
background: 'rgba(255, 255, 255, 0.05)',
bottom: '20px',
right: '-30px',
}}
/>
{/* 用户信息卡片 */}
<View className='relative mx-3 mt-3 p-4 rounded-2xl' style={{
background: 'rgba(255, 255, 255, 0.15)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.2)',
}}>
<View className='flex items-center gap-4' onClick={handleLogin}>
{/* 头像区域 */}
<View className='relative'>
{isLoggedIn && user?.avatar ? (
<Image
className='w-16 h-16 rounded-full border-3 border-white/30'
style={{ boxShadow: '0 4px 16px rgba(0, 0, 0, 0.2)' }}
src={user.avatar}
mode='aspectFill'
/>
) : (
<View
className='w-16 h-16 rounded-full border-3 border-white/30 flex items-center justify-center'
style={{ background: 'rgba(255, 255, 255, 0.25)', boxShadow: '0 4px 16px rgba(0, 0, 0, 0.2)' }}
>
<Text className='text-2xl text-white'>👤</Text>
</View>
)}
{/* VIP标识 */}
{isLoggedIn && user?.memberLevelName && (
<View
className='absolute -bottom-1 -right-1 px-1 py-1 rounded text-xs text-white'
style={{ background: 'linear-gradient(135deg, #fbbf24, #f59e0b)' }}
>
VIP
</View>
)}
</View>
{/* 用户信息 */}
<View className='flex-1'>
<View className='flex items-center gap-2'>
<Text className='text-lg font-bold text-white'>
{isLoggedIn ? (user?.nickname || user?.phone || '用户') : '点击登录'}
</Text>
{isLoggedIn && user?.memberLevelName && (
<MemberBadge levelName={user.memberLevelName} />
)}
</View>
{isLoggedIn ? (
<Text className='text-xs text-white/60 mt-1'>ID: {(user as any)?.id || '暂无'}</Text>
) : (
<Text className='text-xs text-white/60 mt-1'></Text>
)}
</View>
{/* 箭头 */}
<View className='w-6 h-6 rounded-full bg-white/20 flex items-center justify-center'>
<Text className='text-white/80 text-xs'>{'>'}</Text>
</View>
</View>
{/* 数据概览 — 4列白色文字 */}
{isLoggedIn && (
<View
className='grid grid-cols-4 gap-2 mt-4 pt-4'
style={{ borderTop: '1px solid rgba(255, 255, 255, 0.2)' }}
>
<View
className='text-center py-2'
onClick={() => Taro.navigateTo({ url: '/pages/user/wallet' })}
>
<Text className='text-xl font-bold text-white'>
¥{((user as any)?.balance || '0.00')}
</Text>
<Text className='text-xs text-white/70 mt-1 block'></Text>
</View>
<View
className='text-center py-2'
onClick={() => Taro.navigateTo({ url: '/pages/points/index' })}
>
<Text className='text-xl font-bold text-white'>
{(user as any)?.points || 0}
</Text>
<Text className='text-xs text-white/70 mt-1 block'></Text>
</View>
<View
className='text-center py-2'
onClick={() => Taro.navigateTo({ url: '/pages/user/coupon-list' })}
>
<Text className='text-xl font-bold text-white'>
0
</Text>
<Text className='text-xs text-white/70 mt-1 block'></Text>
</View>
<View
className='text-center py-2'
onClick={() => Taro.navigateTo({ url: '/pages/order/list' })}
>
<Text className='text-xl font-bold text-white'>
0
</Text>
<Text className='text-xs text-white/70 mt-1 block'></Text>
</View>
</View>
)}
</View>
</View>
{/* 我的订单快捷入口 */}
<View className='bg-white rounded-xl mx-3 mt-3 p-4' style={{ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)' }}>
<View className='flex justify-between items-center mb-3'>
<Text className='text-base font-medium text-gray-800'></Text>
<Text className='text-xs text-gray-400' onClick={() => Taro.navigateTo({ url: '/pages/order/list' })}>
{'>'}
</Text>
</View>
<View className='grid grid-cols-4 gap-2'>
{[
{ icon: '💳', label: '待付款', status: 0, color: '#f093fb' },
{ icon: '📦', label: '待发货', status: 1, color: '#667eea' },
{ icon: '🚚', label: '待收货', status: 2, color: '#4facfe' },
{ icon: '✅', label: '已完成', status: 3, color: '#52c41a' },
].map(item => (
<View
key={item.status}
className='flex flex-col items-center py-2 rounded-lg'
style={{ background: `${item.color}10` }}
onClick={() => Taro.navigateTo({ url: `/pages/order/list?tab=${item.status}` })}
>
<Text className='text-xl mb-1'>{item.icon}</Text>
<Text className='text-xs text-gray-600'>{item.label}</Text>
</View>
))}
</View>
</View>
{/* 功能菜单 */}
<View className='bg-white rounded-xl mx-3 mt-3 overflow-hidden' style={{ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)' }}>
{menuItems.map((item, idx) => (
<View
key={item.label}
className={`flex items-center justify-between px-4 py-3 ${
idx < menuItems.length - 1 ? 'border-b border-gray-50' : ''
}`}
onClick={() => Taro.navigateTo({ url: item.url })}
>
<View className='flex items-center gap-3'>
<Text className='text-base'>{item.icon}</Text>
<Text className='text-sm text-gray-700'>{item.label}</Text>
</View>
<Text className='text-gray-300 text-sm'>{'>'}</Text>
</View>
))}
</View>
{/* 底部安全区域 */}
<View className='h-20' />
</ScrollView>
</View>
)
}
export default UserPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '邀请记录',
}

View File

@@ -0,0 +1,89 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useScrollHeight } from '@/hooks/useScrollHeight'
import { listShopUserReferee } from '@/api/shop/shopUserReferee'
import type { ShopUserReferee } from '@/api/shop/shopUserReferee/model'
import EmptyState from '@/components/common/EmptyState'
definePageConfig({
navigationBarTitleText: '邀请记录',
})
const InviteRecordPage: React.FC = () => {
const { isLoggedIn } = useUser()
const [records, setRecords] = useState<ShopUserReferee[]>([])
const [loading, setLoading] = useState(true)
const scrollHeight = useScrollHeight(44)
useEffect(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
return
}
fetchRecords()
}, [isLoggedIn])
const fetchRecords = async () => {
try {
const data = await listShopUserReferee({})
setRecords(data || [])
} catch (e) {
console.error('获取邀请记录失败:', e)
} finally {
setLoading(false)
}
}
if (!isLoggedIn) {
return null
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY style={{ height: scrollHeight }}>
{loading ? (
<View className='flex items-center justify-center py-10'>
<Text className='text-gray-400'>...</Text>
</View>
) : records.length === 0 ? (
<EmptyState text='暂无邀请记录' />
) : (
<View className='p-3'>
{records.map((item) => (
<View
key={item.id}
className='bg-white rounded-lg p-3 mb-2'
>
<View className='flex justify-between items-center mb-1'>
<Text className='text-sm font-medium text-gray-800'>
{item.nickname || '用户'}
</Text>
<View
className={`px-2 py-1 rounded-full text-xs ${
item.status === 1 ? 'bg-green-50 text-green-500' : 'bg-orange-50 text-orange-500'
}`}
>
<Text>{item.status === 1 ? '已注册' : '待注册'}</Text>
</View>
</View>
<View className='flex justify-between items-center'>
<Text className='text-xs text-gray-400'>
{item.createTime}
</Text>
{item.isMember && (
<Text className='text-xs text-orange-500'></Text>
)}
</View>
</View>
))}
</View>
)}
</ScrollView>
</View>
)
}
export default InviteRecordPage

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '录入下级',
enableShareAppMessage: true,
})

View File

@@ -0,0 +1,340 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text, ScrollView, Input, Button, Image } from '@tarojs/components'
import Taro, { useRouter } from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useScrollHeight } from '@/hooks/useScrollHeight'
import { getMyRegisterList, addSubordinate, updateSubordinate, MemberRegister } from '@/api/shop/shopMemberRegister'
import EmptyState from '@/components/common/EmptyState'
definePageConfig({
navigationBarTitleText: '录入下级',
})
const InviteSubordinatePage: React.FC = () => {
const router = useRouter()
const { user, isLoggedIn } = useUser()
const scrollHeight = useScrollHeight()
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [list, setList] = useState<MemberRegister[]>([])
const [showAddModal, setShowAddModal] = useState(false)
const [editingItem, setEditingItem] = useState<MemberRegister | null>(null)
// 表单数据
const [phone, setPhone] = useState('')
const [realName, setRealName] = useState('')
// 从首页跳转过来自动弹出添加弹窗
useEffect(() => {
const { from } = router.params
if (from === 'home') {
setShowAddModal(true)
}
}, [router.params])
useEffect(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
return
}
fetchList()
}, [isLoggedIn])
const fetchList = async () => {
try {
setLoading(true)
const data = await getMyRegisterList({ page: 1, limit: 100 })
setList(data || [])
} catch (e: any) {
console.error('获取列表失败:', e)
Taro.showToast({ title: e.message || '获取列表失败', icon: 'none' })
} finally {
setLoading(false)
}
}
// 手机号验证
const validatePhone = (p: string) => {
return /^1[3-9]\d{9}$/.test(p)
}
// 提交表单
const handleSubmit = async () => {
if (!validatePhone(phone)) {
Taro.showToast({ title: '请输入正确的手机号', icon: 'none' })
return
}
setSubmitting(true)
try {
if (editingItem) {
// 修改
const res = await updateSubordinate({
id: editingItem.id,
phone: phone.trim(),
realName: realName.trim(),
})
if (res.code === 0) {
Taro.showToast({ title: '修改成功', icon: 'success' })
setShowAddModal(false)
resetForm()
fetchList()
} else {
Taro.showToast({ title: res.message || '修改失败', icon: 'none' })
}
} else {
// 新增
const res = await addSubordinate({
phone: phone.trim(),
realName: realName.trim(),
})
if (res.code === 0) {
Taro.showToast({ title: '录入成功', icon: 'success' })
setShowAddModal(false)
resetForm()
fetchList()
} else {
Taro.showToast({ title: res.message || '录入失败', icon: 'none' })
}
}
} catch (e: any) {
Taro.showToast({ title: e.message || '操作失败', icon: 'none' })
} finally {
setSubmitting(false)
}
}
// 重置表单
const resetForm = () => {
setPhone('')
setRealName('')
setEditingItem(null)
}
// 打开添加弹窗
const openAddModal = () => {
resetForm()
setShowAddModal(true)
}
// 打开编辑弹窗
const openEditModal = (item: MemberRegister) => {
setEditingItem(item)
setPhone(item.phone)
setRealName(item.realName || '')
setShowAddModal(true)
}
// 跳转到支付页面
const goToPay = (item: MemberRegister) => {
Taro.navigateTo({
url: `/pages/user/register-pay/index?phone=${item.phone}&registerId=${item.id}`,
})
}
// 获取状态文本
const getStatusText = (item: MemberRegister) => {
if (item.status === 1) return { text: '已完成', color: 'text-green-500', bg: 'bg-green-50' }
if (item.status === 2) return { text: '已取消', color: 'text-gray-400', bg: 'bg-gray-100' }
if (item.registerFeeStatus === 1) return { text: '待审核', color: 'text-orange-500', bg: 'bg-orange-50' }
return { text: '待支付注册费', color: 'text-blue-500', bg: 'bg-blue-50' }
}
// 统计
const stats = {
total: list.length,
pending: list.filter(l => l.status === 0).length,
completed: list.filter(l => l.status === 1).length,
}
if (!isLoggedIn) return null
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY style={{ height: scrollHeight }}>
{/* 顶部说明 */}
<View className='mx-3 mt-3 p-4 rounded-xl' style={{ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
<Text className='text-white text-sm font-medium block'></Text>
<Text className='text-white text-xs opacity-80 mt-1 block leading-relaxed'>
198
</Text>
</View>
{/* 统计卡片 */}
<View className='mx-3 mt-3 bg-white rounded-xl p-4'>
<View className='flex justify-around'>
<View className='text-center'>
<Text className='text-2xl font-bold text-gray-800'>{stats.total}</Text>
<Text className='text-xs text-gray-400 mt-1 block'></Text>
</View>
<View className='text-center'>
<Text className='text-2xl font-bold text-blue-500'>{stats.pending}</Text>
<Text className='text-xs text-gray-400 mt-1 block'></Text>
</View>
<View className='text-center'>
<Text className='text-2xl font-bold text-green-500'>{stats.completed}</Text>
<Text className='text-xs text-gray-400 mt-1 block'></Text>
</View>
</View>
</View>
{/* 录入按钮 */}
<View className='mx-3 mt-3'>
<Button
className='bg-blue-500 text-white rounded-full py-3'
style={{ backgroundColor: '#667eea' }}
onClick={openAddModal}
>
<Text className='text-white font-medium'>+ </Text>
</Button>
</View>
{/* 列表 */}
<View className='mx-3 mt-3 mb-4'>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
{loading ? (
<View className='bg-white rounded-xl p-6 text-center'>
<Text className='text-gray-400'>...</Text>
</View>
) : list.length === 0 ? (
<EmptyState text='暂无下级记录' subText='点击上方按钮录入下级会员' />
) : (
<View className='space-y-3'>
{list.map((item) => {
const status = getStatusText(item)
return (
<View key={item.id} className='bg-white rounded-xl p-4'>
<View className='flex items-center justify-between'>
<View className='flex items-center gap-3'>
<View className='w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center'>
<Text className='text-lg'>👤</Text>
</View>
<View>
<Text className='text-gray-800 font-medium'>
{item.realName || '未填写姓名'}
</Text>
<Text className='text-gray-500 text-sm mt-0.5'>
{item.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')}
</Text>
</View>
</View>
<View className={`px-2 py-1 rounded-full ${status.bg}`}>
<Text className={`text-xs ${status.color}`}>{status.text}</Text>
</View>
</View>
{/* 费用信息 */}
<View className='mt-3 pt-3 border-t border-gray-100 flex justify-between text-xs'>
<View>
<Text className='text-gray-400'>(线)</Text>
<Text className='text-gray-600 mt-0.5'>¥298.00</Text>
</View>
<View className='text-right'>
<Text className='text-gray-400'>()</Text>
<Text className='text-gray-600 mt-0.5'>¥198.00</Text>
</View>
</View>
{/* 操作按钮 */}
<View className='mt-3 flex gap-2'>
<Button
size='mini'
className='flex-1 rounded-full'
style={{ borderColor: '#ddd', borderWidth: 1 }}
onClick={() => openEditModal(item)}
disabled={item.status === 1}
>
<Text className='text-gray-600 text-xs'></Text>
</Button>
{item.registerFeeStatus === 0 && (
<Button
size='mini'
className='flex-1 rounded-full bg-blue-500 text-white'
style={{ backgroundColor: '#667eea' }}
onClick={() => goToPay(item)}
>
<Text className='text-white text-xs'></Text>
</Button>
)}
</View>
<Text className='text-gray-300 text-xs mt-2 block'>
: {item.createTime}
</Text>
</View>
)
})}
</View>
)}
</View>
</ScrollView>
{/* 添加/编辑弹窗 */}
{showAddModal && (
<View className='fixed inset-0 z-50'>
{/* 遮罩 */}
<View
className='absolute inset-0 bg-black bg-opacity-50'
onClick={() => { setShowAddModal(false); resetForm() }}
/>
{/* 内容 */}
<View className='absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl p-5'>
<View className='flex justify-between items-center mb-4'>
<Text className='text-lg font-medium text-gray-800'>
{editingItem ? '修改下级信息' : '录入下级'}
</Text>
<Text
className='text-gray-400 text-xl'
onClick={() => { setShowAddModal(false); resetForm() }}
>
×
</Text>
</View>
<View className='mb-4'>
<Text className='text-sm text-gray-600 mb-2 block'> *</Text>
<Input
className='border border-gray-200 rounded-lg px-4 py-3 text-base'
type='number'
maxlength={11}
placeholder='请输入下级手机号'
value={phone}
onInput={(e) => setPhone(e.detail.value)}
/>
</View>
<View className='mb-4'>
<Text className='text-sm text-gray-600 mb-2 block'>()</Text>
<Input
className='border border-gray-200 rounded-lg px-4 py-3 text-base'
placeholder='请输入下级姓名'
value={realName}
onInput={(e) => setRealName(e.detail.value)}
/>
</View>
<View className='bg-blue-50 rounded-lg p-3 mb-4'>
<Text className='text-xs text-blue-600 leading-relaxed block'>
198
</Text>
</View>
<Button
className='rounded-full py-3'
style={{ backgroundColor: '#667eea' }}
loading={submitting}
onClick={handleSubmit}
>
<Text className='text-white font-medium'>
{editingItem ? '保存修改' : '确认录入'}
</Text>
</Button>
</View>
</View>
)}
</View>
)
}
export default InviteSubordinatePage

View File

@@ -0,0 +1,5 @@
export default {
navigationBarTitleText: '会员升级',
navigationBarBackgroundColor: '#667eea',
navigationBarTextStyle: 'white',
}

View File

@@ -0,0 +1,261 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useScrollHeight } from '@/hooks/useScrollHeight'
import { useRequest } from '@/hooks/useRequest'
import { getDealerSetting } from '@/api/shop/shopMemberCenter'
import MemberBadge from '@/components/business/MemberBadge'
definePageConfig({
navigationBarTitleText: '会员升级',
})
interface LevelInfo {
id: number
levelName: string
levelIcon: string
upgradeCondition: string
upgradeAmount: number
commissionRate: number
selfBuyRate: number
}
const MemberUpgradePage: React.FC = () => {
const { user, isLoggedIn } = useUser()
const scrollHeight = useScrollHeight()
const [currentLevel, setCurrentLevel] = useState(0)
// 获取会员等级列表
const { data: levelList, run: runLevelList, loading } = useRequest(getDealerSetting, {
manual: true,
})
useEffect(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
return
}
runLevelList()
}, [isLoggedIn])
// 处理升级
const handleUpgrade = (level: LevelInfo) => {
Taro.showModal({
title: '升级确认',
content: `确定升级为${level.levelName}吗?升级后将享受更多权益。`,
success: (res) => {
if (res.confirm) {
Taro.showToast({
title: '升级功能开发中',
icon: 'none',
})
}
},
})
}
// 当前等级信息
const currentLevelName = user?.memberLevelName || '普通会员'
const currentLevelIndex = levelList?.findIndex(l => l.levelName === currentLevelName) || 0
// 会员等级图标映射
const levelIcons: Record<string, string> = {
'普通会员': '👤',
'白银会员': '🥈',
'黄金会员': '🥇',
'铂金会员': '💎',
'钻石会员': '💠',
'黑金会员': '🏆',
}
// 获取图标
const getLevelIcon = (name: string) => levelIcons[name] || '⭐'
if (!isLoggedIn) {
return null
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY style={{ height: scrollHeight }}>
{/* 顶部背景 */}
<View className='p-5 pb-8 rounded-b-3xl'
style={{ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
<View className='flex items-center justify-center flex-col'>
<View className='w-20 h-20 rounded-full bg-white bg-opacity-20 flex items-center justify-center mb-3'>
<Text className='text-4xl'>{getLevelIcon(currentLevelName)}</Text>
</View>
<Text className='text-white text-lg font-bold'></Text>
<View className='mt-2'>
<MemberBadge levelName={currentLevelName} />
</View>
</View>
</View>
{/* 升级说明 */}
<View className='mx-3 -mt-4 bg-white rounded-xl p-4 shadow-sm'>
<View className='flex items-start gap-3'>
<View className='w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0'>
<Text className='text-base'>💡</Text>
</View>
<View className='flex-1'>
<Text className='text-gray-800 text-sm font-medium block'></Text>
<Text className='text-gray-500 text-xs mt-1 block leading-relaxed'>
</Text>
</View>
</View>
</View>
{/* 会员等级列表 */}
<View className='mx-3 mt-3'>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
{loading ? (
<View className='flex items-center justify-center py-10'>
<Text className='text-gray-400'>...</Text>
</View>
) : !levelList || levelList.length === 0 ? (
<View className='bg-white rounded-xl p-6 text-center'>
<Text className='text-gray-400'></Text>
<Text className='text-gray-400 text-xs mt-2'></Text>
</View>
) : (
<View className='space-y-3'>
{levelList.map((level, idx) => {
const isCurrent = level.levelName === currentLevelName
const isHigher = idx > currentLevelIndex
return (
<View
key={level.id}
className={`bg-white rounded-xl p-4 ${isHigher ? 'border-2 border-dashed border-orange-300' : ''}`}
>
<View className='flex items-start gap-3'>
{/* 等级图标 */}
<View className={`w-12 h-12 rounded-xl flex items-center justify-center ${
idx === 0 ? 'bg-gray-100' :
idx === 1 ? 'bg-orange-100' :
idx === 2 ? 'bg-yellow-100' :
idx === 3 ? 'bg-blue-100' :
idx === 4 ? 'bg-purple-100' : 'bg-pink-100'
}`}>
<Text className='text-2xl'>{getLevelIcon(level.levelName)}</Text>
</View>
{/* 等级信息 */}
<View className='flex-1'>
<View className='flex items-center gap-2'>
<Text className='text-gray-800 font-medium'>{level.levelName}</Text>
{isCurrent && (
<View className='px-2 py-0.5 bg-green-100 rounded-full'>
<Text className='text-green-600 text-xs'></Text>
</View>
)}
{isHigher && (
<View className='px-2 py-0.5 bg-orange-100 rounded-full'>
<Text className='text-orange-600 text-xs'></Text>
</View>
)}
</View>
{/* 佣金比例 */}
<View className='flex gap-4 mt-2'>
<View>
<Text className='text-gray-500 text-xs'></Text>
<Text className='text-orange-500 font-bold'>{(level.commissionRate * 100).toFixed(1)}%</Text>
</View>
<View>
<Text className='text-gray-500 text-xs'></Text>
<Text className='text-green-500 font-bold'>{(level.selfBuyRate * 100).toFixed(1)}%</Text>
</View>
</View>
{/* 升级条件 */}
{level.upgradeCondition && (
<Text className='text-gray-400 text-xs mt-2'>{level.upgradeCondition}</Text>
)}
</View>
{/* 升级按钮 */}
{isHigher && (
<View
className='px-4 py-2 bg-orange-500 rounded-full'
onClick={() => handleUpgrade(level)}
>
<Text className='text-white text-sm'></Text>
</View>
)}
</View>
</View>
)
})}
</View>
)}
</View>
{/* 权益对比 */}
<View className='mx-3 mt-4'>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
<View className='bg-white rounded-xl overflow-hidden'>
<View className='flex bg-gray-50'>
<View className='flex-1 px-3 py-2'>
<Text className='text-gray-500 text-xs text-center'></Text>
</View>
{['普通会员', '白银会员', '黄金会员'].map(name => (
<View key={name} className='flex-1 px-2 py-2'>
<Text className='text-gray-500 text-xs text-center'>{name}</Text>
</View>
))}
</View>
{[
{ label: '购物折扣', values: ['9.5折', '9折', '8.5折'] },
{ label: '团队佣金', values: ['5%', '10%', '15%'] },
{ label: '自购返利', values: ['1%', '3%', '5%'] },
{ label: '专属客服', values: ['❌', '✔', '✔'] },
{ label: '优先发货', values: ['❌', '❌', '✔'] },
].map((row, idx) => (
<View
key={row.label}
className={`flex ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}`}
>
<View className='flex-1 px-3 py-3'>
<Text className='text-gray-700 text-sm text-center'>{row.label}</Text>
</View>
{row.values.map((val, vIdx) => (
<View key={vIdx} className='flex-1 px-2 py-3'>
<Text className={`text-sm text-center ${
val === '❌' ? 'text-gray-300' :
val === '✔' ? 'text-green-500' : 'text-gray-700'
}`}>{val}</Text>
</View>
))}
</View>
))}
</View>
</View>
{/* 联系客服 */}
<View className='mx-3 mt-4 mb-4'>
<View
className='bg-white rounded-xl p-4 flex items-center justify-between'
onClick={() => Taro.navigateTo({ url: '/pages/user/customer-service/index' })}
>
<View className='flex items-center gap-3'>
<View className='w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center'>
<Text className='text-lg'>📞</Text>
</View>
<View>
<Text className='text-gray-800 font-medium block'></Text>
<Text className='text-gray-400 text-xs mt-0.5'></Text>
</View>
</View>
<Text className='text-gray-300'></Text>
</View>
</View>
</ScrollView>
</View>
)
}
export default MemberUpgradePage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '会员中心',
}

View File

@@ -0,0 +1,271 @@
import React, { useState, useEffect } from 'react'
import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useRequest } from '@/hooks/useRequest'
import { getMemberCenterData, type MemberCenterData } from '@/api/shop/shopMemberCenter'
import { listShopUserReferee } from '@/api/shop/shopUserReferee'
import MemberBadge from '@/components/business/MemberBadge'
definePageConfig({
navigationBarTitleText: '会员中心',
})
const MemberPage: React.FC = () => {
const { user, isLoggedIn } = useUser()
// 获取会员中心数据
const { data: memberData, run: runMemberData, loading: memberLoading } = useRequest(getMemberCenterData, {
manual: true,
})
// 获取团队成员数据
const { data: teamData, run: runTeamData } = useRequest(listShopUserReferee, {
manual: true,
})
// 登录后加载数据
useEffect(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
return
}
runMemberData()
runTeamData({})
}, [isLoggedIn])
// 处理升级会员
const handleUpgrade = () => {
Taro.navigateTo({ url: '/pages/user/member-upgrade/index' })
}
// 处理功能项点击
const handleFeatureClick = (url: string) => {
if (url) {
Taro.navigateTo({ url })
}
}
// 会员功能菜单
const memberFeatures = [
{
icon: '💰',
label: '我的佣金',
value: memberData?.commission !== undefined ? `¥${memberData.commission}` : '--',
url: '/pages/user/commission/index',
},
{
icon: '📊',
label: '佣金明细',
value: '',
url: '/pages/user/commission/index',
},
{
icon: '👥',
label: '我的团队',
value: memberData?.teamCount !== undefined ? `${memberData.teamCount}` : '--',
url: '/pages/user/team/index',
},
{
icon: '📋',
label: '邀请记录',
value: '',
url: '/pages/user/invite-record/index',
},
]
// 会员权益
const memberBenefits = [
{ icon: '🎁', text: '享受会员专属折扣' },
{ icon: '💵', text: '获得分销佣金权益' },
{ icon: '🎯', text: '优先客服支持' },
{ icon: '🎉', text: '专属活动邀请' },
]
if (!isLoggedIn) {
return null
}
return (
<View className='flex flex-col bg-gray-50 min-h-screen'>
{/* 会员卡片 - 渐变背景 */}
<View className='mx-3 mt-3 p-5 rounded-2xl relative overflow-hidden'
style={{ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
{/* 装饰圆形 */}
<View className='absolute -right-6 -top-6 w-32 h-32 rounded-full opacity-10 bg-white' />
<View className='absolute -left-4 -bottom-4 w-24 h-24 rounded-full opacity-10 bg-white' />
<View className='relative z-10'>
{/* 用户信息行 */}
<View className='flex items-center justify-between mb-4'>
<View className='flex items-center gap-3'>
{user?.avatar ? (
<Image className='w-12 h-12 rounded-full border-2 border-white' src={user.avatar} mode='aspectFill' />
) : (
<View className='w-12 h-12 rounded-full bg-white bg-opacity-30 flex items-center justify-center'>
<Text className='text-xl text-white'>👤</Text>
</View>
)}
<View>
<Text className='text-white text-lg font-bold'>{user?.nickname || user?.phone || '用户'}</Text>
<View className='flex items-center gap-2 mt-1'>
<MemberBadge levelName={memberData?.memberLevel || user?.memberLevelName || '普通会员'} />
</View>
</View>
</View>
{/* 升级按钮 */}
<View
className='px-3 py-1.5 rounded-full flex items-center gap-1'
style={{ backgroundColor: 'rgba(255,255,255,0.25)' }}
onClick={handleUpgrade}
>
<Text className='text-white text-xs'>{memberData?.isDealer ? '已开通' : '升级会员'}</Text>
<Text className='text-white text-xs'></Text>
</View>
</View>
{/* 佣金数据 */}
<View className='flex justify-around pt-4 border-t border-white border-opacity-20'>
<View className='text-center'>
<Text className='text-white text-2xl font-bold block'>
{memberLoading ? '...' : (memberData?.totalCommission || 0).toFixed(2)}
</Text>
<Text className='text-white text-xs opacity-80 mt-1'>()</Text>
</View>
<View className='w-px bg-white opacity-20' />
<View className='text-center'>
<Text className='text-white text-2xl font-bold block'>
{memberLoading ? '...' : (memberData?.commission || 0).toFixed(2)}
</Text>
<Text className='text-white text-xs opacity-80 mt-1'>()</Text>
</View>
<View className='w-px bg-white opacity-20' />
<View className='text-center'>
<Text className='text-white text-2xl font-bold block'>
{memberLoading ? '...' : (memberData?.teamCount || 0)}
</Text>
<Text className='text-white text-xs opacity-80 mt-1'></Text>
</View>
</View>
</View>
</View>
{/* 功能入口卡片 */}
<View className='bg-white mx-3 mt-3 rounded-xl overflow-hidden'>
<View className='px-4 py-3 border-b border-gray-100'>
<Text className='text-base font-medium text-gray-800'></Text>
</View>
{memberFeatures.map((item, idx) => (
<View
key={item.label}
className={`flex items-center justify-between px-4 py-3 ${
idx < memberFeatures.length - 1 ? 'border-b border-gray-50' : ''
}`}
onClick={() => handleFeatureClick(item.url)}
>
<View className='flex items-center gap-3'>
<Text className='text-xl'>{item.icon}</Text>
<Text className='text-sm text-gray-700'>{item.label}</Text>
</View>
<View className='flex items-center gap-2'>
{item.value && (
<Text className='text-sm font-medium text-orange-500'>{item.value}</Text>
)}
<Text className='text-gray-300 text-sm'></Text>
</View>
</View>
))}
</View>
{/* 会员权益 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<View className='flex items-center justify-between mb-3'>
<Text className='text-base font-medium text-gray-800'></Text>
<Text
className='text-xs text-blue-500'
onClick={() => Taro.navigateTo({ url: '/pages/user/member-upgrade/index' })}
>
</Text>
</View>
<View className='grid grid-cols-2 gap-3'>
{memberBenefits.map((benefit, idx) => (
<View key={idx} className='flex items-center gap-2 bg-gray-50 rounded-lg px-3 py-2'>
<Text className='text-lg'>{benefit.icon}</Text>
<Text className='text-xs text-gray-600 flex-1'>{benefit.text}</Text>
</View>
))}
</View>
</View>
{/* 推广赚钱 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<Text className='text-base font-medium text-gray-800 mb-3 block'>广</Text>
<View className='bg-gradient-to-r from-orange-50 to-red-50 rounded-xl p-4'>
<View className='flex items-start gap-3'>
<View className='w-12 h-12 rounded-full bg-orange-100 flex items-center justify-center'>
<Text className='text-2xl'>🎁</Text>
</View>
<View className='flex-1'>
<Text className='text-orange-600 font-medium block'></Text>
<Text className='text-gray-500 text-xs mt-1 block'>
1
</Text>
<View className='flex gap-2 mt-2'>
<View
className='px-3 py-1.5 bg-orange-500 rounded-full'
onClick={() => Taro.navigateTo({ url: '/pages/user/promotion/index' })}
>
<Text className='text-white text-xs'>广</Text>
</View>
<View
className='px-3 py-1.5 border border-orange-500 rounded-full'
onClick={() => Taro.navigateTo({ url: '/pages/user/invite-record/index' })}
>
<Text className='text-orange-500 text-xs'></Text>
</View>
</View>
</View>
</View>
</View>
</View>
{/* 团队概况 */}
{teamData && teamData.length > 0 && (
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<View className='flex items-center justify-between mb-3'>
<Text className='text-base font-medium text-gray-800'></Text>
<Text
className='text-xs text-blue-500'
onClick={() => Taro.navigateTo({ url: '/pages/user/team/index' })}
>
</Text>
</View>
<View className='grid grid-cols-3 gap-2'>
<View className='bg-blue-50 rounded-lg p-3 text-center'>
<Text className='text-blue-600 text-xl font-bold block'>{teamData.length}</Text>
<Text className='text-gray-500 text-xs mt-1'></Text>
</View>
<View className='bg-green-50 rounded-lg p-3 text-center'>
<Text className='text-green-600 text-xl font-bold block'>
{teamData.filter(m => (m as any)?.isActive).length}
</Text>
<Text className='text-gray-500 text-xs mt-1'></Text>
</View>
<View className='bg-purple-50 rounded-lg p-3 text-center'>
<Text className='text-purple-600 text-xl font-bold block'>
{teamData.filter(m => (m as any)?.isMember).length}
</Text>
<Text className='text-gray-500 text-xs mt-1'></Text>
</View>
</View>
</View>
)}
<View className='h-6' />
</View>
)
}
export default MemberPage

View File

@@ -0,0 +1,260 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useRequest } from '@/hooks/useRequest'
import { pageUserPointsLog, getUserPointsInfo, type UserPointsInfo } from '@/api/system/user/points'
definePageConfig({
navigationBarTitleText: '积分明细',
enablePullDownRefresh: true,
})
// 积分类型映射
const TYPE_MAP: Record<number, { label: string; icon: string; color: string; bgColor: string }> = {
1: { label: '获得', icon: '📈', color: 'text-green-600', bgColor: 'bg-green-50' },
2: { label: '消费', icon: '📉', color: 'text-red-500', bgColor: 'bg-red-50' },
3: { label: '过期', icon: '⏰', color: 'text-gray-500', bgColor: 'bg-gray-50' },
4: { label: '调整', icon: '🔧', color: 'text-blue-500', bgColor: 'bg-blue-50' },
}
const tabs = [
{ label: '全部', value: -1 },
{ label: '获得', value: 1 },
{ label: '消费', value: 2 },
{ label: '过期', value: 3 },
]
const PointsRecordPage: React.FC = () => {
const [activeTab, setActiveTab] = useState(-1)
const [page, setPage] = useState(1)
const [logs, setLogs] = useState<UserPointsLog[]>([])
const [hasMore, setHasMore] = useState(true)
const [pointsInfo, setPointsInfo] = useState<UserPointsInfo>({ points: 0, totalEarned: 0, totalUsed: 0, expiringSoon: 0 })
// 获取积分统计信息
const { run: fetchPointsInfo, loading: infoLoading } = useRequest(getUserPointsInfo, {
manual: true,
onSuccess: (data) => {
setPointsInfo(data)
},
onError: (err) => {
console.error('获取积分信息失败:', err)
}
})
// 获取积分记录
const { run: fetchLogs, loading } = useRequest(pageUserPointsLog, {
manual: true,
onSuccess: (data) => {
if (data?.list) {
if (page === 1) {
setLogs(data.list)
} else {
setLogs(prev => [...prev, ...data.list])
}
setHasMore(data.list.length >= 20)
}
Taro.stopPullDownRefresh()
},
onError: (err) => {
Taro.showToast({ title: err.message || '加载失败', icon: 'none' })
Taro.stopPullDownRefresh()
}
})
// 加载数据
const loadData = (pageNum: number = 1) => {
const params: any = { page: pageNum, limit: 20 }
if (activeTab !== -1) {
params.type = activeTab
}
fetchLogs(params)
}
// 初始化加载
useEffect(() => {
setPage(1)
loadData(1)
fetchPointsInfo() // 加载积分统计信息
}, [activeTab])
// 下拉刷新Taro 自动识别此函数)
function onPullDownRefresh() {
setPage(1)
loadData(1)
}
// 加载更多
const loadMore = () => {
if (loading || !hasMore) return
const nextPage = page + 1
setPage(nextPage)
loadData(nextPage)
}
// 格式化积分
const formatPoints = (points?: number) => {
if (!points && points !== 0) return '0'
return points > 0 ? `+${points}` : String(points)
}
// 获取类型信息
const getTypeInfo = (type?: number) => {
return TYPE_MAP[type || 0] || { label: '未知', icon: '❓', color: 'text-gray-500', bgColor: 'bg-gray-50' }
}
// 格式化时间
const formatTime = (timeStr?: string) => {
if (!timeStr) return ''
const date = new Date(timeStr)
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 `${month}-${day} ${hour}:${minute}`
}
// 按日期分组
const groupLogsByDate = () => {
const groups: { date: string; logs: UserPointsLog[] }[] = []
let currentDate = ''
let currentGroup: UserPointsLog[] = []
logs.forEach(log => {
const date = log.createTime ? log.createTime.split(' ')[0] : ''
if (date !== currentDate) {
if (currentGroup.length > 0) {
groups.push({ date: currentDate, logs: currentGroup })
}
currentDate = date
currentGroup = [log]
} else {
currentGroup.push(log)
}
})
if (currentGroup.length > 0) {
groups.push({ date: currentDate, logs: currentGroup })
}
return groups
}
const groupedLogs = groupLogsByDate()
return (
<View className='min-h-screen' style={{ background: 'linear-gradient(to bottom, #fff7ed, #ffffff)' }}>
{/* 顶部统计卡片 */}
<View className='bg-white mx-3 mt-3 rounded-xl p-4 shadow-sm'>
<View className='flex items-center justify-between'>
<View className='text-center flex-1'>
<Text className='text-2xl font-bold text-orange-500 block'>{infoLoading ? '-' : pointsInfo.points.toLocaleString()}</Text>
<Text className='text-xs text-gray-400 mt-1 block'></Text>
</View>
<View className='h-10 w-px bg-gray-200' />
<View className='text-center flex-1'>
<Text className='text-2xl font-bold text-green-500 block'>{infoLoading ? '-' : `+${pointsInfo.totalEarned.toLocaleString()}`}</Text>
<Text className='text-xs text-gray-400 mt-1 block'></Text>
</View>
<View className='h-10 w-px bg-gray-200' />
<View className='text-center flex-1'>
<Text className='text-2xl font-bold text-red-500 block'>{infoLoading ? '-' : `-${pointsInfo.totalUsed.toLocaleString()}`}</Text>
<Text className='text-xs text-gray-400 mt-1 block'>使</Text>
</View>
</View>
</View>
{/* Tab 栏 */}
<View className='bg-white flex mt-3'>
{tabs.map(tab => (
<View
key={tab.value}
className={`flex-1 text-center py-3 relative ${
activeTab === tab.value ? 'text-orange-500 font-medium' : 'text-gray-600'
}`}
onClick={() => setActiveTab(tab.value)}
>
<Text className='text-sm'>{tab.label}</Text>
{activeTab === tab.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='h-full'
onScrollToLower={loadMore}
>
{logs.length === 0 && !loading ? (
<View className='text-center py-16'>
<Text className='text-6xl mb-4 block'>📊</Text>
<Text className='text-base text-gray-400 mb-2 block'></Text>
<Text className='text-sm text-gray-300'></Text>
</View>
) : (
<View className='p-3'>
{groupedLogs.map(group => (
<View key={group.date} className='mb-3'>
{/* 日期分隔 */}
<View className='flex items-center mb-2'>
<View className='flex-1 h-px bg-gray-200' />
<Text className='text-xs text-gray-400 mx-3'>{group.date}</Text>
<View className='flex-1 h-px bg-gray-200' />
</View>
{/* 当日记录 */}
{group.logs.map(log => {
const typeInfo = getTypeInfo(log.type)
return (
<View key={log.logId} className='bg-white rounded-lg p-4 mb-2 shadow-sm'>
<View className='flex justify-between items-center'>
<View className='flex-1'>
<View className='flex items-center gap-2'>
<View className={`w-8 h-8 rounded-full ${typeInfo.bgColor} flex items-center justify-center`}>
<Text className='text-base'>{typeInfo.icon}</Text>
</View>
<View className='flex-1'>
<Text className='text-sm text-gray-800 block font-medium'>{log.reason || typeInfo.label}</Text>
<Text className='text-xs text-gray-400 mt-1 block'>{formatTime(log.createTime)}</Text>
{log.orderId && (
<Text className='text-xs text-gray-400 mt-0 block'>: {log.orderId}</Text>
)}
</View>
</View>
</View>
<View className='text-right ml-3'>
<Text className={`text-lg font-bold ${typeInfo.color}`}>
{formatPoints(log.points)}
</Text>
</View>
</View>
</View>
)
})}
</View>
))}
{/* 加载更多 */}
{hasMore && (
<View className='text-center py-4'>
<Text className='text-xs text-gray-400'>
{loading ? '加载中...' : '上拉加载更多'}
</Text>
</View>
)}
{!hasMore && logs.length > 0 && (
<View className='text-center py-4'>
<Text className='text-xs text-gray-400'> </Text>
</View>
)}
</View>
)}
</ScrollView>
</View>
)
}
export default PointsRecordPage

View File

@@ -0,0 +1,203 @@
import React, { useState, useEffect } from 'react'
import { View, Text, Image, Input, Button as TaroButton } from '@tarojs/components'
import Taro from '@tarojs/taro'
import BottomButton from '@/components/common/BottomButton'
import { useUser } from '@/hooks/useUser'
import { updateUser } from '@/api/system/user'
import type { User } from '@/api/system/user/model'
import { TenantId } from '@/config/app'
definePageConfig({
navigationBarTitleText: '个人信息',
})
const genderOptions = ['保密', '男', '女']
const ProfilePage: React.FC = () => {
const { user, refreshUser } = useUser()
const [form, setForm] = useState<Partial<User>>({})
const [loading, setLoading] = useState(false)
const [showGenderSheet, setShowGenderSheet] = useState(false)
useEffect(() => {
if (user) {
setForm({
userId: (user as any)?.userId || (user as any)?.id,
nickname: (user as any)?.nickname || '',
avatar: (user as any)?.avatar || '',
gender: (user as any)?.gender ?? 0,
phone: (user as any)?.phone || '',
})
}
}, [user])
// 微信头像选择回调open-type="chooseAvatar"
const handleWechatAvatar = (e: any) => {
const { avatarUrl } = e.detail
if (!avatarUrl) return
Taro.showLoading({ title: '上传中...' })
Taro.uploadFile({
url: 'https://server.websoft.top/api/oss/upload',
filePath: avatarUrl,
name: 'file',
header: {
'content-type': 'application/json',
TenantId,
},
success: (uploadRes) => {
const data = JSON.parse(uploadRes.data)
if (data.code === 0 && data.data?.url) {
setForm(prev => ({ ...prev, avatar: data.data.url }))
Taro.showToast({ title: '上传成功', icon: 'success' })
} else {
Taro.showToast({ title: data.message || '上传失败', icon: 'none' })
}
},
fail: () => {
Taro.showToast({ title: '上传失败', icon: 'none' })
},
complete: () => {
Taro.hideLoading()
},
})
}
// 选择性别
const handleGenderSelect = (index: number) => {
setForm(prev => ({ ...prev, gender: index }))
setShowGenderSheet(false)
}
// 保存
const handleSave = async () => {
if (!form.nickname?.trim()) {
Taro.showToast({ title: '昵称不能为空', icon: 'none' })
return
}
setLoading(true)
try {
await updateUser(form as User)
await refreshUser()
Taro.showToast({ title: '保存成功', icon: 'success' })
} catch (error) {
console.error('保存失败:', error)
Taro.showToast({ title: '保存失败', icon: 'none' })
} finally {
setLoading(false)
}
}
const displayId = (user as any)?.userId || (user as any)?.id || ''
const registerTime = (user as any)?.createTime || (user as any)?.createdAt || ''
return (
<View className='min-h-screen bg-gray-50 pb-32'>
{/* 头像 - 使用 Taro 原生 Button 支持 open-type="chooseAvatar" */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'>
<Text className='text-sm text-gray-700 w-20'></Text>
<View className='flex items-center gap-2'>
<TaroButton
openType='chooseAvatar'
onChooseAvatar={handleWechatAvatar}
plain
style={{
padding: 0,
margin: 0,
background: 'transparent',
border: 'none',
lineHeight: '40px',
width: 'auto',
minHeight: 0,
}}
>
{form.avatar ? (
<Image className='w-10 h-10 rounded-full' src={form.avatar} mode='aspectFill' />
) : (
<View className='w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center'>
<Text className='text-gray-300 text-xs'></Text>
</View>
)}
</TaroButton>
<Text className='text-gray-300 text-sm'>{'>'}</Text>
</View>
</View>
{/* 昵称 - 支持获取微信昵称 */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'>
<Text className='text-sm text-gray-700 w-20'></Text>
<Input
className='flex-1 text-right text-sm text-gray-600'
placeholder='请输入昵称'
value={form.nickname || ''}
onInput={(e: any) => setForm(prev => ({ ...prev, nickname: e.detail.value }))}
maxlength={20}
type='nickname'
/>
</View>
{/* 性别 */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50' onClick={() => setShowGenderSheet(true)}>
<Text className='text-sm text-gray-700 w-20'></Text>
<View className='flex items-center gap-2'>
<Text className='text-sm text-gray-600'>{genderOptions[form.gender ?? 0]}</Text>
<Text className='text-gray-300 text-sm'>{'>'}</Text>
</View>
</View>
{/* 手机号 - 不可编辑 */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'>
<Text className='text-sm text-gray-700 w-20'></Text>
<Text className='text-sm text-gray-600'>{form.phone || '未绑定'}</Text>
</View>
{/* 用户ID */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'>
<Text className='text-sm text-gray-700 w-20'>ID</Text>
<Text className='text-sm text-gray-600'>{displayId || '未设置'}</Text>
</View>
{/* 注册时间 */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'>
<Text className='text-sm text-gray-700 w-20'></Text>
<Text className='text-sm text-gray-600'>{registerTime || '未知'}</Text>
</View>
{/* 保存按钮 */}
<BottomButton
text='保存'
onClick={handleSave}
loading={loading}
disabled={loading}
/>
{/* 性别选择弹窗 */}
{showGenderSheet && (
<View className='fixed inset-0 z-50'>
<View className='absolute inset-0 bg-black bg-opacity-50' onClick={() => setShowGenderSheet(false)} />
<View className='absolute bottom-0 left-0 right-0 bg-white rounded-t-xl'>
<View className='px-4 py-3 border-b border-gray-50'>
<Text className='text-base font-medium text-gray-800'></Text>
</View>
{genderOptions.map((label, index) => (
<View
key={index}
className='px-4 py-3 border-b border-gray-50 flex items-center justify-between'
onClick={() => handleGenderSelect(index)}
>
<Text className={`text-sm ${(form.gender ?? 0) === index ? 'text-green-600 font-medium' : 'text-gray-700'}`}>
{label}
</Text>
{(form.gender ?? 0) === index && <Text className='text-green-600'></Text>}
</View>
))}
<View className='px-4 py-3' onClick={() => setShowGenderSheet(false)}>
<Text className='text-sm text-gray-400 text-center block'></Text>
</View>
</View>
</View>
)}
</View>
)
}
export default ProfilePage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '分销推广',
}

View File

@@ -0,0 +1,123 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, Button, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useScrollHeight } from '@/hooks/useScrollHeight'
definePageConfig({
navigationBarTitleText: '分销推广',
})
const PromotionPage: React.FC = () => {
const { user, isLoggedIn } = useUser()
const [inviteCode, setInviteCode] = useState('')
const [inviteLink, setInviteLink] = useState('')
const [qrCodeUrl, setQrCodeUrl] = useState('')
const scrollHeight = useScrollHeight(44)
useEffect(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
return
}
// 生成邀请码和链接
const code = (user?.userId || user?.id)?.toString() || Taro.getStorageSync('UserId')?.toString() || ''
setInviteCode(code)
setInviteLink(`https://shop-api.websoft.top/invite?ref=${code}`)
setQrCodeUrl(`https://shop-api.websoft.top/api/wx-login/getOrderQRCodeUnlimited/uid_${code}`)
}, [isLoggedIn, user])
const handleCopyLink = () => {
Taro.setClipboardData({
data: inviteLink,
success: () => {
Taro.showToast({ title: '链接已复制', icon: 'success' })
},
})
}
const handleShare = () => {
Taro.showToast({ title: '请点击右上角分享', icon: 'none' })
}
if (!isLoggedIn) {
return null
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY style={{ height: scrollHeight }}>
{/* 推广卡片 */}
<View className='mx-3 mt-3 p-4 rounded-xl' style={{ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
<Text className='text-white text-lg font-bold mb-1 block'>广</Text>
<Text className='text-white text-sm opacity-80 mb-3 block'></Text>
<View className='bg-white rounded-lg p-3 mb-3'>
<Text className='text-xs text-gray-500 mb-1 block'></Text>
<Text className='text-base font-bold text-gray-800'>{inviteCode}</Text>
</View>
{/* 邀请二维码 */}
{qrCodeUrl ? (
<View className='bg-white rounded-lg p-3 flex flex-col items-center'>
<Text className='text-xs text-gray-500 mb-2 block'></Text>
<Image
src={qrCodeUrl}
style={{ width: '180px', height: '180px' }}
mode='aspectFit'
/>
</View>
) : null}
</View>
{/* 推广链接 */}
<View className='mx-3 mt-3 p-4 bg-white rounded-lg'>
<Text className='text-sm font-medium text-gray-800 mb-2 block'>广</Text>
<View className='bg-gray-50 rounded-lg p-3 mb-3'>
<Text className='text-xs text-gray-600 break-all'>{inviteLink}</Text>
</View>
<View className='grid grid-cols-2 gap-2'>
<Button
className='rounded-full'
style={{ backgroundColor: '#0e932e' }}
onClick={handleCopyLink}
>
</Button>
<Button
className='rounded-full'
style={{ backgroundColor: '#1989fa' }}
onClick={handleShare}
>
</Button>
</View>
</View>
{/* 推广说明 */}
<View className='mx-3 mt-3 p-4 bg-white rounded-lg'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'>广</Text>
<View className='flex items-start gap-2 mb-2'>
<Text className='text-green-500 mt-0'>1.</Text>
<Text className='text-sm text-gray-600 flex-1'></Text>
</View>
<View className='flex items-start gap-2 mb-2'>
<Text className='text-green-500 mt-0'>2.</Text>
<Text className='text-sm text-gray-600 flex-1'></Text>
</View>
<View className='flex items-start gap-2 mb-2'>
<Text className='text-green-500 mt-0'>3.</Text>
<Text className='text-sm text-gray-600 flex-1'></Text>
</View>
<View className='flex items-start gap-2'>
<Text className='text-green-500 mt-0'>4.</Text>
<Text className='text-sm text-gray-600 flex-1'></Text>
</View>
</View>
<View className='h-6' />
</ScrollView>
</View>
)
}
export default PromotionPage

View File

@@ -0,0 +1,196 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useRequest } from '@/hooks/useRequest'
import { pageUserRechargeOrder, type RechargeOrder } from '@/api/shop/shopRechargeOrder'
definePageConfig({
navigationBarTitleText: '充值记录',
})
const tabs = [
{ label: '全部', value: -1 },
{ label: '已支付', value: 20 },
{ label: '待支付', value: 10 },
]
const RechargeRecordPage: React.FC = () => {
const [activeTab, setActiveTab] = useState(-1)
const [page, setPage] = useState(1)
const [orders, setOrders] = useState<RechargeOrder[]>([])
const [hasMore, setHasMore] = useState(true)
// 获取充值记录
const { run: fetchOrders, loading } = useRequest(pageUserRechargeOrder, {
manual: true,
onSuccess: (data) => {
if (data?.records) {
if (page === 1) {
setOrders(data.records)
} else {
setOrders(prev => [...prev, ...data.records])
}
setHasMore(data.records.length >= 20)
}
},
onError: (err) => {
Taro.showToast({ title: err.message || '加载失败', icon: 'none' })
}
})
// 加载数据
const loadData = (pageNum: number = 1) => {
const params: any = { page: pageNum, limit: 20 }
if (activeTab !== -1) {
params.payStatus = activeTab
}
fetchOrders(params)
}
// 初始化加载
useEffect(() => {
setPage(1)
setOrders([])
loadData(1)
}, [activeTab])
// 加载更多
const loadMore = () => {
if (loading || !hasMore) return
const nextPage = page + 1
setPage(nextPage)
loadData(nextPage)
}
// 格式化金额
const formatMoney = (money?: string) => {
if (!money) return '0.00'
return parseFloat(money).toFixed(2)
}
// 格式化时间戳
const formatTime = (timestamp?: number) => {
if (!timestamp) return '-'
const date = new Date(timestamp * 1000)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
// 获取支付状态文本
const getStatusText = (status?: number) => {
return status === 20 ? '已支付' : '待支付'
}
// 获取支付状态颜色
const getStatusColor = (status?: number) => {
return status === 20 ? 'text-green-600' : 'text-gray-400'
}
// 获取支付方式文本
const getPayMethodText = (method?: string) => {
if (!method) return '-'
if (method.includes('wechat') || method.includes('wx')) return '微信支付'
if (method.includes('alipay') || method.includes('ali')) return '支付宝'
return method
}
return (
<View className='min-h-screen bg-gray-50'>
{/* Tab 栏 */}
<View className='bg-white flex'>
{tabs.map(tab => (
<View
key={tab.value}
className={`flex-1 text-center py-3 relative ${
activeTab === tab.value ? 'text-orange-500 font-medium' : 'text-gray-600'
}`}
onClick={() => setActiveTab(tab.value)}
>
<Text className='text-sm'>{tab.label}</Text>
{activeTab === tab.value && (
<View className='absolute bottom-0 left-0 right-0 flex justify-center'>
<View className='w-8 h-px bg-orange-500 rounded' />
</View>
)}
</View>
))}
</View>
{/* 充值记录列表 */}
<ScrollView
scrollY
className='flex-1'
onScrollToLower={loadMore}
>
{orders.length === 0 && !loading ? (
<View className='text-center py-16'>
<Text className='text-sm text-gray-400'></Text>
</View>
) : (
<View className='p-3'>
{orders.map(order => (
<View key={order.orderId} className='bg-white rounded-lg p-4 mb-2'>
<View className='flex justify-between items-center'>
<View className='flex-1'>
<Text className='text-sm text-gray-800 block'></Text>
<Text className='text-xs text-gray-400 mt-0 block'>
{order.createTime || formatTime(order.payTime)}
</Text>
{order.orderNo && (
<Text className='text-xs text-gray-400 mt-0 block'>
: {order.orderNo}
</Text>
)}
</View>
<View className='text-right'>
<Text className='text-base font-bold text-orange-600'>
+¥{formatMoney(order.actualMoney || order.payPrice)}
</Text>
<Text className={`text-xs mt-0 block ${getStatusColor(order.payStatus)}`}>
{getStatusText(order.payStatus)}
</Text>
</View>
</View>
{/* 详细信息 */}
<View className='mt-2 pt-2 border-t border-gray-100 flex justify-between text-xs text-gray-400'>
<Text>: {getPayMethodText(order.payMethod)}</Text>
{order.payStatus === 20 && order.balance && (
<Text>: ¥{formatMoney(order.balance)}</Text>
)}
</View>
{/* 赠送金额提示 */}
{order.payStatus === 20 && order.giftMoney && parseFloat(order.giftMoney) > 0 && (
<View className='mt-1 text-xs text-green-600'>
: ¥{formatMoney(order.giftMoney)}
</View>
)}
</View>
))}
{/* 加载更多 */}
{hasMore && (
<View className='text-center py-4'>
<Text className='text-xs text-gray-400'>
{loading ? '加载中...' : '上拉加载更多'}
</Text>
</View>
)}
{!hasMore && orders.length > 0 && (
<View className='text-center py-4'>
<Text className='text-xs text-gray-400'></Text>
</View>
)}
</View>
)}
</ScrollView>
</View>
)
}
export default RechargeRecordPage

View File

@@ -0,0 +1,213 @@
import React, { useState } from 'react'
import { View, Text, Input, ScrollView } from '@tarojs/components'
import Taro, { useDidShow } from '@tarojs/taro'
import { useRequest } from '@/hooks/useRequest'
import { createUserRecharge, payUserRecharge, type UserRecharge } from '@/api/shop/shopRecharge'
import { getUserBalance } from '@/api/system/user/balance'
definePageConfig({
navigationBarTitleText: '充值',
})
const amounts = [10, 20, 50, 100, 200, 500]
const RechargePage: React.FC = () => {
const [selected, setSelected] = useState(0)
const [customAmount, setCustomAmount] = useState('')
const [balance, setBalance] = useState<string>('')
// 获取当前余额
const { run: runGetBalance } = useRequest(getUserBalance, {
manual: true,
onSuccess: (data) => {
setBalance(data.balance)
}
})
// 页面显示时刷新余额
useDidShow(() => {
runGetBalance()
})
// 发起支付
const handlePay = async (rechargeOrder: UserRecharge) => {
try {
const payParams = await payUserRecharge(rechargeOrder.rechargeId!)
if (payParams) {
// 后端返回已支付(回调丢失自动修复场景)
if ((payParams as Record<string, unknown>).paid === 'true') {
Taro.showToast({ title: '充值成功', icon: 'success' })
try {
const balanceData = await getUserBalance()
setBalance(balanceData.balance)
} catch (e) { }
setTimeout(() => {
Taro.navigateBack()
}, 1500)
return
}
// 调用微信支付
await Taro.requestPayment({
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonceStr,
package: payParams.package,
signType: payParams.signType,
paySign: payParams.paySign,
success: async () => {
Taro.showToast({ title: '充值成功', icon: 'success' })
// 立即刷新余额
try {
const balanceData = await getUserBalance()
setBalance(balanceData.balance)
} catch (e) {
// 忽略刷新失败
}
setTimeout(() => {
Taro.navigateBack()
}, 1500)
},
fail: (err) => {
if (err.errMsg.indexOf('cancel') > -1) {
Taro.showToast({ title: '已取消支付', icon: 'none' })
} else {
Taro.showToast({ title: '支付失败', icon: 'none' })
}
}
})
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '支付失败'
Taro.showToast({ title: message, icon: 'none' })
}
}
// 创建充值订单
const { run: runCreate, loading: creating } = useRequest(createUserRecharge, {
manual: true,
onSuccess: (data) => {
if (data) {
handlePay(data)
}
},
onError: (err) => {
Taro.showToast({ title: err.message || '创建订单失败', icon: 'none' })
}
})
const handleRecharge = () => {
const amount = customAmount || String(amounts[selected])
if (!amount || Number(amount) <= 0) {
Taro.showToast({ title: '请选择充值金额', icon: 'none' })
return
}
if (Number(amount) < 1) {
Taro.showToast({ title: '充值金额不能小于1元', icon: 'none' })
return
}
if (Number(amount) > 5000) {
Taro.showToast({ title: '单次充值金额不能超过5000元', icon: 'none' })
return
}
// 创建充值订单
runCreate({
amount: amount,
payType: 0, // 微信支付
})
}
return (
<View className="min-h-screen bg-gray-50 flex flex-col">
<ScrollView scrollY className="flex-1">
<View className="p-4">
{/* 当前余额提示 */}
{balance && (
<View className="bg-gradient-to-r from-orange-500 to-orange-600 rounded-xl p-4 mb-3">
<Text className="text-white text-xs opacity-80 block"></Text>
<Text className="text-white text-2xl font-bold block mt-1">¥{parseFloat(balance).toFixed(2)}</Text>
</View>
)}
{/* 充值金额 */}
<View className="bg-white rounded-xl p-4">
<Text className="text-sm font-medium text-gray-800 mb-3 block"></Text>
<View className="grid grid-cols-3 gap-2">
{amounts.map((amount, idx) => (
<View
key={amount}
className={`py-3 rounded-lg text-center border ${
selected === idx && !customAmount
? 'border-orange-500 bg-orange-50'
: 'border-gray-200'
}`}
onClick={() => { setSelected(idx); setCustomAmount('') }}
>
<Text className={`text-base font-medium ${selected === idx && !customAmount ? 'text-orange-600' : 'text-gray-700'}`}>
¥{amount}
</Text>
</View>
))}
</View>
{/* 自定义金额 */}
<View className="mt-3">
<Text className="text-sm text-gray-600 mb-1 block"></Text>
<View className="flex items-center border border-gray-200 rounded-lg px-3 py-2">
<Text className="text-sm text-gray-500 mr-1">¥</Text>
<Input
type="digit"
className="flex-1 text-sm"
placeholder="请输入金额1-5000"
value={customAmount}
onInput={e => setCustomAmount(e.detail.value)}
/>
</View>
</View>
</View>
{/* 支付方式 */}
<View className="bg-white rounded-xl p-4 mt-3">
<Text className="text-sm font-medium text-gray-800 mb-3 block"></Text>
<View className="flex items-center justify-between py-2">
<View className="flex items-center gap-2">
<Text className="text-lg">💳</Text>
<Text className="text-sm text-gray-700"></Text>
</View>
<View className="w-5 h-5 rounded-full border-2 border-orange-500 flex items-center justify-center">
<View className="w-3 h-3 rounded-full bg-orange-500" />
</View>
</View>
</View>
{/* 充值说明 */}
<View className="bg-white rounded-xl p-4 mt-3">
<Text className="text-sm font-medium text-gray-800 mb-2 block"></Text>
<View className="text-xs text-gray-400 leading-5">
<Text className="block mb-1">1. </Text>
<Text className="block mb-1">2. 使</Text>
<Text className="block mb-1">3. </Text>
<Text className="block">4. </Text>
</View>
</View>
</View>
{/* 底部按钮占位 */}
<View style={{ height: '80px' }} />
</ScrollView>
{/* 底部充值按钮 - flex 布局 */}
<View className="bg-white border-t border-gray-200 px-4 py-3" style={{ paddingBottom: '20px' }}>
<View
className="py-3 rounded-full text-center"
style={{ backgroundColor: '#f97316' }}
onClick={handleRecharge}
>
<Text className="text-white font-medium text-sm">
{creating ? '处理中...' : `立即充值 ¥${customAmount || amounts[selected]}`}
</Text>
</View>
</View>
</View>
)
}
export default RechargePage

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '兑换码充值',
})

View File

@@ -0,0 +1,294 @@
import React, { useState, useEffect } from 'react'
import { View, Text, Input, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useRequest } from '@/hooks/useRequest'
import { useScrollHeight } from '@/hooks/useScrollHeight'
import { useRechargeCode, pageRechargeCodeRecords } from '@/api/shop/shopRechargeCode'
import type { ShopRechargeCode } from '@/api/shop/shopRechargeCode/model'
import { getUserBalance } from '@/api/system/user'
definePageConfig({
navigationBarTitleText: '兑换码充值',
})
const RedeemPage: React.FC = () => {
const { isLoggedIn } = useUser()
const maxPopupHeight = useScrollHeight(0)
// 兑换码输入
const [code, setCode] = useState('')
const [submitting, setSubmitting] = useState(false)
const [redeemSuccess, setRedeemSuccess] = useState(false)
const [redeemAmount, setRedeemAmount] = useState('')
// 兑换记录
const [records, setRecords] = useState<ShopRechargeCode[]>([])
const [loadingRecords, setLoadingRecords] = useState(false)
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
// 兑换请求
const { run: runUseCode } = useRequest(useRechargeCode, { manual: true })
// 加载兑换记录
const loadRecords = async (isLoadMore = false) => {
if (isLoadMore) {
if (loadingMore || !hasMore) return
setLoadingMore(true)
} else {
setLoadingRecords(true)
setPage(1)
}
try {
const res = await pageRechargeCodeRecords({ page: isLoadMore ? page + 1 : 1, limit: 10 })
const newRecords = res?.records || []
if (isLoadMore) {
setRecords(prev => [...prev, ...newRecords])
setPage(prev => prev + 1)
} else {
setRecords(newRecords)
}
setHasMore(newRecords.length >= 10)
} catch (err) {
console.error('加载兑换记录失败', err)
} finally {
setLoadingRecords(false)
setLoadingMore(false)
}
}
// 初始化加载
useEffect(() => {
if (isLoggedIn) {
loadRecords()
}
}, [isLoggedIn])
// 兑换
const handleRedeem = async () => {
const trimCode = code.trim().toUpperCase()
if (!trimCode) {
Taro.showToast({ title: '请输入兑换码', icon: 'none' })
return
}
setSubmitting(true)
try {
const res = await runUseCode(trimCode)
setRedeemSuccess(true)
setRedeemAmount(res?.amount || '0')
Taro.showToast({ title: '兑换成功', icon: 'success' })
// 刷新余额(通过事件通知)
const pages = Taro.getCurrentPages()
const userPage = pages.find(p => p.route === 'pages/user/wallet')
if (userPage) {
userPage.onShow()
}
// 刷新记录
await loadRecords()
} catch (err: any) {
Taro.showToast({ title: err.message || '兑换失败,兑换码无效或已过期', icon: 'none' })
} finally {
setSubmitting(false)
}
}
// 重置表单
const resetForm = () => {
setCode('')
setRedeemSuccess(false)
setRedeemAmount('')
}
// 复制兑换码
const copyCode = (codeStr: string) => {
Taro.setClipboardData({
data: codeStr,
success: () => {
Taro.showToast({ title: '已复制', icon: 'success' })
}
})
}
// 滚动到底部加载更多
const handleScrollToLower = () => {
if (hasMore && !loadingMore) {
loadRecords(true)
}
}
// 状态文本
const getStatusText = (status: number) => {
const map: Record<number, string> = { 0: '未使用', 1: '已使用', 2: '已过期' }
return map[status] || '未知'
}
// 状态颜色
const getStatusColor = (status: number) => {
const map: Record<number, string> = { 0: 'text-blue-500', 1: 'text-green-500', 2: 'text-gray-400' }
return map[status] || 'text-gray-400'
}
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
<ScrollView
scrollY
className='flex-1'
onScrollToLower={handleScrollToLower}
lowerThreshold={100}
>
<View className='p-3'>
{/* 兑换说明卡片 */}
<View className='bg-white rounded-xl p-4 mb-3'>
<View className='flex items-start gap-3'>
<Text className='text-2xl'>🎫</Text>
<View className='flex-1'>
<Text className='text-sm font-medium text-gray-800 block'></Text>
<Text className='text-xs text-gray-500 block mt-1'>
使
</Text>
</View>
</View>
</View>
{/* 兑换表单 */}
{!redeemSuccess ? (
<View className='bg-white rounded-xl p-4'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='bg-gray-50 rounded-lg px-4 py-3 mb-4'>
<Input
className='text-sm'
placeholder='请输入兑换码'
value={code}
onInput={(e) => setCode(e.detail.value)}
maxlength={32}
/>
</View>
<View
className={`py-3 px-4 rounded-lg text-center text-sm font-medium ${
code.trim() && !submitting
? 'bg-green-600 text-white'
: 'bg-gray-200 text-gray-400'
}`}
onClick={handleRedeem}
>
{submitting ? '兑换中...' : '立即兑换'}
</View>
</View>
) : (
/* 兑换成功结果 */
<View className='bg-white rounded-xl p-4'>
<View className='text-center py-6'>
<Text className='text-5xl block mb-4'>🎉</Text>
<Text className='text-lg font-medium text-gray-800 block'></Text>
<Text className='text-sm text-gray-500 block mt-2'>
¥{redeemAmount}
</Text>
</View>
<View className='flex gap-3'>
<View
className='flex-1 py-3 px-4 rounded-lg text-center text-sm font-medium bg-gray-100 text-gray-700'
onClick={resetForm}
>
</View>
<View
className='flex-1 py-3 px-4 rounded-lg text-center text-sm font-medium bg-green-600 text-white'
onClick={() => Taro.navigateTo({ url: '/pages/user/wallet' })}
>
</View>
</View>
</View>
)}
{/* 兑换记录 */}
<View className='bg-white rounded-xl mt-3 overflow-hidden'>
<View className='p-4 border-b border-gray-100 flex justify-between items-center'>
<Text className='text-sm font-medium text-gray-800'></Text>
<Text
className='text-xs text-gray-400'
onClick={() => loadRecords()}
>
🔄
</Text>
</View>
{loadingRecords ? (
<View className='py-10 text-center'>
<Text className='text-sm text-gray-400'>...</Text>
</View>
) : records.length === 0 ? (
<View className='py-10 text-center'>
<Text className='text-4xl block mb-3'>📋</Text>
<Text className='text-sm text-gray-400'></Text>
</View>
) : (
<View>
{records.map((record, idx) => (
<View
key={record.id}
className={`p-4 flex justify-between items-center border-b border-gray-50 ${idx === records.length - 1 ? 'border-b-0' : ''}`}
>
<View className='flex-1'>
<View className='flex items-center gap-2'>
<Text className={`text-xs ${getStatusColor(record.status)}`}>
{getStatusText(record.status)}
</Text>
{record.status === 1 && (
<Text className='text-xs text-green-500'>
+¥{Number(record.amount).toFixed(2)}
</Text>
)}
</View>
<Text
className='text-xs text-gray-400 mt-1 block'
onClick={() => copyCode(record.code)}
>
: {record.code} 📋
</Text>
<Text className='text-xs text-gray-300 mt-1 block'>
{record.usedAt || record.createTime}
</Text>
</View>
{record.status === 1 && (
<Text className='text-base font-bold text-green-500'>
+¥{Number(record.amount).toFixed(2)}
</Text>
)}
</View>
))}
{loadingMore && (
<View className='py-3 text-center'>
<Text className='text-xs text-gray-400'>...</Text>
</View>
)}
{!hasMore && records.length > 0 && (
<View className='py-3 text-center'>
<Text className='text-xs text-gray-400'></Text>
</View>
)}
</View>
)}
</View>
<View className='h-6' />
</View>
</ScrollView>
</View>
)
}
export default RedeemPage

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '支付注册费',
})

View File

@@ -0,0 +1,240 @@
import React, { useState, useEffect } from 'react'
import { View, Text, Image, Input } from '@tarojs/components'
import Taro, { useRouter } from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useScrollHeight } from '@/hooks/useScrollHeight'
import { getRegisterPayInfo, getMyRegisterStatus, RegisterPayInfo } from '@/api/shop/shopMemberRegister'
import { getUserInfo } from '@/api/passport/login'
import PayModal from '@/components/business/PayModal'
definePageConfig({
navigationBarTitleText: '支付注册费',
})
const RegisterPayPage: React.FC = () => {
const router = useRouter()
const { user, isLoggedIn, refreshUser } = useUser()
const scrollHeight = useScrollHeight()
const [loading, setLoading] = useState(true)
const [payInfo, setPayInfo] = useState<RegisterPayInfo | null>(null)
const [showPayModal, setShowPayModal] = useState(false)
const [userStatus, setUserStatus] = useState<any>(null)
// 获取URL参数
const phone = router.params.phone || ''
const registerId = router.params.registerId ? Number(router.params.registerId) : 0
useEffect(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
return
}
checkAndFetchData()
}, [isLoggedIn])
// 检查用户状态并获取支付信息
const checkAndFetchData = async () => {
try {
setLoading(true)
// 检查当前用户状态
const status = await getMyRegisterStatus()
setUserStatus(status)
// 如果已经是会员,跳转到会员中心
if (status?.isDealer) {
Taro.showModal({
title: '提示',
content: '您已经是会员,无需再次支付注册费',
showCancel: false,
success: () => {
Taro.navigateBack()
},
})
return
}
// 获取支付信息
if (phone) {
const info = await getRegisterPayInfo(phone)
if (info) {
setPayInfo(info)
} else {
Taro.showToast({ title: '未找到支付信息,请联系上级会员', icon: 'none' })
}
} else {
// 如果没有传入手机号,尝试获取当前用户的注册记录
if (status?.register) {
setPayInfo({
id: status.register.id,
phone: status.register.phone,
realName: status.register.realName,
registerFee: status.register.registerFee,
dealerName: status.register.dealerName,
dealerPhone: status.register.dealerPhone,
})
}
}
} catch (e: any) {
console.error('获取数据失败:', e)
Taro.showToast({ title: e.message || '获取数据失败', icon: 'none' })
} finally {
setLoading(false)
}
}
// 发起支付
const handlePay = () => {
if (!payInfo) {
Taro.showToast({ title: '支付信息加载中,请稍候', icon: 'none' })
return
}
setShowPayModal(true)
}
// 支付成功回调
const handlePaySuccess = async () => {
setShowPayModal(false)
Taro.showModal({
title: '支付成功',
content: '恭喜!您已成为平台会员,请使用手机号登录查看。',
showCancel: false,
success: () => {
// 刷新用户信息
refreshUser?.()
Taro.redirectTo({ url: '/pages/user/index' })
},
})
}
// 支付失败回调
const handlePayFail = (err: string) => {
setShowPayModal(false)
Taro.showToast({ title: err || '支付失败', icon: 'none' })
}
if (!isLoggedIn) return null
if (loading) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-gray-400'>...</Text>
</View>
)
}
return (
<View className='min-h-screen bg-gray-50'>
<View className='p-4' style={{ height: scrollHeight }}>
{/* 支付卡片 */}
<View className='bg-white rounded-2xl overflow-hidden shadow-sm'>
{/* 顶部背景 */}
<View
className='p-6 text-center'
style={{ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}
>
<Text className='text-white text-sm'></Text>
<View className='flex items-center justify-center mt-2'>
<Text className='text-white text-3xl font-bold'>¥</Text>
<Text className='text-white text-5xl font-bold ml-1'>198</Text>
<Text className='text-white text-xl font-medium ml-1'>.00</Text>
</View>
</View>
{/* 支付信息 */}
<View className='p-4'>
<View className='flex items-start gap-3 mb-4'>
<View className='w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0'>
<Text className='text-base'>💡</Text>
</View>
<View>
<Text className='text-gray-800 text-sm font-medium block'></Text>
<Text className='text-gray-500 text-xs mt-1 block leading-relaxed'>
</Text>
</View>
</View>
{/* 费用明细 */}
<View className='bg-gray-50 rounded-xl p-4 mb-4'>
<Text className='text-gray-600 text-sm font-medium mb-3 block'></Text>
<View className='space-y-2'>
<View className='flex justify-between'>
<Text className='text-gray-500 text-sm'>()</Text>
<Text className='text-gray-600 text-sm'>¥298.00</Text>
</View>
<View className='flex justify-between'>
<Text className='text-gray-500 text-sm'>()</Text>
<Text className='text-gray-600 text-sm'>¥198.00</Text>
</View>
<View className='border-t border-gray-200 pt-2 flex justify-between'>
<Text className='text-gray-700 text-sm font-medium'></Text>
<Text className='text-orange-500 text-base font-bold'>¥496.00</Text>
</View>
</View>
</View>
{/* 提示 */}
<View className='bg-orange-50 rounded-xl p-3 mb-4'>
<Text className='text-xs text-orange-600 leading-relaxed block'>
(¥298)线(¥198)
</Text>
</View>
{/* 会员权益 */}
<View className='mb-4'>
<Text className='text-gray-600 text-sm font-medium mb-3 block'></Text>
<View className='grid grid-cols-2 gap-2'>
{[
{ icon: '💰', text: '下级消费获得佣金' },
{ icon: '🎁', text: '兑换权益包' },
{ icon: '📈', text: '团队业绩统计' },
{ icon: '💳', text: '佣金提现到微信' },
].map((item, idx) => (
<View key={idx} className='flex items-center gap-2 bg-gray-50 rounded-lg p-2'>
<Text className='text-base'>{item.icon}</Text>
<Text className='text-gray-600 text-xs'>{item.text}</Text>
</View>
))}
</View>
</View>
</View>
</View>
{/* 下一步按钮 */}
<View className='mt-6'>
<View
className='rounded-full py-4 text-center'
style={{ backgroundColor: '#667eea' }}
onClick={handlePay}
>
<Text className='text-white text-lg font-medium'> ¥198.00</Text>
</View>
</View>
{/* 联系客服 */}
<View className='mt-4 text-center'>
<Text
className='text-gray-400 text-sm'
onClick={() => Taro.navigateTo({ url: '/pages/user/customer-service/index' })}
>
</Text>
</View>
</View>
{/* 支付弹窗 */}
{showPayModal && payInfo && (
<PayModal
amount={payInfo.registerFee || 198}
subject='注册费'
onSuccess={handlePaySuccess}
onFail={handlePayFail}
onClose={() => setShowPayModal(false)}
/>
)}
</View>
)
}
export default RegisterPayPage

View File

@@ -0,0 +1,77 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
definePageConfig({
navigationBarTitleText: '设置',
})
const SettingPage: React.FC = () => {
const { isLoggedIn, logoutUser } = useUser()
const handleClearCache = () => {
Taro.showModal({
title: '提示',
content: '确定要清除缓存吗?',
success: (res) => {
if (res.confirm) {
Taro.clearStorageSync()
Taro.showToast({ title: '缓存已清除', icon: 'success' })
}
},
})
}
const handleLogout = () => {
if (!isLoggedIn) return
Taro.showModal({
title: '提示',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
logoutUser()
Taro.reLaunch({ url: '/pages/index/index' })
}
},
})
}
const menuItems = [
{ label: '清除缓存', action: handleClearCache },
{ label: '关于我们', action: () => Taro.showToast({ title: 'v1.0.0', icon: 'none' }) },
{ label: '用户协议', action: () => Taro.navigateTo({ url: '/passport/agreement' }) },
{ label: '隐私政策', action: () => Taro.navigateTo({ url: '/passport/agreement' }) },
]
return (
<View className='min-h-screen bg-gray-50 p-3'>
<View className='bg-white rounded-xl overflow-hidden'>
{menuItems.map((item, idx) => (
<View
key={item.label}
className={`flex items-center justify-between px-4 py-3 ${
idx < menuItems.length - 1 ? 'border-b border-gray-50' : ''
}`}
onClick={item.action}
>
<Text className='text-sm text-gray-700'>{item.label}</Text>
<Text className='text-gray-300 text-sm'>{'>'}</Text>
</View>
))}
</View>
{isLoggedIn && (
<View className='mt-6 mx-4'>
<View
className='py-2 rounded-full text-center bg-white'
onClick={handleLogout}
>
<Text className='text-red-500 text-sm font-medium'>退</Text>
</View>
</View>
)}
</View>
)
}
export default SettingPage

View File

@@ -0,0 +1,217 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, Input, Switch } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useScrollHeight } from '@/hooks/useScrollHeight'
import { getShopSettingByCategory, batchSaveShopSetting } from '@/api/shop/shopSetting'
definePageConfig({
navigationBarTitleText: '商城设置',
})
type Category = 'basic' | 'order' | 'points' | 'dealer' | 'notify'
const CATEGORIES: { key: Category; label: string }[] = [
{ key: 'basic', label: '基础设置' },
{ key: 'order', label: '订单设置' },
{ key: 'points', label: '积分设置' },
{ key: 'dealer', label: '分销设置' },
{ key: 'notify', label: '通知设置' },
]
const ShopSettingPage: React.FC = () => {
const scrollHeight = useScrollHeight(44)
const [activeCategory, setActiveCategory] = useState<Category>('basic')
const [settings, setSettings] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => {
fetchSettings(activeCategory)
}, [activeCategory])
const fetchSettings = async (category: Category) => {
setLoading(true)
try {
const list = await getShopSettingByCategory(category)
const map: Record<string, string> = {}
list.forEach(item => {
if (item.settingKey) {
map[item.settingKey] = item.settingValue ?? ''
}
})
setSettings(map)
} catch (e: any) {
Taro.showToast({ title: e?.message || '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
const handleSave = async () => {
setSaving(true)
try {
await batchSaveShopSetting(activeCategory, settings as Record<string, unknown>)
Taro.showToast({ title: '保存成功', icon: 'success' })
} catch (e: any) {
Taro.showToast({ title: e?.message || '保存失败', icon: 'none' })
} finally {
setSaving(false)
}
}
const handleChange = (key: string, value: string) => {
setSettings(prev => ({ ...prev, [key]: value }))
}
const handleSwitchChange = (key: string, checked: boolean) => {
setSettings(prev => ({ ...prev, [key]: checked ? '1' : '0' }))
}
const renderSettingItem = (key: string, label: string, type: 'text' | 'switch' | 'number' = 'text', placeholder = '') => {
const value = settings[key] ?? ''
if (type === 'switch') {
return (
<View key={key} className='flex items-center justify-between px-4 py-3 border-b border-gray-50'>
<Text className='text-sm text-gray-700'>{label}</Text>
<Switch
checked={value === '1' || value === 'true'}
color='#0e932e'
onChange={e => handleSwitchChange(key, e.detail.value)}
/>
</View>
)
}
return (
<View key={key} className='flex items-center justify-between px-4 py-3 border-b border-gray-50'>
<Text className='text-sm text-gray-700 flex-shrink-0 mr-3'>{label}</Text>
<Input
className='text-sm text-gray-800 text-right flex-1'
value={value}
type={type === 'number' ? 'number' : 'text'}
placeholder={placeholder || `请输入${label}`}
placeholderStyle='color:#ccc;font-size:12px'
onInput={e => handleChange(key, e.detail.value)}
/>
</View>
)
}
const renderCategorySettings = () => {
if (loading) {
return (
<View className='flex items-center justify-center py-10'>
<Text className='text-gray-400 text-sm'>...</Text>
</View>
)
}
switch (activeCategory) {
case 'basic':
return (
<View className='bg-white rounded-xl overflow-hidden'>
{renderSettingItem('shopName', '商城名称', 'text', '请输入商城名称')}
{renderSettingItem('shopLogo', '商城Logo', 'text', '请输入Logo地址')}
{renderSettingItem('shopDesc', '商城描述', 'text', '请输入商城描述')}
{renderSettingItem('shopPhone', '客服电话', 'text', '请输入客服电话')}
{renderSettingItem('shopAddress', '商城地址', 'text', '请输入商城地址')}
{renderSettingItem('shopEnabled', '商城开关', 'switch')}
</View>
)
case 'order':
return (
<View className='bg-white rounded-xl overflow-hidden'>
{renderSettingItem('orderAutoConfirmDays', '自动确认收货(天)', 'number', '如: 7')}
{renderSettingItem('orderAutoCloseMins', '未付款自动关闭(分钟)', 'number', '如: 30')}
{renderSettingItem('orderAutoCommentDays', '自动好评(天)', 'number', '如: 15')}
{renderSettingItem('orderFreightFreeAmount', '免运费金额', 'number', '如: 99')}
{renderSettingItem('orderRefundEnabled', '允许退款', 'switch')}
</View>
)
case 'points':
return (
<View className='bg-white rounded-xl overflow-hidden'>
{renderSettingItem('pointsEnabled', '积分功能', 'switch')}
{renderSettingItem('pointsSigninEnabled', '签到积分', 'switch')}
{renderSettingItem('pointsSigninAmount', '每日签到积分', 'number', '如: 10')}
{renderSettingItem('pointsOrderRate', '消费积分比例(元/积分)', 'number', '如: 10')}
{renderSettingItem('pointsExchangeRate', '积分兑换比例(积分/元)', 'number', '如: 100')}
</View>
)
case 'dealer':
return (
<View className='bg-white rounded-xl overflow-hidden'>
{renderSettingItem('dealerEnabled', '分销功能', 'switch')}
{renderSettingItem('dealerApplyEnabled', '开放申请', 'switch')}
{renderSettingItem('dealerLevel1Rate', '一级佣金比例(%)', 'number', '如: 10')}
{renderSettingItem('dealerLevel2Rate', '二级佣金比例(%)', 'number', '如: 5')}
{renderSettingItem('dealerWithdrawMin', '最低提现金额', 'number', '如: 10')}
</View>
)
case 'notify':
return (
<View className='bg-white rounded-xl overflow-hidden'>
{renderSettingItem('notifyOrderEnabled', '订单通知', 'switch')}
{renderSettingItem('notifyPayEnabled', '支付通知', 'switch')}
{renderSettingItem('notifyRefundEnabled', '退款通知', 'switch')}
{renderSettingItem('notifyShipEnabled', '发货通知', 'switch')}
{renderSettingItem('notifySignEnabled', '签收通知', 'switch')}
</View>
)
default:
return null
}
}
return (
<View className='min-h-screen bg-gray-50'>
{/* 分类 Tab */}
<View className='bg-white flex overflow-x-auto border-b border-gray-100'>
{CATEGORIES.map(cat => (
<View
key={cat.key}
className='flex-shrink-0 px-4 py-3 text-center'
onClick={() => setActiveCategory(cat.key)}
>
<Text
className='text-sm'
style={{ color: activeCategory === cat.key ? '#0e932e' : '#666' }}
>
{cat.label}
</Text>
{activeCategory === cat.key && (
<View
className='mt-1 mx-auto rounded-full'
style={{ height: '2px', backgroundColor: '#0e932e', width: '20px' }}
/>
)}
</View>
))}
</View>
<ScrollView scrollY style={{ height: scrollHeight }}>
<View className='p-3'>
{renderCategorySettings()}
</View>
{/* 保存按钮 */}
{!loading && (
<View className='px-4 py-3'>
<View
className='py-3 rounded-xl text-center'
style={{ backgroundColor: saving ? '#ccc' : '#0e932e' }}
onClick={!saving ? handleSave : undefined}
>
<Text className='text-white text-sm font-medium'>
{saving ? '保存中...' : '保存设置'}
</Text>
</View>
</View>
)}
<View className='h-6' />
</ScrollView>
</View>
)
}
export default ShopSettingPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '我的团队',
}

View File

@@ -0,0 +1,127 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useScrollHeight } from '@/hooks/useScrollHeight'
import { listShopUserReferee } from '@/api/shop/shopUserReferee'
import type { ShopUserReferee } from '@/api/shop/shopUserReferee/model'
import EmptyState from '@/components/common/EmptyState'
definePageConfig({
navigationBarTitleText: '我的团队',
})
const TeamPage: React.FC = () => {
const { isLoggedIn } = useUser()
const [teamMembers, setTeamMembers] = useState<ShopUserReferee[]>([])
const [loading, setLoading] = useState(true)
const scrollHeight = useScrollHeight(44)
useEffect(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
return
}
fetchTeamMembers()
}, [isLoggedIn])
const fetchTeamMembers = async () => {
try {
const data = await listShopUserReferee({})
setTeamMembers(data || [])
} catch (e) {
console.error('获取团队信息失败:', e)
} finally {
setLoading(false)
}
}
if (!isLoggedIn) {
return null
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY style={{ height: scrollHeight }}>
{/* 团队统计 */}
<View className='mx-3 mt-3 p-4 bg-white rounded-lg'>
<View className='flex justify-around'>
<View className='text-center'>
<Text className='text-2xl font-bold text-green-600 block'>
{teamMembers.length}
</Text>
<Text className='text-xs text-gray-500'></Text>
</View>
<View className='text-center'>
<Text className='text-2xl font-bold text-blue-500 block'>
{teamMembers.filter(m => m.isActive).length}
</Text>
<Text className='text-xs text-gray-500'></Text>
</View>
<View className='text-center'>
<Text className='text-2xl font-bold text-orange-500 block'>
{teamMembers.filter(m => m.isMember).length}
</Text>
<Text className='text-xs text-gray-500'></Text>
</View>
</View>
</View>
{/* 团队成员列表 */}
<View className='mx-3 mt-3'>
<Text className='text-base font-medium text-gray-800 mb-2 block'></Text>
{loading ? (
<View className='flex items-center justify-center py-10'>
<Text className='text-gray-400'>...</Text>
</View>
) : teamMembers.length === 0 ? (
<EmptyState text='暂无团队成员' />
) : (
<View className='bg-white rounded-lg overflow-hidden'>
{teamMembers.map((member, idx) => (
<View
key={member.id}
className={`flex items-center px-4 py-3 ${
idx < teamMembers.length - 1 ? 'border-b border-gray-50' : ''
}`}
>
<Image
className='w-10 h-10 rounded-full bg-gray-100'
src={member.avatar}
mode='aspectFill'
/>
<View className='flex-1 ml-3'>
<View className='flex items-center gap-2'>
<Text className='text-sm font-medium text-gray-800'>
{member.nickname || '用户'}
</Text>
{member.isMember && (
<View className='px-1 py-0 bg-orange-500 rounded'>
<Text className='text-xs text-white'></Text>
</View>
)}
</View>
<Text className='text-xs text-gray-400'>
{member.createTime}
</Text>
</View>
<View className='text-right'>
<Text className='text-sm font-medium text-green-500'>
¥{member.totalCommission || 0}
</Text>
<Text className='text-xs text-gray-400 block'></Text>
</View>
</View>
))}
</View>
)}
</View>
<View className='h-6' />
</ScrollView>
</View>
)
}
export default TeamPage

View File

@@ -0,0 +1,6 @@
export default {
navigationBarTitleText: '我的',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
backgroundColor: '#f8f8f8'
}

View File

@@ -0,0 +1,9 @@
.home-page {
min-height: 100vh;
padding: 32px;
box-sizing: border-box;
.nut-avatar {
margin: 0 auto;
}
}

210
src_bak/pages/user/user.tsx Normal file
View File

@@ -0,0 +1,210 @@
import React, { useEffect, useState } from 'react'
import { View, Text, Image, ScrollView } from '@tarojs/components'
import Taro, { useDidShow } from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useScrollHeight } from '@/hooks/useScrollHeight'
import { useRequest } from '@/hooks/useRequest'
import { getUserCardStats, getUserOrderStats, type UserCardStats, type UserOrderStats } from '@/api/shop/shopUserCard'
import { getMyClerk } from '@/api/shop/shopStoreUser'
import MemberBadge from '@/components/business/MemberBadge'
const UserPage: React.FC = () => {
const scrollHeight = useScrollHeight()
const { user, isLoggedIn, loading: userLoading, refreshUser, syncFromStorage } = useUser()
// 查询门店关联信息
const [storeInfo, setStoreInfo] = useState<any>(null)
// 获取用户卡片统计(余额/积分/优惠券/礼品卡)
const { data: cardStats, run: runCardStats, loading: cardStatsLoading } = useRequest(getUserCardStats, {
manual: true,
refreshDeps: [isLoggedIn]
})
// 获取用户订单统计
const { data: orderStats, run: runOrderStats, loading: orderStatsLoading } = useRequest(getUserOrderStats, {
manual: true,
refreshDeps: [isLoggedIn]
})
// 登录后加载数据
useEffect(() => {
if (isLoggedIn) {
runCardStats()
runOrderStats()
// 查询门店关联
getMyClerk().then(data => setStoreInfo(data)).catch(() => setStoreInfo(null))
} else {
setStoreInfo(null)
}
}, [isLoggedIn])
// 每次页面重新显示时(从登录页返回、从其他页面返回)检查登录状态并刷新数据
useDidShow(() => {
// 先从 storage 快速同步,立即更新 UI无网络延迟
const hasUser = syncFromStorage()
if (hasUser) {
runCardStats()
runOrderStats()
// 刷新门店关联
getMyClerk().then(data => setStoreInfo(data)).catch(() => setStoreInfo(null))
}
})
const menuItems = [
// { icon: '🏆', label: '赛事活动', url: '/pages/event/my/index' },
// { icon: '📅', label: '预约穿线', url: '/pages/booking/list/index' },
// { icon: '🎫', label: '优惠券', url: '/pages/user/coupon-list' },
{ icon: '💰', label: '我的钱包', url: '/pages/user/wallet' },
{ icon: '📍', label: '收货地址', url: '/pages/user/address-list' },
// { icon: '⭐', label: '积分明细', url: '/pages/user/points-record' },
{ icon: '❤️', label: '我的收藏', url: '/pages/user/favorite-list/index' },
// { icon: '🕐', label: '浏览历史', url: '/pages/user/history-list/index' },
{ icon: '❓', label: '帮助中心', url: '/pages/user/help-center/index' },
// { icon: '🔔', label: '消息通知', url: '/pages/index/notification' },
// 门店管理:仅门店经理/店员显示
// ...(storeInfo ? [{ icon: '🏪', label: '门店管理', url: '/pages/store/list/index' }] : []),
{ icon: '⚙️', label: '设置', url: '/pages/user/setting' },
]
const handleAvatarClick = () => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
} else {
Taro.navigateTo({ url: '/pages/user/profile' })
}
}
const loading = cardStatsLoading || orderStatsLoading
// 下拉刷新状态
const [refreshing, setRefreshing] = useState(false)
// 下拉刷新:同时刷新用户信息、卡片统计、订单统计
const onRefresh = async () => {
if (!isLoggedIn) { setRefreshing(false); return }
setRefreshing(true)
try {
await Promise.all([refreshUser(), runCardStats(), runOrderStats()])
} finally {
setRefreshing(false)
}
}
return (
<View className='h-full bg-gray-50'>
<ScrollView scrollY refresherEnabled={!!isLoggedIn} refresherTriggered={refreshing} onRefresherRefresh={onRefresh} style={{ height: scrollHeight }}>
{/* 用户信息卡片 */}
<View className='mx-3 mt-3 p-4 bg-white rounded-xl'>
<View className='flex items-center gap-3' onClick={handleAvatarClick}>
{isLoggedIn && user?.avatar ? (
<Image className='w-14 h-14 rounded-full' src={user.avatar} mode='aspectFill' />
) : (
<View className='w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center'>
<Text className='text-2xl text-gray-300'>👤</Text>
</View>
)}
<View className='flex-1'>
<Text className='text-lg font-medium text-gray-800 block'>
{isLoggedIn ? (user?.nickname || user?.phone || '用户') : '点击登录'}
</Text>
<View className='flex items-center gap-2 mt-1'>
{isLoggedIn && <MemberBadge levelName={(user as any)?.memberLevelName} />}
</View>
</View>
{isLoggedIn && <Text className='text-gray-300 text-sm'>{'>'}</Text>}
</View>
{/* 数据概览 */}
{isLoggedIn && (
<View className='grid grid-cols-3 gap-2 mt-4 pt-4 border-t border-gray-50'>
<View className='text-center' onClick={() => Taro.navigateTo({ url: '/pages/user/wallet' })}>
{loading ? (
<Text className='text-lg font-bold text-gray-300 block'>...</Text>
) : (
<Text className='text-lg font-bold text-gray-800 block'>
{cardStats?.balance || '0.00'}
</Text>
)}
<Text className='text-xs text-gray-400'></Text>
</View>
<View className='text-center' onClick={() => Taro.navigateTo({ url: '/pages/user/points-record' })}>
{loading ? (
<Text className='text-lg font-bold text-gray-300 block'>...</Text>
) : (
<Text className='text-lg font-bold text-gray-800 block'>
{cardStats?.points || (user as any)?.points || 0}
</Text>
)}
<Text className='text-xs text-gray-400'></Text>
</View>
<View className='text-center' onClick={() => Taro.navigateTo({ url: '/pages/user/coupon-list' })}>
{loading ? (
<Text className='text-lg font-bold text-gray-300 block'>...</Text>
) : (
<Text className='text-lg font-bold text-gray-800 block'>
{cardStats?.coupons || 0}
</Text>
)}
<Text className='text-xs text-gray-400'></Text>
</View>
</View>
)}
</View>
{/* 我的订单快捷入口 */}
<View className='bg-white rounded-xl mx-3 mt-3 p-4'>
<View className='flex justify-between items-center mb-3'>
<Text className='text-base font-medium text-gray-800'></Text>
<Text className='text-xs text-gray-400' onClick={() => Taro.navigateTo({ url: '/pages/order/list' })}>
{'>'}
</Text>
</View>
<View className='grid grid-cols-4 gap-2'>
{[
{ icon: '💳', label: '待付款', status: 0, count: orderStats?.pending },
{ icon: '📦', label: '待发货', status: 1, count: orderStats?.paid },
{ icon: '🚚', label: '待收货', status: 2, count: orderStats?.shipped },
{ icon: '✅', label: '已完成', status: 3, count: orderStats?.completed },
].map(item => (
<View
key={item.status}
className='flex flex-col items-center py-2 relative'
onClick={() => Taro.navigateTo({ url: `/pages/order/list?tab=${item.status}` })}
>
<Text className='text-xl mb-1'>{item.icon}</Text>
<Text className='text-xs text-gray-600'>{item.label}</Text>
{/* 数量角标 */}
{item.count && item.count > 0 && (
<View className='absolute top-0 right-2 bg-red-500 text-white text-xs rounded-full min-w-4 h-4 flex items-center justify-center px-1'>
{item.count > 99 ? '99+' : item.count}
</View>
)}
</View>
))}
</View>
</View>
{/* 功能菜单 */}
<View className='bg-white rounded-xl mx-3 mt-3 overflow-hidden'>
{menuItems.map((item, idx) => (
<View
key={item.label}
className={`flex items-center justify-between px-4 py-3 ${
idx < menuItems.length - 1 ? 'border-b border-gray-50' : ''
}`}
onClick={() => Taro.navigateTo({ url: item.url })}
>
<View className='flex items-center gap-3'>
<Text className='text-base'>{item.icon}</Text>
<Text className='text-sm text-gray-700'>{item.label}</Text>
</View>
<Text className='text-gray-300 text-sm'>{'>'}</Text>
</View>
))}
</View>
<View className='h-6' />
</ScrollView>
</View>
)
}
export default UserPage

View File

@@ -0,0 +1,88 @@
import React, { useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useRequest } from '@/hooks/useRequest'
import { getUserCardStats, type UserCardStats } from '@/api/shop/shopUserCard'
definePageConfig({
navigationBarTitleText: '我的钱包',
})
const WalletPage: React.FC = () => {
const { isLoggedIn } = useUser()
// 获取用户余额信息(使用与"我的"页面相同的 API
const { data: balanceData, run: fetchBalance, loading: balanceLoading } = useRequest(getUserCardStats, {
manual: true,
})
// 加载数据
useEffect(() => {
if (isLoggedIn) {
fetchBalance()
}
}, [isLoggedIn, fetchBalance])
// getUserCardStats 返回 { balance: string }
const balance = (balanceData as UserCardStats)?.balance || '0.00'
// 格式化金额
const formatAmount = (amount: string) => {
return parseFloat(amount).toFixed(2)
}
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
<ScrollView scrollY className='flex-1' onScrollToLower={() => {}}>
{/* 余额卡片 */}
<View className='mx-3 mt-3 p-5 rounded-xl text-center' style={{ background: 'linear-gradient(135deg, #f59e0b, #f97316)' }}>
<Text className='text-white text-sm opacity-80 block mb-1'> ()</Text>
<Text className='text-white text-3xl font-bold block'>
{balanceLoading ? '...' : formatAmount(balance)}
</Text>
<View className='flex gap-3 mt-4'>
<View
className='flex-1 py-2 rounded-full text-center'
style={{ backgroundColor: 'rgba(255,255,255,0.2)' }}
onClick={() => Taro.navigateTo({ url: '/pages/user/recharge' })}
>
<Text className='text-white text-sm font-medium'></Text>
</View>
</View>
</View>
{/* 快捷操作 */}
<View className='mx-3 mt-3 bg-white rounded-xl p-4'>
<View className='flex justify-between'>
<View
className='flex-1 flex flex-col items-center py-2'
onClick={() => Taro.navigateTo({ url: '/pages/user/balance-log/index' })}
>
<Text className='text-lg mb-1'>📄</Text>
<Text className='text-xs text-gray-600'></Text>
</View>
<View
className='flex-1 flex flex-col items-center py-2'
onClick={() => Taro.navigateTo({ url: '/pages/user/recharge-record/index' })}
>
<Text className='text-lg mb-1'>💳</Text>
<Text className='text-xs text-gray-600'></Text>
</View>
<View
className='flex-1 flex flex-col items-center py-2'
onClick={() => Taro.navigateTo({ url: '/pages/user/redeem/index' })}
>
<Text className='text-lg mb-1'>🎫</Text>
<Text className='text-xs text-gray-600'></Text>
</View>
</View>
</View>
<View className='h-6' />
</ScrollView>
</View>
)
}
export default WalletPage

View File

@@ -0,0 +1,139 @@
.withdraw-list-page {
display: flex;
flex-direction: column;
height: 100vh;
background: #f5f5f5;
}
.stats-card {
display: flex;
justify-content: space-around;
margin: 24rpx;
padding: 32rpx;
background: #fff;
border-radius: 16rpx;
}
.stats-item {
display: flex;
flex-direction: column;
align-items: center;
}
.stats-label {
font-size: 24rpx;
color: #666;
margin-bottom: 8rpx;
}
.stats-value {
font-size: 36rpx;
font-weight: bold;
color: #333;
}
.tab-bar {
display: flex;
background: #fff;
border-bottom: 1rpx solid #eee;
}
.tab-item {
flex: 1;
text-align: center;
padding: 24rpx 0;
font-size: 28rpx;
color: #666;
position: relative;
}
.tab-item.active {
color: #ff6b35;
font-weight: bold;
}
.tab-item.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 48rpx;
height: 4rpx;
background: #ff6b35;
border-radius: 2rpx;
}
.withdraw-scroll {
flex: 1;
padding: 24rpx;
}
.withdraw-item {
background: #fff;
border-radius: 12rpx;
padding: 24rpx;
margin-bottom: 16rpx;
}
.withdraw-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.withdraw-amount {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.status-tag {
font-size: 22rpx;
padding: 4rpx 12rpx;
border-radius: 4rpx;
}
.status-pending {
color: #ff9a56;
background: rgba(255, 154, 86, 0.1);
}
.status-success {
color: #07c160;
background: rgba(7, 193, 96, 0.1);
}
.status-fail {
color: #999;
background: rgba(153, 153, 153, 0.1);
}
.withdraw-info {
display: flex;
gap: 24rpx;
margin-bottom: 8rpx;
}
.info-text {
font-size: 24rpx;
color: #666;
}
.withdraw-time {
font-size: 22rpx;
color: #999;
display: block;
margin-bottom: 8rpx;
}
.audit-remark {
font-size: 22rpx;
color: #ff6b35;
display: block;
margin-top: 8rpx;
padding: 8rpx;
background: #f5f5f5;
border-radius: 4rpx;
}

View File

@@ -0,0 +1,169 @@
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { getWithdrawStats, getMyWithdrawList } from '@/api/shop/shopWithdrawRecord';
import EmptyState from '@/components/common/EmptyState';
import { Loading } from '@nutui/nutui-react-taro';
import './index.scss';
definePageConfig({
navigationBarTitleText: '提现记录',
});
export default function WithdrawListPage() {
const [stats, setStats] = useState<any>({});
const [list, setList] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [activeTab, setActiveTab] = useState(-1); // -1全部/0待审核/1通过/4完成
useEffect(() => {
loadStats();
loadList(true);
}, []);
const loadStats = async () => {
try {
const res = await getWithdrawStats();
if (res.code === 200) {
setStats(res.data);
}
} catch (error) {
console.error('加载统计失败', error);
}
};
const loadList = async (isRefresh = false) => {
if (loading) return;
setLoading(true);
try {
const currentPage = isRefresh ? 1 : page;
const params: any = {
page: currentPage,
limit: 20
};
if (activeTab >= 0) {
params.status = activeTab;
}
const res = await getMyWithdrawList(params);
if (res.code === 200) {
const newList = res.data.list || [];
setList(isRefresh ? newList : [...list, ...newList]);
setPage(currentPage + 1);
setHasMore(newList.length >= 20);
}
} catch (error) {
console.error('加载列表失败', error);
} finally {
setLoading(false);
}
};
const handleTabChange = (tab: number) => {
setActiveTab(tab);
setPage(1);
setHasMore(true);
loadList(true);
};
const getStatusText = (status: number) => {
const map: Record<number, string> = {
0: '待审核',
1: '审核通过',
2: '审核拒绝',
3: '打款中',
4: '已完成',
5: '已取消'
};
return map[status] || '未知';
};
const getStatusClass = (status: number) => {
if (status === 4) return 'status-success';
if (status === 2 || status === 5) return 'status-fail';
return 'status-pending';
};
return (
<View className="withdraw-list-page">
{/* 统计区域 */}
<View className="stats-card">
<View className="stats-item">
<Text className="stats-label">()</Text>
<Text className="stats-value">{stats.withdrawingTotal || '0.00'}</Text>
</View>
<View className="stats-item">
<Text className="stats-label">()</Text>
<Text className="stats-value">{stats.todayTotal || '0.00'}</Text>
</View>
</View>
{/* 标签页 */}
<View className="tab-bar">
<View
className={`tab-item ${activeTab === -1 ? 'active' : ''}`}
onClick={() => handleTabChange(-1)}
>
</View>
<View
className={`tab-item ${activeTab === 0 ? 'active' : ''}`}
onClick={() => handleTabChange(0)}
>
</View>
<View
className={`tab-item ${activeTab === 4 ? 'active' : ''}`}
onClick={() => handleTabChange(4)}
>
</View>
</View>
{/* 列表 */}
<ScrollView
className="withdraw-scroll"
scrollY
onScrollToLower={() => hasMore && loadList()}
>
{list.length === 0 && !loading ? (
<EmptyState description="暂无提现记录" />
) : (
list.map((item) => (
<View key={item.id} className="withdraw-item">
<View className="withdraw-header">
<Text className="withdraw-amount">¥{item.withdrawAmount}</Text>
<Text className={`status-tag ${getStatusClass(item.status)}`}>
{getStatusText(item.status)}
</Text>
</View>
<View className="withdraw-info">
<Text className="info-text">
: ¥{item.actualAmount}
</Text>
{item.fee > 0 && (
<Text className="info-text">
: ¥{item.fee}
</Text>
)}
</View>
<Text className="withdraw-time">{item.createTime}</Text>
{item.auditRemark && (
<Text className="audit-remark">
: {item.auditRemark}
</Text>
)}
</View>
))
)}
{loading && <Loading>...</Loading>}
</ScrollView>
</View>
);
}

View File

@@ -0,0 +1,250 @@
.withdraw-page {
display: flex;
flex-direction: column;
min-height: 100vh;
background: #f5f6fa;
padding-bottom: env(safe-area-inset-bottom);
}
/* ========== 余额卡片 ========== */
.balance-card {
margin: 24rpx;
padding: 40rpx 32rpx 32rpx;
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
border-radius: 20rpx;
color: #fff;
}
.balance-header {
margin-bottom: 16rpx;
}
.balance-label {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.85);
}
.balance-amount-row {
display: flex;
align-items: baseline;
}
.currency-symbol-lg {
font-size: 40rpx;
font-weight: bold;
color: #fff;
margin-right: 8rpx;
}
.balance-number {
font-size: 72rpx;
font-weight: bold;
color: #fff;
line-height: 1;
}
.balance-tips {
margin-top: 16rpx;
}
.balance-tip-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
}
/* ========== 提现输入卡片 ========== */
.withdraw-card {
margin: 24rpx;
padding: 32rpx;
background: #fff;
border-radius: 20rpx;
}
.card-title {
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-bottom: 28rpx;
}
.amount-input-wrap {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 2rpx solid #eee;
margin-bottom: 16rpx;
}
.currency-symbol {
font-size: 48rpx;
font-weight: bold;
color: #333;
margin-right: 16rpx;
}
.amount-input {
flex: 1;
font-size: 48rpx;
font-weight: bold;
color: #333;
}
.all-withdraw {
flex-shrink: 0;
padding: 8rpx 20rpx;
background: #fff3ee;
color: #ff6b35;
font-size: 24rpx;
font-weight: bold;
border-radius: 24rpx;
border: 2rpx solid #ffd4bc;
}
.fee-tips {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 16rpx;
padding: 12rpx 16rpx;
font-size: 24rpx;
color: #e67e22;
background: #fef9f5;
border-radius: 8rpx;
}
.fee-free {
color: #27ae60;
background: #f0faf4;
}
.fee-icon {
font-size: 24rpx;
}
/* ========== 提现规则区域(核心审核项)========== */
.rules-section {
margin: 0 24rpx 24rpx;
background: #fff;
border-radius: 20rpx;
overflow: hidden;
}
/* 标题栏 */
.rules-header {
display: flex;
align-items: center;
gap: 12rpx;
padding: 28rpx 32rpx 20rpx;
background: linear-gradient(135deg, #fef0eb 0%, #fff8f5 100%);
border-bottom: 2rpx solid #fce8e1;
}
.rules-header-icon {
font-size: 36rpx;
}
.rules-header-title {
font-size: 30rpx;
font-weight: bold;
color: #c0392b;
}
/* 规则网格 - 两列 */
.rules-grid {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 24rpx 24rpx 8rpx;
gap: 16rpx;
}
.rule-cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20rpx 12rpx;
background: #fafafa;
border-radius: 12rpx;
border: 2rpx solid #f0f0f0;
}
.rule-cell-wide {
grid-column: span 2;
flex-direction: row;
justify-content: space-between;
padding: 18rpx 24rpx;
}
.rule-cell-label {
font-size: 23rpx;
color: #999;
margin-bottom: 8rpx;
}
.rule-cell-value {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.rule-cell-wide .rule-cell-label {
margin-bottom: 0;
}
/* 详细规则文字列表 */
.rules-detail {
padding: 20rpx 32rpx 28rpx;
}
.detail-item {
display: flex;
align-items: flex-start;
gap: 10rpx;
padding: 10rpx 0;
}
.detail-bullet {
flex-shrink: 0;
font-size: 26rpx;
color: #ff6b35;
margin-top: 2rpx;
}
.detail-text {
font-size: 25rpx;
color: #666;
line-height: 1.7;
}
/* ========== 底部按钮区 ========== */
.bottom-area {
margin-top: auto;
padding: 20rpx 40rpx 40rpx;
}
.submit-btn {
width: 100%;
padding: 26rpx 0;
text-align: center;
background: linear-gradient(135deg, #ff6b35 0%, #ff9a56 100%);
color: #fff;
font-size: 32rpx;
font-weight: bold;
border-radius: 48rpx;
box-shadow: 0 8rpx 24rpx rgba(255, 107, 53, 0.35);
}
.submit-btn.disabled {
opacity: 0.45;
box-shadow: none;
}
.submit-disclaimer {
text-align: center;
margin-top: 16rpx;
}
.disclaimer-text {
font-size: 22rpx;
color: #bbb;
}

View File

@@ -0,0 +1,261 @@
import { View, Text, Input } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { getWithdrawConfig, getWithdrawStats, applyWithdraw } from '@/api/shop/shopWithdrawRecord';
import PayModal from '@/components/business/PayModal';
import './index.scss';
definePageConfig({
navigationBarTitleText: '提现',
});
export default function WithdrawPage() {
const [config, setConfig] = useState<any>({});
const [stats, setStats] = useState<any>({});
const [amount, setAmount] = useState('');
const [showPayModal, setShowPayModal] = useState(false);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
loadConfig();
loadStats();
}, []);
const loadConfig = async () => {
try {
const res: any = await getWithdrawConfig();
if (res.code === 200 || res.code === 0) {
setConfig(res.data || {});
}
} catch (error) {
console.error('加载配置失败', error);
}
};
const loadStats = async () => {
try {
const res: any = await getWithdrawStats();
if (res.code === 200 || res.code === 0) {
setStats(res.data || {});
}
} catch (error) {
console.error('加载统计失败', error);
}
};
// 从配置或默认值读取
const minAmount = config.withdraw_min_amount || '10';
const maxAmount = config.withdraw_max_amount || '200';
const dailyLimit = config.withdraw_daily_limit || '2000';
const dailyCount = config.withdraw_daily_count || 3;
const timeStart = config.withdraw_time_start || '';
const timeEnd = config.withdraw_time_end || '';
const feeRate = config.withdraw_fee_rate || '0';
const arrivalTime = config.arrival_time || '';
const handleWithdraw = () => {
if (!amount || parseFloat(amount) <= 0) {
Taro.showToast({ title: '请输入提现金额', icon: 'none' });
return;
}
const amountNum = parseFloat(amount);
if (amountNum < parseFloat(minAmount)) {
Taro.showToast({ title: `最低提现金额为${minAmount}`, icon: 'none' });
return;
}
if (amountNum > parseFloat(maxAmount)) {
Taro.showToast({ title: `单次最大提现金额为${maxAmount}`, icon: 'none' });
return;
}
setShowPayModal(true);
};
const handlePayConfirm = async (payType: number) => {
setShowPayModal(false);
if (payType !== 0) return;
setSubmitting(true);
try {
const res: any = await applyWithdraw({ amount: parseFloat(amount) });
if (res.code === 200 || res.code === 0) {
Taro.showToast({ title: '提现申请已提交', icon: 'success' });
setTimeout(() => {
Taro.navigateBack();
}, 1500);
} else {
Taro.showToast({ title: res.message || '提交失败', icon: 'none' });
}
} catch (error) {
console.error('提现申请失败', error);
Taro.showToast({ title: '提交失败', icon: 'none' });
} finally {
setSubmitting(false);
}
};
const handleAllWithdraw = () => {
const available = stats.withdrawingTotal || stats.balance || '0.00';
setAmount(available);
};
const availableBalance = stats.withdrawingTotal || stats.balance || '0.00';
return (
<View className="withdraw-page">
{/* 余额卡片 */}
<View className="balance-card">
<View className="balance-header">
<Text className="balance-label"></Text>
</View>
<View className="balance-amount-row">
<Text className="currency-symbol-lg">¥</Text>
<Text className="balance-number">{availableBalance}</Text>
</View>
<View className="balance-tips">
<Text className="balance-tip-text"></Text>
</View>
</View>
{/* 提现输入 */}
<View className="withdraw-card">
<View className="card-title"></View>
<View className="amount-input-wrap">
<Text className="currency-symbol">¥</Text>
<Input
className="amount-input"
type="digit"
placeholder={`最低 ${minAmount}`}
value={amount}
onInput={(e) => setAmount(e.detail.value)}
/>
<Text
className="all-withdraw"
onClick={handleAllWithdraw}
>
</Text>
</View>
{feeRate && parseFloat(feeRate) > 0 ? (
<View className="fee-tips">
<Text className="fee-icon"></Text>
<Text> {feeRate}% </Text>
</View>
) : (
<View className="fee-tips fee-free">
<Text className="fee-icon"></Text>
<Text></Text>
</View>
)}
</View>
{/* ====== 提现规则展示(核心审核项)====== */}
<View className="rules-section">
{/* 标题栏 */}
<View className="rules-header">
<Text className="rules-header-icon">📋</Text>
<Text className="rules-header-title"></Text>
</View>
{/* 规则网格 - 两列布局更醒目 */}
<View className="rules-grid">
<View className="rule-cell">
<Text className="rule-cell-label"></Text>
<Text className="rule-cell-value">{minAmount} </Text>
</View>
<View className="rule-cell">
<Text className="rule-cell-label"></Text>
<Text className="rule-cell-value">{maxAmount} /</Text>
</View>
<View className="rule-cell">
<Text className="rule-cell-label"></Text>
<Text className="rule-cell-value">{dailyLimit} /</Text>
</View>
<View className="rule-cell">
<Text className="rule-cell-label"></Text>
<Text className="rule-cell-value">{dailyCount} /</Text>
</View>
<View className="rule-cell">
<Text className="rule-cell-label"></Text>
<Text className="rule-cell-value">{timeStart && timeEnd ? `${timeStart}-${timeEnd}` : '全天可提'}</Text>
</View>
<View className="rule-cell">
<Text className="rule-cell-label"></Text>
<Text className="rule-cell-value">{arrivalTime || '1-3个工作日'}</Text>
</View>
<View className="rule-cell rule-cell-wide">
<Text className="rule-cell-label"></Text>
<Text className="rule-cell-value">
{feeRate && parseFloat(feeRate) > 0 ? `${feeRate}%` : '免手续费'}
</Text>
</View>
</View>
{/* 详细规则文字 */}
<View className="rules-detail">
<View className="detail-item">
<Text className="detail-bullet"></Text>
<Text className="detail-text"> ¥{minAmount} ~ ¥{maxAmount}</Text>
</View>
<View className="detail-item">
<Text className="detail-bullet"></Text>
<Text className="detail-text"> {dailyCount} ¥{dailyLimit}</Text>
</View>
{timeStart && timeEnd ? (
<View className="detail-item">
<Text className="detail-bullet"></Text>
<Text className="detail-text"> {timeStart} {timeEnd} </Text>
</View>
) : (
<View className="detail-item">
<Text className="detail-bullet"></Text>
<Text className="detail-text"> 24 </Text>
</View>
)}
<View className="detail-item">
<Text className="detail-bullet"></Text>
<Text className="detail-text"> {arrivalTime || '1-3个工作日'} </Text>
</View>
{feeRate && parseFloat(feeRate) > 0 ? (
<View className="detail-item">
<Text className="detail-bullet"></Text>
<Text className="detail-text"> {feeRate}% </Text>
</View>
) : (
<View className="detail-item">
<Text className="detail-bullet"></Text>
<Text className="detail-text"></Text>
</View>
)}
<View className="detail-item">
<Text className="detail-bullet"></Text>
<Text className="detail-text">退</Text>
</View>
</View>
</View>
{/* 底部按钮 */}
<View className="bottom-area">
<View
className={`submit-btn ${amount && !submitting ? '' : 'disabled'}`}
onClick={handleWithdraw}
>
{submitting ? '提交中...' : '确认提现'}
</View>
<View className="submit-disclaimer">
<Text className="disclaimer-text"></Text>
</View>
</View>
<PayModal
visible={showPayModal}
amount={parseFloat(amount) || 0}
onConfirm={handlePayConfirm}
onClose={() => setShowPayModal(false)}
/>
</View>
);
}