feat(store): 新增门店中心及VIP会员升级功能

- 个人信息页新增门店名称和门店地址展示,支持异步加载与只读显示
- 用户信息卡片整体改为绿色渐变背景,添加光晕和白色边框样式
- 新增升级VIP会员入口,突出展示并添加推荐标签
- 新建VIP会员升级页面,包含门店信息表单和VIP权益预览
- 提交升级申请调用新增api,支持状态检测表单只读
- 门店中心页面重构,简化订单管理,新增功能卡片及VIP审核入口
- 页面加载校验店员身份,非店员拒绝访问并提示
- 购物相关页面和组件全面支持VIP会员价格优先显示dealerPrice
- 新增ShopDealerApply模型门店相关字段,丰富会员申请数据记录
This commit is contained in:
2026-07-04 10:26:47 +08:00
parent 2deaaffe13
commit 994695c405
19 changed files with 1744 additions and 632 deletions

View File

@@ -6,3 +6,26 @@
- 角色以蓝色圆角徽章形式展示在昵称下方,与会员等级徽章并排
- 支持多角色展示flex-wrap支持下拉刷新同步更新
- 登录/退出/API失败等边界情况均已处理
## 个人信息页新增门店名称和门店地址展示
-`src/pages/user/profile.tsx` 中新增两个只读字段:门店名称、门店地址
- 通过 `getMyStore()` API (`GET /shop/shop-store/my-store`) 获取当前用户关联的门店信息
- 在页面挂载时user 可用后)异步加载,无门店关联时显示"暂无"
- 门店名称存储到 `sys_user.merchantName`,门店地址存储到 `sys_user.address`
- 门店地址改为普通 Input 手输(与门店名称一致)
## 用户信息卡片绿色渐变背景
- user.tsx 用户信息卡片改为绿色渐变背景 (#15803d#22c55e#4ade80)
- 添加装饰光晕效果,头像加白色边框,文字全部白色
- 徽章、数据概览、骨架屏颜色均适配绿色主题
## 新增升级VIP会员功能
- 在 user.tsx 功能菜单中"我的钱包"上方新增"升级VIP会员"入口
- 入口使用金色渐变背景 + "推荐"红色标签,与普通菜单明显区分
- 新建 `src/pages/user/vip-upgrade/index.tsx` 页面
- 页面含绿色渐变顶部、提示卡片、门店名称+门店地址表单、VIP权益预览
- 保存时调用 `addShopDealerApply` API 提交到 `shop_dealer_apply`
- `ShopDealerApply` model 新增 `merchantName``address` 字段
- 提交时设置 `applyType: 10`(需后台审核)、`applyStatus: 10`(待审核)
- 页面加载时检查已有申请记录:待审核/已通过时表单只读,隐藏提交按钮,显示状态提示
- 已在 `app.config.ts` 中注册页面路由

View File

@@ -0,0 +1,76 @@
# 2026-07-04 工作日志
## VIP会员审核功能开发
### 功能概述
为 xinlong-shop-taro 小程序新增了 VIP 会员审核功能,包含以下三个核心模块:
### 1. VIP会员审核菜单入口user.tsx
- 在"门店中心"下方添加了"VIP会员审核"菜单项
- 仅门店店员身份显示(与门店中心相同的 `storeInfo` 判断条件)
- 点击跳转到 `/pages/user/vip-review/index`
- 同时在页面加载、useDidShow、下拉刷新时调用 `checkAndCacheVipStatus` 更新当前用户的 VIP 状态缓存
### 2. VIP会员审核页面vip-review/index.tsx
- 新建页面 `src/pages/user/vip-review/index.tsx`
- 页面进入时验证当前用户是否为店员(通过 `getMyClerk()`
- 三个 Tab待审核(applyStatus=10)、已通过(20)、已驳回(30)
- 展示申请人的门店名称、门店地址、申请时间
- 店员可"通过"(设 applyStatus=20或"驳回"(设 applyStatus=30附带驳回原因
- 使用 `updateShopDealerApply` API 进行审核操作
- 与 vip-upgrade/index.tsx 配套:用户提交申请 → 店员实地考察 → 审核通过/驳回
### 3. VIP会员价格特权dealerPrice
- 创建了 VIP 状态工具 `src/utils/vip.ts`
- `isVipMember()` - 同步从缓存读取 VIP 状态
- `checkAndCacheVipStatus(userId)` - 异步检查并缓存(查询 ShopDealerApply applyStatus=20
- `getDisplayPrice(product)` - 获取商品对当前用户的实际价格
- 创建了 `src/hooks/useVipStatus.ts` Hook
- 修改了以下页面/组件支持 VIP 显示 dealerPrice
- `ProductCard` - 商品列表卡片价格
- `product-detail.tsx` - 商品详情页价格 + 立即购买传参
- `SkuSelector` - SKU 选择弹窗价格 + 确认时 fakeSku 价格
- `cart.tsx` - 购物车商品价格 + 推荐商品价格
- `CartContext.tsx` - `calcPrice` 计算总价时使用 dealerPrice
- `checkout.tsx` - 结算页金额计算 + 单价显示
### 4. 路由注册
-`app.config.ts` 中注册了 `pages/user/vip-review/index`
### 5. VIP升级页字段来源修正
- 修改 `vip-upgrade/index.tsx`
- 门店名称和门店地址不再从 `user` 对象读取,统一从 `shop-dealer-apply` 表最新记录读取
- 待审核(10) 和 已通过(20) 时只读展示;已驳回(30) 时允许编辑并重新提交
- TypeScript 类型检查通过
### 6. 审核通过时添加 VIP 角色
-`vip-review/index.tsx` 中新增了 `assignVipRole` 辅助函数
- 审核通过applyStatus=20自动调用 `addUserRole` 给申请人添加 `VIP会员` 角色roleId=2032, roleCode=vip
- 先调用 `listUserRole({ userId })` 检查用户是否已有 VIP 角色,避免重复绑定
- TypeScript 类型检查通过
### 7. 门店中心改造为仪表盘
-`store/center/index.tsx` 从订单管理页改造为功能入口仪表盘
- 新建 `store/orders/index.tsx`,将原订单管理逻辑完整搬入
- VIP会员审核入口从 `user.tsx` 独立菜单移除,统一放入门店中心仪表盘
- 门店中心仪表盘功能卡片支持扩展FEATURE_CARDS 数组,新增只需加一项)
- VIP会员审核卡片显示待审核数量角标红底白字
- TypeScript 类型检查通过
### 8. 门店中心顶部显示店员头像和门店名称
- 修改 `store/center/index.tsx` 顶部信息区
- 左侧显示圆形店员头像(读取 `storeInfo.avatar`),无头像时显示默认 👤 图标
- 右侧显示门店名称(`storeInfo.storeName`)和店员姓名 + 手机号(`storeInfo.name``storeInfo.phone`
- TypeScript 类型检查通过
### 涉及文件(补充)
- 修改:`src/pages/store/center/index.tsx`
### 9. 商品详情页价格下方 0 值隐藏
- 修改 `product-detail.tsx` 价格区域
-`dealerPrice``salePrice``memberStorePrice` 增加 `Number(value) > 0` 判断
- 0 或无效值时不再渲染,避免价格下方出现单独的 "0"
- TypeScript 类型检查通过
### 涉及文件(补充)
- 修改:`src/pages/shop/product-detail.tsx`

View File

@@ -0,0 +1,27 @@
# 项目长期记忆
## 项目概述
- 项目名xinlong-shop-taroTaro 微信小程序商城)
- 技术栈Taro + React + TypeScript + Tailwind CSS
- 后端 API 前缀:`/shop/``/api/`
- 门店店员身份判断:通过 `getMyClerk()` API (`/shop/shop-store-user/my`) 返回值判断
- VIP/分销商申请记录表:`ShopDealerApply`,状态码 10待审核/20通过/30驳回
## VIP 会员机制
- 用户在 `vip-upgrade` 页面提交门店名称和地址申请
- 门店店员在 `vip-review` 页面审核(通过/驳回)
- VIP 会员特权:商品显示 `dealerPrice` 价格,下单使用该价格结算
- VIP 状态判断:`isVipMember()` 从 localStorage 缓存读取,`checkAndCacheVipStatus(userId)` 异步更新
## 商品价格字段约定
- `product.price` - 到手价(主价格)
- `product.salePrice` - 市场价(划掉的)
- `product.dealerPrice` - VIP 会员专享价
- `product.memberStorePrice` - 会员价
## 关键文件位置
- VIP 工具:`src/utils/vip.ts`
- VIP Hook`src/hooks/useVipStatus.ts`
- VIP 审核页:`src/pages/user/vip-review/index.tsx`
- VIP 升级页:`src/pages/user/vip-upgrade/index.tsx`
- 门店中心:`src/pages/store/center/index.tsx`

View File

@@ -30,6 +30,10 @@ export interface ShopDealerApply {
createTime?: string;
// 修改时间
updateTime?: string;
// 门店名称
merchantName?: string;
// 门店地址
address?: string;
}
/**

View File

@@ -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',

View File

@@ -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]) || ''

View File

@@ -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'>

View File

@@ -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
View 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 }
}

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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='保存'

View File

@@ -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>

View 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

View 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
View 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'
}