feat(store): 新增门店中心及VIP会员升级功能
- 个人信息页新增门店名称和门店地址展示,支持异步加载与只读显示 - 用户信息卡片整体改为绿色渐变背景,添加光晕和白色边框样式 - 新增升级VIP会员入口,突出展示并添加推荐标签 - 新建VIP会员升级页面,包含门店信息表单和VIP权益预览 - 提交升级申请调用新增api,支持状态检测表单只读 - 门店中心页面重构,简化订单管理,新增功能卡片及VIP审核入口 - 页面加载校验店员身份,非店员拒绝访问并提示 - 购物相关页面和组件全面支持VIP会员价格优先显示dealerPrice - 新增ShopDealerApply模型门店相关字段,丰富会员申请数据记录
This commit is contained in:
@@ -30,6 +30,10 @@ export interface ShopDealerApply {
|
||||
createTime?: string;
|
||||
// 修改时间
|
||||
updateTime?: string;
|
||||
// 门店名称
|
||||
merchantName?: string;
|
||||
// 门店地址
|
||||
address?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,6 +52,8 @@ export default {
|
||||
'pages/user/shop-setting/index',
|
||||
'pages/user/member/index',
|
||||
'pages/user/member-upgrade/index',
|
||||
'pages/user/vip-upgrade/index',
|
||||
'pages/user/vip-review/index',
|
||||
'pages/user/invite-subordinate/index',
|
||||
'pages/user/register-pay/index',
|
||||
'pages/user/promotion/index',
|
||||
@@ -87,6 +89,7 @@ export default {
|
||||
'pages/store/list/index',
|
||||
'pages/store/booking/index',
|
||||
'pages/store/center/index',
|
||||
'pages/store/orders/index',
|
||||
// 售后页面
|
||||
'pages/after-sale/apply/index',
|
||||
'pages/after-sale/progress/index',
|
||||
|
||||
@@ -4,6 +4,7 @@ import Taro from '@tarojs/taro'
|
||||
import type { ShopGoods, ShopGoodsSku } from '@/api/shop/shopGoods/model'
|
||||
import type { ShopGoodsSpec } from '@/api/shop/shopGoodsSpec/model'
|
||||
import Price from '@/components/common/Price'
|
||||
import { isVipMember } from '@/utils/vip'
|
||||
|
||||
interface SkuSelectorProps {
|
||||
visible: boolean
|
||||
@@ -154,11 +155,13 @@ const SkuSelector: React.FC<SkuSelectorProps> = ({
|
||||
try {
|
||||
// 单规格商品(无SKU列表 或 SKU列表为空数组)
|
||||
if ((!product?.goodsSkus || product.goodsSkus.length === 0) && product) {
|
||||
// VIP 会员使用 dealerPrice 作为结算价
|
||||
const vipPrice = isVipMember() && product.dealerPrice ? product.dealerPrice : undefined
|
||||
const fakeSku: ShopGoodsSku = {
|
||||
id: 0,
|
||||
goodsId: product.goodsId!,
|
||||
// price 字段是到手价(主价格),salePrice 是划掉的"市场价"
|
||||
price: product.price,
|
||||
price: vipPrice || product.price,
|
||||
salePrice: product.salePrice,
|
||||
stock: product.stock,
|
||||
image: product.image,
|
||||
@@ -191,8 +194,10 @@ const SkuSelector: React.FC<SkuSelectorProps> = ({
|
||||
// 价格字段约定:
|
||||
// - product.salePrice / sku.salePrice : 划掉的"市场价"
|
||||
// - product.price / sku.price : 实际"到手价"(详情页主价格)
|
||||
// 弹窗应展示到手价,避免误把市场价当成售价
|
||||
const currentPrice = selectedSku?.price || product?.price || selectedSku?.salePrice || product?.salePrice || '0'
|
||||
// - product.dealerPrice : VIP 会员专享价
|
||||
// VIP 会员优先使用 dealerPrice
|
||||
const vipPrice = isVipMember() ? (product?.dealerPrice || selectedSku?.price) : null
|
||||
const currentPrice = vipPrice || selectedSku?.price || product?.price || selectedSku?.salePrice || product?.salePrice || '0'
|
||||
const currentStock = selectedSku?.stock ?? product?.stock ?? 0
|
||||
const currentImage = selectedSku?.image || product?.image || (product?.files?.split(',')[0]) || ''
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, Text, Image } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import Price from '../Price'
|
||||
import { isGuest } from '@/utils/auth'
|
||||
import { isVipMember } from '@/utils/vip'
|
||||
import type { ShopGoods } from '@/api/shop/shopGoods/model'
|
||||
|
||||
interface ProductCardProps {
|
||||
@@ -36,14 +37,19 @@ const ProductCard: React.FC<ProductCardProps> = ({ product, onClick }) => {
|
||||
</Text>
|
||||
<View className='flex items-end justify-between mt-2'>
|
||||
<View className='flex-1'>
|
||||
<Price price={product.price || '0'} size='small' loginMask />
|
||||
{product.salePrice && product.salePrice !== product.price && (
|
||||
<Price price={isVipMember() && product.dealerPrice ? product.dealerPrice : (product.price || '0')} size='small' loginMask />
|
||||
{isVipMember() && product.dealerPrice ? (
|
||||
// VIP 用户显示原价划掉
|
||||
<Text className='text-xs text-gray-400 line-through ml-1'>
|
||||
¥{product.price}
|
||||
</Text>
|
||||
) : product.salePrice && product.salePrice !== product.price ? (
|
||||
isGuest() ? null : (
|
||||
<Text className='text-xs text-gray-400 line-through ml-1'>
|
||||
¥{product.salePrice}
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
{product.sales !== undefined && product.sales > 0 && (
|
||||
<Text className='text-xs text-gray-400'>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { listShopCart, addToCart, updateCartNum, removeShopCart, removeBatchShopCart, updateCartAllChecked } from '@/api/shop/shopCart'
|
||||
import { listShopCart, addToCart, updateCartNum, removeShopCart, removeBatchShopCart, updateCartAllChecked, updateCartChecked } from '@/api/shop/shopCart'
|
||||
import type { ShopGoods, ShopGoodsSku } from '@/api/shop/shopGoods/model'
|
||||
import { isVipMember } from '@/utils/vip'
|
||||
|
||||
export interface CartItem {
|
||||
id?: number
|
||||
@@ -194,8 +195,15 @@ export const CartProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
return items.find(i => i.goodsId === goodsId && i.skuId === skuId)?.quantity || 0
|
||||
}, [items])
|
||||
|
||||
const calcPrice = (list: CartItem[]) =>
|
||||
list.reduce((sum, i) => sum + Number(i.skuPrice || i.sku?.price || i.product?.salePrice || i.product?.price || 0) * i.quantity, 0).toFixed(2)
|
||||
const calcPrice = (list: CartItem[]) => {
|
||||
const vip = isVipMember()
|
||||
return list.reduce((sum, i) => {
|
||||
// VIP 会员优先使用 dealerPrice
|
||||
const dealerPrice = vip ? (i.product as any)?.dealerPrice : null
|
||||
const unitPrice = dealerPrice || i.skuPrice || i.sku?.price || i.product?.salePrice || i.product?.price || 0
|
||||
return sum + Number(unitPrice) * i.quantity
|
||||
}, 0).toFixed(2)
|
||||
}
|
||||
|
||||
const selectedItems = items.filter(i => i.checked)
|
||||
const selectedCount = selectedItems.reduce((s, i) => s + i.quantity, 0)
|
||||
|
||||
48
src/hooks/useVipStatus.ts
Normal file
48
src/hooks/useVipStatus.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { checkAndCacheVipStatus, getVipStatusFromCache } from '@/utils/vip'
|
||||
import { useUser } from '@/hooks/useUser'
|
||||
|
||||
/**
|
||||
* useVipStatus - 判断当前用户是否为 VIP 会员
|
||||
*
|
||||
* 工作原理:
|
||||
* 1. 首先从 localStorage 缓存快速读取(即时返回)
|
||||
* 2. 异步调用后端接口验证并更新缓存
|
||||
*
|
||||
* 使用场景:
|
||||
* - 商品详情页价格展示
|
||||
* - 结算页价格计算
|
||||
* - 任何需要判断 VIP 身份的页面
|
||||
*/
|
||||
export const useVipStatus = () => {
|
||||
const { user, isLoggedIn } = useUser()
|
||||
const [isVip, setIsVip] = useState<boolean>(getVipStatusFromCache())
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!isLoggedIn) {
|
||||
setIsVip(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const userId = (user as any)?.userId || (user as any)?.id
|
||||
const result = await checkAndCacheVipStatus(userId)
|
||||
setIsVip(result)
|
||||
} catch {
|
||||
// 保持缓存值
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [isLoggedIn, user])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
refresh()
|
||||
} else {
|
||||
setIsVip(false)
|
||||
}
|
||||
}, [isLoggedIn, refresh])
|
||||
|
||||
return { isVip, loading, refresh }
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import EmptyState from '@/components/common/EmptyState'
|
||||
import Loading from '@/components/common/Loading'
|
||||
import { isGuest } from '@/utils/auth'
|
||||
import { requireLogin } from '@/utils/login-guard'
|
||||
import { isVipMember } from '@/utils/vip'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '购物车',
|
||||
@@ -180,7 +181,7 @@ const CartPage: React.FC = () => {
|
||||
)}
|
||||
<View className="flex justify-between items-center">
|
||||
<Text className="text-sm font-bold text-red-500">
|
||||
¥{item.skuPrice || item.sku?.price || item.product?.salePrice || item.product?.price || '0'}
|
||||
¥{isVipMember() && (item.product as any)?.dealerPrice ? (item.product as any).dealerPrice : (item.skuPrice || item.sku?.price || item.product?.salePrice || item.product?.price || '0')}
|
||||
</Text>
|
||||
<View className="flex items-center gap-3">
|
||||
<View className="w-6 h-6 rounded bg-gray-100 flex items-center justify-center" onClick={() => updateQuantity(item.goodsId, item.skuId, item.quantity - 1)}>
|
||||
@@ -229,7 +230,7 @@ const CartPage: React.FC = () => {
|
||||
</Text>
|
||||
<View className="flex items-center justify-between mt-1">
|
||||
<Text className="text-xs font-bold text-red-500">
|
||||
¥{goods.salePrice || goods.price || '0'}
|
||||
¥{isVipMember() && goods.dealerPrice ? goods.dealerPrice : (goods.salePrice || goods.price || '0')}
|
||||
</Text>
|
||||
<View
|
||||
className="w-5 h-5 rounded-full flex items-center justify-center"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { listShopGoods } from '@/api/shop/shopGoods'
|
||||
import type { ShopGoods } from '@/api/shop/shopGoods/model'
|
||||
import type { ShopUserCoupon } from '@/api/shop/shopUserCoupon/model'
|
||||
import type { OrderGoodsItem, OrderCreateRequest } from '@/api/shop/shopOrder/model'
|
||||
import { isVipMember } from '@/utils/vip'
|
||||
|
||||
// 满减门槛配置
|
||||
const THRESHOLDS = [
|
||||
@@ -145,8 +146,12 @@ const CheckoutPage: React.FC = () => {
|
||||
// 计算金额
|
||||
const goodsPrice = useMemo(() => {
|
||||
if (!items || items.length === 0) return 0
|
||||
const vip = isVipMember()
|
||||
return items.reduce((sum, item) => {
|
||||
return sum + Number(item.skuPrice || item.sku?.price || item.product?.salePrice || item.product?.price || 0) * (item.quantity || item.num || 1)
|
||||
// VIP 会员优先使用 dealerPrice
|
||||
const dealerPrice = vip ? (item.product as any)?.dealerPrice : null
|
||||
const unitPrice = dealerPrice || item.skuPrice || item.sku?.price || item.product?.salePrice || item.product?.price || 0
|
||||
return sum + Number(unitPrice) * (item.quantity || item.num || 1)
|
||||
}, 0)
|
||||
}, [buyNowItems, selectedItems])
|
||||
|
||||
@@ -362,7 +367,7 @@ const CheckoutPage: React.FC = () => {
|
||||
<View className='flex justify-between items-center mt-1'>
|
||||
<Text className='text-xs text-gray-500'>x{item.quantity || item.num || 1}</Text>
|
||||
<Text className='text-sm font-medium text-gray-800'>
|
||||
¥{item.skuPrice || item.sku?.salePrice || item.sku?.price || item.product?.salePrice || item.product?.price || '0'}
|
||||
¥{isVipMember() && (item.product as any)?.dealerPrice ? (item.product as any).dealerPrice : (item.skuPrice || item.sku?.salePrice || item.sku?.price || item.product?.salePrice || item.product?.price || '0')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useUserContext } from '@/contexts/UserContext'
|
||||
import { useScrollHeight } from '@/hooks/useScrollHeight'
|
||||
import { isGuest } from '@/utils/auth'
|
||||
import { requireLogin } from '@/utils/login-guard'
|
||||
import { isVipMember } from '@/utils/vip'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '商品详情',
|
||||
@@ -66,6 +67,14 @@ const ProductDetailPage: React.FC = () => {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// VIP 会员显示 dealerPrice,普通用户显示 price
|
||||
const getDisplayPrice = (): string => {
|
||||
if (isVipMember() && product?.dealerPrice) {
|
||||
return product.dealerPrice
|
||||
}
|
||||
return product?.price || '0'
|
||||
}
|
||||
|
||||
const checkFavoriteStatus = async () => {
|
||||
try {
|
||||
const status = await getShopGoodsFavoriteStatus({ goodsId: id })
|
||||
@@ -142,6 +151,8 @@ const ProductDetailPage: React.FC = () => {
|
||||
console.error('[ProductDetail] 加入购物车失败:', err)
|
||||
}
|
||||
} else {
|
||||
// VIP 会员使用 dealerPrice 作为结算价
|
||||
const vipPrice = isVipMember() && product.dealerPrice ? product.dealerPrice : undefined
|
||||
const buyNowData = [{
|
||||
goodsId: product.goodsId!,
|
||||
skuId: sku?.id || 0,
|
||||
@@ -149,6 +160,7 @@ const ProductDetailPage: React.FC = () => {
|
||||
num: quantity,
|
||||
product: product,
|
||||
sku: sku,
|
||||
skuPrice: vipPrice,
|
||||
checked: true,
|
||||
}]
|
||||
|
||||
@@ -261,14 +273,22 @@ const ProductDetailPage: React.FC = () => {
|
||||
{/* 价格区域 */}
|
||||
<View className='bg-white p-4'>
|
||||
<View className='flex items-baseline gap-2'>
|
||||
<Price price={product.price || '0'} size='large' color='#ee0a24' loginMask />
|
||||
<Price price={getDisplayPrice()} size='large' color='#ee0a24' loginMask />
|
||||
<Tag>到手价</Tag>
|
||||
{!isGuest() && product.salePrice && product.salePrice !== product.price && (
|
||||
<Text className='text-xs text-gray-400 ml-2'>¥{product.salePrice}</Text>
|
||||
{/* VIP 会员价 */}
|
||||
{!isGuest() && isVipMember() && Number(product.dealerPrice) > 0 ? (
|
||||
<View className='ml-auto inline-flex items-center gap-1 bg-amber-50 rounded px-2 py-1'>
|
||||
<Text className='text-xs text-amber-600 font-medium'>VIP专享</Text>
|
||||
<Text className='text-sm text-amber-700 font-bold'>¥{product.dealerPrice}</Text>
|
||||
</View>
|
||||
) : (
|
||||
!isGuest() && Number(product.salePrice) > 0 && product.salePrice !== product.price && (
|
||||
<Text className='text-xs text-gray-400 ml-2 line-through'>¥{product.salePrice}</Text>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
{/* 会员价 */}
|
||||
{!isGuest() && product.memberStorePrice && product.memberStorePrice !== product.price && (
|
||||
{!isGuest() && !isVipMember() && Number(product.memberStorePrice) > 0 && product.memberStorePrice !== product.price && (
|
||||
<View className='mt-2 inline-block bg-orange-50 rounded px-2 py-1'>
|
||||
<Text className='text-xs text-orange-500'>会员价: ¥{product.memberStorePrice}</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,564 +1,163 @@
|
||||
import React, {useState, useEffect, useCallback, useRef} from 'react'
|
||||
import {View, Text, Image, ScrollView} from '@tarojs/components'
|
||||
import Taro, {useDidShow} from '@tarojs/taro'
|
||||
import {pageShopOrder, updateShopOrder, removeShopOrder} from '@/api/shop/shopOrder'
|
||||
import type {ShopOrder, ShopOrderParam} from '@/api/shop/shopOrder/model'
|
||||
import {TenantId} from '../../../config/app'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { getMyClerk } from '@/api/shop/shopStoreUser'
|
||||
import { listShopDealerApply } from '@/api/shop/shopDealerApply'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '门店中心',
|
||||
navigationBarTitleText: '门店中心',
|
||||
})
|
||||
|
||||
// ─── Tab 配置(全部 + 待处理 + 已完成)──────────────────────────
|
||||
type TabKey = 'all' | 'pending' | 'completed'
|
||||
|
||||
const TABS: { key: TabKey; label: string; params: Partial<ShopOrderParam>[] }[] = [
|
||||
// 全部:不传 statusFilter
|
||||
{key: 'all', label: '全部', params: [{}]},
|
||||
// 已完成:statusFilter=5(与后台管理一致)
|
||||
{key: 'completed', label: '已完成', params: [{statusFilter: 5}]},
|
||||
// 待处理:合并 statusFilter=1(待发货)和 statusFilter=8(已关闭)
|
||||
{key: 'pending', label: '已关闭', params: [{statusFilter: 1}, {statusFilter: 8}]},
|
||||
// 功能卡片定义(未来新增功能只需在这里加一项)
|
||||
const FEATURE_CARDS = [
|
||||
{
|
||||
key: 'orders',
|
||||
icon: '📦',
|
||||
title: '订单管理',
|
||||
desc: '查看和处理门店订单',
|
||||
url: '/pages/store/orders/index',
|
||||
color: '#15803d',
|
||||
bgColor: '#dcfce7',
|
||||
},
|
||||
{
|
||||
key: 'vip-review',
|
||||
icon: '👑',
|
||||
title: 'VIP会员审核',
|
||||
desc: '审核客户VIP会员申请',
|
||||
url: '/pages/user/vip-review/index',
|
||||
color: '#7c3aed',
|
||||
bgColor: '#ede9fe',
|
||||
// 显示待审核数量角标
|
||||
showBadge: true,
|
||||
},
|
||||
]
|
||||
|
||||
// ─── 操作类型 ─────────────────────────────────────────────────────
|
||||
type OpType = 'pay' | 'complete'
|
||||
|
||||
const OP_LABEL: Record<OpType, string> = {
|
||||
pay: '已收款',
|
||||
complete: '已完成',
|
||||
}
|
||||
|
||||
const OP_DESC: Record<OpType, string> = {
|
||||
pay: '变更支付状态为已付款',
|
||||
complete: '同时变更支付状态为已付款、收货状态为已收货、订单状态为已完成',
|
||||
}
|
||||
|
||||
// ─── 图片上传 ─────────────────────────────────────────────────────
|
||||
const uploadImage = (filePath: string): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
Taro.uploadFile({
|
||||
url: 'https://server.websoft.top/api/oss/upload',
|
||||
filePath,
|
||||
name: 'file',
|
||||
header: {'content-type': 'application/json', TenantId},
|
||||
success: (res) => {
|
||||
try {
|
||||
const data = JSON.parse(res.data)
|
||||
if (data.code === 0 && data.data?.url) {
|
||||
resolve(data.data.url)
|
||||
} else {
|
||||
reject(new Error(data.message || '上传失败'))
|
||||
}
|
||||
} catch {
|
||||
reject(new Error('解析上传响应失败'))
|
||||
}
|
||||
},
|
||||
fail: () => reject(new Error('上传请求失败')),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 状态辅助函数 ──────────────────────────────────────────────────
|
||||
|
||||
/** 格式化日期为 Spring Boot LocalDateTime 可接受的格式:yyyy-MM-dd HH:mm:ss */
|
||||
function formatDateTime(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
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 mi = String(date.getMinutes()).padStart(2, '0')
|
||||
const s = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${h}:${mi}:${s}`
|
||||
}
|
||||
|
||||
/** 解析 sendEndImg:兼容 JSON 数组(新格式)和逗号分隔(旧格式) */
|
||||
function parseSendEndImg(raw: string): string[] {
|
||||
if (!raw || !raw.trim()) return []
|
||||
const trimmed = raw.trim()
|
||||
// 尝试 JSON 解析
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
const arr = JSON.parse(trimmed)
|
||||
if (Array.isArray(arr)) return arr.filter(Boolean)
|
||||
} catch { /* fallback to comma split */
|
||||
}
|
||||
}
|
||||
// 旧格式:逗号分隔(兼容单张图不含逗号的 URL)
|
||||
return trimmed.split(',').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function getStatusText(order: ShopOrder): string {
|
||||
if (order.orderStatus === 1) return '已完成'
|
||||
if (order.orderStatus === 2) return '已关闭'
|
||||
if (order.payStatus === false || order.payStatus === null) return '待收款'
|
||||
if (order.payStatus && order.deliveryStatus === 10) return '待发货'
|
||||
if (order.payStatus && order.deliveryStatus === 20) return '待收货'
|
||||
return '进行中'
|
||||
}
|
||||
|
||||
function getStatusColor(order: ShopOrder): string {
|
||||
if (order.orderStatus === 1) return '#0e932e'
|
||||
if (order.orderStatus === 2) return '#999'
|
||||
if (order.payStatus === false || order.payStatus === null) return '#ee0a24'
|
||||
if (order.payStatus) return '#ff7d00'
|
||||
return '#999'
|
||||
}
|
||||
|
||||
/** 判断订单是否可操作(非已完成、非已关闭) */
|
||||
function isOrderActionable(order: ShopOrder): boolean {
|
||||
return order.orderStatus !== 1 && order.orderStatus !== 2
|
||||
}
|
||||
|
||||
/** 获取订单可执行的操作 */
|
||||
function getOrderActions(order: ShopOrder): { label: string; type: OpType }[] {
|
||||
if (!isOrderActionable(order)) return []
|
||||
const actions: { label: string; type: OpType }[] = []
|
||||
// 货到付款:未付款 / 已付款 都直接"确认完成",
|
||||
// 确认完成会同时设置 payStatus=true,无需单独的"确认收款"按钮
|
||||
if (order.orderStatus !== 1 && order.orderStatus !== 2) {
|
||||
actions.push({label: '确认完成', type: 'complete'})
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// ─── 页面组件 ──────────────────────────────────────────────────────
|
||||
export default function StoreCenterPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('all')
|
||||
const [storeInfo, setStoreInfo] = useState<any>(null)
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// 订单列表与分页
|
||||
const [orderList, setOrderList] = useState<ShopOrder[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// 操作弹窗状态
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [currentOrder, setCurrentOrder] = useState<ShopOrder | null>(null)
|
||||
const [opType, setOpType] = useState<OpType>('pay')
|
||||
const [proofImages, setProofImages] = useState<string[]>([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const pageSize = 10
|
||||
const loadingRef = useRef(false)
|
||||
|
||||
/** 加载订单列表(待处理 Tab 会合并多个 statusFilter 的结果) */
|
||||
const loadOrders = useCallback(async (tab: TabKey, pageNo: number = 1, append = false) => {
|
||||
if (loadingRef.current) return
|
||||
loadingRef.current = true
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const tabConfig = TABS.find(t => t.key === tab)
|
||||
if (!tabConfig) return
|
||||
|
||||
// 并行请求所有 params 组合,合并去重
|
||||
const allRequests = tabConfig.params.map(p =>
|
||||
pageShopOrder({...p, page: pageNo, limit: pageSize})
|
||||
)
|
||||
const allResults = await Promise.all(allRequests)
|
||||
|
||||
// 合并所有结果列表,按 orderId 去重
|
||||
const mergedList = allResults.reduce<ShopOrder[]>((acc, res) => {
|
||||
const list = res?.list || []
|
||||
list.forEach(item => {
|
||||
if (!acc.some(existing => existing.orderId === item.orderId)) {
|
||||
acc.push(item)
|
||||
}
|
||||
})
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
// 按 createTime 降序排列
|
||||
mergedList.sort((a, b) => {
|
||||
const ta = a.createTime ? new Date(a.createTime).getTime() : 0
|
||||
const tb = b.createTime ? new Date(b.createTime).getTime() : 0
|
||||
return tb - ta
|
||||
})
|
||||
|
||||
setOrderList(prev => append ? [...prev, ...mergedList] : mergedList)
|
||||
setPage(pageNo)
|
||||
// 只要任意一个请求还有数据就允许继续加载
|
||||
const maxLen = Math.max(...allResults.map(r => (r?.list || []).length))
|
||||
setHasMore(maxLen >= pageSize)
|
||||
} catch (e: any) {
|
||||
Taro.showToast({title: e.message || '加载失败', icon: 'none'})
|
||||
if (!append) setOrderList([])
|
||||
} finally {
|
||||
loadingRef.current = false
|
||||
setLoading(false)
|
||||
useEffect(() => {
|
||||
// 验证店员身份
|
||||
getMyClerk()
|
||||
.then(data => {
|
||||
if (!data) {
|
||||
Taro.showToast({ title: '仅门店店员可访问', icon: 'none' })
|
||||
setTimeout(() => Taro.navigateBack(), 1500)
|
||||
return
|
||||
}
|
||||
}, [])
|
||||
setStoreInfo(data)
|
||||
// 加载待审核 VIP 申请数量
|
||||
loadPendingCount()
|
||||
})
|
||||
.catch(() => {
|
||||
Taro.showToast({ title: '仅门店店员可访问', icon: 'none' })
|
||||
setTimeout(() => Taro.navigateBack(), 1500)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// 切换 tab 时重新加载
|
||||
useEffect(() => {
|
||||
loadOrders(activeTab, 1)
|
||||
}, [activeTab, loadOrders])
|
||||
|
||||
// 页面重新显示时刷新
|
||||
useDidShow(() => {
|
||||
loadOrders(activeTab, 1)
|
||||
})
|
||||
|
||||
/** 加载更多 */
|
||||
const handleLoadMore = () => {
|
||||
if (hasMore && !loadingRef.current) {
|
||||
loadOrders(activeTab, page + 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开操作弹窗 */
|
||||
const openModal = (order: ShopOrder, type: OpType) => {
|
||||
setCurrentOrder(order)
|
||||
setOpType(type)
|
||||
setProofImages([])
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const closeModal = () => {
|
||||
setShowModal(false)
|
||||
setCurrentOrder(null)
|
||||
setProofImages([])
|
||||
}
|
||||
|
||||
/** 选择并上传凭证图片 */
|
||||
const chooseProofImage = () => {
|
||||
const maxCount = 3
|
||||
const remaining = maxCount - proofImages.length
|
||||
if (remaining <= 0) return
|
||||
|
||||
Taro.chooseImage({
|
||||
count: remaining,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['camera', 'album'],
|
||||
success: async (res) => {
|
||||
for (const filePath of res.tempFilePaths) {
|
||||
try {
|
||||
Taro.showLoading({title: '上传中...'})
|
||||
const url = await uploadImage(filePath)
|
||||
Taro.hideLoading()
|
||||
setProofImages(prev => [...prev, url])
|
||||
} catch (e: any) {
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({title: e.message || '上传失败', icon: 'none'})
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 移除凭证图片 */
|
||||
const removeProofImage = (idx: number) => {
|
||||
setProofImages(prev => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
/** 提交操作 */
|
||||
const submitOperation = async () => {
|
||||
if (!currentOrder) return
|
||||
|
||||
// 确认完成必须上传凭证照片
|
||||
if (opType === 'complete' && proofImages.length === 0) {
|
||||
Taro.showToast({title: '请上传配送凭证照片', icon: 'none'})
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const updateData: any = {orderId: currentOrder.orderId}
|
||||
|
||||
if (opType === 'pay') {
|
||||
// 确认收款:变更支付状态为已付款
|
||||
updateData.payStatus = true
|
||||
updateData.payTime = formatDateTime(new Date())
|
||||
} else if (opType === 'complete') {
|
||||
// 确认完成:用户已收到货
|
||||
updateData.payStatus = true
|
||||
updateData.payTime = formatDateTime(new Date())
|
||||
updateData.deliveryStatus = 30 // 发货状态 → 已收货
|
||||
updateData.deliveryTime = formatDateTime(new Date()) // 收货时间
|
||||
updateData.orderStatus = 1 // 订单状态 → 已完成
|
||||
updateData.sendEndImg = JSON.stringify(proofImages) // 配送员送达拍照(JSON数组)
|
||||
}
|
||||
|
||||
// 收款操作如有凭证也追加到备注
|
||||
if (opType === 'pay' && proofImages.length > 0) {
|
||||
const proofText = `【门店收款凭证】:${JSON.stringify(proofImages)}`
|
||||
updateData.comments = (currentOrder.comments || '') + `\n${proofText}`
|
||||
}
|
||||
|
||||
await updateShopOrder(updateData)
|
||||
Taro.showToast({title: '操作成功', icon: 'success'})
|
||||
closeModal()
|
||||
loadOrders(activeTab, 1) // 刷新列表
|
||||
} catch (e: any) {
|
||||
Taro.showToast({title: e.message || '操作失败', icon: 'none'})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除订单 */
|
||||
const handleDelete = (order: ShopOrder) => {
|
||||
Taro.showModal({
|
||||
title: '提示',
|
||||
content: `确定要删除订单 ${order.orderNo} 吗?删除后无法恢复。`,
|
||||
confirmColor: '#ee0a24',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
try {
|
||||
Taro.showLoading({title: '删除中...'})
|
||||
await removeShopOrder(order.orderId)
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({title: '删除成功', icon: 'success'})
|
||||
loadOrders(activeTab, 1)
|
||||
} catch (e: any) {
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({title: e.message || '删除失败', icon: 'none'})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 渲染单个订单卡片 */
|
||||
const renderOrderCard = (order: ShopOrder) => {
|
||||
const actions = getOrderActions(order)
|
||||
const canDelete = isOrderActionable(order) // 仅未完成、未关闭的订单可删除
|
||||
const orderGoods = (order as any).orderGoods || []
|
||||
|
||||
return (
|
||||
<View key={order.orderId} className='bg-white rounded-xl mx-3 mt-3 p-4'>
|
||||
{/* 订单头部 */}
|
||||
<View className='flex justify-between items-center mb-3'>
|
||||
<Text className='text-xs text-gray-400'>订单号:{order.orderNo}</Text>
|
||||
<Text className='text-xs' style={{color: getStatusColor(order)}}>
|
||||
{getStatusText(order)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 商品列表 */}
|
||||
{orderGoods.map((goods: any, idx: number) => (
|
||||
<View key={idx} className='flex items-center gap-3 mb-3'>
|
||||
{goods.coverImage && (
|
||||
<Image className='w-16 h-16 rounded-lg bg-gray-50' src={goods.coverImage}
|
||||
mode='aspectFill'/>
|
||||
)}
|
||||
<View className='flex-1'>
|
||||
<Text className='text-sm text-gray-800 block'>{goods.goodsName}</Text>
|
||||
{goods.specInfo && (
|
||||
<Text className='text-xs text-gray-400 mt-1 block'>{goods.specInfo}</Text>
|
||||
)}
|
||||
<View className='flex justify-between items-center mt-1'>
|
||||
<Text className='text-sm text-red-500 font-medium'>¥{goods.price}</Text>
|
||||
<Text className='text-xs text-gray-400'>x{goods.quantity}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* 收货信息 */}
|
||||
{(order.realName || order.phone) && (
|
||||
<View className='bg-gray-50 rounded-lg p-3 mb-3'>
|
||||
<Text className='text-sm text-gray-700 block'>{order.realName} {order.phone}</Text>
|
||||
{order.address && (
|
||||
<Text className='text-xs text-gray-400 mt-1 block'>{order.address}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 送达凭证(已完成订单) */}
|
||||
{order.sendEndImg && (() => {
|
||||
const imgs = parseSendEndImg(order.sendEndImg)
|
||||
return imgs.length > 0 && (
|
||||
<View className='bg-gray-50 rounded-lg p-3 mb-3'>
|
||||
<Text className='text-xs text-gray-500 mb-2 block'>配送凭证:</Text>
|
||||
<View className='flex flex-wrap gap-2'>
|
||||
{imgs.map((img, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
className='w-20 h-20 rounded-lg bg-gray-100'
|
||||
src={img}
|
||||
mode='aspectFill'
|
||||
onClick={() => Taro.previewImage({
|
||||
current: img,
|
||||
urls: imgs
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* 金额 + 支付方式 */}
|
||||
<View className='flex justify-between items-center mb-3'>
|
||||
<Text className='text-xs text-gray-400'>
|
||||
{order.payType === 0 ? '货到付款' : order.payType === 4 ? '现金支付' : '货到付款'}
|
||||
</Text>
|
||||
<Text className='text-sm text-gray-700'>
|
||||
实付:<Text className='text-red-500 font-medium'>¥{order.payPrice || order.totalPrice}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='flex justify-between items-center border-t border-gray-50 pt-3'>
|
||||
{/* 删除按钮:仅未完成/未关闭的订单显示 */}
|
||||
{canDelete ? (
|
||||
<View
|
||||
className='px-3 py-1.5'
|
||||
onClick={() => handleDelete(order)}
|
||||
>
|
||||
<Text className='text-xs text-gray-400'>删除订单</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View/>
|
||||
)}
|
||||
|
||||
<View className={`flex gap-2 ${!canDelete ? 'w-full justify-end' : ''}`}>
|
||||
{actions.map(act => (
|
||||
<View
|
||||
key={act.type}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
act.type === 'complete'
|
||||
? 'bg-green-500'
|
||||
: 'border border-blue-500'
|
||||
}`}
|
||||
onClick={() => openModal(order, act.type)}
|
||||
>
|
||||
<Text className={`text-sm ${
|
||||
act.type === 'complete' ? 'text-white' : 'text-blue-500'
|
||||
}`}>{act.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
const loadPendingCount = async () => {
|
||||
try {
|
||||
const data = await listShopDealerApply({ applyStatus: 10 })
|
||||
setPendingCount((data || []).length)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className='min-h-full bg-gray-50'>
|
||||
{/* Tab 栏 */}
|
||||
<View className='bg-white flex'>
|
||||
{TABS.map(tab => (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`flex-1 text-center py-3 border-b-2 ${
|
||||
activeTab === tab.key ? 'border-green-500' : 'border-transparent'
|
||||
}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
<Text
|
||||
className={`text-sm ${activeTab === tab.key ? 'text-green-500 font-medium' : 'text-gray-500'}`}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 订单列表 */}
|
||||
<ScrollView
|
||||
scrollY
|
||||
style={{height: 'calc(100vh - 50px)'}}
|
||||
onScrollToLower={handleLoadMore}
|
||||
lowerThreshold={100}
|
||||
>
|
||||
{loading && orderList.length === 0 ? (
|
||||
<View className='flex justify-center items-center py-20'>
|
||||
<Text className='text-gray-400'>加载中...</Text>
|
||||
</View>
|
||||
) : orderList.length === 0 ? (
|
||||
<View className='flex justify-center items-center py-20'>
|
||||
<Text className='text-gray-400'>暂无订单</Text>
|
||||
</View>
|
||||
) : (
|
||||
orderList.map(renderOrderCard)
|
||||
)}
|
||||
{loading && orderList.length > 0 && (
|
||||
<View className='flex justify-center items-center py-4'>
|
||||
<Text className='text-gray-400 text-sm'>加载中...</Text>
|
||||
</View>
|
||||
)}
|
||||
{!hasMore && orderList.length > 0 && (
|
||||
<View className='flex justify-center items-center py-4'>
|
||||
<Text className='text-gray-300 text-xs'>没有更多了</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='h-6'/>
|
||||
</ScrollView>
|
||||
|
||||
{/* 操作弹窗 */}
|
||||
{showModal && (
|
||||
<View className='fixed inset-0 z-50 flex items-end justify-center'>
|
||||
{/* 遮罩 */}
|
||||
<View className='absolute inset-0 bg-black/50' onClick={closeModal}/>
|
||||
{/* 弹窗内容 */}
|
||||
<View className='relative bg-white rounded-t-2xl w-full px-5 pt-6 pb-10'>
|
||||
<Text className='text-lg font-medium text-gray-800 text-center mb-5 block'>
|
||||
确认{OP_LABEL[opType]}
|
||||
</Text>
|
||||
|
||||
{/* 订单信息 */}
|
||||
{currentOrder && (
|
||||
<View className='bg-gray-50 rounded-xl p-4 mb-5'>
|
||||
<Text className='text-sm text-gray-700 block'>订单号:{currentOrder.orderNo}</Text>
|
||||
<Text className='text-sm text-gray-700 mt-1 block'>
|
||||
实付金额:<Text
|
||||
className='text-red-500 font-medium'>¥{currentOrder.payPrice || currentOrder.totalPrice}</Text>
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-500 mt-2 block'>
|
||||
操作说明:{OP_DESC[opType]}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 凭证上传 */}
|
||||
<Text className='text-sm text-gray-600 mb-3 block'>
|
||||
上传凭证照片{opType === 'complete' ? '(必填,配送货物到达后拍照上传,最多3张)' : '(选填,最多3张)'}
|
||||
</Text>
|
||||
<View className='flex flex-wrap gap-3 mb-6'>
|
||||
{proofImages.map((url, idx) => (
|
||||
<View key={idx} className='relative'>
|
||||
<Image className='w-20 h-20 rounded-lg bg-gray-50' src={url} mode='aspectFill'/>
|
||||
<View
|
||||
className='absolute -top-2 -right-2 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center'
|
||||
onClick={() => removeProofImage(idx)}
|
||||
>
|
||||
<Text className='text-white text-xs'>×</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
{proofImages.length < 3 && (
|
||||
<View
|
||||
className='w-20 h-20 rounded-lg bg-gray-50 border-2 border-dashed border-gray-200 flex items-center justify-center'
|
||||
onClick={chooseProofImage}
|
||||
>
|
||||
<Text className='text-2xl text-gray-300'>+</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='flex gap-3'>
|
||||
<View className='flex-1 py-3 rounded-xl bg-gray-100 text-center' onClick={closeModal}>
|
||||
<Text className='text-sm text-gray-600'>取消</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`flex-1 py-3 rounded-xl text-center flex items-center justify-center ${
|
||||
opType === 'complete' ? 'bg-green-500' : 'bg-blue-500'
|
||||
}`}
|
||||
onClick={submitting ? undefined : submitOperation}
|
||||
>
|
||||
{submitting ? (
|
||||
<Text className='text-sm text-white'>提交中...</Text>
|
||||
) : (
|
||||
<Text className='text-sm text-white'>确认{OP_LABEL[opType]}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className='min-h-full bg-gray-50 flex items-center justify-center'>
|
||||
<Text className='text-gray-400'>加载中...</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!storeInfo) return null
|
||||
|
||||
return (
|
||||
<View className='min-h-full bg-gray-50'>
|
||||
|
||||
{/* 顶部信息区:店员头像 + 门店名称 */}
|
||||
<View
|
||||
className='pt-8 pb-12 px-5 rounded-b-3xl relative overflow-hidden'
|
||||
style={{ background: 'linear-gradient(135deg, #15803d 0%, #22c55e 60%, #4ade80 100%)' }}
|
||||
>
|
||||
<View className='absolute -top-10 -right-10 w-40 h-40 rounded-full opacity-10'
|
||||
style={{ background: 'radial-gradient(circle, #ffffff, transparent)' }} />
|
||||
<View className='absolute -bottom-6 -left-6 w-24 h-24 rounded-full opacity-15'
|
||||
style={{ background: 'radial-gradient(circle, #ffffff, transparent)' }} />
|
||||
<View className='relative z-10 flex items-center gap-4'>
|
||||
{/* 店员头像 */}
|
||||
<View className='w-16 h-16 rounded-full bg-white/20 border-2 border-white/40 overflow-hidden flex-shrink-0'>
|
||||
{storeInfo.avatar ? (
|
||||
<Image
|
||||
className='w-full h-full'
|
||||
src={storeInfo.avatar}
|
||||
mode='aspectFill'
|
||||
/>
|
||||
) : (
|
||||
<View className='w-full h-full flex items-center justify-center'>
|
||||
<Text className='text-2xl'>👤</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 门店信息 */}
|
||||
<View className='flex-1 min-w-0'>
|
||||
<Text className='text-white text-lg font-bold block truncate'>
|
||||
{storeInfo.storeName || '门店中心'}
|
||||
</Text>
|
||||
<Text className='text-white text-opacity-80 text-sm mt-1 block truncate'>
|
||||
{storeInfo.name} {storeInfo.phone ? `· ${storeInfo.phone}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 功能卡片区 */}
|
||||
<View className='mx-3 -mt-6 relative z-20'>
|
||||
|
||||
<View className='flex flex-col gap-3'>
|
||||
{FEATURE_CARDS.map(card => (
|
||||
<View
|
||||
key={card.key}
|
||||
className='bg-white rounded-xl p-4 flex items-center gap-4 active:opacity-80 relative'
|
||||
onClick={() => Taro.navigateTo({ url: card.url })}
|
||||
>
|
||||
{/* 图标 */}
|
||||
<View
|
||||
className='w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0'
|
||||
style={{ background: card.bgColor }}
|
||||
>
|
||||
<Text className='text-2xl'>{card.icon}</Text>
|
||||
</View>
|
||||
|
||||
{/* 文字 */}
|
||||
<View className='flex-1 min-w-0'>
|
||||
<Text className='text-gray-800 text-base font-medium block'>{card.title}</Text>
|
||||
<Text className='text-gray-400 text-xs mt-0.5 block'>{card.desc}</Text>
|
||||
</View>
|
||||
|
||||
{/* 待审核角标 */}
|
||||
{card.showBadge && pendingCount > 0 && (
|
||||
<View className='absolute -top-1 -right-1 min-w-5 h-5 rounded-full bg-red-500 flex items-center justify-center px-1'>
|
||||
<Text className='text-white text-xs font-bold'>{pendingCount > 99 ? '99+' : pendingCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 箭头 */}
|
||||
<Text className='text-gray-300 text-lg flex-shrink-0'>›</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 底部提示 */}
|
||||
<View className='mx-3 mt-6 mb-4'>
|
||||
<Text className='text-gray-300 text-xs text-center block'>
|
||||
未来更多功能将持续上线
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
563
src/pages/store/orders/index.tsx
Normal file
563
src/pages/store/orders/index.tsx
Normal file
@@ -0,0 +1,563 @@
|
||||
import React, {useState, useEffect, useCallback, useRef} from 'react'
|
||||
import {View, Text, Image, ScrollView} from '@tarojs/components'
|
||||
import Taro, {useDidShow} from '@tarojs/taro'
|
||||
import {pageShopOrder, updateShopOrder, removeShopOrder} from '@/api/shop/shopOrder'
|
||||
import type {ShopOrder, ShopOrderParam} from '@/api/shop/shopOrder/model'
|
||||
import {TenantId} from '@/config/app'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '订单管理',
|
||||
})
|
||||
|
||||
// ─── Tab 配置(全部 + 待处理 + 已完成)──────────────────────────
|
||||
type TabKey = 'all' | 'pending' | 'completed'
|
||||
|
||||
const TABS: { key: TabKey; label: string; params: Partial<ShopOrderParam>[] }[] = [
|
||||
// 全部:不传 statusFilter
|
||||
{key: 'all', label: '全部', params: [{}]},
|
||||
// 已完成:statusFilter=5(与后台管理一致)
|
||||
{key: 'completed', label: '已完成', params: [{statusFilter: 5}]},
|
||||
// 待处理:合并 statusFilter=1(待发货)和 statusFilter=8(已关闭)
|
||||
{key: 'pending', label: '已关闭', params: [{statusFilter: 1}, {statusFilter: 8}]},
|
||||
]
|
||||
|
||||
// ─── 操作类型 ─────────────────────────────────────────────────────
|
||||
type OpType = 'pay' | 'complete'
|
||||
|
||||
const OP_LABEL: Record<OpType, string> = {
|
||||
pay: '已收款',
|
||||
complete: '已完成',
|
||||
}
|
||||
|
||||
const OP_DESC: Record<OpType, string> = {
|
||||
pay: '变更支付状态为已付款',
|
||||
complete: '同时变更支付状态为已付款、收货状态为已收货、订单状态为已完成',
|
||||
}
|
||||
|
||||
// ─── 图片上传 ─────────────────────────────────────────────────────
|
||||
const uploadImage = (filePath: string): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
Taro.uploadFile({
|
||||
url: 'https://server.websoft.top/api/oss/upload',
|
||||
filePath,
|
||||
name: 'file',
|
||||
header: {'content-type': 'application/json', TenantId},
|
||||
success: (res) => {
|
||||
try {
|
||||
const data = JSON.parse(res.data)
|
||||
if (data.code === 0 && data.data?.url) {
|
||||
resolve(data.data.url)
|
||||
} else {
|
||||
reject(new Error(data.message || '上传失败'))
|
||||
}
|
||||
} catch {
|
||||
reject(new Error('解析上传响应失败'))
|
||||
}
|
||||
},
|
||||
fail: () => reject(new Error('上传请求失败')),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 状态辅助函数 ──────────────────────────────────────────────────
|
||||
|
||||
/** 格式化日期为 Spring Boot LocalDateTime 可接受的格式:yyyy-MM-dd HH:mm:ss */
|
||||
function formatDateTime(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
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 mi = String(date.getMinutes()).padStart(2, '0')
|
||||
const s = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${h}:${mi}:${s}`
|
||||
}
|
||||
|
||||
/** 解析 sendEndImg:兼容 JSON 数组(新格式)和逗号分隔(旧格式) */
|
||||
function parseSendEndImg(raw: string): string[] {
|
||||
if (!raw || !raw.trim()) return []
|
||||
const trimmed = raw.trim()
|
||||
// 尝试 JSON 解析
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
const arr = JSON.parse(trimmed)
|
||||
if (Array.isArray(arr)) return arr.filter(Boolean)
|
||||
} catch { /* fallback to comma split */ }
|
||||
}
|
||||
// 旧格式:逗号分隔(兼容单张图不含逗号的 URL)
|
||||
return trimmed.split(',').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function getStatusText(order: ShopOrder): string {
|
||||
if (order.orderStatus === 1) return '已完成'
|
||||
if (order.orderStatus === 2) return '已关闭'
|
||||
if (order.payStatus === false || order.payStatus === null) return '待收款'
|
||||
if (order.payStatus && order.deliveryStatus === 10) return '待发货'
|
||||
if (order.payStatus && order.deliveryStatus === 20) return '待收货'
|
||||
return '进行中'
|
||||
}
|
||||
|
||||
function getStatusColor(order: ShopOrder): string {
|
||||
if (order.orderStatus === 1) return '#0e932e'
|
||||
if (order.orderStatus === 2) return '#999'
|
||||
if (order.payStatus === false || order.payStatus === null) return '#ee0a24'
|
||||
if (order.payStatus) return '#ff7d00'
|
||||
return '#999'
|
||||
}
|
||||
|
||||
/** 判断订单是否可操作(非已完成、非已关闭) */
|
||||
function isOrderActionable(order: ShopOrder): boolean {
|
||||
return order.orderStatus !== 1 && order.orderStatus !== 2
|
||||
}
|
||||
|
||||
/** 获取订单可执行的操作 */
|
||||
function getOrderActions(order: ShopOrder): { label: string; type: OpType }[] {
|
||||
if (!isOrderActionable(order)) return []
|
||||
const actions: { label: string; type: OpType }[] = []
|
||||
// 货到付款:未付款 / 已付款 都直接"确认完成",
|
||||
// 确认完成会同时设置 payStatus=true,无需单独的"确认收款"按钮
|
||||
if (order.orderStatus !== 1 && order.orderStatus !== 2) {
|
||||
actions.push({label: '确认完成', type: 'complete'})
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// ─── 页面组件 ──────────────────────────────────────────────────────
|
||||
export default function StoreOrdersPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('all')
|
||||
|
||||
// 订单列表与分页
|
||||
const [orderList, setOrderList] = useState<ShopOrder[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// 操作弹窗状态
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [currentOrder, setCurrentOrder] = useState<ShopOrder | null>(null)
|
||||
const [opType, setOpType] = useState<OpType>('pay')
|
||||
const [proofImages, setProofImages] = useState<string[]>([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const pageSize = 10
|
||||
const loadingRef = useRef(false)
|
||||
|
||||
/** 加载订单列表(待处理 Tab 会合并多个 statusFilter 的结果) */
|
||||
const loadOrders = useCallback(async (tab: TabKey, pageNo: number = 1, append = false) => {
|
||||
if (loadingRef.current) return
|
||||
loadingRef.current = true
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const tabConfig = TABS.find(t => t.key === tab)
|
||||
if (!tabConfig) return
|
||||
|
||||
// 并行请求所有 params 组合,合并去重
|
||||
const allRequests = tabConfig.params.map(p =>
|
||||
pageShopOrder({...p, page: pageNo, limit: pageSize})
|
||||
)
|
||||
const allResults = await Promise.all(allRequests)
|
||||
|
||||
// 合并所有结果列表,按 orderId 去重
|
||||
const mergedList = allResults.reduce<ShopOrder[]>((acc, res) => {
|
||||
const list = res?.list || []
|
||||
list.forEach(item => {
|
||||
if (!acc.some(existing => existing.orderId === item.orderId)) {
|
||||
acc.push(item)
|
||||
}
|
||||
})
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
// 按 createTime 降序排列
|
||||
mergedList.sort((a, b) => {
|
||||
const ta = a.createTime ? new Date(a.createTime).getTime() : 0
|
||||
const tb = b.createTime ? new Date(b.createTime).getTime() : 0
|
||||
return tb - ta
|
||||
})
|
||||
|
||||
setOrderList(prev => append ? [...prev, ...mergedList] : mergedList)
|
||||
setPage(pageNo)
|
||||
// 只要任意一个请求还有数据就允许继续加载
|
||||
const maxLen = Math.max(...allResults.map(r => (r?.list || []).length))
|
||||
setHasMore(maxLen >= pageSize)
|
||||
} catch (e: any) {
|
||||
Taro.showToast({title: e.message || '加载失败', icon: 'none'})
|
||||
if (!append) setOrderList([])
|
||||
} finally {
|
||||
loadingRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 切换 tab 时重新加载
|
||||
useEffect(() => {
|
||||
loadOrders(activeTab, 1)
|
||||
}, [activeTab, loadOrders])
|
||||
|
||||
// 页面重新显示时刷新
|
||||
useDidShow(() => {
|
||||
loadOrders(activeTab, 1)
|
||||
})
|
||||
|
||||
/** 加载更多 */
|
||||
const handleLoadMore = () => {
|
||||
if (hasMore && !loadingRef.current) {
|
||||
loadOrders(activeTab, page + 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开操作弹窗 */
|
||||
const openModal = (order: ShopOrder, type: OpType) => {
|
||||
setCurrentOrder(order)
|
||||
setOpType(type)
|
||||
setProofImages([])
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const closeModal = () => {
|
||||
setShowModal(false)
|
||||
setCurrentOrder(null)
|
||||
setProofImages([])
|
||||
}
|
||||
|
||||
/** 选择并上传凭证图片 */
|
||||
const chooseProofImage = () => {
|
||||
const maxCount = 3
|
||||
const remaining = maxCount - proofImages.length
|
||||
if (remaining <= 0) return
|
||||
|
||||
Taro.chooseImage({
|
||||
count: remaining,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['camera', 'album'],
|
||||
success: async (res) => {
|
||||
for (const filePath of res.tempFilePaths) {
|
||||
try {
|
||||
Taro.showLoading({title: '上传中...'})
|
||||
const url = await uploadImage(filePath)
|
||||
Taro.hideLoading()
|
||||
setProofImages(prev => [...prev, url])
|
||||
} catch (e: any) {
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({title: e.message || '上传失败', icon: 'none'})
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 移除凭证图片 */
|
||||
const removeProofImage = (idx: number) => {
|
||||
setProofImages(prev => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
/** 提交操作 */
|
||||
const submitOperation = async () => {
|
||||
if (!currentOrder) return
|
||||
|
||||
// 确认完成必须上传凭证照片
|
||||
if (opType === 'complete' && proofImages.length === 0) {
|
||||
Taro.showToast({title: '请上传配送凭证照片', icon: 'none'})
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const updateData: any = {orderId: currentOrder.orderId}
|
||||
|
||||
if (opType === 'pay') {
|
||||
// 确认收款:变更支付状态为已付款
|
||||
updateData.payStatus = true
|
||||
updateData.payTime = formatDateTime(new Date())
|
||||
} else if (opType === 'complete') {
|
||||
// 确认完成:用户已收到货
|
||||
updateData.payStatus = true
|
||||
updateData.payTime = formatDateTime(new Date())
|
||||
updateData.deliveryStatus = 30 // 发货状态 → 已收货
|
||||
updateData.deliveryTime = formatDateTime(new Date()) // 收货时间
|
||||
updateData.orderStatus = 1 // 订单状态 → 已完成
|
||||
updateData.sendEndImg = JSON.stringify(proofImages) // 配送员送达拍照(JSON数组)
|
||||
}
|
||||
|
||||
// 收款操作如有凭证也追加到备注
|
||||
if (opType === 'pay' && proofImages.length > 0) {
|
||||
const proofText = `【门店收款凭证】:${JSON.stringify(proofImages)}`
|
||||
updateData.comments = (currentOrder.comments || '') + `\n${proofText}`
|
||||
}
|
||||
|
||||
await updateShopOrder(updateData)
|
||||
Taro.showToast({title: '操作成功', icon: 'success'})
|
||||
closeModal()
|
||||
loadOrders(activeTab, 1) // 刷新列表
|
||||
} catch (e: any) {
|
||||
Taro.showToast({title: e.message || '操作失败', icon: 'none'})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除订单 */
|
||||
const handleDelete = (order: ShopOrder) => {
|
||||
Taro.showModal({
|
||||
title: '提示',
|
||||
content: `确定要删除订单 ${order.orderNo} 吗?删除后无法恢复。`,
|
||||
confirmColor: '#ee0a24',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
try {
|
||||
Taro.showLoading({title: '删除中...'})
|
||||
await removeShopOrder(order.orderId)
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({title: '删除成功', icon: 'success'})
|
||||
loadOrders(activeTab, 1)
|
||||
} catch (e: any) {
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({title: e.message || '删除失败', icon: 'none'})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 渲染单个订单卡片 */
|
||||
const renderOrderCard = (order: ShopOrder) => {
|
||||
const actions = getOrderActions(order)
|
||||
const canDelete = isOrderActionable(order) // 仅未完成、未关闭的订单可删除
|
||||
const orderGoods = (order as any).orderGoods || []
|
||||
|
||||
return (
|
||||
<View key={order.orderId} className='bg-white rounded-xl mx-3 mt-3 p-4'>
|
||||
{/* 订单头部 */}
|
||||
<View className='flex justify-between items-center mb-3'>
|
||||
<Text className='text-xs text-gray-400'>订单号:{order.orderNo}</Text>
|
||||
<Text className='text-xs' style={{color: getStatusColor(order)}}>
|
||||
{getStatusText(order)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 商品列表 */}
|
||||
{orderGoods.map((goods: any, idx: number) => (
|
||||
<View key={idx} className='flex items-center gap-3 mb-3'>
|
||||
{goods.coverImage && (
|
||||
<Image className='w-16 h-16 rounded-lg bg-gray-50' src={goods.coverImage}
|
||||
mode='aspectFill'/>
|
||||
)}
|
||||
<View className='flex-1'>
|
||||
<Text className='text-sm text-gray-800 block'>{goods.goodsName}</Text>
|
||||
{goods.specInfo && (
|
||||
<Text className='text-xs text-gray-400 mt-1 block'>{goods.specInfo}</Text>
|
||||
)}
|
||||
<View className='flex justify-between items-center mt-1'>
|
||||
<Text className='text-sm text-red-500 font-medium'>¥{goods.price}</Text>
|
||||
<Text className='text-xs text-gray-400'>x{goods.quantity}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* 收货信息 */}
|
||||
{(order.realName || order.phone) && (
|
||||
<View className='bg-gray-50 rounded-lg p-3 mb-3'>
|
||||
<Text className='text-sm text-gray-700 block'>{order.realName} {order.phone}</Text>
|
||||
{order.address && (
|
||||
<Text className='text-xs text-gray-400 mt-1 block'>{order.address}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 送达凭证(已完成订单) */}
|
||||
{order.sendEndImg && (() => {
|
||||
const imgs = parseSendEndImg(order.sendEndImg)
|
||||
return imgs.length > 0 && (
|
||||
<View className='bg-gray-50 rounded-lg p-3 mb-3'>
|
||||
<Text className='text-xs text-gray-500 mb-2 block'>配送凭证:</Text>
|
||||
<View className='flex flex-wrap gap-2'>
|
||||
{imgs.map((img, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
className='w-20 h-20 rounded-lg bg-gray-100'
|
||||
src={img}
|
||||
mode='aspectFill'
|
||||
onClick={() => Taro.previewImage({
|
||||
current: img,
|
||||
urls: imgs
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* 金额 + 支付方式 */}
|
||||
<View className='flex justify-between items-center mb-3'>
|
||||
<Text className='text-xs text-gray-400'>
|
||||
{order.payType === 0 ? '货到付款' : order.payType === 4 ? '现金支付' : '货到付款'}
|
||||
</Text>
|
||||
<Text className='text-sm text-gray-700'>
|
||||
实付:<Text className='text-red-500 font-medium'>¥{order.payPrice || order.totalPrice}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='flex justify-between items-center border-t border-gray-50 pt-3'>
|
||||
{/* 删除按钮:仅未完成/未关闭的订单显示 */}
|
||||
{canDelete ? (
|
||||
<View
|
||||
className='px-3 py-1.5'
|
||||
onClick={() => handleDelete(order)}
|
||||
>
|
||||
<Text className='text-xs text-gray-400'>删除订单</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View/>
|
||||
)}
|
||||
|
||||
<View className={`flex gap-2 ${!canDelete ? 'w-full justify-end' : ''}`}>
|
||||
{actions.map(act => (
|
||||
<View
|
||||
key={act.type}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
act.type === 'complete'
|
||||
? 'bg-green-500'
|
||||
: 'border border-blue-500'
|
||||
}`}
|
||||
onClick={() => openModal(order, act.type)}
|
||||
>
|
||||
<Text className={`text-sm ${
|
||||
act.type === 'complete' ? 'text-white' : 'text-blue-500'
|
||||
}`}>{act.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='min-h-full bg-gray-50'>
|
||||
{/* Tab 栏 */}
|
||||
<View className='bg-white flex'>
|
||||
{TABS.map(tab => (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`flex-1 text-center py-3 border-b-2 ${
|
||||
activeTab === tab.key ? 'border-green-500' : 'border-transparent'
|
||||
}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
<Text
|
||||
className={`text-sm ${activeTab === tab.key ? 'text-green-500 font-medium' : 'text-gray-500'}`}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 订单列表 */}
|
||||
<ScrollView
|
||||
scrollY
|
||||
style={{height: 'calc(100vh - 50px)'}}
|
||||
onScrollToLower={handleLoadMore}
|
||||
lowerThreshold={100}
|
||||
>
|
||||
{loading && orderList.length === 0 ? (
|
||||
<View className='flex justify-center items-center py-20'>
|
||||
<Text className='text-gray-400'>加载中...</Text>
|
||||
</View>
|
||||
) : orderList.length === 0 ? (
|
||||
<View className='flex justify-center items-center py-20'>
|
||||
<Text className='text-gray-400'>暂无订单</Text>
|
||||
</View>
|
||||
) : (
|
||||
orderList.map(renderOrderCard)
|
||||
)}
|
||||
{loading && orderList.length > 0 && (
|
||||
<View className='flex justify-center items-center py-4'>
|
||||
<Text className='text-gray-400 text-sm'>加载中...</Text>
|
||||
</View>
|
||||
)}
|
||||
{!hasMore && orderList.length > 0 && (
|
||||
<View className='flex justify-center items-center py-4'>
|
||||
<Text className='text-gray-300 text-xs'>没有更多了</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='h-6'/>
|
||||
</ScrollView>
|
||||
|
||||
{/* 操作弹窗 */}
|
||||
{showModal && (
|
||||
<View className='fixed inset-0 z-50 flex items-end justify-center'>
|
||||
{/* 遮罩 */}
|
||||
<View className='absolute inset-0 bg-black/50' onClick={closeModal}/>
|
||||
{/* 弹窗内容 */}
|
||||
<View className='relative bg-white rounded-t-2xl w-full px-5 pt-6 pb-10'>
|
||||
<Text className='text-lg font-medium text-gray-800 text-center mb-5 block'>
|
||||
确认{OP_LABEL[opType]}
|
||||
</Text>
|
||||
|
||||
{/* 订单信息 */}
|
||||
{currentOrder && (
|
||||
<View className='bg-gray-50 rounded-xl p-4 mb-5'>
|
||||
<Text className='text-sm text-gray-700 block'>订单号:{currentOrder.orderNo}</Text>
|
||||
<Text className='text-sm text-gray-700 mt-1 block'>
|
||||
实付金额:<Text
|
||||
className='text-red-500 font-medium'>¥{currentOrder.payPrice || currentOrder.totalPrice}</Text>
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-500 mt-2 block'>
|
||||
操作说明:{OP_DESC[opType]}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 凭证上传 */}
|
||||
<Text className='text-sm text-gray-600 mb-3 block'>
|
||||
上传凭证照片{opType === 'complete' ? '(必填,配送货物到达后拍照上传,最多3张)' : '(选填,最多3张)'}
|
||||
</Text>
|
||||
<View className='flex flex-wrap gap-3 mb-6'>
|
||||
{proofImages.map((url, idx) => (
|
||||
<View key={idx} className='relative'>
|
||||
<Image className='w-20 h-20 rounded-lg bg-gray-50' src={url} mode='aspectFill'/>
|
||||
<View
|
||||
className='absolute -top-2 -right-2 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center'
|
||||
onClick={() => removeProofImage(idx)}
|
||||
>
|
||||
<Text className='text-white text-xs'>×</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
{proofImages.length < 3 && (
|
||||
<View
|
||||
className='w-20 h-20 rounded-lg bg-gray-50 border-2 border-dashed border-gray-200 flex items-center justify-center'
|
||||
onClick={chooseProofImage}
|
||||
>
|
||||
<Text className='text-2xl text-gray-300'>+</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='flex gap-3'>
|
||||
<View className='flex-1 py-3 rounded-xl bg-gray-100 text-center' onClick={closeModal}>
|
||||
<Text className='text-sm text-gray-600'>取消</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`flex-1 py-3 rounded-xl text-center flex items-center justify-center ${
|
||||
opType === 'complete' ? 'bg-green-500' : 'bg-blue-500'
|
||||
}`}
|
||||
onClick={submitting ? undefined : submitOperation}
|
||||
>
|
||||
{submitting ? (
|
||||
<Text className='text-sm text-white'>提交中...</Text>
|
||||
) : (
|
||||
<Text className='text-sm text-white'>确认{OP_LABEL[opType]}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,8 @@ const ProfilePage: React.FC = () => {
|
||||
avatar: (user as any)?.avatar || '',
|
||||
gender: (user as any)?.gender ?? 0,
|
||||
phone: (user as any)?.phone || '',
|
||||
merchantName: (user as any)?.merchantName || '',
|
||||
address: (user as any)?.address || '',
|
||||
})
|
||||
}
|
||||
}, [user])
|
||||
@@ -162,6 +164,30 @@ const ProfilePage: React.FC = () => {
|
||||
<Text className='text-sm text-gray-600'>{registerTime || '未知'}</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>
|
||||
<Input
|
||||
className='flex-1 text-right text-sm text-gray-600'
|
||||
placeholder='请输入门店名称'
|
||||
value={(form as any)?.merchantName || ''}
|
||||
onInput={(e: any) => setForm(prev => ({ ...prev, merchantName: e.detail.value }))}
|
||||
maxlength={50}
|
||||
/>
|
||||
</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 as any)?.address || ''}
|
||||
onInput={(e: any) => setForm(prev => ({ ...prev, address: e.detail.value }))}
|
||||
maxlength={100}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<BottomButton
|
||||
text='保存'
|
||||
|
||||
@@ -4,10 +4,11 @@ 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 { getUserCardStats, getUserOrderStats } from '@/api/shop/shopUserCard'
|
||||
import { getMyClerk } from '@/api/shop/shopStoreUser'
|
||||
import { listUserRole } from '@/api/system/userRole'
|
||||
import type { UserRole } from '@/api/system/userRole/model'
|
||||
import { checkAndCacheVipStatus } from '@/utils/vip'
|
||||
import MemberBadge from '@/components/business/MemberBadge'
|
||||
|
||||
const UserPage: React.FC = () => {
|
||||
@@ -44,6 +45,8 @@ const UserPage: React.FC = () => {
|
||||
getMyClerk().then(data => setStoreInfo(data)).catch(() => setStoreInfo(null))
|
||||
// 查询用户角色
|
||||
listUserRole({ userId: user?.userId }).then(data => setUserRoles(data || [])).catch(() => setUserRoles([]))
|
||||
// 检查并缓存 VIP 状态
|
||||
checkAndCacheVipStatus((user as any)?.userId || (user as any)?.id)
|
||||
} else {
|
||||
setStoreInfo(null)
|
||||
setUserRoles([])
|
||||
@@ -64,11 +67,15 @@ const UserPage: React.FC = () => {
|
||||
const userId = Taro.getStorageSync('UserId')
|
||||
if (userId) {
|
||||
listUserRole({ userId }).then(data => setUserRoles(data || [])).catch(() => {})
|
||||
// 刷新 VIP 状态缓存
|
||||
checkAndCacheVipStatus(userId)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const menuItems = [
|
||||
// 升级VIP会员 - 醒目样式
|
||||
{ icon: '👑', label: '升级VIP会员', url: '/pages/user/vip-upgrade/index', highlight: true },
|
||||
// 门店中心:仅门店店员/店长显示(通过 /shop/shop-store-user/my 判断)
|
||||
...(storeInfo ? [{ icon: '🏪', label: '门店中心', url: '/pages/store/center/index' }] : []),
|
||||
{ icon: '💰', label: '我的钱包', url: '/pages/user/wallet' },
|
||||
@@ -110,6 +117,8 @@ const UserPage: React.FC = () => {
|
||||
const uid = user?.userId
|
||||
if (uid) {
|
||||
listUserRole({ userId: uid }).then(data => setUserRoles(data || [])).catch(() => {})
|
||||
// 刷新 VIP 状态
|
||||
checkAndCacheVipStatus(uid)
|
||||
}
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
@@ -120,69 +129,81 @@ const UserPage: React.FC = () => {
|
||||
<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 className='mx-3 mt-3 p-4 rounded-xl relative overflow-hidden' style={{ background: 'linear-gradient(135deg, #15803d 0%, #22c55e 60%, #4ade80 100%)' }}>
|
||||
{/* 装饰性光晕 */}
|
||||
<View className='absolute -top-8 -right-8 w-32 h-32 rounded-full opacity-20' style={{ background: 'radial-gradient(circle, #ffffff, transparent)' }} />
|
||||
<View className='absolute -bottom-4 -left-4 w-20 h-20 rounded-full opacity-10' style={{ background: 'radial-gradient(circle, #ffffff, transparent)' }} />
|
||||
<View className='relative z-10'>
|
||||
<View className='flex items-center gap-3' onClick={handleAvatarClick}>
|
||||
{isLoggedIn && user?.avatar ? (
|
||||
<Image className='w-14 h-14 rounded-full border-2 border-white shadow-sm' src={user.avatar} mode='aspectFill' />
|
||||
) : (
|
||||
<View className='w-14 h-14 rounded-full bg-white bg-opacity-20 flex items-center justify-center border-2 border-white border-opacity-30'>
|
||||
<Text className='text-2xl'>👤</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='flex-1'>
|
||||
<Text className='text-lg font-medium text-white block'>
|
||||
{isLoggedIn ? (user?.nickname || user?.phone || '用户') : '点击登录'}
|
||||
</Text>
|
||||
<View className='flex items-center gap-2 mt-1 flex-wrap'>
|
||||
{/*{isLoggedIn && (*/}
|
||||
{/* <View*/}
|
||||
{/* className='inline-flex items-center rounded-full text-xs px-2 py-1'*/}
|
||||
{/* style={{ background: 'rgba(255,255,255,0.2)', color: '#ffffff', border: '1px solid rgba(255,255,255,0.3)' }}*/}
|
||||
{/* >*/}
|
||||
{/* <Text>{(user as any)?.memberLevelName || '普通用户'}</Text>*/}
|
||||
{/* </View>*/}
|
||||
{/*)}*/}
|
||||
{userRoles.map(role => (
|
||||
<View
|
||||
key={role.roleId}
|
||||
className='inline-flex items-center rounded-full text-xs px-2 py-0.5'
|
||||
style={{ background: 'rgba(255,255,255,0.2)', color: '#ffffff', border: '1px solid rgba(255,255,255,0.3)' }}
|
||||
>
|
||||
<Text>{role.roleName}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
{isLoggedIn && <Text className='text-white text-opacity-60 text-sm'>{'>'}</Text>}
|
||||
</View>
|
||||
{/* 数据概览 */}
|
||||
{isLoggedIn && (
|
||||
<View className='grid grid-cols-3 gap-2 mt-4 pt-4 border-t border-white border-opacity-15'>
|
||||
<View className='text-center' onClick={() => Taro.navigateTo({ url: '/pages/user/wallet' })}>
|
||||
{!hasCardData ? (
|
||||
<View className='h-7 w-16 mx-auto rounded animate-pulse' style={{ background: 'rgba(255,255,255,0.25)' }} />
|
||||
) : (
|
||||
<Text className='text-lg font-bold text-white block'>
|
||||
{cardStats?.balance || '0.00'}
|
||||
</Text>
|
||||
)}
|
||||
<Text className='text-xs text-white text-opacity-70'>余额</Text>
|
||||
</View>
|
||||
<View className='text-center' onClick={() => Taro.navigateTo({ url: '/pages/user/points-record' })}>
|
||||
{!hasCardData ? (
|
||||
<View className='h-7 w-10 mx-auto rounded animate-pulse' style={{ background: 'rgba(255,255,255,0.25)' }} />
|
||||
) : (
|
||||
<Text className='text-lg font-bold text-white block'>
|
||||
{cardStats?.points || (user as any)?.points || 0}
|
||||
</Text>
|
||||
)}
|
||||
<Text className='text-xs text-white text-opacity-70'>积分</Text>
|
||||
</View>
|
||||
<View className='text-center' onClick={() => Taro.navigateTo({ url: '/pages/user/coupon-list' })}>
|
||||
{!hasCardData ? (
|
||||
<View className='h-7 w-10 mx-auto rounded animate-pulse' style={{ background: 'rgba(255,255,255,0.25)' }} />
|
||||
) : (
|
||||
<Text className='text-lg font-bold text-white block'>
|
||||
{cardStats?.coupons || 0}
|
||||
</Text>
|
||||
)}
|
||||
<Text className='text-xs text-white text-opacity-70'>优惠券</Text>
|
||||
</View>
|
||||
</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 flex-wrap'>
|
||||
{isLoggedIn && <MemberBadge levelName={(user as any)?.memberLevelName} />}
|
||||
{userRoles.map(role => (
|
||||
<View
|
||||
key={role.roleId}
|
||||
className='inline-flex items-center rounded-full text-xs px-2 py-0.5'
|
||||
style={{ background: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe' }}
|
||||
>
|
||||
<Text>{role.roleName}</Text>
|
||||
</View>
|
||||
))}
|
||||
</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' })}>
|
||||
{!hasCardData ? (
|
||||
<View className='h-7 w-16 mx-auto bg-gray-200 rounded animate-pulse' />
|
||||
) : (
|
||||
<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' })}>
|
||||
{!hasCardData ? (
|
||||
<View className='h-7 w-10 mx-auto bg-gray-200 rounded animate-pulse' />
|
||||
) : (
|
||||
<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' })}>
|
||||
{!hasCardData ? (
|
||||
<View className='h-7 w-10 mx-auto bg-gray-200 rounded animate-pulse' />
|
||||
) : (
|
||||
<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'>
|
||||
@@ -223,14 +244,26 @@ const UserPage: React.FC = () => {
|
||||
key={item.label}
|
||||
className={`flex items-center justify-between px-4 py-3 ${
|
||||
idx < menuItems.length - 1 ? 'border-b border-gray-50' : ''
|
||||
}`}
|
||||
} ${(item as any).highlight ? '' : ''}`}
|
||||
style={(item as any).highlight ? {
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
} : {}}
|
||||
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 className={`${(item as any).highlight ? 'w-8 h-8 rounded-lg flex items-center justify-center text-lg' : ''}`} style={(item as any).highlight ? { background: 'linear-gradient(135deg, #f59e0b, #d97706)' } : {}}>
|
||||
<Text className='text-base'>{(item as any).highlight ? '' : item.icon}{(item as any).highlight ? item.icon : ''}</Text>
|
||||
</View>
|
||||
<View className='flex items-center gap-2'>
|
||||
<Text className={`text-sm ${(item as any).highlight ? 'text-amber-900 font-semibold' : 'text-gray-700'}`}>{item.label}</Text>
|
||||
{(item as any).highlight && (
|
||||
<View className='px-1.5 py-0.5 rounded-full' style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)' }}>
|
||||
<Text className='text-white text-xs'>推荐</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<Text className='text-gray-300 text-sm'>{'>'}</Text>
|
||||
<Text className={`text-sm ${(item as any).highlight ? 'text-amber-700' : 'text-gray-300'}`}>{'>'}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
389
src/pages/user/vip-review/index.tsx
Normal file
389
src/pages/user/vip-review/index.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { listShopDealerApply, updateShopDealerApply } from '@/api/shop/shopDealerApply'
|
||||
import type { ShopDealerApply } from '@/api/shop/shopDealerApply/model'
|
||||
import { addUserRole, listUserRole } from '@/api/system/userRole'
|
||||
import { getMyClerk } from '@/api/shop/shopStoreUser'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: 'VIP会员审核',
|
||||
})
|
||||
|
||||
// Tab 配置
|
||||
type TabKey = 'pending' | 'approved' | 'rejected'
|
||||
|
||||
const TABS: { key: TabKey; label: string; status: number }[] = [
|
||||
{ key: 'pending', label: '待审核', status: 10 },
|
||||
{ key: 'approved', label: '已通过', status: 20 },
|
||||
{ key: 'rejected', label: '已驳回', status: 30 },
|
||||
]
|
||||
|
||||
// VIP 角色信息(对应 system/role 表)
|
||||
const VIP_ROLE_ID = 2032
|
||||
const VIP_ROLE_CODE = 'vip'
|
||||
const VIP_ROLE_NAME = 'VIP会员'
|
||||
|
||||
// 格式化时间戳
|
||||
function formatTime(time?: number | string): string {
|
||||
if (!time) return '-'
|
||||
let date: Date
|
||||
if (typeof time === 'number') {
|
||||
// 后端 applyTime 是毫秒时间戳
|
||||
date = new Date(time > 1e12 ? time : time * 1000)
|
||||
} else {
|
||||
date = new Date(time)
|
||||
}
|
||||
if (isNaN(date.getTime())) return '-'
|
||||
const y = date.getFullYear()
|
||||
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 mi = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${h}:${mi}`
|
||||
}
|
||||
|
||||
// 格式化日期为 Spring Boot LocalDateTime 格式
|
||||
function formatDateTime(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
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 mi = String(date.getMinutes()).padStart(2, '0')
|
||||
const s = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${h}:${mi}:${s}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 给用户添加 VIP 角色
|
||||
* 先查询是否已有 VIP 角色,没有则新增,避免重复绑定
|
||||
*/
|
||||
async function assignVipRole(userId?: number): Promise<void> {
|
||||
if (!userId) return
|
||||
|
||||
// 查询用户已有角色
|
||||
const userRoles = await listUserRole({ userId })
|
||||
const hasVip = (userRoles || []).some(
|
||||
r => r.roleId === VIP_ROLE_ID || r.roleCode === VIP_ROLE_CODE
|
||||
)
|
||||
|
||||
if (hasVip) {
|
||||
// 已拥有 VIP 角色,跳过
|
||||
return
|
||||
}
|
||||
|
||||
// 新增 VIP 角色绑定
|
||||
await addUserRole({
|
||||
userId,
|
||||
roleId: VIP_ROLE_ID,
|
||||
roleCode: VIP_ROLE_CODE,
|
||||
roleName: VIP_ROLE_NAME,
|
||||
})
|
||||
}
|
||||
|
||||
const VipReviewPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('pending')
|
||||
const [list, setList] = useState<ShopDealerApply[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [isClerk, setIsClerk] = useState(false)
|
||||
const loadingRef = useRef(false)
|
||||
|
||||
// 验证当前用户是否为店员
|
||||
useEffect(() => {
|
||||
getMyClerk()
|
||||
.then(data => {
|
||||
if (data) {
|
||||
setIsClerk(true)
|
||||
} else {
|
||||
setIsClerk(false)
|
||||
Taro.showToast({ title: '仅门店店员可访问', icon: 'none' })
|
||||
setTimeout(() => Taro.navigateBack(), 1500)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setIsClerk(false)
|
||||
Taro.showToast({ title: '获取店员信息失败', icon: 'none' })
|
||||
setTimeout(() => Taro.navigateBack(), 1500)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 加载申请列表
|
||||
const loadList = useCallback(async (tab: TabKey) => {
|
||||
if (loadingRef.current) return
|
||||
loadingRef.current = true
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const tabConfig = TABS.find(t => t.key === tab)
|
||||
if (!tabConfig) return
|
||||
|
||||
// 获取所有申请记录,前端按状态过滤
|
||||
const data = await listShopDealerApply({})
|
||||
const filtered = (data || []).filter(item => item.applyStatus === tabConfig.status)
|
||||
// 按申请时间倒序
|
||||
filtered.sort((a, b) => {
|
||||
const ta = a.applyTime ? (typeof a.applyTime === 'number' ? a.applyTime : new Date(a.applyTime).getTime()) : 0
|
||||
const tb = b.applyTime ? (typeof b.applyTime === 'number' ? b.applyTime : new Date(b.applyTime).getTime()) : 0
|
||||
return tb - ta
|
||||
})
|
||||
setList(filtered)
|
||||
} catch (e: any) {
|
||||
Taro.showToast({ title: e.message || '加载失败', icon: 'none' })
|
||||
setList([])
|
||||
} finally {
|
||||
loadingRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 切换 tab 时重新加载
|
||||
useEffect(() => {
|
||||
if (isClerk) {
|
||||
loadList(activeTab)
|
||||
}
|
||||
}, [activeTab, isClerk, loadList])
|
||||
|
||||
// 页面重新显示时刷新
|
||||
useDidShow(() => {
|
||||
if (isClerk) {
|
||||
loadList(activeTab)
|
||||
}
|
||||
})
|
||||
|
||||
// 审核通过
|
||||
const handleApprove = (item: ShopDealerApply) => {
|
||||
Taro.showModal({
|
||||
title: '确认通过',
|
||||
content: `确认通过「${item.realName || item.merchantName || '该用户'}」的VIP会员申请?\n\n请确认已实地考察核实门店名称和地址。`,
|
||||
confirmColor: '#22c55e',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await updateShopDealerApply({
|
||||
applyId: item.applyId,
|
||||
userId: item.userId,
|
||||
realName: item.realName,
|
||||
merchantName: item.merchantName,
|
||||
address: item.address,
|
||||
applyType: item.applyType,
|
||||
applyStatus: 20, // 审核通过
|
||||
auditTime: Date.now(),
|
||||
} as ShopDealerApply)
|
||||
|
||||
// 审核通过后,给用户添加 VIP 角色
|
||||
await assignVipRole(item.userId)
|
||||
|
||||
Taro.showToast({ title: '已通过', icon: 'success' })
|
||||
loadList(activeTab)
|
||||
} catch (e: any) {
|
||||
Taro.showToast({ title: e.message || '操作失败', icon: 'none' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 审核驳回
|
||||
const handleReject = (item: ShopDealerApply) => {
|
||||
Taro.showModal({
|
||||
title: '驳回申请',
|
||||
content: `确认驳回「${item.realName || item.merchantName || '该用户'}」的VIP会员申请?`,
|
||||
confirmColor: '#ef4444',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await updateShopDealerApply({
|
||||
applyId: item.applyId,
|
||||
userId: item.userId,
|
||||
realName: item.realName,
|
||||
merchantName: item.merchantName,
|
||||
address: item.address,
|
||||
applyType: item.applyType,
|
||||
applyStatus: 30, // 驳回
|
||||
auditTime: Date.now(),
|
||||
rejectReason: '门店信息核实不通过',
|
||||
} as ShopDealerApply)
|
||||
Taro.showToast({ title: '已驳回', icon: 'success' })
|
||||
loadList(activeTab)
|
||||
} catch (e: any) {
|
||||
Taro.showToast({ title: e.message || '操作失败', icon: 'none' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 渲染状态标签
|
||||
const renderStatusTag = (status: number) => {
|
||||
if (status === 10) {
|
||||
return (
|
||||
<View className='px-2 py-0.5 rounded-full bg-amber-100'>
|
||||
<Text className='text-xs text-amber-600'>待审核</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (status === 20) {
|
||||
return (
|
||||
<View className='px-2 py-0.5 rounded-full bg-green-100'>
|
||||
<Text className='text-xs text-green-600'>已通过</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (status === 30) {
|
||||
return (
|
||||
<View className='px-2 py-0.5 rounded-full bg-red-100'>
|
||||
<Text className='text-xs text-red-500'>已驳回</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 渲染申请卡片
|
||||
const renderCard = (item: ShopDealerApply) => {
|
||||
const canReview = item.applyStatus === 10
|
||||
return (
|
||||
<View key={item.applyId} className='bg-white rounded-xl mx-3 mt-3 p-4'>
|
||||
{/* 头部:申请人 + 状态 */}
|
||||
<View className='flex justify-between items-center mb-3'>
|
||||
<View className='flex items-center gap-2'>
|
||||
<View className='w-8 h-8 rounded-full bg-green-50 flex items-center justify-center'>
|
||||
<Text className='text-sm'>👤</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text className='text-sm font-medium text-gray-800 block'>
|
||||
{item.realName || item.merchantName || '未知用户'}
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-400'>用户ID: {item.userId}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{renderStatusTag(item.applyStatus!)}
|
||||
</View>
|
||||
|
||||
{/* 门店信息 */}
|
||||
<View className='bg-gray-50 rounded-lg p-3 mb-3'>
|
||||
<View className='flex items-start gap-2 mb-2'>
|
||||
<Text className='text-xs text-gray-400 mt-0.5'>🏪</Text>
|
||||
<View className='flex-1'>
|
||||
<Text className='text-xs text-gray-400 block'>门店名称</Text>
|
||||
<Text className='text-sm text-gray-700 block mt-0.5'>
|
||||
{item.merchantName || item.realName || '-'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='flex items-start gap-2'>
|
||||
<Text className='text-xs text-gray-400 mt-0.5'>📍</Text>
|
||||
<View className='flex-1'>
|
||||
<Text className='text-xs text-gray-400 block'>门店地址</Text>
|
||||
<Text className='text-sm text-gray-700 block mt-0.5'>
|
||||
{item.address || '-'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 时间信息 */}
|
||||
<View className='flex justify-between items-center mb-3'>
|
||||
<Text className='text-xs text-gray-400'>
|
||||
申请时间:{formatTime(item.applyTime)}
|
||||
</Text>
|
||||
{item.auditTime ? (
|
||||
<Text className='text-xs text-gray-400'>
|
||||
审核时间:{formatTime(item.auditTime)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{/* 驳回原因 */}
|
||||
{item.applyStatus === 30 && item.rejectReason && (
|
||||
<View className='bg-red-50 rounded-lg p-3 mb-3'>
|
||||
<Text className='text-xs text-red-400 block'>驳回原因</Text>
|
||||
<Text className='text-sm text-red-600 block mt-1'>{item.rejectReason}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{canReview && (
|
||||
<View className='flex gap-3 border-t border-gray-50 pt-3'>
|
||||
<View
|
||||
className='flex-1 py-2.5 rounded-lg border border-red-300 flex items-center justify-center'
|
||||
onClick={submitting ? undefined : () => handleReject(item)}
|
||||
>
|
||||
<Text className='text-sm text-red-500'>驳回</Text>
|
||||
</View>
|
||||
<View
|
||||
className='flex-1 py-2.5 rounded-lg flex items-center justify-center'
|
||||
style={{ backgroundColor: submitting ? '#86efac' : '#22c55e' }}
|
||||
onClick={submitting ? undefined : () => handleApprove(item)}
|
||||
>
|
||||
<Text className='text-sm text-white'>通过</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isClerk) {
|
||||
return (
|
||||
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
|
||||
<Text className='text-gray-400 text-sm'>验证身份中...</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='min-h-full bg-gray-50'>
|
||||
{/* 提示信息 */}
|
||||
<View className='bg-green-50 mx-3 mt-3 rounded-xl p-3 flex items-start gap-2'>
|
||||
<Text className='text-sm'>💡</Text>
|
||||
<View className='flex-1'>
|
||||
<Text className='text-xs text-green-700 block font-medium'>审核说明</Text>
|
||||
<Text className='text-xs text-green-600 block mt-1 leading-relaxed'>
|
||||
请实地考察核实申请人的门店名称和地址是否与实际一致,确认无误后给予通过,升级为VIP会员。
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tab 栏 */}
|
||||
<View className='bg-white mx-3 mt-3 rounded-xl flex'>
|
||||
{TABS.map(tab => (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`flex-1 text-center py-3 ${activeTab === tab.key ? 'border-b-2 border-green-500' : ''}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
<Text
|
||||
className={`text-sm ${activeTab === tab.key ? 'text-green-500 font-medium' : 'text-gray-500'}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 列表 */}
|
||||
<ScrollView scrollY style={{ height: 'calc(100vh - 180px)' }}>
|
||||
{loading && list.length === 0 ? (
|
||||
<View className='flex justify-center items-center py-20'>
|
||||
<Text className='text-gray-400'>加载中...</Text>
|
||||
</View>
|
||||
) : list.length === 0 ? (
|
||||
<View className='flex justify-center items-center py-20'>
|
||||
<Text className='text-gray-400 text-sm'>暂无{activeTab === 'pending' ? '待审核' : activeTab === 'approved' ? '已通过' : '已驳回'}的申请</Text>
|
||||
</View>
|
||||
) : (
|
||||
list.map(renderCard)
|
||||
)}
|
||||
<View className='h-6' />
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default VipReviewPage
|
||||
208
src/pages/user/vip-upgrade/index.tsx
Normal file
208
src/pages/user/vip-upgrade/index.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { View, Text, Input } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useUser } from '@/hooks/useUser'
|
||||
import { addShopDealerApply, listShopDealerApply } from '@/api/shop/shopDealerApply'
|
||||
import type { ShopDealerApply } from '@/api/shop/shopDealerApply/model'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '升级VIP会员',
|
||||
})
|
||||
|
||||
const VipUpgradePage: React.FC = () => {
|
||||
const { user, isLoggedIn } = useUser()
|
||||
const [realName, setRealName] = useState('')
|
||||
const [address, setAddress] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [existingApply, setExistingApply] = useState<ShopDealerApply | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) {
|
||||
Taro.navigateTo({ url: '/passport/login' })
|
||||
return
|
||||
}
|
||||
if (user) {
|
||||
// 门店名称和地址统一从 shop-dealer-apply 表读取
|
||||
listShopDealerApply({ userId: (user as any)?.userId || (user as any)?.id })
|
||||
.then(data => {
|
||||
if (!data || data.length === 0) return
|
||||
// 取最新一条申请记录(按 applyId 倒序)
|
||||
const latest = [...data].sort((a, b) => (b.applyId || 0) - (a.applyId || 0))[0]
|
||||
if (latest) {
|
||||
setExistingApply(latest)
|
||||
setRealName(latest.realName || '')
|
||||
setAddress(latest.address || '')
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [user, isLoggedIn])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!realName.trim()) {
|
||||
Taro.showToast({ title: '请输入门店名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!address.trim()) {
|
||||
Taro.showToast({ title: '请输入门店地址', icon: 'none' })
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
await addShopDealerApply({
|
||||
userId: (user as any)?.userId || (user as any)?.id,
|
||||
realName: realName.trim(),
|
||||
address: address.trim(),
|
||||
applyType: 10, // 需后台审核
|
||||
applyStatus: 10, // 待审核
|
||||
} as ShopDealerApply)
|
||||
Taro.showToast({ title: '提交成功,等待审核', icon: 'none' })
|
||||
setTimeout(() => {
|
||||
Taro.navigateBack()
|
||||
}, 1500)
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
Taro.showToast({ title: '提交失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 待审核(10) 和 已通过(20) 时只读展示;已驳回(30) 允许编辑后重新提交
|
||||
const isReadonly = existingApply?.applyStatus === 10 || existingApply?.applyStatus === 20
|
||||
|
||||
if (!isLoggedIn) return null
|
||||
|
||||
return (
|
||||
<View className='min-h-screen bg-gray-50'>
|
||||
{/* 顶部渐变背景 */}
|
||||
<View className='pt-8 pb-12 px-5 rounded-b-3xl relative overflow-hidden'
|
||||
style={{ background: 'linear-gradient(135deg, #15803d 0%, #22c55e 60%, #4ade80 100%)' }}
|
||||
>
|
||||
<View className='absolute -top-10 -right-10 w-40 h-40 rounded-full opacity-10' style={{ background: 'radial-gradient(circle, #ffffff, transparent)' }} />
|
||||
<View className='absolute -bottom-6 -left-6 w-24 h-24 rounded-full opacity-15' style={{ background: 'radial-gradient(circle, #ffffff, transparent)' }} />
|
||||
<View className='relative z-10 flex flex-col items-center'>
|
||||
<View className='w-16 h-16 rounded-full bg-white bg-opacity-20 flex items-center justify-center mb-3'>
|
||||
<Text className='text-3xl'>👑</Text>
|
||||
</View>
|
||||
<Text className='text-white text-xl font-bold'>升级VIP会员</Text>
|
||||
<Text className='text-white text-opacity-80 text-sm mt-1'>
|
||||
{existingApply?.applyStatus === 20 ? '恭喜您已是VIP会员' : '填写门店信息,申请VIP会员资格'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 状态提示 */}
|
||||
{existingApply && (
|
||||
<View className='mx-3 -mt-6 bg-white rounded-xl p-4 shadow-sm relative z-20 mb-3'>
|
||||
<View className='flex items-start gap-3'>
|
||||
<View className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
existingApply.applyStatus === 10 ? 'bg-amber-100' : 'bg-green-100'
|
||||
}`}>
|
||||
<Text className='text-sm'>{existingApply.applyStatus === 10 ? '⏳' : '✅'}</Text>
|
||||
</View>
|
||||
<View className='flex-1'>
|
||||
<Text className='text-gray-800 text-sm font-medium block'>
|
||||
{existingApply.applyStatus === 10 ? '审核中' : '审核通过'}
|
||||
</Text>
|
||||
<Text className='text-gray-500 text-xs mt-1 block leading-relaxed'>
|
||||
{existingApply.applyStatus === 10
|
||||
? '您的VIP申请正在审核中,请耐心等待。审核结果将通过消息通知您。'
|
||||
: '恭喜!您的VIP会员申请已审核通过,现在可以享受全部VIP专属权益。'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 说明卡片 */}
|
||||
{!existingApply && (
|
||||
<View className='mx-3 -mt-6 bg-white rounded-xl p-4 shadow-sm relative z-20'>
|
||||
<View className='flex items-start gap-3'>
|
||||
<View className='w-8 h-8 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0'>
|
||||
<Text className='text-sm'>💡</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'>
|
||||
请填写您的门店信息,提交后管理员将进行审核。审核通过后即可享受VIP会员专属权益,包括更高佣金、专属折扣等特权。
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 表单 */}
|
||||
<View className={`${existingApply ? 'mt-3' : ''} mx-3 mt-3 bg-white rounded-xl overflow-hidden`}>
|
||||
{/* 门店名称 */}
|
||||
<View className='px-4 py-4 border-b border-gray-50'>
|
||||
<Text className='text-sm text-gray-700 font-medium block mb-2'>门店名称 <Text className='text-red-500'>*</Text></Text>
|
||||
<Input
|
||||
className='w-full text-sm text-gray-800 bg-gray-50 rounded-lg px-3 py-3'
|
||||
placeholder='请输入您的门店名称'
|
||||
value={realName}
|
||||
onInput={(e: any) => setRealName(e.detail.value)}
|
||||
maxlength={50}
|
||||
disabled={isReadonly}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 门店地址 */}
|
||||
<View className='px-4 py-4'>
|
||||
<Text className='text-sm text-gray-700 font-medium block mb-2'>门店地址 <Text className='text-red-500'>*</Text></Text>
|
||||
<Input
|
||||
className='w-full text-sm text-gray-800 bg-gray-50 rounded-lg px-3 py-3'
|
||||
placeholder='请输入您的门店详细地址'
|
||||
value={address}
|
||||
onInput={(e: any) => setAddress(e.detail.value)}
|
||||
maxlength={100}
|
||||
disabled={isReadonly}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* VIP权益预览 */}
|
||||
<View className='mx-3 mt-4'>
|
||||
<Text className='text-base font-medium text-gray-800 mb-3 block'>VIP会员专属权益</Text>
|
||||
<View className='grid grid-cols-2 gap-3'>
|
||||
{[
|
||||
{ icon: '💎', title: '更高佣金', desc: '享受高额团队佣金' },
|
||||
{ icon: '🎁', title: '专属折扣', desc: '购物享VIP专属价' },
|
||||
{ icon: '⚡', title: '优先发货', desc: '订单优先处理配送' },
|
||||
{ icon: '🎧', title: '专属客服', desc: '一对一VIP服务' },
|
||||
].map(item => (
|
||||
<View key={item.title} className='bg-white rounded-xl p-4 flex items-center gap-3'>
|
||||
<View className='w-10 h-10 rounded-full bg-green-50 flex items-center justify-center flex-shrink-0'>
|
||||
<Text className='text-lg'>{item.icon}</Text>
|
||||
</View>
|
||||
<View className='flex-1 min-w-0'>
|
||||
<Text className='text-gray-800 text-sm font-medium block'>{item.title}</Text>
|
||||
<Text className='text-gray-400 text-xs mt-0.5 block truncate'>{item.desc}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
{!existingApply && (
|
||||
<View className='mx-3 mt-6 mb-4'>
|
||||
<View
|
||||
className='w-full py-4 rounded-xl flex items-center justify-center active:opacity-90'
|
||||
style={{ background: loading ? '#a3e635' : 'linear-gradient(135deg, #16a34a, #22c55e)' }}
|
||||
onClick={loading ? undefined : handleSave}
|
||||
>
|
||||
<Text className='text-white text-base font-semibold'>
|
||||
{loading ? '提交中...' : '提交申请'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='text-gray-400 text-xs mt-2 text-center block'>
|
||||
提交后管理员将在1-3个工作日内审核
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default VipUpgradePage
|
||||
68
src/utils/vip.ts
Normal file
68
src/utils/vip.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import { listShopDealerApply } from '@/api/shop/shopDealerApply'
|
||||
|
||||
const VIP_STORAGE_KEY = 'is_vip_member'
|
||||
|
||||
/**
|
||||
* 从本地缓存读取 VIP 状态
|
||||
*/
|
||||
export function getVipStatusFromCache(): boolean {
|
||||
return Taro.getStorageSync(VIP_STORAGE_KEY) === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 VIP 状态到本地缓存
|
||||
*/
|
||||
export function setVipStatus(isVip: boolean) {
|
||||
Taro.setStorageSync(VIP_STORAGE_KEY, isVip)
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步检查 VIP 状态(从缓存读取,无网络请求)
|
||||
* 用于价格展示等需要即时判断的场景
|
||||
*/
|
||||
export function isVipMember(): boolean {
|
||||
return getVipStatusFromCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步检查 VIP 状态并更新缓存
|
||||
* 通过查询 ShopDealerApply 记录判断(applyStatus === 20 表示已通过)
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
export async function checkAndCacheVipStatus(userId?: number): Promise<boolean> {
|
||||
if (!userId) {
|
||||
// 尝试从 storage 获取 userId
|
||||
const uid = Taro.getStorageSync('UserId')
|
||||
if (!uid) {
|
||||
setVipStatus(false)
|
||||
return false
|
||||
}
|
||||
userId = uid
|
||||
}
|
||||
|
||||
try {
|
||||
const list = await listShopDealerApply({ userId })
|
||||
const approved = (list || []).some(item => item.applyStatus === 20)
|
||||
setVipStatus(approved)
|
||||
return approved
|
||||
} catch {
|
||||
// 请求失败时保持原缓存值
|
||||
return getVipStatusFromCache()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商品对当前用户的实际购买价格
|
||||
* VIP 会员返回 dealerPrice,普通用户返回 price
|
||||
*/
|
||||
export function getDisplayPrice(product: {
|
||||
dealerPrice?: string
|
||||
price?: string
|
||||
salePrice?: string
|
||||
}): string {
|
||||
if (isVipMember() && product.dealerPrice) {
|
||||
return product.dealerPrice
|
||||
}
|
||||
return product.price || product.salePrice || '0'
|
||||
}
|
||||
Reference in New Issue
Block a user