From 994695c40541d899a540be144c8855d4bbd308f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Sat, 4 Jul 2026 10:26:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(store):=20=E6=96=B0=E5=A2=9E=E9=97=A8?= =?UTF-8?q?=E5=BA=97=E4=B8=AD=E5=BF=83=E5=8F=8AVIP=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 个人信息页新增门店名称和门店地址展示,支持异步加载与只读显示 - 用户信息卡片整体改为绿色渐变背景,添加光晕和白色边框样式 - 新增升级VIP会员入口,突出展示并添加推荐标签 - 新建VIP会员升级页面,包含门店信息表单和VIP权益预览 - 提交升级申请调用新增api,支持状态检测表单只读 - 门店中心页面重构,简化订单管理,新增功能卡片及VIP审核入口 - 页面加载校验店员身份,非店员拒绝访问并提示 - 购物相关页面和组件全面支持VIP会员价格优先显示dealerPrice - 新增ShopDealerApply模型门店相关字段,丰富会员申请数据记录 --- .workbuddy/memory/2026-07-03.md | 23 + .workbuddy/memory/2026-07-04.md | 76 ++ .workbuddy/memory/MEMORY.md | 27 + src/api/shop/shopDealerApply/model/index.ts | 4 + src/app.config.ts | 3 + src/components/business/SkuSelector/index.tsx | 11 +- src/components/common/ProductCard/index.tsx | 12 +- src/contexts/CartContext.tsx | 14 +- src/hooks/useVipStatus.ts | 48 ++ src/pages/shop/cart.tsx | 5 +- src/pages/shop/checkout.tsx | 9 +- src/pages/shop/product-detail.tsx | 28 +- src/pages/store/center/index.tsx | 699 ++++-------------- src/pages/store/orders/index.tsx | 563 ++++++++++++++ src/pages/user/profile.tsx | 26 + src/pages/user/user.tsx | 163 ++-- src/pages/user/vip-review/index.tsx | 389 ++++++++++ src/pages/user/vip-upgrade/index.tsx | 208 ++++++ src/utils/vip.ts | 68 ++ 19 files changed, 1744 insertions(+), 632 deletions(-) create mode 100644 .workbuddy/memory/2026-07-04.md create mode 100644 .workbuddy/memory/MEMORY.md create mode 100644 src/hooks/useVipStatus.ts create mode 100644 src/pages/store/orders/index.tsx create mode 100644 src/pages/user/vip-review/index.tsx create mode 100644 src/pages/user/vip-upgrade/index.tsx create mode 100644 src/utils/vip.ts diff --git a/.workbuddy/memory/2026-07-03.md b/.workbuddy/memory/2026-07-03.md index 6717463..e5ea8ba 100644 --- a/.workbuddy/memory/2026-07-03.md +++ b/.workbuddy/memory/2026-07-03.md @@ -6,3 +6,26 @@ - 角色以蓝色圆角徽章形式展示在昵称下方,与会员等级徽章并排 - 支持多角色展示(flex-wrap),支持下拉刷新同步更新 - 登录/退出/API失败等边界情况均已处理 + +## 个人信息页新增门店名称和门店地址展示 +- 在 `src/pages/user/profile.tsx` 中新增两个只读字段:门店名称、门店地址 +- 通过 `getMyStore()` API (`GET /shop/shop-store/my-store`) 获取当前用户关联的门店信息 +- 在页面挂载时(user 可用后)异步加载,无门店关联时显示"暂无" +- 门店名称存储到 `sys_user.merchantName`,门店地址存储到 `sys_user.address` +- 门店地址改为普通 Input 手输(与门店名称一致) + +## 用户信息卡片绿色渐变背景 +- user.tsx 用户信息卡片改为绿色渐变背景 (#15803d → #22c55e → #4ade80) +- 添加装饰光晕效果,头像加白色边框,文字全部白色 +- 徽章、数据概览、骨架屏颜色均适配绿色主题 + +## 新增升级VIP会员功能 +- 在 user.tsx 功能菜单中"我的钱包"上方新增"升级VIP会员"入口 +- 入口使用金色渐变背景 + "推荐"红色标签,与普通菜单明显区分 +- 新建 `src/pages/user/vip-upgrade/index.tsx` 页面 +- 页面含绿色渐变顶部、提示卡片、门店名称+门店地址表单、VIP权益预览 +- 保存时调用 `addShopDealerApply` API 提交到 `shop_dealer_apply` 表 +- `ShopDealerApply` model 新增 `merchantName` 和 `address` 字段 +- 提交时设置 `applyType: 10`(需后台审核)、`applyStatus: 10`(待审核) +- 页面加载时检查已有申请记录:待审核/已通过时表单只读,隐藏提交按钮,显示状态提示 +- 已在 `app.config.ts` 中注册页面路由 diff --git a/.workbuddy/memory/2026-07-04.md b/.workbuddy/memory/2026-07-04.md new file mode 100644 index 0000000..7722d5f --- /dev/null +++ b/.workbuddy/memory/2026-07-04.md @@ -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` diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md new file mode 100644 index 0000000..f7ee4ff --- /dev/null +++ b/.workbuddy/memory/MEMORY.md @@ -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` diff --git a/src/api/shop/shopDealerApply/model/index.ts b/src/api/shop/shopDealerApply/model/index.ts index 8ea27cc..8e9a9ac 100644 --- a/src/api/shop/shopDealerApply/model/index.ts +++ b/src/api/shop/shopDealerApply/model/index.ts @@ -30,6 +30,10 @@ export interface ShopDealerApply { createTime?: string; // 修改时间 updateTime?: string; + // 门店名称 + merchantName?: string; + // 门店地址 + address?: string; } /** diff --git a/src/app.config.ts b/src/app.config.ts index 126d95e..629941b 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -52,6 +52,8 @@ export default { 'pages/user/shop-setting/index', 'pages/user/member/index', 'pages/user/member-upgrade/index', + 'pages/user/vip-upgrade/index', + 'pages/user/vip-review/index', 'pages/user/invite-subordinate/index', 'pages/user/register-pay/index', 'pages/user/promotion/index', @@ -87,6 +89,7 @@ export default { 'pages/store/list/index', 'pages/store/booking/index', 'pages/store/center/index', + 'pages/store/orders/index', // 售后页面 'pages/after-sale/apply/index', 'pages/after-sale/progress/index', diff --git a/src/components/business/SkuSelector/index.tsx b/src/components/business/SkuSelector/index.tsx index 7fdfe3c..de6afbe 100644 --- a/src/components/business/SkuSelector/index.tsx +++ b/src/components/business/SkuSelector/index.tsx @@ -4,6 +4,7 @@ import Taro from '@tarojs/taro' import type { ShopGoods, ShopGoodsSku } from '@/api/shop/shopGoods/model' import type { ShopGoodsSpec } from '@/api/shop/shopGoodsSpec/model' import Price from '@/components/common/Price' +import { isVipMember } from '@/utils/vip' interface SkuSelectorProps { visible: boolean @@ -154,11 +155,13 @@ const SkuSelector: React.FC = ({ try { // 单规格商品(无SKU列表 或 SKU列表为空数组) if ((!product?.goodsSkus || product.goodsSkus.length === 0) && product) { + // VIP 会员使用 dealerPrice 作为结算价 + const vipPrice = isVipMember() && product.dealerPrice ? product.dealerPrice : undefined const fakeSku: ShopGoodsSku = { id: 0, goodsId: product.goodsId!, // price 字段是到手价(主价格),salePrice 是划掉的"市场价" - price: product.price, + price: vipPrice || product.price, salePrice: product.salePrice, stock: product.stock, image: product.image, @@ -191,8 +194,10 @@ const SkuSelector: React.FC = ({ // 价格字段约定: // - product.salePrice / sku.salePrice : 划掉的"市场价" // - product.price / sku.price : 实际"到手价"(详情页主价格) - // 弹窗应展示到手价,避免误把市场价当成售价 - const currentPrice = selectedSku?.price || product?.price || selectedSku?.salePrice || product?.salePrice || '0' + // - product.dealerPrice : VIP 会员专享价 + // VIP 会员优先使用 dealerPrice + const vipPrice = isVipMember() ? (product?.dealerPrice || selectedSku?.price) : null + const currentPrice = vipPrice || selectedSku?.price || product?.price || selectedSku?.salePrice || product?.salePrice || '0' const currentStock = selectedSku?.stock ?? product?.stock ?? 0 const currentImage = selectedSku?.image || product?.image || (product?.files?.split(',')[0]) || '' diff --git a/src/components/common/ProductCard/index.tsx b/src/components/common/ProductCard/index.tsx index 5f52ae2..324ea3b 100644 --- a/src/components/common/ProductCard/index.tsx +++ b/src/components/common/ProductCard/index.tsx @@ -3,6 +3,7 @@ import { View, Text, Image } from '@tarojs/components' import Taro from '@tarojs/taro' import Price from '../Price' import { isGuest } from '@/utils/auth' +import { isVipMember } from '@/utils/vip' import type { ShopGoods } from '@/api/shop/shopGoods/model' interface ProductCardProps { @@ -36,14 +37,19 @@ const ProductCard: React.FC = ({ product, onClick }) => { - - {product.salePrice && product.salePrice !== product.price && ( + + {isVipMember() && product.dealerPrice ? ( + // VIP 用户显示原价划掉 + + ¥{product.price} + + ) : product.salePrice && product.salePrice !== product.price ? ( isGuest() ? null : ( ¥{product.salePrice} ) - )} + ) : null} {product.sales !== undefined && product.sales > 0 && ( diff --git a/src/contexts/CartContext.tsx b/src/contexts/CartContext.tsx index 78639eb..2d7b85f 100644 --- a/src/contexts/CartContext.tsx +++ b/src/contexts/CartContext.tsx @@ -1,7 +1,8 @@ import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react' import Taro from '@tarojs/taro' -import { listShopCart, addToCart, updateCartNum, removeShopCart, removeBatchShopCart, updateCartAllChecked } from '@/api/shop/shopCart' +import { listShopCart, addToCart, updateCartNum, removeShopCart, removeBatchShopCart, updateCartAllChecked, updateCartChecked } from '@/api/shop/shopCart' import type { ShopGoods, ShopGoodsSku } from '@/api/shop/shopGoods/model' +import { isVipMember } from '@/utils/vip' export interface CartItem { id?: number @@ -194,8 +195,15 @@ export const CartProvider: React.FC<{ children: ReactNode }> = ({ children }) => return items.find(i => i.goodsId === goodsId && i.skuId === skuId)?.quantity || 0 }, [items]) - const calcPrice = (list: CartItem[]) => - list.reduce((sum, i) => sum + Number(i.skuPrice || i.sku?.price || i.product?.salePrice || i.product?.price || 0) * i.quantity, 0).toFixed(2) + const calcPrice = (list: CartItem[]) => { + const vip = isVipMember() + return list.reduce((sum, i) => { + // VIP 会员优先使用 dealerPrice + const dealerPrice = vip ? (i.product as any)?.dealerPrice : null + const unitPrice = dealerPrice || i.skuPrice || i.sku?.price || i.product?.salePrice || i.product?.price || 0 + return sum + Number(unitPrice) * i.quantity + }, 0).toFixed(2) + } const selectedItems = items.filter(i => i.checked) const selectedCount = selectedItems.reduce((s, i) => s + i.quantity, 0) diff --git a/src/hooks/useVipStatus.ts b/src/hooks/useVipStatus.ts new file mode 100644 index 0000000..45d640e --- /dev/null +++ b/src/hooks/useVipStatus.ts @@ -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(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 } +} diff --git a/src/pages/shop/cart.tsx b/src/pages/shop/cart.tsx index 1988e27..d25acc5 100644 --- a/src/pages/shop/cart.tsx +++ b/src/pages/shop/cart.tsx @@ -9,6 +9,7 @@ import EmptyState from '@/components/common/EmptyState' import Loading from '@/components/common/Loading' import { isGuest } from '@/utils/auth' import { requireLogin } from '@/utils/login-guard' +import { isVipMember } from '@/utils/vip' definePageConfig({ navigationBarTitleText: '购物车', @@ -180,7 +181,7 @@ const CartPage: React.FC = () => { )} - ¥{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')} updateQuantity(item.goodsId, item.skuId, item.quantity - 1)}> @@ -229,7 +230,7 @@ const CartPage: React.FC = () => { - ¥{goods.salePrice || goods.price || '0'} + ¥{isVipMember() && goods.dealerPrice ? goods.dealerPrice : (goods.salePrice || goods.price || '0')} { // 计算金额 const goodsPrice = useMemo(() => { if (!items || items.length === 0) return 0 + const vip = isVipMember() return items.reduce((sum, item) => { - return sum + Number(item.skuPrice || item.sku?.price || item.product?.salePrice || item.product?.price || 0) * (item.quantity || item.num || 1) + // VIP 会员优先使用 dealerPrice + const dealerPrice = vip ? (item.product as any)?.dealerPrice : null + const unitPrice = dealerPrice || item.skuPrice || item.sku?.price || item.product?.salePrice || item.product?.price || 0 + return sum + Number(unitPrice) * (item.quantity || item.num || 1) }, 0) }, [buyNowItems, selectedItems]) @@ -362,7 +367,7 @@ const CheckoutPage: React.FC = () => { x{item.quantity || item.num || 1} - ¥{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')} diff --git a/src/pages/shop/product-detail.tsx b/src/pages/shop/product-detail.tsx index d1a9b45..c737420 100644 --- a/src/pages/shop/product-detail.tsx +++ b/src/pages/shop/product-detail.tsx @@ -16,6 +16,7 @@ import { useUserContext } from '@/contexts/UserContext' import { useScrollHeight } from '@/hooks/useScrollHeight' import { isGuest } from '@/utils/auth' import { requireLogin } from '@/utils/login-guard' +import { isVipMember } from '@/utils/vip' definePageConfig({ navigationBarTitleText: '商品详情', @@ -66,6 +67,14 @@ const ProductDetailPage: React.FC = () => { } catch { /* ignore */ } } + // VIP 会员显示 dealerPrice,普通用户显示 price + const getDisplayPrice = (): string => { + if (isVipMember() && product?.dealerPrice) { + return product.dealerPrice + } + return product?.price || '0' + } + const checkFavoriteStatus = async () => { try { const status = await getShopGoodsFavoriteStatus({ goodsId: id }) @@ -142,6 +151,8 @@ const ProductDetailPage: React.FC = () => { console.error('[ProductDetail] 加入购物车失败:', err) } } else { + // VIP 会员使用 dealerPrice 作为结算价 + const vipPrice = isVipMember() && product.dealerPrice ? product.dealerPrice : undefined const buyNowData = [{ goodsId: product.goodsId!, skuId: sku?.id || 0, @@ -149,6 +160,7 @@ const ProductDetailPage: React.FC = () => { num: quantity, product: product, sku: sku, + skuPrice: vipPrice, checked: true, }] @@ -261,14 +273,22 @@ const ProductDetailPage: React.FC = () => { {/* 价格区域 */} - + 到手价 - {!isGuest() && product.salePrice && product.salePrice !== product.price && ( - ¥{product.salePrice} + {/* VIP 会员价 */} + {!isGuest() && isVipMember() && Number(product.dealerPrice) > 0 ? ( + + VIP专享 + ¥{product.dealerPrice} + + ) : ( + !isGuest() && Number(product.salePrice) > 0 && product.salePrice !== product.price && ( + ¥{product.salePrice} + ) )} {/* 会员价 */} - {!isGuest() && product.memberStorePrice && product.memberStorePrice !== product.price && ( + {!isGuest() && !isVipMember() && Number(product.memberStorePrice) > 0 && product.memberStorePrice !== product.price && ( 会员价: ¥{product.memberStorePrice} diff --git a/src/pages/store/center/index.tsx b/src/pages/store/center/index.tsx index 420a4ba..aff6453 100644 --- a/src/pages/store/center/index.tsx +++ b/src/pages/store/center/index.tsx @@ -1,564 +1,163 @@ -import React, {useState, useEffect, useCallback, useRef} from 'react' -import {View, Text, Image, ScrollView} from '@tarojs/components' -import Taro, {useDidShow} from '@tarojs/taro' -import {pageShopOrder, updateShopOrder, removeShopOrder} from '@/api/shop/shopOrder' -import type {ShopOrder, ShopOrderParam} from '@/api/shop/shopOrder/model' -import {TenantId} from '../../../config/app' +import React, { useState, useEffect } from 'react' +import { View, Text, Image } from '@tarojs/components' +import Taro from '@tarojs/taro' +import { getMyClerk } from '@/api/shop/shopStoreUser' +import { listShopDealerApply } from '@/api/shop/shopDealerApply' definePageConfig({ - navigationBarTitleText: '门店中心', + navigationBarTitleText: '门店中心', }) -// ─── Tab 配置(全部 + 待处理 + 已完成)────────────────────────── -type TabKey = 'all' | 'pending' | 'completed' - -const TABS: { key: TabKey; label: string; params: Partial[] }[] = [ - // 全部:不传 statusFilter - {key: 'all', label: '全部', params: [{}]}, - // 已完成:statusFilter=5(与后台管理一致) - {key: 'completed', label: '已完成', params: [{statusFilter: 5}]}, - // 待处理:合并 statusFilter=1(待发货)和 statusFilter=8(已关闭) - {key: 'pending', label: '已关闭', params: [{statusFilter: 1}, {statusFilter: 8}]}, +// 功能卡片定义(未来新增功能只需在这里加一项) +const FEATURE_CARDS = [ + { + key: 'orders', + icon: '📦', + title: '订单管理', + desc: '查看和处理门店订单', + url: '/pages/store/orders/index', + color: '#15803d', + bgColor: '#dcfce7', + }, + { + key: 'vip-review', + icon: '👑', + title: 'VIP会员审核', + desc: '审核客户VIP会员申请', + url: '/pages/user/vip-review/index', + color: '#7c3aed', + bgColor: '#ede9fe', + // 显示待审核数量角标 + showBadge: true, + }, ] -// ─── 操作类型 ───────────────────────────────────────────────────── -type OpType = 'pay' | 'complete' - -const OP_LABEL: Record = { - pay: '已收款', - complete: '已完成', -} - -const OP_DESC: Record = { - pay: '变更支付状态为已付款', - complete: '同时变更支付状态为已付款、收货状态为已收货、订单状态为已完成', -} - -// ─── 图片上传 ───────────────────────────────────────────────────── -const uploadImage = (filePath: string): Promise => { - return new Promise((resolve, reject) => { - Taro.uploadFile({ - url: 'https://server.websoft.top/api/oss/upload', - filePath, - name: 'file', - header: {'content-type': 'application/json', TenantId}, - success: (res) => { - try { - const data = JSON.parse(res.data) - if (data.code === 0 && data.data?.url) { - resolve(data.data.url) - } else { - reject(new Error(data.message || '上传失败')) - } - } catch { - reject(new Error('解析上传响应失败')) - } - }, - fail: () => reject(new Error('上传请求失败')), - }) - }) -} - -// ─── 状态辅助函数 ────────────────────────────────────────────────── - -/** 格式化日期为 Spring Boot LocalDateTime 可接受的格式:yyyy-MM-dd HH:mm:ss */ -function formatDateTime(date: Date): string { - const y = date.getFullYear() - const m = String(date.getMonth() + 1).padStart(2, '0') - const d = String(date.getDate()).padStart(2, '0') - const h = String(date.getHours()).padStart(2, '0') - const mi = String(date.getMinutes()).padStart(2, '0') - const s = String(date.getSeconds()).padStart(2, '0') - return `${y}-${m}-${d} ${h}:${mi}:${s}` -} - -/** 解析 sendEndImg:兼容 JSON 数组(新格式)和逗号分隔(旧格式) */ -function parseSendEndImg(raw: string): string[] { - if (!raw || !raw.trim()) return [] - const trimmed = raw.trim() - // 尝试 JSON 解析 - if (trimmed.startsWith('[')) { - try { - const arr = JSON.parse(trimmed) - if (Array.isArray(arr)) return arr.filter(Boolean) - } catch { /* fallback to comma split */ - } - } - // 旧格式:逗号分隔(兼容单张图不含逗号的 URL) - return trimmed.split(',').map(s => s.trim()).filter(Boolean) -} - -function getStatusText(order: ShopOrder): string { - if (order.orderStatus === 1) return '已完成' - if (order.orderStatus === 2) return '已关闭' - if (order.payStatus === false || order.payStatus === null) return '待收款' - if (order.payStatus && order.deliveryStatus === 10) return '待发货' - if (order.payStatus && order.deliveryStatus === 20) return '待收货' - return '进行中' -} - -function getStatusColor(order: ShopOrder): string { - if (order.orderStatus === 1) return '#0e932e' - if (order.orderStatus === 2) return '#999' - if (order.payStatus === false || order.payStatus === null) return '#ee0a24' - if (order.payStatus) return '#ff7d00' - return '#999' -} - -/** 判断订单是否可操作(非已完成、非已关闭) */ -function isOrderActionable(order: ShopOrder): boolean { - return order.orderStatus !== 1 && order.orderStatus !== 2 -} - -/** 获取订单可执行的操作 */ -function getOrderActions(order: ShopOrder): { label: string; type: OpType }[] { - if (!isOrderActionable(order)) return [] - const actions: { label: string; type: OpType }[] = [] - // 货到付款:未付款 / 已付款 都直接"确认完成", - // 确认完成会同时设置 payStatus=true,无需单独的"确认收款"按钮 - if (order.orderStatus !== 1 && order.orderStatus !== 2) { - actions.push({label: '确认完成', type: 'complete'}) - } - return actions -} - -// ─── 页面组件 ────────────────────────────────────────────────────── export default function StoreCenterPage() { - const [activeTab, setActiveTab] = useState('all') + const [storeInfo, setStoreInfo] = useState(null) + const [pendingCount, setPendingCount] = useState(0) + const [loading, setLoading] = useState(true) - // 订单列表与分页 - const [orderList, setOrderList] = useState([]) - const [page, setPage] = useState(1) - const [hasMore, setHasMore] = useState(true) - const [loading, setLoading] = useState(false) - - // 操作弹窗状态 - const [showModal, setShowModal] = useState(false) - const [currentOrder, setCurrentOrder] = useState(null) - const [opType, setOpType] = useState('pay') - const [proofImages, setProofImages] = useState([]) - 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((acc, res) => { - const list = res?.list || [] - list.forEach(item => { - if (!acc.some(existing => existing.orderId === item.orderId)) { - acc.push(item) - } - }) - return acc - }, []) - - // 按 createTime 降序排列 - mergedList.sort((a, b) => { - const ta = a.createTime ? new Date(a.createTime).getTime() : 0 - const tb = b.createTime ? new Date(b.createTime).getTime() : 0 - return tb - ta - }) - - setOrderList(prev => append ? [...prev, ...mergedList] : mergedList) - setPage(pageNo) - // 只要任意一个请求还有数据就允许继续加载 - const maxLen = Math.max(...allResults.map(r => (r?.list || []).length)) - setHasMore(maxLen >= pageSize) - } catch (e: any) { - Taro.showToast({title: e.message || '加载失败', icon: 'none'}) - if (!append) setOrderList([]) - } finally { - loadingRef.current = false - setLoading(false) + useEffect(() => { + // 验证店员身份 + getMyClerk() + .then(data => { + if (!data) { + Taro.showToast({ title: '仅门店店员可访问', icon: 'none' }) + setTimeout(() => Taro.navigateBack(), 1500) + return } - }, []) + setStoreInfo(data) + // 加载待审核 VIP 申请数量 + loadPendingCount() + }) + .catch(() => { + Taro.showToast({ title: '仅门店店员可访问', icon: 'none' }) + setTimeout(() => Taro.navigateBack(), 1500) + }) + .finally(() => setLoading(false)) + }, []) - // 切换 tab 时重新加载 - useEffect(() => { - loadOrders(activeTab, 1) - }, [activeTab, loadOrders]) - - // 页面重新显示时刷新 - useDidShow(() => { - loadOrders(activeTab, 1) - }) - - /** 加载更多 */ - const handleLoadMore = () => { - if (hasMore && !loadingRef.current) { - loadOrders(activeTab, page + 1, true) - } - } - - /** 打开操作弹窗 */ - const openModal = (order: ShopOrder, type: OpType) => { - setCurrentOrder(order) - setOpType(type) - setProofImages([]) - setShowModal(true) - } - - /** 关闭弹窗 */ - const closeModal = () => { - setShowModal(false) - setCurrentOrder(null) - setProofImages([]) - } - - /** 选择并上传凭证图片 */ - const chooseProofImage = () => { - const maxCount = 3 - const remaining = maxCount - proofImages.length - if (remaining <= 0) return - - Taro.chooseImage({ - count: remaining, - sizeType: ['compressed'], - sourceType: ['camera', 'album'], - success: async (res) => { - for (const filePath of res.tempFilePaths) { - try { - Taro.showLoading({title: '上传中...'}) - const url = await uploadImage(filePath) - Taro.hideLoading() - setProofImages(prev => [...prev, url]) - } catch (e: any) { - Taro.hideLoading() - Taro.showToast({title: e.message || '上传失败', icon: 'none'}) - } - } - }, - }) - } - - /** 移除凭证图片 */ - const removeProofImage = (idx: number) => { - setProofImages(prev => prev.filter((_, i) => i !== idx)) - } - - /** 提交操作 */ - const submitOperation = async () => { - if (!currentOrder) return - - // 确认完成必须上传凭证照片 - if (opType === 'complete' && proofImages.length === 0) { - Taro.showToast({title: '请上传配送凭证照片', icon: 'none'}) - return - } - - setSubmitting(true) - try { - const updateData: any = {orderId: currentOrder.orderId} - - if (opType === 'pay') { - // 确认收款:变更支付状态为已付款 - updateData.payStatus = true - updateData.payTime = formatDateTime(new Date()) - } else if (opType === 'complete') { - // 确认完成:用户已收到货 - updateData.payStatus = true - updateData.payTime = formatDateTime(new Date()) - updateData.deliveryStatus = 30 // 发货状态 → 已收货 - updateData.deliveryTime = formatDateTime(new Date()) // 收货时间 - updateData.orderStatus = 1 // 订单状态 → 已完成 - updateData.sendEndImg = JSON.stringify(proofImages) // 配送员送达拍照(JSON数组) - } - - // 收款操作如有凭证也追加到备注 - if (opType === 'pay' && proofImages.length > 0) { - const proofText = `【门店收款凭证】:${JSON.stringify(proofImages)}` - updateData.comments = (currentOrder.comments || '') + `\n${proofText}` - } - - await updateShopOrder(updateData) - Taro.showToast({title: '操作成功', icon: 'success'}) - closeModal() - loadOrders(activeTab, 1) // 刷新列表 - } catch (e: any) { - Taro.showToast({title: e.message || '操作失败', icon: 'none'}) - } finally { - setSubmitting(false) - } - } - - /** 删除订单 */ - const handleDelete = (order: ShopOrder) => { - Taro.showModal({ - title: '提示', - content: `确定要删除订单 ${order.orderNo} 吗?删除后无法恢复。`, - confirmColor: '#ee0a24', - success: async (res) => { - if (!res.confirm) return - try { - Taro.showLoading({title: '删除中...'}) - await removeShopOrder(order.orderId) - Taro.hideLoading() - Taro.showToast({title: '删除成功', icon: 'success'}) - loadOrders(activeTab, 1) - } catch (e: any) { - Taro.hideLoading() - Taro.showToast({title: e.message || '删除失败', icon: 'none'}) - } - }, - }) - } - - /** 渲染单个订单卡片 */ - const renderOrderCard = (order: ShopOrder) => { - const actions = getOrderActions(order) - const canDelete = isOrderActionable(order) // 仅未完成、未关闭的订单可删除 - const orderGoods = (order as any).orderGoods || [] - - return ( - - {/* 订单头部 */} - - 订单号:{order.orderNo} - - {getStatusText(order)} - - - - {/* 商品列表 */} - {orderGoods.map((goods: any, idx: number) => ( - - {goods.coverImage && ( - - )} - - {goods.goodsName} - {goods.specInfo && ( - {goods.specInfo} - )} - - ¥{goods.price} - x{goods.quantity} - - - - ))} - - {/* 收货信息 */} - {(order.realName || order.phone) && ( - - {order.realName} {order.phone} - {order.address && ( - {order.address} - )} - - )} - - {/* 送达凭证(已完成订单) */} - {order.sendEndImg && (() => { - const imgs = parseSendEndImg(order.sendEndImg) - return imgs.length > 0 && ( - - 配送凭证: - - {imgs.map((img, i) => ( - Taro.previewImage({ - current: img, - urls: imgs - })} - /> - ))} - - - ) - })()} - - {/* 金额 + 支付方式 */} - - - {order.payType === 0 ? '货到付款' : order.payType === 4 ? '现金支付' : '货到付款'} - - - 实付:¥{order.payPrice || order.totalPrice} - - - - {/* 操作按钮 */} - - {/* 删除按钮:仅未完成/未关闭的订单显示 */} - {canDelete ? ( - handleDelete(order)} - > - 删除订单 - - ) : ( - - )} - - - {actions.map(act => ( - openModal(order, act.type)} - > - {act.label} - - ))} - - - - ) - } + const loadPendingCount = async () => { + try { + const data = await listShopDealerApply({ applyStatus: 10 }) + setPendingCount((data || []).length) + } catch { /* ignore */ } + } + if (loading) { return ( - - {/* Tab 栏 */} - - {TABS.map(tab => ( - setActiveTab(tab.key)} - > - - {tab.label} - - - ))} - - - {/* 订单列表 */} - - {loading && orderList.length === 0 ? ( - - 加载中... - - ) : orderList.length === 0 ? ( - - 暂无订单 - - ) : ( - orderList.map(renderOrderCard) - )} - {loading && orderList.length > 0 && ( - - 加载中... - - )} - {!hasMore && orderList.length > 0 && ( - - 没有更多了 - - )} - - - - {/* 操作弹窗 */} - {showModal && ( - - {/* 遮罩 */} - - {/* 弹窗内容 */} - - - 确认{OP_LABEL[opType]} - - - {/* 订单信息 */} - {currentOrder && ( - - 订单号:{currentOrder.orderNo} - - 实付金额:¥{currentOrder.payPrice || currentOrder.totalPrice} - - - 操作说明:{OP_DESC[opType]} - - - )} - - {/* 凭证上传 */} - - 上传凭证照片{opType === 'complete' ? '(必填,配送货物到达后拍照上传,最多3张)' : '(选填,最多3张)'} - - - {proofImages.map((url, idx) => ( - - - removeProofImage(idx)} - > - × - - - ))} - {proofImages.length < 3 && ( - - + - - )} - - - {/* 操作按钮 */} - - - 取消 - - - {submitting ? ( - 提交中... - ) : ( - 确认{OP_LABEL[opType]} - )} - - - - - )} - + + 加载中... + ) + } + + if (!storeInfo) return null + + return ( + + + {/* 顶部信息区:店员头像 + 门店名称 */} + + + + + {/* 店员头像 */} + + {storeInfo.avatar ? ( + + ) : ( + + 👤 + + )} + + + {/* 门店信息 */} + + + {storeInfo.storeName || '门店中心'} + + + {storeInfo.name} {storeInfo.phone ? `· ${storeInfo.phone}` : ''} + + + + + + {/* 功能卡片区 */} + + + + {FEATURE_CARDS.map(card => ( + Taro.navigateTo({ url: card.url })} + > + {/* 图标 */} + + {card.icon} + + + {/* 文字 */} + + {card.title} + {card.desc} + + + {/* 待审核角标 */} + {card.showBadge && pendingCount > 0 && ( + + {pendingCount > 99 ? '99+' : pendingCount} + + )} + + {/* 箭头 */} + + + ))} + + + + {/* 底部提示 */} + + + 未来更多功能将持续上线 + + + + ) } diff --git a/src/pages/store/orders/index.tsx b/src/pages/store/orders/index.tsx new file mode 100644 index 0000000..43cce12 --- /dev/null +++ b/src/pages/store/orders/index.tsx @@ -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[] }[] = [ + // 全部:不传 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 = { + pay: '已收款', + complete: '已完成', +} + +const OP_DESC: Record = { + pay: '变更支付状态为已付款', + complete: '同时变更支付状态为已付款、收货状态为已收货、订单状态为已完成', +} + +// ─── 图片上传 ───────────────────────────────────────────────────── +const uploadImage = (filePath: string): Promise => { + 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('all') + + // 订单列表与分页 + const [orderList, setOrderList] = useState([]) + const [page, setPage] = useState(1) + const [hasMore, setHasMore] = useState(true) + const [loading, setLoading] = useState(false) + + // 操作弹窗状态 + const [showModal, setShowModal] = useState(false) + const [currentOrder, setCurrentOrder] = useState(null) + const [opType, setOpType] = useState('pay') + const [proofImages, setProofImages] = useState([]) + 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((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 ( + + {/* 订单头部 */} + + 订单号:{order.orderNo} + + {getStatusText(order)} + + + + {/* 商品列表 */} + {orderGoods.map((goods: any, idx: number) => ( + + {goods.coverImage && ( + + )} + + {goods.goodsName} + {goods.specInfo && ( + {goods.specInfo} + )} + + ¥{goods.price} + x{goods.quantity} + + + + ))} + + {/* 收货信息 */} + {(order.realName || order.phone) && ( + + {order.realName} {order.phone} + {order.address && ( + {order.address} + )} + + )} + + {/* 送达凭证(已完成订单) */} + {order.sendEndImg && (() => { + const imgs = parseSendEndImg(order.sendEndImg) + return imgs.length > 0 && ( + + 配送凭证: + + {imgs.map((img, i) => ( + Taro.previewImage({ + current: img, + urls: imgs + })} + /> + ))} + + + ) + })()} + + {/* 金额 + 支付方式 */} + + + {order.payType === 0 ? '货到付款' : order.payType === 4 ? '现金支付' : '货到付款'} + + + 实付:¥{order.payPrice || order.totalPrice} + + + + {/* 操作按钮 */} + + {/* 删除按钮:仅未完成/未关闭的订单显示 */} + {canDelete ? ( + handleDelete(order)} + > + 删除订单 + + ) : ( + + )} + + + {actions.map(act => ( + openModal(order, act.type)} + > + {act.label} + + ))} + + + + ) + } + + return ( + + {/* Tab 栏 */} + + {TABS.map(tab => ( + setActiveTab(tab.key)} + > + + {tab.label} + + + ))} + + + {/* 订单列表 */} + + {loading && orderList.length === 0 ? ( + + 加载中... + + ) : orderList.length === 0 ? ( + + 暂无订单 + + ) : ( + orderList.map(renderOrderCard) + )} + {loading && orderList.length > 0 && ( + + 加载中... + + )} + {!hasMore && orderList.length > 0 && ( + + 没有更多了 + + )} + + + + {/* 操作弹窗 */} + {showModal && ( + + {/* 遮罩 */} + + {/* 弹窗内容 */} + + + 确认{OP_LABEL[opType]} + + + {/* 订单信息 */} + {currentOrder && ( + + 订单号:{currentOrder.orderNo} + + 实付金额:¥{currentOrder.payPrice || currentOrder.totalPrice} + + + 操作说明:{OP_DESC[opType]} + + + )} + + {/* 凭证上传 */} + + 上传凭证照片{opType === 'complete' ? '(必填,配送货物到达后拍照上传,最多3张)' : '(选填,最多3张)'} + + + {proofImages.map((url, idx) => ( + + + removeProofImage(idx)} + > + × + + + ))} + {proofImages.length < 3 && ( + + + + + )} + + + {/* 操作按钮 */} + + + 取消 + + + {submitting ? ( + 提交中... + ) : ( + 确认{OP_LABEL[opType]} + )} + + + + + )} + + ) +} diff --git a/src/pages/user/profile.tsx b/src/pages/user/profile.tsx index fbcc490..b7afc38 100644 --- a/src/pages/user/profile.tsx +++ b/src/pages/user/profile.tsx @@ -27,6 +27,8 @@ const ProfilePage: React.FC = () => { avatar: (user as any)?.avatar || '', gender: (user as any)?.gender ?? 0, phone: (user as any)?.phone || '', + merchantName: (user as any)?.merchantName || '', + address: (user as any)?.address || '', }) } }, [user]) @@ -162,6 +164,30 @@ const ProfilePage: React.FC = () => { {registerTime || '未知'} + {/* 门店名称 */} + + 门店名称 + setForm(prev => ({ ...prev, merchantName: e.detail.value }))} + maxlength={50} + /> + + + {/* 门店地址 */} + + 门店地址 + setForm(prev => ({ ...prev, address: e.detail.value }))} + maxlength={100} + /> + + {/* 保存按钮 */} { @@ -44,6 +45,8 @@ const UserPage: React.FC = () => { getMyClerk().then(data => setStoreInfo(data)).catch(() => setStoreInfo(null)) // 查询用户角色 listUserRole({ userId: user?.userId }).then(data => setUserRoles(data || [])).catch(() => setUserRoles([])) + // 检查并缓存 VIP 状态 + checkAndCacheVipStatus((user as any)?.userId || (user as any)?.id) } else { setStoreInfo(null) setUserRoles([]) @@ -64,11 +67,15 @@ const UserPage: React.FC = () => { const userId = Taro.getStorageSync('UserId') if (userId) { listUserRole({ userId }).then(data => setUserRoles(data || [])).catch(() => {}) + // 刷新 VIP 状态缓存 + checkAndCacheVipStatus(userId) } } }) const menuItems = [ + // 升级VIP会员 - 醒目样式 + { icon: '👑', label: '升级VIP会员', url: '/pages/user/vip-upgrade/index', highlight: true }, // 门店中心:仅门店店员/店长显示(通过 /shop/shop-store-user/my 判断) ...(storeInfo ? [{ icon: '🏪', label: '门店中心', url: '/pages/store/center/index' }] : []), { icon: '💰', label: '我的钱包', url: '/pages/user/wallet' }, @@ -110,6 +117,8 @@ const UserPage: React.FC = () => { const uid = user?.userId if (uid) { listUserRole({ userId: uid }).then(data => setUserRoles(data || [])).catch(() => {}) + // 刷新 VIP 状态 + checkAndCacheVipStatus(uid) } } finally { setRefreshing(false) @@ -120,69 +129,81 @@ const UserPage: React.FC = () => { {/* 用户信息卡片 */} - - - {isLoggedIn && user?.avatar ? ( - - ) : ( - - 👤 + + {/* 装饰性光晕 */} + + + + + {isLoggedIn && user?.avatar ? ( + + ) : ( + + 👤 + + )} + + + {isLoggedIn ? (user?.nickname || user?.phone || '用户') : '点击登录'} + + + {/*{isLoggedIn && (*/} + {/* */} + {/* {(user as any)?.memberLevelName || '普通用户'}*/} + {/* */} + {/*)}*/} + {userRoles.map(role => ( + + {role.roleName} + + ))} + + + {isLoggedIn && {'>'}} + + {/* 数据概览 */} + {isLoggedIn && ( + + Taro.navigateTo({ url: '/pages/user/wallet' })}> + {!hasCardData ? ( + + ) : ( + + {cardStats?.balance || '0.00'} + + )} + 余额 + + Taro.navigateTo({ url: '/pages/user/points-record' })}> + {!hasCardData ? ( + + ) : ( + + {cardStats?.points || (user as any)?.points || 0} + + )} + 积分 + + Taro.navigateTo({ url: '/pages/user/coupon-list' })}> + {!hasCardData ? ( + + ) : ( + + {cardStats?.coupons || 0} + + )} + 优惠券 + )} - - - {isLoggedIn ? (user?.nickname || user?.phone || '用户') : '点击登录'} - - - {isLoggedIn && } - {userRoles.map(role => ( - - {role.roleName} - - ))} - - - {isLoggedIn && {'>'}} - {/* 数据概览 */} - {isLoggedIn && ( - - Taro.navigateTo({ url: '/pages/user/wallet' })}> - {!hasCardData ? ( - - ) : ( - - {cardStats?.balance || '0.00'} - - )} - 余额 - - Taro.navigateTo({ url: '/pages/user/points-record' })}> - {!hasCardData ? ( - - ) : ( - - {cardStats?.points || (user as any)?.points || 0} - - )} - 积分 - - Taro.navigateTo({ url: '/pages/user/coupon-list' })}> - {!hasCardData ? ( - - ) : ( - - {cardStats?.coupons || 0} - - )} - 优惠券 - - - )} {/* 我的订单快捷入口 */} @@ -223,14 +244,26 @@ const UserPage: React.FC = () => { key={item.label} className={`flex items-center justify-between px-4 py-3 ${ idx < menuItems.length - 1 ? 'border-b border-gray-50' : '' - }`} + } ${(item as any).highlight ? '' : ''}`} + style={(item as any).highlight ? { + background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)', + } : {}} onClick={() => Taro.navigateTo({ url: item.url })} > - {item.icon} - {item.label} + + {(item as any).highlight ? '' : item.icon}{(item as any).highlight ? item.icon : ''} + + + {item.label} + {(item as any).highlight && ( + + 推荐 + + )} + - {'>'} + {'>'} ))} diff --git a/src/pages/user/vip-review/index.tsx b/src/pages/user/vip-review/index.tsx new file mode 100644 index 0000000..0e03a0e --- /dev/null +++ b/src/pages/user/vip-review/index.tsx @@ -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 { + 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('pending') + const [list, setList] = useState([]) + 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 ( + + 待审核 + + ) + } + if (status === 20) { + return ( + + 已通过 + + ) + } + if (status === 30) { + return ( + + 已驳回 + + ) + } + return null + } + + // 渲染申请卡片 + const renderCard = (item: ShopDealerApply) => { + const canReview = item.applyStatus === 10 + return ( + + {/* 头部:申请人 + 状态 */} + + + + 👤 + + + + {item.realName || item.merchantName || '未知用户'} + + 用户ID: {item.userId} + + + {renderStatusTag(item.applyStatus!)} + + + {/* 门店信息 */} + + + 🏪 + + 门店名称 + + {item.merchantName || item.realName || '-'} + + + + + 📍 + + 门店地址 + + {item.address || '-'} + + + + + + {/* 时间信息 */} + + + 申请时间:{formatTime(item.applyTime)} + + {item.auditTime ? ( + + 审核时间:{formatTime(item.auditTime)} + + ) : null} + + + {/* 驳回原因 */} + {item.applyStatus === 30 && item.rejectReason && ( + + 驳回原因 + {item.rejectReason} + + )} + + {/* 操作按钮 */} + {canReview && ( + + handleReject(item)} + > + 驳回 + + handleApprove(item)} + > + 通过 + + + )} + + ) + } + + if (!isClerk) { + return ( + + 验证身份中... + + ) + } + + return ( + + {/* 提示信息 */} + + 💡 + + 审核说明 + + 请实地考察核实申请人的门店名称和地址是否与实际一致,确认无误后给予通过,升级为VIP会员。 + + + + + {/* Tab 栏 */} + + {TABS.map(tab => ( + setActiveTab(tab.key)} + > + + {tab.label} + + + ))} + + + {/* 列表 */} + + {loading && list.length === 0 ? ( + + 加载中... + + ) : list.length === 0 ? ( + + 暂无{activeTab === 'pending' ? '待审核' : activeTab === 'approved' ? '已通过' : '已驳回'}的申请 + + ) : ( + list.map(renderCard) + )} + + + + ) +} + +export default VipReviewPage diff --git a/src/pages/user/vip-upgrade/index.tsx b/src/pages/user/vip-upgrade/index.tsx new file mode 100644 index 0000000..476eb8a --- /dev/null +++ b/src/pages/user/vip-upgrade/index.tsx @@ -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(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 ( + + {/* 顶部渐变背景 */} + + + + + + 👑 + + 升级VIP会员 + + {existingApply?.applyStatus === 20 ? '恭喜您已是VIP会员' : '填写门店信息,申请VIP会员资格'} + + + + + {/* 状态提示 */} + {existingApply && ( + + + + {existingApply.applyStatus === 10 ? '⏳' : '✅'} + + + + {existingApply.applyStatus === 10 ? '审核中' : '审核通过'} + + + {existingApply.applyStatus === 10 + ? '您的VIP申请正在审核中,请耐心等待。审核结果将通过消息通知您。' + : '恭喜!您的VIP会员申请已审核通过,现在可以享受全部VIP专属权益。'} + + + + + )} + + {/* 说明卡片 */} + {!existingApply && ( + + + + 💡 + + + 温馨提示 + + 请填写您的门店信息,提交后管理员将进行审核。审核通过后即可享受VIP会员专属权益,包括更高佣金、专属折扣等特权。 + + + + + )} + + {/* 表单 */} + + {/* 门店名称 */} + + 门店名称 * + setRealName(e.detail.value)} + maxlength={50} + disabled={isReadonly} + /> + + + {/* 门店地址 */} + + 门店地址 * + setAddress(e.detail.value)} + maxlength={100} + disabled={isReadonly} + /> + + + + {/* VIP权益预览 */} + + VIP会员专属权益 + + {[ + { icon: '💎', title: '更高佣金', desc: '享受高额团队佣金' }, + { icon: '🎁', title: '专属折扣', desc: '购物享VIP专属价' }, + { icon: '⚡', title: '优先发货', desc: '订单优先处理配送' }, + { icon: '🎧', title: '专属客服', desc: '一对一VIP服务' }, + ].map(item => ( + + + {item.icon} + + + {item.title} + {item.desc} + + + ))} + + + + {/* 提交按钮 */} + {!existingApply && ( + + + + {loading ? '提交中...' : '提交申请'} + + + + 提交后管理员将在1-3个工作日内审核 + + + )} + + ) +} + +export default VipUpgradePage diff --git a/src/utils/vip.ts b/src/utils/vip.ts new file mode 100644 index 0000000..60ac16d --- /dev/null +++ b/src/utils/vip.ts @@ -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 { + 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' +}