feat(store): 新增门店中心及VIP会员升级功能
- 个人信息页新增门店名称和门店地址展示,支持异步加载与只读显示 - 用户信息卡片整体改为绿色渐变背景,添加光晕和白色边框样式 - 新增升级VIP会员入口,突出展示并添加推荐标签 - 新建VIP会员升级页面,包含门店信息表单和VIP权益预览 - 提交升级申请调用新增api,支持状态检测表单只读 - 门店中心页面重构,简化订单管理,新增功能卡片及VIP审核入口 - 页面加载校验店员身份,非店员拒绝访问并提示 - 购物相关页面和组件全面支持VIP会员价格优先显示dealerPrice - 新增ShopDealerApply模型门店相关字段,丰富会员申请数据记录
This commit is contained in:
@@ -6,3 +6,26 @@
|
|||||||
- 角色以蓝色圆角徽章形式展示在昵称下方,与会员等级徽章并排
|
- 角色以蓝色圆角徽章形式展示在昵称下方,与会员等级徽章并排
|
||||||
- 支持多角色展示(flex-wrap),支持下拉刷新同步更新
|
- 支持多角色展示(flex-wrap),支持下拉刷新同步更新
|
||||||
- 登录/退出/API失败等边界情况均已处理
|
- 登录/退出/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` 中注册页面路由
|
||||||
|
|||||||
76
.workbuddy/memory/2026-07-04.md
Normal file
76
.workbuddy/memory/2026-07-04.md
Normal 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`
|
||||||
27
.workbuddy/memory/MEMORY.md
Normal file
27
.workbuddy/memory/MEMORY.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# 项目长期记忆
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
- 项目名:xinlong-shop-taro(Taro 微信小程序商城)
|
||||||
|
- 技术栈: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`
|
||||||
@@ -30,6 +30,10 @@ export interface ShopDealerApply {
|
|||||||
createTime?: string;
|
createTime?: string;
|
||||||
// 修改时间
|
// 修改时间
|
||||||
updateTime?: string;
|
updateTime?: string;
|
||||||
|
// 门店名称
|
||||||
|
merchantName?: string;
|
||||||
|
// 门店地址
|
||||||
|
address?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export default {
|
|||||||
'pages/user/shop-setting/index',
|
'pages/user/shop-setting/index',
|
||||||
'pages/user/member/index',
|
'pages/user/member/index',
|
||||||
'pages/user/member-upgrade/index',
|
'pages/user/member-upgrade/index',
|
||||||
|
'pages/user/vip-upgrade/index',
|
||||||
|
'pages/user/vip-review/index',
|
||||||
'pages/user/invite-subordinate/index',
|
'pages/user/invite-subordinate/index',
|
||||||
'pages/user/register-pay/index',
|
'pages/user/register-pay/index',
|
||||||
'pages/user/promotion/index',
|
'pages/user/promotion/index',
|
||||||
@@ -87,6 +89,7 @@ export default {
|
|||||||
'pages/store/list/index',
|
'pages/store/list/index',
|
||||||
'pages/store/booking/index',
|
'pages/store/booking/index',
|
||||||
'pages/store/center/index',
|
'pages/store/center/index',
|
||||||
|
'pages/store/orders/index',
|
||||||
// 售后页面
|
// 售后页面
|
||||||
'pages/after-sale/apply/index',
|
'pages/after-sale/apply/index',
|
||||||
'pages/after-sale/progress/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 { ShopGoods, ShopGoodsSku } from '@/api/shop/shopGoods/model'
|
||||||
import type { ShopGoodsSpec } from '@/api/shop/shopGoodsSpec/model'
|
import type { ShopGoodsSpec } from '@/api/shop/shopGoodsSpec/model'
|
||||||
import Price from '@/components/common/Price'
|
import Price from '@/components/common/Price'
|
||||||
|
import { isVipMember } from '@/utils/vip'
|
||||||
|
|
||||||
interface SkuSelectorProps {
|
interface SkuSelectorProps {
|
||||||
visible: boolean
|
visible: boolean
|
||||||
@@ -154,11 +155,13 @@ const SkuSelector: React.FC<SkuSelectorProps> = ({
|
|||||||
try {
|
try {
|
||||||
// 单规格商品(无SKU列表 或 SKU列表为空数组)
|
// 单规格商品(无SKU列表 或 SKU列表为空数组)
|
||||||
if ((!product?.goodsSkus || product.goodsSkus.length === 0) && product) {
|
if ((!product?.goodsSkus || product.goodsSkus.length === 0) && product) {
|
||||||
|
// VIP 会员使用 dealerPrice 作为结算价
|
||||||
|
const vipPrice = isVipMember() && product.dealerPrice ? product.dealerPrice : undefined
|
||||||
const fakeSku: ShopGoodsSku = {
|
const fakeSku: ShopGoodsSku = {
|
||||||
id: 0,
|
id: 0,
|
||||||
goodsId: product.goodsId!,
|
goodsId: product.goodsId!,
|
||||||
// price 字段是到手价(主价格),salePrice 是划掉的"市场价"
|
// price 字段是到手价(主价格),salePrice 是划掉的"市场价"
|
||||||
price: product.price,
|
price: vipPrice || product.price,
|
||||||
salePrice: product.salePrice,
|
salePrice: product.salePrice,
|
||||||
stock: product.stock,
|
stock: product.stock,
|
||||||
image: product.image,
|
image: product.image,
|
||||||
@@ -191,8 +194,10 @@ const SkuSelector: React.FC<SkuSelectorProps> = ({
|
|||||||
// 价格字段约定:
|
// 价格字段约定:
|
||||||
// - product.salePrice / sku.salePrice : 划掉的"市场价"
|
// - product.salePrice / sku.salePrice : 划掉的"市场价"
|
||||||
// - product.price / sku.price : 实际"到手价"(详情页主价格)
|
// - product.price / sku.price : 实际"到手价"(详情页主价格)
|
||||||
// 弹窗应展示到手价,避免误把市场价当成售价
|
// - product.dealerPrice : VIP 会员专享价
|
||||||
const currentPrice = selectedSku?.price || product?.price || selectedSku?.salePrice || product?.salePrice || '0'
|
// 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 currentStock = selectedSku?.stock ?? product?.stock ?? 0
|
||||||
const currentImage = selectedSku?.image || product?.image || (product?.files?.split(',')[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 Taro from '@tarojs/taro'
|
||||||
import Price from '../Price'
|
import Price from '../Price'
|
||||||
import { isGuest } from '@/utils/auth'
|
import { isGuest } from '@/utils/auth'
|
||||||
|
import { isVipMember } from '@/utils/vip'
|
||||||
import type { ShopGoods } from '@/api/shop/shopGoods/model'
|
import type { ShopGoods } from '@/api/shop/shopGoods/model'
|
||||||
|
|
||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
@@ -36,14 +37,19 @@ const ProductCard: React.FC<ProductCardProps> = ({ product, onClick }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
<View className='flex items-end justify-between mt-2'>
|
<View className='flex items-end justify-between mt-2'>
|
||||||
<View className='flex-1'>
|
<View className='flex-1'>
|
||||||
<Price price={product.price || '0'} size='small' loginMask />
|
<Price price={isVipMember() && product.dealerPrice ? product.dealerPrice : (product.price || '0')} size='small' loginMask />
|
||||||
{product.salePrice && product.salePrice !== product.price && (
|
{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 : (
|
isGuest() ? null : (
|
||||||
<Text className='text-xs text-gray-400 line-through ml-1'>
|
<Text className='text-xs text-gray-400 line-through ml-1'>
|
||||||
¥{product.salePrice}
|
¥{product.salePrice}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
)}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
{product.sales !== undefined && product.sales > 0 && (
|
{product.sales !== undefined && product.sales > 0 && (
|
||||||
<Text className='text-xs text-gray-400'>
|
<Text className='text-xs text-gray-400'>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
|
import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
|
||||||
import Taro from '@tarojs/taro'
|
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 type { ShopGoods, ShopGoodsSku } from '@/api/shop/shopGoods/model'
|
||||||
|
import { isVipMember } from '@/utils/vip'
|
||||||
|
|
||||||
export interface CartItem {
|
export interface CartItem {
|
||||||
id?: number
|
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
|
return items.find(i => i.goodsId === goodsId && i.skuId === skuId)?.quantity || 0
|
||||||
}, [items])
|
}, [items])
|
||||||
|
|
||||||
const calcPrice = (list: CartItem[]) =>
|
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 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 selectedItems = items.filter(i => i.checked)
|
||||||
const selectedCount = selectedItems.reduce((s, i) => s + i.quantity, 0)
|
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 Loading from '@/components/common/Loading'
|
||||||
import { isGuest } from '@/utils/auth'
|
import { isGuest } from '@/utils/auth'
|
||||||
import { requireLogin } from '@/utils/login-guard'
|
import { requireLogin } from '@/utils/login-guard'
|
||||||
|
import { isVipMember } from '@/utils/vip'
|
||||||
|
|
||||||
definePageConfig({
|
definePageConfig({
|
||||||
navigationBarTitleText: '购物车',
|
navigationBarTitleText: '购物车',
|
||||||
@@ -180,7 +181,7 @@ const CartPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
<View className="flex justify-between items-center">
|
<View className="flex justify-between items-center">
|
||||||
<Text className="text-sm font-bold text-red-500">
|
<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>
|
</Text>
|
||||||
<View className="flex items-center gap-3">
|
<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)}>
|
<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>
|
</Text>
|
||||||
<View className="flex items-center justify-between mt-1">
|
<View className="flex items-center justify-between mt-1">
|
||||||
<Text className="text-xs font-bold text-red-500">
|
<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>
|
</Text>
|
||||||
<View
|
<View
|
||||||
className="w-5 h-5 rounded-full flex items-center justify-center"
|
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 { ShopGoods } from '@/api/shop/shopGoods/model'
|
||||||
import type { ShopUserCoupon } from '@/api/shop/shopUserCoupon/model'
|
import type { ShopUserCoupon } from '@/api/shop/shopUserCoupon/model'
|
||||||
import type { OrderGoodsItem, OrderCreateRequest } from '@/api/shop/shopOrder/model'
|
import type { OrderGoodsItem, OrderCreateRequest } from '@/api/shop/shopOrder/model'
|
||||||
|
import { isVipMember } from '@/utils/vip'
|
||||||
|
|
||||||
// 满减门槛配置
|
// 满减门槛配置
|
||||||
const THRESHOLDS = [
|
const THRESHOLDS = [
|
||||||
@@ -145,8 +146,12 @@ const CheckoutPage: React.FC = () => {
|
|||||||
// 计算金额
|
// 计算金额
|
||||||
const goodsPrice = useMemo(() => {
|
const goodsPrice = useMemo(() => {
|
||||||
if (!items || items.length === 0) return 0
|
if (!items || items.length === 0) return 0
|
||||||
|
const vip = isVipMember()
|
||||||
return items.reduce((sum, item) => {
|
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)
|
}, 0)
|
||||||
}, [buyNowItems, selectedItems])
|
}, [buyNowItems, selectedItems])
|
||||||
|
|
||||||
@@ -362,7 +367,7 @@ const CheckoutPage: React.FC = () => {
|
|||||||
<View className='flex justify-between items-center mt-1'>
|
<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-xs text-gray-500'>x{item.quantity || item.num || 1}</Text>
|
||||||
<Text className='text-sm font-medium text-gray-800'>
|
<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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useUserContext } from '@/contexts/UserContext'
|
|||||||
import { useScrollHeight } from '@/hooks/useScrollHeight'
|
import { useScrollHeight } from '@/hooks/useScrollHeight'
|
||||||
import { isGuest } from '@/utils/auth'
|
import { isGuest } from '@/utils/auth'
|
||||||
import { requireLogin } from '@/utils/login-guard'
|
import { requireLogin } from '@/utils/login-guard'
|
||||||
|
import { isVipMember } from '@/utils/vip'
|
||||||
|
|
||||||
definePageConfig({
|
definePageConfig({
|
||||||
navigationBarTitleText: '商品详情',
|
navigationBarTitleText: '商品详情',
|
||||||
@@ -66,6 +67,14 @@ const ProductDetailPage: React.FC = () => {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VIP 会员显示 dealerPrice,普通用户显示 price
|
||||||
|
const getDisplayPrice = (): string => {
|
||||||
|
if (isVipMember() && product?.dealerPrice) {
|
||||||
|
return product.dealerPrice
|
||||||
|
}
|
||||||
|
return product?.price || '0'
|
||||||
|
}
|
||||||
|
|
||||||
const checkFavoriteStatus = async () => {
|
const checkFavoriteStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const status = await getShopGoodsFavoriteStatus({ goodsId: id })
|
const status = await getShopGoodsFavoriteStatus({ goodsId: id })
|
||||||
@@ -142,6 +151,8 @@ const ProductDetailPage: React.FC = () => {
|
|||||||
console.error('[ProductDetail] 加入购物车失败:', err)
|
console.error('[ProductDetail] 加入购物车失败:', err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// VIP 会员使用 dealerPrice 作为结算价
|
||||||
|
const vipPrice = isVipMember() && product.dealerPrice ? product.dealerPrice : undefined
|
||||||
const buyNowData = [{
|
const buyNowData = [{
|
||||||
goodsId: product.goodsId!,
|
goodsId: product.goodsId!,
|
||||||
skuId: sku?.id || 0,
|
skuId: sku?.id || 0,
|
||||||
@@ -149,6 +160,7 @@ const ProductDetailPage: React.FC = () => {
|
|||||||
num: quantity,
|
num: quantity,
|
||||||
product: product,
|
product: product,
|
||||||
sku: sku,
|
sku: sku,
|
||||||
|
skuPrice: vipPrice,
|
||||||
checked: true,
|
checked: true,
|
||||||
}]
|
}]
|
||||||
|
|
||||||
@@ -261,14 +273,22 @@ const ProductDetailPage: React.FC = () => {
|
|||||||
{/* 价格区域 */}
|
{/* 价格区域 */}
|
||||||
<View className='bg-white p-4'>
|
<View className='bg-white p-4'>
|
||||||
<View className='flex items-baseline gap-2'>
|
<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>
|
<Tag>到手价</Tag>
|
||||||
{!isGuest() && product.salePrice && product.salePrice !== product.price && (
|
{/* VIP 会员价 */}
|
||||||
<Text className='text-xs text-gray-400 ml-2'>¥{product.salePrice}</Text>
|
{!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>
|
</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'>
|
<View className='mt-2 inline-block bg-orange-50 rounded px-2 py-1'>
|
||||||
<Text className='text-xs text-orange-500'>会员价: ¥{product.memberStorePrice}</Text>
|
<Text className='text-xs text-orange-500'>会员价: ¥{product.memberStorePrice}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,564 +1,163 @@
|
|||||||
import React, {useState, useEffect, useCallback, useRef} from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import {View, Text, Image, ScrollView} from '@tarojs/components'
|
import { View, Text, Image } from '@tarojs/components'
|
||||||
import Taro, {useDidShow} from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import {pageShopOrder, updateShopOrder, removeShopOrder} from '@/api/shop/shopOrder'
|
import { getMyClerk } from '@/api/shop/shopStoreUser'
|
||||||
import type {ShopOrder, ShopOrderParam} from '@/api/shop/shopOrder/model'
|
import { listShopDealerApply } from '@/api/shop/shopDealerApply'
|
||||||
import {TenantId} from '../../../config/app'
|
|
||||||
|
|
||||||
definePageConfig({
|
definePageConfig({
|
||||||
navigationBarTitleText: '门店中心',
|
navigationBarTitleText: '门店中心',
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Tab 配置(全部 + 待处理 + 已完成)──────────────────────────
|
// 功能卡片定义(未来新增功能只需在这里加一项)
|
||||||
type TabKey = 'all' | 'pending' | 'completed'
|
const FEATURE_CARDS = [
|
||||||
|
{
|
||||||
const TABS: { key: TabKey; label: string; params: Partial<ShopOrderParam>[] }[] = [
|
key: 'orders',
|
||||||
// 全部:不传 statusFilter
|
icon: '📦',
|
||||||
{key: 'all', label: '全部', params: [{}]},
|
title: '订单管理',
|
||||||
// 已完成:statusFilter=5(与后台管理一致)
|
desc: '查看和处理门店订单',
|
||||||
{key: 'completed', label: '已完成', params: [{statusFilter: 5}]},
|
url: '/pages/store/orders/index',
|
||||||
// 待处理:合并 statusFilter=1(待发货)和 statusFilter=8(已关闭)
|
color: '#15803d',
|
||||||
{key: 'pending', label: '已关闭', params: [{statusFilter: 1}, {statusFilter: 8}]},
|
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() {
|
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)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 切换 tab 时重新加载
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadOrders(activeTab, 1)
|
// 验证店员身份
|
||||||
}, [activeTab, loadOrders])
|
getMyClerk()
|
||||||
|
.then(data => {
|
||||||
// 页面重新显示时刷新
|
if (!data) {
|
||||||
useDidShow(() => {
|
Taro.showToast({ title: '仅门店店员可访问', icon: 'none' })
|
||||||
loadOrders(activeTab, 1)
|
setTimeout(() => Taro.navigateBack(), 1500)
|
||||||
})
|
|
||||||
|
|
||||||
/** 加载更多 */
|
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
setStoreInfo(data)
|
||||||
setSubmitting(true)
|
// 加载待审核 VIP 申请数量
|
||||||
try {
|
loadPendingCount()
|
||||||
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'})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
.catch(() => {
|
||||||
|
Taro.showToast({ title: '仅门店店员可访问', icon: 'none' })
|
||||||
|
setTimeout(() => Taro.navigateBack(), 1500)
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadPendingCount = async () => {
|
||||||
|
try {
|
||||||
|
const data = await listShopDealerApply({ applyStatus: 10 })
|
||||||
|
setPendingCount((data || []).length)
|
||||||
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 渲染单个订单卡片 */
|
if (loading) {
|
||||||
const renderOrderCard = (order: ShopOrder) => {
|
|
||||||
const actions = getOrderActions(order)
|
|
||||||
const canDelete = isOrderActionable(order) // 仅未完成、未关闭的订单可删除
|
|
||||||
const orderGoods = (order as any).orderGoods || []
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View key={order.orderId} className='bg-white rounded-xl mx-3 mt-3 p-4'>
|
<View className='min-h-full bg-gray-50 flex items-center justify-center'>
|
||||||
{/* 订单头部 */}
|
<Text className='text-gray-400'>加载中...</Text>
|
||||||
<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>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!storeInfo) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='min-h-full bg-gray-50'>
|
<View className='min-h-full bg-gray-50'>
|
||||||
{/* Tab 栏 */}
|
|
||||||
<View className='bg-white flex'>
|
{/* 顶部信息区:店员头像 + 门店名称 */}
|
||||||
{TABS.map(tab => (
|
|
||||||
<View
|
<View
|
||||||
key={tab.key}
|
className='pt-8 pb-12 px-5 rounded-b-3xl relative overflow-hidden'
|
||||||
className={`flex-1 text-center py-3 border-b-2 ${
|
style={{ background: 'linear-gradient(135deg, #15803d 0%, #22c55e 60%, #4ade80 100%)' }}
|
||||||
activeTab === tab.key ? 'border-green-500' : 'border-transparent'
|
|
||||||
}`}
|
|
||||||
onClick={() => setActiveTab(tab.key)}
|
|
||||||
>
|
>
|
||||||
<Text
|
<View className='absolute -top-10 -right-10 w-40 h-40 rounded-full opacity-10'
|
||||||
className={`text-sm ${activeTab === tab.key ? 'text-green-500 font-medium' : 'text-gray-500'}`}>
|
style={{ background: 'radial-gradient(circle, #ffffff, transparent)' }} />
|
||||||
{tab.label}
|
<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>
|
||||||
|
<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>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* 订单列表 */}
|
{/* 底部提示 */}
|
||||||
<ScrollView
|
<View className='mx-3 mt-6 mb-4'>
|
||||||
scrollY
|
<Text className='text-gray-300 text-xs text-center block'>
|
||||||
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>
|
</Text>
|
||||||
</View>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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 || '',
|
avatar: (user as any)?.avatar || '',
|
||||||
gender: (user as any)?.gender ?? 0,
|
gender: (user as any)?.gender ?? 0,
|
||||||
phone: (user as any)?.phone || '',
|
phone: (user as any)?.phone || '',
|
||||||
|
merchantName: (user as any)?.merchantName || '',
|
||||||
|
address: (user as any)?.address || '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [user])
|
}, [user])
|
||||||
@@ -162,6 +164,30 @@ const ProfilePage: React.FC = () => {
|
|||||||
<Text className='text-sm text-gray-600'>{registerTime || '未知'}</Text>
|
<Text className='text-sm text-gray-600'>{registerTime || '未知'}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 门店名称 */}
|
||||||
|
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'>
|
||||||
|
<Text className='text-sm text-gray-700 w-20'>门店名称</Text>
|
||||||
|
<Input
|
||||||
|
className='flex-1 text-right text-sm text-gray-600'
|
||||||
|
placeholder='请输入门店名称'
|
||||||
|
value={(form 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
|
<BottomButton
|
||||||
text='保存'
|
text='保存'
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import Taro, { useDidShow } from '@tarojs/taro'
|
|||||||
import { useUser } from '@/hooks/useUser'
|
import { useUser } from '@/hooks/useUser'
|
||||||
import { useScrollHeight } from '@/hooks/useScrollHeight'
|
import { useScrollHeight } from '@/hooks/useScrollHeight'
|
||||||
import { useRequest } from '@/hooks/useRequest'
|
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 { getMyClerk } from '@/api/shop/shopStoreUser'
|
||||||
import { listUserRole } from '@/api/system/userRole'
|
import { listUserRole } from '@/api/system/userRole'
|
||||||
import type { UserRole } from '@/api/system/userRole/model'
|
import type { UserRole } from '@/api/system/userRole/model'
|
||||||
|
import { checkAndCacheVipStatus } from '@/utils/vip'
|
||||||
import MemberBadge from '@/components/business/MemberBadge'
|
import MemberBadge from '@/components/business/MemberBadge'
|
||||||
|
|
||||||
const UserPage: React.FC = () => {
|
const UserPage: React.FC = () => {
|
||||||
@@ -44,6 +45,8 @@ const UserPage: React.FC = () => {
|
|||||||
getMyClerk().then(data => setStoreInfo(data)).catch(() => setStoreInfo(null))
|
getMyClerk().then(data => setStoreInfo(data)).catch(() => setStoreInfo(null))
|
||||||
// 查询用户角色
|
// 查询用户角色
|
||||||
listUserRole({ userId: user?.userId }).then(data => setUserRoles(data || [])).catch(() => setUserRoles([]))
|
listUserRole({ userId: user?.userId }).then(data => setUserRoles(data || [])).catch(() => setUserRoles([]))
|
||||||
|
// 检查并缓存 VIP 状态
|
||||||
|
checkAndCacheVipStatus((user as any)?.userId || (user as any)?.id)
|
||||||
} else {
|
} else {
|
||||||
setStoreInfo(null)
|
setStoreInfo(null)
|
||||||
setUserRoles([])
|
setUserRoles([])
|
||||||
@@ -64,11 +67,15 @@ const UserPage: React.FC = () => {
|
|||||||
const userId = Taro.getStorageSync('UserId')
|
const userId = Taro.getStorageSync('UserId')
|
||||||
if (userId) {
|
if (userId) {
|
||||||
listUserRole({ userId }).then(data => setUserRoles(data || [])).catch(() => {})
|
listUserRole({ userId }).then(data => setUserRoles(data || [])).catch(() => {})
|
||||||
|
// 刷新 VIP 状态缓存
|
||||||
|
checkAndCacheVipStatus(userId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
|
// 升级VIP会员 - 醒目样式
|
||||||
|
{ icon: '👑', label: '升级VIP会员', url: '/pages/user/vip-upgrade/index', highlight: true },
|
||||||
// 门店中心:仅门店店员/店长显示(通过 /shop/shop-store-user/my 判断)
|
// 门店中心:仅门店店员/店长显示(通过 /shop/shop-store-user/my 判断)
|
||||||
...(storeInfo ? [{ icon: '🏪', label: '门店中心', url: '/pages/store/center/index' }] : []),
|
...(storeInfo ? [{ icon: '🏪', label: '门店中心', url: '/pages/store/center/index' }] : []),
|
||||||
{ icon: '💰', label: '我的钱包', url: '/pages/user/wallet' },
|
{ icon: '💰', label: '我的钱包', url: '/pages/user/wallet' },
|
||||||
@@ -110,6 +117,8 @@ const UserPage: React.FC = () => {
|
|||||||
const uid = user?.userId
|
const uid = user?.userId
|
||||||
if (uid) {
|
if (uid) {
|
||||||
listUserRole({ userId: uid }).then(data => setUserRoles(data || [])).catch(() => {})
|
listUserRole({ userId: uid }).then(data => setUserRoles(data || [])).catch(() => {})
|
||||||
|
// 刷新 VIP 状态
|
||||||
|
checkAndCacheVipStatus(uid)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
@@ -120,70 +129,82 @@ const UserPage: React.FC = () => {
|
|||||||
<View className='h-full bg-gray-50'>
|
<View className='h-full bg-gray-50'>
|
||||||
<ScrollView scrollY refresherEnabled={!!isLoggedIn} refresherTriggered={refreshing} onRefresherRefresh={onRefresh} style={{ height: scrollHeight }}>
|
<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='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}>
|
<View className='flex items-center gap-3' onClick={handleAvatarClick}>
|
||||||
{isLoggedIn && user?.avatar ? (
|
{isLoggedIn && user?.avatar ? (
|
||||||
<Image className='w-14 h-14 rounded-full' src={user.avatar} mode='aspectFill' />
|
<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-gray-100 flex items-center justify-center'>
|
<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-gray-300'>👤</Text>
|
<Text className='text-2xl'>👤</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View className='flex-1'>
|
<View className='flex-1'>
|
||||||
<Text className='text-lg font-medium text-gray-800 block'>
|
<Text className='text-lg font-medium text-white block'>
|
||||||
{isLoggedIn ? (user?.nickname || user?.phone || '用户') : '点击登录'}
|
{isLoggedIn ? (user?.nickname || user?.phone || '用户') : '点击登录'}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='flex items-center gap-2 mt-1 flex-wrap'>
|
<View className='flex items-center gap-2 mt-1 flex-wrap'>
|
||||||
{isLoggedIn && <MemberBadge levelName={(user as any)?.memberLevelName} />}
|
{/*{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 => (
|
{userRoles.map(role => (
|
||||||
<View
|
<View
|
||||||
key={role.roleId}
|
key={role.roleId}
|
||||||
className='inline-flex items-center rounded-full text-xs px-2 py-0.5'
|
className='inline-flex items-center rounded-full text-xs px-2 py-0.5'
|
||||||
style={{ background: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe' }}
|
style={{ background: 'rgba(255,255,255,0.2)', color: '#ffffff', border: '1px solid rgba(255,255,255,0.3)' }}
|
||||||
>
|
>
|
||||||
<Text>{role.roleName}</Text>
|
<Text>{role.roleName}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{isLoggedIn && <Text className='text-gray-300 text-sm'>{'>'}</Text>}
|
{isLoggedIn && <Text className='text-white text-opacity-60 text-sm'>{'>'}</Text>}
|
||||||
</View>
|
</View>
|
||||||
{/* 数据概览 */}
|
{/* 数据概览 */}
|
||||||
{isLoggedIn && (
|
{isLoggedIn && (
|
||||||
<View className='grid grid-cols-3 gap-2 mt-4 pt-4 border-t border-gray-50'>
|
<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' })}>
|
<View className='text-center' onClick={() => Taro.navigateTo({ url: '/pages/user/wallet' })}>
|
||||||
{!hasCardData ? (
|
{!hasCardData ? (
|
||||||
<View className='h-7 w-16 mx-auto bg-gray-200 rounded animate-pulse' />
|
<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-gray-800 block'>
|
<Text className='text-lg font-bold text-white block'>
|
||||||
{cardStats?.balance || '0.00'}
|
{cardStats?.balance || '0.00'}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text className='text-xs text-gray-400'>余额</Text>
|
<Text className='text-xs text-white text-opacity-70'>余额</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='text-center' onClick={() => Taro.navigateTo({ url: '/pages/user/points-record' })}>
|
<View className='text-center' onClick={() => Taro.navigateTo({ url: '/pages/user/points-record' })}>
|
||||||
{!hasCardData ? (
|
{!hasCardData ? (
|
||||||
<View className='h-7 w-10 mx-auto bg-gray-200 rounded animate-pulse' />
|
<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-gray-800 block'>
|
<Text className='text-lg font-bold text-white block'>
|
||||||
{cardStats?.points || (user as any)?.points || 0}
|
{cardStats?.points || (user as any)?.points || 0}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text className='text-xs text-gray-400'>积分</Text>
|
<Text className='text-xs text-white text-opacity-70'>积分</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='text-center' onClick={() => Taro.navigateTo({ url: '/pages/user/coupon-list' })}>
|
<View className='text-center' onClick={() => Taro.navigateTo({ url: '/pages/user/coupon-list' })}>
|
||||||
{!hasCardData ? (
|
{!hasCardData ? (
|
||||||
<View className='h-7 w-10 mx-auto bg-gray-200 rounded animate-pulse' />
|
<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-gray-800 block'>
|
<Text className='text-lg font-bold text-white block'>
|
||||||
{cardStats?.coupons || 0}
|
{cardStats?.coupons || 0}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text className='text-xs text-gray-400'>优惠券</Text>
|
<Text className='text-xs text-white text-opacity-70'>优惠券</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
{/* 我的订单快捷入口 */}
|
{/* 我的订单快捷入口 */}
|
||||||
<View className='bg-white rounded-xl mx-3 mt-3 p-4'>
|
<View className='bg-white rounded-xl mx-3 mt-3 p-4'>
|
||||||
<View className='flex justify-between items-center mb-3'>
|
<View className='flex justify-between items-center mb-3'>
|
||||||
@@ -223,14 +244,26 @@ const UserPage: React.FC = () => {
|
|||||||
key={item.label}
|
key={item.label}
|
||||||
className={`flex items-center justify-between px-4 py-3 ${
|
className={`flex items-center justify-between px-4 py-3 ${
|
||||||
idx < menuItems.length - 1 ? 'border-b border-gray-50' : ''
|
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 })}
|
onClick={() => Taro.navigateTo({ url: item.url })}
|
||||||
>
|
>
|
||||||
<View className='flex items-center gap-3'>
|
<View className='flex items-center gap-3'>
|
||||||
<Text className='text-base'>{item.icon}</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-sm text-gray-700'>{item.label}</Text>
|
<Text className='text-base'>{(item as any).highlight ? '' : item.icon}{(item as any).highlight ? item.icon : ''}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className='text-gray-300 text-sm'>{'>'}</Text>
|
<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-sm ${(item as any).highlight ? 'text-amber-700' : 'text-gray-300'}`}>{'>'}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</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