32 Commits

Author SHA1 Message Date
pikachu1995@126.com
792229a11b feat(promotion,stock): 商家出库单管理、库存流水查询及优惠券礼包推广二维码 2026-11-12 16:25:10 +08:00
pikachu1995@126.com
242d3a0f9f fix(seller): 移除商户端装修跳转选择器中的店铺选项以避免404与跨店跳转 2026-11-12 15:21:16 +08:00
pikachu1995@126.com
bfcd81ca81 fix(seller): 优化移动装修编辑器三栏布局与配置面板弹性适配防止溢出 2026-11-12 15:11:11 +08:00
pikachu1995@126.com
8d2adf6732 fix(seller): 移除未完成库存模块的未提交页面路由引用 2026-11-12 14:47:48 +08:00
pikachu1995@126.com
935d56268c fix(seller): 修复商户端移动装修和PC装修组件缩放与边界溢出问题 (#IKULXP) 2026-11-12 14:28:45 +08:00
pikachu1995@126.com
52c92731c8 fix(order): 修复已取消订单退款标签及支付状态显示问题
- 新增 canShowRefundTag 函数,未支付订单不再展示退款标签
- 已取消(CANCELLED)订单的支付方式字段返回空字符串
- 修改 isUnpaid 函数,排除已取消订单被误判为未支付状态
2026-11-12 09:49:16 +08:00
pikachu1995@126.com
3d77c58e63 fix(buyer): 修复已取消订单仍可发起投诉与售后申请的问题 2026-11-11 22:41:44 +08:00
pikachu1995@126.com
c398ff4717 feat(im): 客服聊天支持图片上传、独立OSS存储配置及发送失败状态处理 2026-11-11 22:41:18 +08:00
pikachu1995@126.com
60de941749 feat(order): 管理端支持微信小程序发货信息同步、人工补报与审计日志 2026-11-11 22:41:10 +08:00
pikachu1995@126.com
402c9c9ebb fix(buyer): 优化首屏站点及主题请求去重,并在顶栏退出时联动后端登出注销会话 2026-11-11 14:46:15 +08:00
pikachu1995@126.com
2252bcb8af fix(seller): 修复新版菜单栏布局下大尺寸弹窗未按内容区居中且被左侧菜单遮挡的问题 (#IKUDFL) 2026-11-11 09:35:40 +08:00
pikachu1995@126.com
b4735c89f5 feat(goods): 商品列表页新增批量导入弹窗 2026-11-10 18:58:13 +08:00
pikachu1995@126.com
eb017244d3 feat(promotion): 新增满折满赠活动及阶梯优惠规则支持
- 满额活动列表页支持满减、满折、满赠三种类型筛选与标签展示
- 新增阶梯优惠规则列,按活动类型格式化展示各级优惠详情
- 活动详情页支持1至3级阶梯优惠配置及满赠赠品选择
- 页面装修组件增加满折、满赠活动类型标签展示
- 隐藏"添加活动优惠券"按钮入口
- 优化活动类型、状态、时间等表格列宽与样式
2026-11-10 15:55:43 +08:00
pikachu1995@126.com
2be2227644 fix(seller): 裁剪userInfoSeller存储字段,避免Cookie超限导致登录卡死 2026-11-10 15:36:19 +08:00
pikachu1995@126.com
9761479c82 feat(order,goods): 详情页促销模块独立拆分与实物赠品多端随单履约展示
- 详情页重构:ShowGoods.vue 价格卡片与促销卡片解耦,促销卡片独立分行展示优惠券与满额活动;
- 买家端结算页:Pay.vue 清单中直观展示随单 0 元实物赠品明细行与数量;
- 买家端订单中心:MyOrder.vue 与 OrderDetail.vue 标注【赠品】红色徽标、原价划线展示并屏蔽单独售后申请;
- 管理端与商家端后台:orderDetail.vue 商品表格与待发货弹窗中醒目标注【赠品】及满赠活动类型。
2026-11-10 15:20:26 +08:00
pikachu1995@126.com
f33c969d1e fix(buyer): 优化秒杀页面布局与顶部搜索栏样式统一
- 增加秒杀顶部氛围横幅与数字倒计时模块
- 修复时间轴 Tab 场次少时异常拉伸撑满的问题,支持单场次居中对齐
- 重构秒杀商品网格与卡片排版,统一两行截断、进度条占位与按钮状态
- 顶部搜索栏统一引用 SearchHeader,规范整站搜索组件蓝色基调
2026-11-10 10:55:57 +08:00
pikachu1995@126.com
3c4604a342 fix(im): 修复商家端客服台接收新消息不刷新会话列表与摘要的问题 2026-11-09 20:03:35 +08:00
pikachu1995@126.com
a094a470f7 feat(seller): 商品库存页支持下载导入模板与按增减修改库存 2026-11-09 19:42:50 +08:00
pikachu1995@126.com
ecbb80e24a fix(buyer): 修复登录过期异常及全局请求错误处理 2026-11-09 16:06:33 +08:00
pikachu1995@126.com
b2981b3ad7 docs(readme): 更新已完成功能列表并新增提货卡记录 2026-11-09 15:06:14 +08:00
pikachu1995@126.com
76b0d6efc8 refactor(layout): 重构商家后台布局与导航结构
- 移除多标签页内嵌模式,详情页固定在新窗口打开
- 重构顶部栏与侧边栏布局,Logo 移至顶部栏区域
- 新增未读消息角标,与消息页面实时同步数量
- 新增面包屑路径栏替代静态页面标题展示
- 删除页面配置抽屉和菜单搜索组件
- 将发货与退货地址设置拆分为独立页面(店铺信息、地址管理)
- 增加 Logo 加载失败回退及 favicon 健壮性处理
- 注册店铺信息与地址管理页面的显式懒加载映射
2026-11-09 15:01:19 +08:00
pikachu1995@126.com
fd241bc36a fix(buyer): 修复路由兼容与页面加载体验
- 为文章 API 添加 skipMessage 选项,由页面接管错误提示
- 添加旧路由重定向(UserInfo -> Profile, Msg -> MsgList)
- 重构文章页面,增加分页、骨架屏、分类独立加载与重试机制
- 用户权益组件接入真实登录状态与购物车、优惠券、订单数量
- 修正装修组件中收藏、足迹、领券等服务的旧地址映射
- 删除已过时的 MIGRATION-VUE3.md 迁移文档
2026-11-09 11:08:37 +08:00
pikachu1995@126.com
d799b37f0c fix(pickup-card): PC兑换请求传递订单来源 2026-11-09 11:04:28 +08:00
pikachu1995@126.com
0232ddeb27 feat(seller): 调整商家菜单布局与商品列表展示 2026-11-09 10:32:19 +08:00
pikachu1995@126.com
2497f33cb4 feat(gift-card): 新增提货卡类型支持
- 列表页拆分为提货卡和现金卡两个独立创建入口
- 创建页支持提货卡兑换方式配置(任选一种/全部兑换/任选N种)
- 集成 SKU 商品选择器,支持最多50个商品兑换配置
- 批次凭证页增加"已提货"状态标签及提货卡面额展示适配
- 批次凭证页新增导出卡密功能
- 重构表单布局为响应式 grid 并优化有效期选项交互样式
2026-11-06 15:20:42 +08:00
pikachu1995@126.com
0f08807f00 feat(coupon): 重构优惠券类型为满减、满折、立减三种分类
- 新增立减优惠券类型,支持无门槛直接抵扣
- 优惠券列表页统一使用行对象判断类型并显示完整类型名称
- 发布页增加优惠券类型选择组件,切换时自动联动字段显隐
- 买家端购物车满减门槛为零时显示"无门槛使用"
- 卖家端发布页新增券面实时预览面板
- 卖家端发布页重构为分步布局并优化表单交互样式
- 面额与消费门槛校验增加大于零的数值约束
- 移除列表页冗余的标签颜色映射函数,简化展示逻辑
2026-11-05 22:36:21 +08:00
pikachu1995@126.com
29a6b00a21 fix(setting): 保留提现金额与开关的真实类型 2026-11-05 21:31:22 +08:00
pikachu1995@126.com
6be934d00b feat(member): 优化等级权益管理与买家展示
- 优化客户权益配置、编辑表单及独立发放明细页面
- 完善客户等级图标、背景配置提示与 PC 权益展示
- 复用图片选择器并补齐素材元数据回传
2026-11-05 21:27:37 +08:00
pikachu1995@126.com
560286b7f6 refactor: 移除停用促销、批发和短信管理界面 2026-11-05 15:20:38 +08:00
pikachu1995@126.com
961565149c feat(manager): 新增运营模块小程序订单管理
固定小程序来源及微信支付筛选,复用订单列表和详情,新增微信订单自动上报开关并更新升级计划。
2026-11-04 15:44:54 +08:00
pikachu1995@126.com
1a457a60f8 fix(buyer): 补齐领券中心页头并处理秒杀空数据 2026-11-04 14:51:13 +08:00
pikachu1995@126.com
d521587ad8 feat(coupon): 支持立减优惠券并统一营销管理页面 2026-11-04 14:50:33 +08:00
173 changed files with 10172 additions and 5809 deletions

View File

@@ -1,201 +0,0 @@
# Lilishop UI — Vue 3 迁移与上线前检查清单
四个子项目(buyer / seller / manager / im)已完成 **Vue 3 + Element Plus** 迁移,并完成依赖小版本对齐。本文档供上线前统一回归使用。
## 技术栈(四端一致)
| 类别 | 包 | 版本(package.json) |
|------|-----|---------------------|
| 框架 | vue | ^3.5.35 |
| 编译 | @vue/compiler-sfc | ^3.5.35 |
| 路由 | vue-router | ^4.6.4 |
| 状态 | pinia | ^4.0.3(四端已全部从 vuex 迁移完成) |
| 组件写法 | `<script setup>` | 四端 597 个组件已全部完成 Options API → 组合式 API 迁移;各端 eslint 已启用 `vue/component-api-style: ['warn', ['script-setup']]` 防止回退 |
| UI | element-plus | ^2.14.1 |
| 图标 | @element-plus/icons-vue | ^2.3.2 |
| 请求 | axios | ^1.7.9 |
| 兼容 | core-js | ^3.49.0 |
| 构建 | @vue/cli-service | ^5.0.9 |
> 使用 `^` 范围,实际安装版本以各目录 `yarn.lock` 为准。
> 要求 **Node >= 16**,包管理统一 **yarn**。
## 本地启动
```bash
# 买家端
cd buyer && yarn install && yarn dev # 端口 10000
# 商家端
cd seller && yarn install && yarn dev # 端口 10002
# 管理端
cd manager && yarn install && yarn dev # 端口 10003
# IM 客服
cd im && yarn install && yarn dev # 端口 8000
```
生产构建(上线前必跑):
```bash
cd buyer && yarn build
cd seller && yarn build
cd manager && yarn build
cd im && yarn build
```
---
## 一、通用检查(四端都要过)
### 1. 认证与请求(axios 1.x 重点)
各端请求封装位置:
| 端 | 文件 |
|----|------|
| buyer | `buyer/src/plugins/request.js` |
| seller | `seller/src/libs/axios.js` |
| manager | `manager/src/libs/axios.js` |
| im | `im/src/utils/request.js` |
- [ ] **登录**:账号密码登录成功,token 写入正常
- [ ] **登出**:清除 token,跳转登录页
- [ ] **401**:token 过期后跳转登录 / 刷新 token(buyer/manager/seller 有刷新逻辑)
- [ ] **403**:无权限时提示文案正确、不白屏
- [ ] **400 / 5xx**:接口报错能弹出 `Message` / `ElMessage` / `Notification`
- [ ] **网络超时**:断网或超时后有错误提示,页面不卡死
- [ ] **GET 带参**:列表筛选、分页参数序列化正常
- [ ] **POST JSON**:`Content-Type: application/json` 请求体正确
- [ ] **POST 表单**:`application/x-www-form-urlencoded` 提交正常(im 等)
### 2. Element Plus UI(2.6 → 2.14 重点)
- [ ] **表格** `el-table`:列展示、排序、空数据、加载态
- [ ] **分页** `el-pagination`:翻页、每页条数切换
- [ ] **表单** `el-form`:必填校验、错误提示位置
- [ ] **弹窗** `el-dialog` / `ElMessageBox`:打开、确认、取消、关闭
- [ ] **下拉** `el-select` / `el-dropdown`:选项、点击外部关闭
- [ ] **消息** `ElMessage` / `$Message` 兼容层:success / error / warning
- [ ] **加载** `ElLoading` / `v-loading`:请求期间遮罩正常消失
- [ ] **日期** `el-date-picker`:选择、清空、范围选择
- [ ] **上传** `el-upload`:选文件、进度、成功 / 失败回调
### 3. 路由与页面
- [ ] 浏览器 **刷新** 当前路由不 404(history 模式 + 服务端配置)
- [ ] **动态路由** 加载正常(seller / manager 菜单权限路由)
- [ ] 浏览器 **前进 / 后退** 正常
- [ ] 各端 **404 页** 可回到首页
### 4. 构建与部署
- [ ] 四端 `yarn build` 无 error(warning 可记录,不阻断)
- [ ] 生产环境 API 地址配置正确(`config` / 环境变量)
- [ ] 静态资源 `publicPath` 与 Nginx / CDN 路径一致
- [ ] gzip 压缩包(如有)可正常访问
---
## 二、分端专项检查
### Buyer 买家端(10000)
详见 [buyer/MIGRATION-VUE3.md](./buyer/MIGRATION-VUE3.md)
- [ ] 首页装修模块渲染(轮播、商品列表、秒杀区等)
- [ ] 商品详情:规格选择、加购、收藏、`el-image` 预览
- [ ] 购物车 → 结算 → 支付流程
- [ ] 用户中心:订单列表、地址、优惠券、售后
- [ ] 登录 / 注册 / 忘记密码
- [ ] 店铺入驻、商户页
- [ ] 地图(高德 `@amap/amap-jsapi-loader`)定位与选点
- [ ] 侧边栏 `el-menu`、头部 `el-dropdown`、动态图标 `iconMap.js`
### Seller 商家端(10002)
详见 [seller/MIGRATION-VUE3.md](./seller/MIGRATION-VUE3.md)
- [ ] 商家登录、店铺信息
- [ ] 商品发布三步流程(`goodsOperation` 步骤条)
- [ ] 订单管理:发货、退款、详情
- [ ] 促销 / 分销 / 统计图表(`@antv/g2`)
- [ ] 页面装修(wap / modelList,从 manager 同步部分)
- [ ] 图片懒加载 `vue-lazyload`
- [ ] Excel 导出 `vue-json-excel`
### Manager 管理端(10003)
详见 [manager/MIGRATION-VUE3.md](./manager/MIGRATION-VUE3.md)
- [ ] 管理员登录、权限菜单、动态路由
- [ ] 系统设置(`sys/setting-manage` 各 Tab)
- [ ] 菜单 / 部门管理
- [ ] 页面装修(PC + H5)
- [ ] 文章管理
- [ ] 商品 / 订单 / 会员 / 促销等核心列表 CRUD
- [ ] 富文本 TinyMCE 编辑、图片上传
- [ ] 多语言 `vue-i18n` 切换(如有启用)
### IM 客服端(8000)
详见 [im/MIGRATION-VUE3.md](./im/MIGRATION-VUE3.md)
- [ ] 带 token 参数进入会话页
- [ ] 对话列表加载、切换会话、未读数
- [ ] 发送文字、表情、图片消息
- [ ] 商品 / 订单卡片链接跳转(`linkToGoods` / `linkToOrders`)
- [ ] 右键菜单(复制、撤回、删除等)
- [ ] WebSocket 连接 / 断线重连 / 新消息通知
- [ ] 最近浏览、订单列表侧栏(商家场景)
- [ ] 用户名片弹层 `$user()`
---
## 三、已知可忽略项
| 项 | 说明 |
|----|------|
| `vue-awesome-swiper` peer warning | 历史依赖,Vue 3 下仍有 peer 告警,功能正常可暂忽略 |
| im 内 `<i class="el-icon-*">` | Element Plus 无字体图标,部分旧图标可能不显示,不影响核心功能 |
| `vue-prism-editor@2.0.0-alpha.2` | im 代码块编辑器刻意锁定 alpha 版 |
| 打包体积 warning | chunk-vendors 偏大,属 Element Plus 全量引入,可后续做按需优化 |
---
## 四、问题记录模板
上线测试时建议按端记录:
```markdown
### [端名] 问题标题
- 页面/路由:
- 操作步骤:
- 预期:
- 实际:
- 控制台报错:
- 优先级:P0 / P1 / P2
```
---
## 五、子项目文档索引
| 端 | 迁移说明 |
|----|----------|
| buyer | [buyer/MIGRATION-VUE3.md](./buyer/MIGRATION-VUE3.md) |
| seller | [seller/MIGRATION-VUE3.md](./seller/MIGRATION-VUE3.md) |
| manager | [manager/MIGRATION-VUE3.md](./manager/MIGRATION-VUE3.md) |
| im | [im/MIGRATION-VUE3.md](./im/MIGRATION-VUE3.md) |
---
## 六、建议上线顺序
1. **manager**(后台配置源头)
2. **seller**(商家经营)
3. **buyer**(C 端流量最大)
4. **im**(客服,依赖前后端 WebSocket)
每上一端,至少完成本文 **第一节通用检查** + 对应 **第二节分端检查** 后再切下一端。

View File

@@ -162,18 +162,45 @@ lilishop-ui
## 8. 升级计划
> 更新日期:2026-11-03,依据各端近期提交及当前代码实现同步。
### ✅ 已完成功能
### ✅ 已完成特性
| 功能模块 | 功能模块 |
| :--- | :--- |
| ✅ 商品定时上下架 | ✅ 商品分组 / 会员分组 |
| ✅ 商品虚拟销量 | ✅ 专用发票(发票上传) |
| ✅ 置顶评论 | ✅ 客户等级体系 |
| ✅ 购物卡 | ✅ 腾讯云-云直播 |
| ✅ 卡密商品 | ✅ 二级分销 |
| ✅ 定时任务管理(数据库配置驱动) | ✅ 预约服务商品 |
| ✅ 主题色管理 | |
按可核实的实现或完善时间倒序记录;日期表示代码完成或功能更新时间,不代表生产上线时间。
| 时间 | 功能模块 | 完成内容 |
| :--- | :--- | :--- |
| 2026-11-12 | 优惠券礼包 | 支持券礼包活动创建、多券组合打包、管理端 H5 推广二维码生成及链接复制。 |
| 2026-11-12 | 出库单 / 库存流水 | 支持商家端出库单管理(录入、审核与作废)及商品库存流水台账。 |
| 2026-11-06 | 提货卡 | 支持提货卡活动与候选商品配置、制卡发卡、兑换核销及使用记录管理。 |
| 2026-11-05 | 付费会员 | 单一付费会员、开通与续费、有效期、关联权益、商家与商品折扣、平台补贴及付费记录。 |
| 2026-11-04 | 客户等级体系 / 客户权益 | 等级配置、权益介绍、经验值任务与明细、权益发放及重试,提供默认等级与权益数据。 |
| 2026-11-04 | 小程序订单管理 | 增加小程序订单查询入口及微信订单自动上报开关。 |
| 2026-11-04 | 立减优惠券 | 新增立减券,完善优惠券校验、领取与订单优惠计算。 |
| 2026-11-04 | 商品定时上下架 | 完善定时上下架计划管理、独立列表及执行校验。 |
| 2026-11-04 | 微信小程序原生能力 | 接入微信地址导入、头像选择和昵称填写。 |
| 2026-11-02 | 消息模板 | 支持多渠道模板矩阵维护,统一站内信、短信、微信公众号及小程序订阅消息的场景配置和异步发送。 |
| 2026-10-30 | PC 页面装修 | 重构楼层组件,支持自适应高度、可视化热区绘制及配置交互优化。 |
| 2026-10-29 | 定时任务管理 | 重构为数据库配置驱动,支持运营后台维护任务。 |
| 2026-10-29 | 敏感词管理 | 支持按业务场景配置过滤开关,并优化词库加载。 |
| 2026-10-29 | PC 店铺搜索 | 新增店铺列表,支持商品与店铺双模式搜索。 |
| 2026-10-22 | 分类设置 | 支持四种分类页模板选择、商品列表展示字段开关及模板图片预览。 |
| 2026-10-20 | 移动端页面装修 | 完善可视化编辑器,新增首页组合组件及优惠券、店铺、文章等楼层。 |
| 2026-10-09 | 主题色管理 | 完善主题设置,支持自定义模式。 |
| 2026-10-02 | 预约服务商品 | 完善预约商品、预约交易及订单详情功能。 |
| 2026-08-05 | 二级分销 | 由一级分销扩展为二级分销,增加节点配置及分销员中心。 |
| 2026-08-03 | 卡密商品 | 完善电子卡券履约、订单状态及卡池管理。 |
| 2026-07-23 | 素材库管理 | 增加文件分类、目录管理及目录归属校验。 |
| 2026-07-20 | 经营统计报表 | 新增店铺业绩、商品同比环比、销售订单明细及商品销售汇总报表。 |
| 2026-07-13 | 腾讯云直播 | 接入并完善直播、聊天与直播商品相关功能。 |
| 2026-06-30 | 移动端技术升级 | 升级 Vue 3 与 uView Plus,适配组件和页面。 |
| 2026-06-29 | 电子面单发货 | 完善快递鸟电子面单,支持自动携带运单号发货。 |
| 2026-06-05 | 后端与管理端框架升级 | 5 月 25 日至 6 月 5 日完成 Spring Boot 4、管理端 Vue 3 与 Element Plus 升级。 |
| 2026-05-22 | 购物卡 | 新增礼品卡接口,以及选择、绑定、激活和列表管理。 |
| 2026-05-20 | 微信提现 | 完善转账状态查询、确认回调和重试处理,后续补充提现规则说明配置。 |
| 2026-04-16 | 置顶评论 | 支持评论置顶及商品评价管理。 |
| 2026-04-13 | 专用发票(发票上传) | 完善发票信息字段、发票详情及管理展示。 |
| 2026-04-01 | 商品虚拟销量 | 支持按商品规格设置虚拟销量,提供单个及批量设置入口。 |
| 2026-03-30 | 商品分组 | 新增商品分组管理及商品关联功能。 |
| 2026-03-24 | 会员分组 | 新增会员分组管理。 |
### 🔜 近期规划中
- 🔜 微信小店对接

View File

@@ -8,8 +8,7 @@
import { onMounted } from "vue";
import { useRoute } from "vue-router";
import storage from "@/plugins/storage";
import { getBaseSite, getThemeSetting } from "@/api/common.js";
import { fetchAndApplyTheme } from "@/utils/theme";
import { loadSiteSettings } from "@/composables/useSiteSettings";
import { useAppStore } from "@/stores/app";
defineOptions({ name: "App" });
@@ -49,27 +48,15 @@ function init() {
});
}
getSite();
getTheme();
}
function getSite() {
getBaseSite().then((res) => {
if (res.success && res.result.settingValue) {
try {
const data = JSON.parse(res.result.settingValue);
loadSiteSettings().then((data) => {
if (data) {
applySiteInfo(data);
} catch {
// 站点配置解析失败时使用缓存
}
}
}).catch((err) => {
console.warn('获取站点配置失败', err);
});
}
function getTheme() {
fetchAndApplyTheme(getThemeSetting).catch((err) => {
console.warn('获取主题配置失败', err);
console.warn("获取站点配置失败", err);
});
}

View File

@@ -45,32 +45,36 @@ export function getRegion (id) {
/**
* 分页获取文章列表
* @param cateId 文章分类id
* @param params 分页及分类查询条件
* @param options 请求选项,skipMessage 为 true 时由页面展示错误
*/
export function articleList (params) {
export function articleList (params, options = {}) {
return request({
url: `/buyer/other/article`,
method: Method.GET,
params
params,
skipMessage: options.skipMessage
});
}
/**
* 获取帮助中心文章分类列表
* @param cateId 文章分类id
* @param options 请求选项,skipMessage 为 true 时由页面展示错误
*/
export function articleCateList () {
export function articleCateList (options = {}) {
return request({
url: `/buyer/other/article/articleCategory/list`,
method: Method.GET
method: Method.GET,
skipMessage: options.skipMessage
});
}
// 通过id获取文章
export function articleDetail (id) {
/** 通过文章 ID 获取正文;可由页面接管错误提示。 */
export function articleDetail (id, options = {}) {
return request({
url: `/buyer/other/article/get/${id}`,
method: Method.GET
method: Method.GET,
skipMessage: options.skipMessage
});
}

View File

@@ -0,0 +1,13 @@
/**
* 提货卡选品、试算及一次兑换接口。
* @author Bulbasaur
* @since 2026-11-05
*/
import request, { Method } from '@/plugins/request.js';
/** 查询当前会员的卡规则及候选 SKU。 */
export const getPickupCard = id => request({ url: `/buyer/promotion/pickupCard/${encodeURIComponent(id)}`, method: Method.GET, needToken: true });
/** 服务端校验商品与地址,返回零现金试算。 */
export const previewPickupCard = data => request({ url: '/buyer/promotion/pickupCard/preview', method: Method.POST, needToken: true, headers: { 'Content-Type': 'application/json' }, data: { ...data, client: 'PC' } });
/** 一次兑换;重试返回同一卡已创建的交易。 */
export const redeemPickupCard = data => request({ url: '/buyer/promotion/pickupCard/redeem', method: Method.POST, needToken: true, headers: { 'Content-Type': 'application/json' }, data: { ...data, client: 'PC' } });

View File

@@ -449,12 +449,12 @@ onUnmounted(() => {
user-select: none;
&:hover {
color: $theme_color;
color: #409EFF;
}
&.active {
color: #fff;
background: $theme_color;
background: #409EFF;
}
}

View File

@@ -39,7 +39,7 @@
<div>
<span v-if="coupon.couponType === 'PRICE'" class="fontsize_12 global_color">¥<span class="price">{{ $filters.unitPrice(coupon.price) }}</span></span>
<span v-if="coupon.couponType === 'DISCOUNT'" class="fontsize_12 global_color"><span class="price">{{coupon.couponDiscount}}</span>折</span>
<span class="describe">满{{coupon.consumeThreshold}}元可用</span>
<span class="describe">{{ coupon.consumeThreshold > 0 ? '满' + coupon.consumeThreshold + '元可用' : '无门槛使用' }}</span>
</div>
<p>使用范围:{{useScope(coupon.scopeType, coupon.storeName)}}</p>
<p>有效期:{{coupon.endTime}}</p>

View File

@@ -70,7 +70,7 @@
v-if="promotionMap['SECKILL']"
:time="promotionMap['SECKILL'].endTime"
></Promotion>
<!-- 商品详细 价格、优惠券、促销 -->
<!-- 商品详细 价格 -->
<div class="item-detail-price-row">
<div class="item-price-left">
<!-- 秒杀价格 -->
@@ -89,9 +89,23 @@
<div class="item-price-row" v-else>
<span class="item-price">{{ $filters.unitPrice(skuDetail.price, "¥") }}</span>
</div>
</div>
<div class="item-price-right">
<div class="item-remarks-sum">
<p>累计评价</p>
<p>
<span class="item-remarks-num"
>{{ skuDetail.commentNum || 0 }} 条</span>
</p>
</div>
</div>
</div>
<!-- 独立促销与优惠模块 -->
<div class="goods-promotions-box" v-if="hasPromotions">
<!-- 优惠券展示 -->
<div class="item-price-coupon-row" v-if="promotionMap['COUPON'].length">
<span class="item-price-title">优惠券</span>
<div class="promo-item-row" v-if="promotionMap['COUPON'] && promotionMap['COUPON'].length">
<span class="promo-title">优惠券</span>
<div class="coupon-tags">
<span
class="item-coupon"
@@ -100,7 +114,7 @@
@click="handleReceiveCoupon(item.id)"
>
<span v-if="item.couponType == 'PRICE'"
>满{{ item.consumeThreshold }}减{{ item.price }}</span>
>{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '减' + item.price : '无门槛,立减' + item.price + '元' }}</span>
<span v-if="item.couponType == 'DISCOUNT'"
>满{{ item.consumeThreshold }}打{{
item.couponDiscount
@@ -117,7 +131,7 @@
@click="handleReceiveCoupon(item.id)"
>
<span v-if="item.couponType == 'PRICE'"
>满{{ item.consumeThreshold }}减{{ item.price }}</span>
>{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '减' + item.price : '无门槛,立减' + item.price + '元' }}</span>
<span v-if="item.couponType == 'DISCOUNT'"
>满{{ item.consumeThreshold }}打{{
item.couponDiscount
@@ -127,66 +141,25 @@
</div>
</div>
</div>
<!-- 限时折扣 -->
<div class="item-price-row" v-if="promotionMap['DISCOUNT']">
<p>
<span class="item-price-title">促销</span>
<span class="item-promotion">限时折扣</span>
<span class="item-desc-pintuan" v-if="promotionMap['DISCOUNT'].discount">
享 {{ promotionMap["DISCOUNT"].discount }} 折
</span>
</p>
</div>
<!-- 第N件优惠 -->
<div class="item-price-row" v-if="promotionMap['NTH']">
<p>
<span class="item-price-title">促销</span>
<span class="item-promotion">第N件</span>
<span class="item-desc-pintuan">
第 {{ promotionMap["NTH"].nthNum || promotionMap["NTH"].num }} 件优惠
</span>
</p>
<!-- 促销活动行(满减/满折/满赠、拼团) -->
<div class="promo-item-row" v-if="promotionMap['FULL_DISCOUNT'] || promotionMap['PINTUAN']">
<span class="promo-title">促&emsp;销</span>
<div class="promo-content-column">
<!-- 满减/满折/满赠 -->
<div class="promo-activity-entry" v-if="promotionMap['FULL_DISCOUNT']">
<span class="promo-badge">{{ getFullDiscountTag(promotionMap['FULL_DISCOUNT']) }}</span>
<span class="promo-desc">{{ formatFullDiscountDesc(promotionMap['FULL_DISCOUNT']) }}</span>
</div>
<!-- 拼团 -->
<div class="item-price-row" v-if="promotionMap['PINTUAN']">
<p>
<span class="item-price-title">拼团</span>
<span class="item-promotion">拼团价</span>
<span class="item-desc-pintuan" v-if="promotionMap['PINTUAN'].price">
{{ $filters.unitPrice(promotionMap["PINTUAN"].price, "¥") }}
<div class="promo-activity-entry" v-if="promotionMap['PINTUAN']">
<span class="promo-badge">拼团</span>
<span class="promo-desc">
拼团价
<span class="promo-price-text">{{ $filters.unitPrice(promotionMap["PINTUAN"].price, "¥") }}</span>
,多人拼单更实惠
</span>
</p>
</div>
<!-- 满减展示 -->
<div class="item-price-row" v-if="promotionMap['FULL_DISCOUNT']">
<p>
<span class="item-price-title">促销</span>
<span class="item-promotion">满减</span>
<span
class="item-desc-pintuan"
v-if="promotionMap['FULL_DISCOUNT'].fullMinus"
>满{{ promotionMap["FULL_DISCOUNT"].fullMoney }}元,立减现金{{
promotionMap["FULL_DISCOUNT"].fullMinus
}}元</span>
<span
class="item-desc-pintuan"
v-if="
promotionMap['FULL_DISCOUNT'].fullRate &&
promotionMap['FULL_DISCOUNT'].fullRateFlag
"
>满{{ promotionMap["FULL_DISCOUNT"].fullMoney }}元,立享{{
promotionMap["FULL_DISCOUNT"].fullRate
}}折</span>
</p>
</div>
</div>
<div class="item-price-right">
<div class="item-remarks-sum">
<p>累计评价</p>
<p>
<span class="item-remarks-num"
>{{ skuDetail.commentNum || 0 }} 条</span>
</p>
</div>
</div>
</div>
@@ -384,6 +357,14 @@ const currentImg = computed(() => {
return item.type === "video" ? item.poster : item.url || item;
});
/** 是否包含有效促销/优惠(优惠券、满减/满折/满赠、拼团) */
const hasPromotions = computed(() => {
const hasCoupon = Array.isArray(promotionMap.value?.COUPON) && promotionMap.value.COUPON.length > 0;
const hasFullDiscount = !!promotionMap.value?.FULL_DISCOUNT;
const hasPintuan = !!promotionMap.value?.PINTUAN;
return hasCoupon || hasFullDiscount || hasPintuan;
});
/** 卡密商品:禁加购,仅立即购买;库存读 quantity(M-02 起支持促销) */
const isECouponGoods = computed(() => {
@@ -553,6 +534,53 @@ function pointPay() {
startCheckout(E_COUPON_CART_POINTS, "loading");
}
function getFullDiscountTag(fd) {
if (!fd) return "满优惠";
if (fd.activityType === "FULL_RATE") return "满折";
if (fd.activityType === "FULL_GIFT") return "满赠";
if (fd.activityType === "FULL_MINUS") return "满减";
if (fd.fullRateFlag) return "满折";
if (fd.giftFlag) return "满赠";
return "满减";
}
function formatFullDiscountDesc(fd) {
if (!fd) return "";
let rules = fd.ruleList;
if (!rules && fd.rulesJson) {
try {
rules = JSON.parse(fd.rulesJson);
} catch (e) {
rules = null;
}
}
if (Array.isArray(rules) && rules.length > 0) {
return rules
.map((rule) => {
if (fd.activityType === "FULL_RATE") {
return `满${rule.fullMoney}元打${rule.fullRate}折`;
}
if (fd.activityType === "FULL_GIFT") {
const gifts = Array.isArray(rule.giftItems)
? rule.giftItems
.map((g) => `${g.goodsName || "赠品"}x${g.num || 1}`)
.join("、")
: "赠品";
return `满${rule.fullMoney}元送${gifts}`;
}
return `满${rule.fullMoney}元减${rule.fullMinus}元`;
})
.join(";");
}
if (fd.fullMinusFlag && fd.fullMinus) {
return `满${fd.fullMoney}元,立减现金${fd.fullMinus}元`;
}
if (fd.fullRateFlag && fd.fullRate) {
return `满${fd.fullMoney}元,立享${fd.fullRate}折`;
}
return fd.title || "";
}
function startCheckout(action, loadingKey) {
const params = {
num: count.value,
@@ -926,6 +954,7 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
background: #fafafa;
border-radius: 4px;
}
@@ -968,15 +997,70 @@ onBeforeUnmount(() => {
margin-left: 5px;
}
.item-price-coupon-row {
/* 促销与优惠独立展示模块 */
.goods-promotions-box {
margin-top: 12px;
padding: 12px 20px;
background: #fafafa;
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 12px;
}
.promo-item-row {
display: flex;
align-items: flex-start;
margin: 8px 0px;
line-height: 24px;
}
.item-price-title {
.promo-title {
flex-shrink: 0;
width: 56px;
margin-right: 12px;
color: #666;
font-size: 14px;
line-height: 24px;
text-align: left;
}
.promo-content-column {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.promo-activity-entry {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
line-height: 22px;
}
}
.promo-badge {
display: inline-block;
padding: 1px 6px;
font-size: 12px;
line-height: 18px;
color: $theme_color;
background: rgba(235, 69, 73, 0.08);
border: 1px solid $theme_color;
border-radius: 2px;
white-space: nowrap;
}
.promo-desc {
font-size: 13px;
color: #333;
line-height: 22px;
word-break: break-all;
}
.promo-price-text {
color: $theme_color;
font-weight: 600;
}
.coupon-tags {

View File

@@ -49,7 +49,7 @@ const brandList = computed(() => {
{ title: '数码潮玩 · 爆款立减500', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_1.png', url: '/goodsList?category=1' },
{ title: '美妆奢宠 · 每满300减50', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_2.png', url: '/goodsList?category=2' },
{ title: '运动潮品 · 爆款低至5折', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_3.png', url: '/goodsList?category=3' },
{ title: '品质生活 · 限时第2件半价', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_4.png', url: '/goodsList?category=4' }
{ title: '品质生活 · 精选好物', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_4.png', url: '/goodsList?category=4' }
];
});

View File

@@ -37,6 +37,8 @@
<script setup>
import { computed } from 'vue';
import Cookies from 'js-cookie';
import storage from '@/plugins/storage';
import { logout } from '@/api/account';
const props = defineProps({
options: {
@@ -50,10 +52,10 @@ const props = defineProps({
});
const defaultMenus = [
{ title: '基本信息', url: '/home/UserInfo' },
{ title: '基本信息', url: '/home/Profile' },
{ title: '我的订单', url: '/home/MyOrder' },
{ title: '账户安全', url: '/home/AccountSafe' },
{ title: '消息', url: '/home/Msg' }
{ title: '消息', url: '/home/MsgList' }
];
const username = computed(() => {
@@ -69,12 +71,22 @@ const username = computed(() => {
return '';
});
function handleLogout() {
function clearLocalAuth() {
storage.removeItem('userInfo');
storage.removeItem('accessToken');
storage.removeItem('refreshToken');
storage.removeItem('cartNum');
Cookies.remove('userInfo');
Cookies.remove('accessToken');
window.location.reload();
}
function handleLogout() {
logout().finally(() => {
clearLocalAuth();
});
}
const customStyle = computed(() => {
const s = props.componentStyle || {};
const opt = props.options || {};

View File

@@ -3,7 +3,7 @@
<!-- 1. 用户头像与信息区 -->
<div class="user-main-info">
<div class="avatar-box">
<img v-if="options.avatar" :src="options.avatar" alt="avatar" />
<img v-if="displayAvatar" :src="displayAvatar" alt="用户头像" />
<div v-else class="avatar-placeholder">
<svg viewBox="0 0 24 24" width="22" height="22" fill="#ffffff">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
@@ -12,18 +12,22 @@
</div>
<div class="user-greeting">
<div class="badge-tag">{{ options.badgeText || '请登录' }}</div>
<div class="welcome-text" :title="options.welcomeText || '欢迎来到商城'">
{{ options.welcomeText || '欢迎来到商城' }}
<div class="badge-tag">{{ isLoggedIn ? '已登录' : (options.badgeText || '请登录') }}</div>
<div class="welcome-text" :title="welcomeText">
{{ welcomeText }}
</div>
<div class="sub-text" :title="options.subtitle || '登录后查看专属权益'">
{{ options.subtitle || '登录后查看专属权益' }}
<div class="sub-text" :title="subtitle">
{{ subtitle }}
</div>
</div>
</div>
<!-- 2. 登录 / 注册按钮组 -->
<div class="action-btn-row">
<!-- 已登录时提供会员入口,装修中的登录与注册文案仅用于游客。 -->
<div v-if="isLoggedIn" class="action-btn-row">
<router-link to="/home" class="action-btn login-btn">会员中心</router-link>
<router-link to="/home/MyOrder" class="action-btn register-btn">我的订单</router-link>
</div>
<div v-else class="action-btn-row">
<a href="javascript:;" class="action-btn login-btn" @click="handleNavigate(options.loginUrl || '/login')">
{{ options.loginText || '登录' }}
</a>
@@ -35,19 +39,23 @@
<!-- 3. 指标数据卡 (购物车 / 优惠券 / 订单) -->
<div class="metrics-cards-row">
<a href="javascript:;" class="metric-card" v-if="options.showCart !== false" @click="handleNavigate(options.cartUrl || '/cart')">
<div class="metric-val">0</div>
<div class="metric-val">{{ metricText('cart') }}</div>
<div class="metric-label">{{ options.cartText || '购物车' }}</div>
</a>
<a href="javascript:;" class="metric-card" v-if="options.showCoupons !== false" @click="handleNavigate(options.couponUrl || '/member/coupon')">
<div class="metric-val">0</div>
<a href="javascript:;" class="metric-card" v-if="options.showCoupons !== false" @click="handleNavigate(metricUrl('coupons'))">
<div class="metric-val">{{ metricText('coupons') }}</div>
<div class="metric-label">{{ options.couponText || '优惠券' }}</div>
</a>
<a href="javascript:;" class="metric-card" v-if="options.showOrders !== false" @click="handleNavigate(options.orderUrl || '/member/order')">
<div class="metric-val">0</div>
<a href="javascript:;" class="metric-card" v-if="options.showOrders !== false" @click="handleNavigate(metricUrl('orders'))">
<div class="metric-val">{{ metricText('orders') }}</div>
<div class="metric-label">{{ options.orderText || '订单' }}</div>
</a>
</div>
<button v-if="hasMetricError" type="button" class="metrics-retry" @click="loadMetrics">
数量加载失败,点击重试
</button>
<!-- 4. 快捷便民服务入口(底部图标排) -->
<div class="quick-service-row">
<a
@@ -65,11 +73,17 @@
</template>
<script setup>
import { computed } from 'vue';
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Star, Clock, Present, Service } from '@element-plus/icons-vue';
import storage from '@/plugins/storage';
import { cartCount } from '@/api/cart';
import { memberCouponList } from '@/api/member';
import { getOrderList } from '@/api/order';
import { useAppStore } from '@/stores/app';
const router = useRouter();
const appStore = useAppStore();
const props = defineProps({
options: {
@@ -86,6 +100,113 @@ const props = defineProps({
}
});
const userInfo = ref({});
const isLoggedIn = ref(false);
/** 读取现有登录缓存;缺少令牌时按游客展示,损坏的资料缓存不阻断首页。 */
function syncLoginState() {
isLoggedIn.value = Boolean(storage.getItem('accessToken'));
userInfo.value = {};
if (!isLoggedIn.value) return;
try {
const cached = JSON.parse(storage.getItem('userInfo') || '{}');
userInfo.value = cached && typeof cached === 'object' ? cached : {};
} catch {
userInfo.value = {};
}
}
syncLoginState();
// 购物车沿用顶部购物车的有效 SKU 种数;优惠券统计未使用张数,订单统计全部订单。
const metrics = reactive({
cart: { status: 'idle', value: 0 },
coupons: { status: 'idle', value: 0 },
orders: { status: 'idle', value: 0 }
});
let metricRequestId = 0;
let metricsLoading = false;
const hasMetricError = computed(() => Object.values(metrics).some(item => item.status === 'error'));
/** 游客显示 0;已登录时区分加载、失败和真实的零值,购物车随公共状态更新。 */
function metricText(key) {
if (!isLoggedIn.value) return 0;
const metric = metrics[key];
if (metric.status === 'loading' || metric.status === 'idle') return '…';
if (metric.status === 'error') return '—';
return key === 'cart' ? appStore.cartNum : metric.value;
}
/** 将装修默认的历史地址映射到现有列表,保留商家配置的其他自定义链接。 */
function metricUrl(key) {
const isCoupon = key === 'coupons';
const configured = isCoupon ? props.options.couponUrl : props.options.orderUrl;
const legacy = isCoupon ? '/member/coupon' : '/member/order';
return !configured || configured === legacy
? (isCoupon ? '/home/Coupons' : '/home/MyOrder')
: configured;
}
/** 并行读取汇总;失败独立展示,离开页面后忽略旧请求,重入首页时刷新数量。 */
async function loadMetrics() {
if (metricsLoading) return;
syncLoginState();
const requestId = ++metricRequestId;
if (!isLoggedIn.value) {
Object.values(metrics).forEach(item => { item.status = 'idle'; item.value = 0; });
return;
}
metricsLoading = true;
const page = { pageNumber: 1, pageSize: 1 };
const queries = [
['cart', props.options.showCart !== false, () => cartCount()],
['coupons', props.options.showCoupons !== false, () => memberCouponList({ ...page, memberCouponStatus: 'NEW' })],
['orders', props.options.showOrders !== false, () => getOrderList({ ...page, tag: 'ALL' })]
];
await Promise.all(queries.filter(([, visible]) => visible).map(async ([key, , fetchCount]) => {
metrics[key].status = 'loading';
try {
const response = await fetchCount();
const raw = key === 'cart' ? response?.result : response?.result?.total;
const count = Number(raw);
if (!response?.success || raw == null || !Number.isSafeInteger(count) || count < 0) {
throw new Error('无效的数量响应');
}
if (requestId !== metricRequestId) return;
metrics[key].value = count;
metrics[key].status = 'ready';
if (key === 'cart') {
appStore.setCartNum(count);
storage.setItem('cartNum', count);
}
} catch {
if (requestId === metricRequestId) metrics[key].status = 'error';
}
}));
if (requestId === metricRequestId) metricsLoading = false;
}
/** 使离开首页前的异步响应失效,避免覆盖下一次进入后的数据。 */
function invalidateMetrics() {
metricRequestId += 1;
metricsLoading = false;
}
onMounted(loadMetrics);
onActivated(loadMetrics);
onDeactivated(invalidateMetrics);
onBeforeUnmount(invalidateMetrics);
const displayAvatar = computed(() => isLoggedIn.value
? userInfo.value.face
: props.options.avatar);
const welcomeText = computed(() => isLoggedIn.value
? `你好,${userInfo.value.nickName || userInfo.value.username || '会员'}`
: (props.options.welcomeText || '欢迎来到商城'));
const subtitle = computed(() => isLoggedIn.value
? '查看我的订单与专属权益'
: (props.options.subtitle || '登录后查看专属权益'));
function handleNavigate(url) {
if (!url) return;
if (/^https?:\/\//i.test(url)) {
@@ -96,10 +217,10 @@ function handleNavigate(url) {
}
const defaultServices = [
{ name: '收藏', url: '/member/favorites', icon: 'Star' },
{ name: '足迹', url: '/member/footprint', icon: 'Clock' },
{ name: '红包', url: '/couponCenter', icon: 'Present' },
{ name: '客服', url: '/article', icon: 'Service' }
{ name: '收藏', url: '/home/Favorites', icon: 'Star' },
{ name: '足迹', url: '/home/MyTracks', icon: 'Clock' },
{ name: '领券', url: '/coupon', icon: 'Present' },
{ name: '帮助', url: '/article', icon: 'Service' }
];
const iconMap = {
@@ -114,12 +235,26 @@ function getServiceIcon(iconName) {
}
const displayServices = computed(() => {
if (Array.isArray(props.options?.services) && props.options.services.length > 0) {
return props.options.services;
}
return defaultServices;
const services = Array.isArray(props.options?.services) && props.options.services.length > 0
? props.options.services
: defaultServices;
return services.map(normalizeService);
});
/** 兼容装修中保存的旧默认入口,仅修正已知地址,不覆盖其他自定义链接。 */
function normalizeService(service) {
const legacyRoutes = {
'/member/favorites': '/home/Favorites',
'/member/footprint': '/home/MyTracks',
'/couponCenter': '/coupon'
};
const url = legacyRoutes[service.url] || service.url;
let name = service.name;
if (name === '红包' && url === '/coupon') name = '领券';
if (name === '客服' && url === '/article') name = '帮助';
return { ...service, url, name };
}
const customStyle = computed(() => {
const s = props.componentStyle || {};
const hasRowHeight = Boolean(props.rowStyle?.height && Number(props.rowStyle.height) > 0);
@@ -135,6 +270,14 @@ const customStyle = computed(() => {
</script>
<style lang="scss" scoped>
.metrics-retry {
border: 0;
background: transparent;
color: #409eff;
font-size: 11px;
cursor: pointer;
}
.decor-user-benefits {
width: 100%;
height: 100%;

View File

@@ -2,11 +2,14 @@ import { computed, onMounted, ref } from "vue";
import { getBaseSite } from "@/api/common.js";
import config from "@/config";
// 同页的页头、页脚共享请求;每次重新进入页面时刷新,避免旧缓存覆盖后台设置。
// 全局共享站点配置;应用生命周期内仅拉取一次,同页与切页均复用请求与结果。
const settings = ref(null);
let pendingRequest;
let pendingRequest = null;
export function loadSiteSettings() {
if (settings.value) {
return Promise.resolve(settings.value);
}
if (!pendingRequest) {
pendingRequest = getBaseSite()
.then((res) => {
@@ -16,7 +19,9 @@ export function loadSiteSettings() {
return settings.value;
})
.catch(() => settings.value)
.finally(() => { pendingRequest = null; });
.finally(() => {
pendingRequest = null;
});
}
return pendingRequest;
}

View File

@@ -14,7 +14,7 @@ export const E_COUPON_ORDER_TYPE = "E_COUPON";
/** 单次购买数量上限(O-09 默认:min(quantity, 999)) */
export const E_COUPON_MAX_BUY_NUM = 999;
/** 常规立即购买(秒杀/满减/券/限时/第N件等) */
/** 常规立即购买(秒杀/满减/券等) */
export const E_COUPON_CART_BUY_NOW = "BUY_NOW";
export const E_COUPON_CART_PINTUAN = "PINTUAN";
export const E_COUPON_CART_POINTS = "POINTS";
@@ -72,8 +72,6 @@ export function createEmptyPromotionMap() {
SECKILL: null,
FULL_DISCOUNT: null,
COUPON: [],
DISCOUNT: null,
NTH: null,
PINTUAN: null,
KANJIA: null,
};

View File

@@ -1,3 +1,4 @@
import { setupRequestErrorHandling } from "@/utils/requestError";
import { createApp } from "vue";
import { createPinia } from "pinia";
import "core-js/stable";
@@ -24,6 +25,7 @@ if (aMapSecurityJsCode) {
}
const app = createApp(App);
setupRequestErrorHandling(app);
setupElementPlus(app);
setupLegacyMessage(app);

View File

@@ -75,7 +75,7 @@
>¥{{ item.price }}</span>
<span v-if="item.couponType === 'DISCOUNT'"
>{{ item.couponDiscount }}折</span>
<span>满{{ item.consumeThreshold }}元可用</span>
<span>{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '元可用' : '无门槛使用' }}</span>
<el-button
class="coupon-btn"
size="small"

View File

@@ -1,6 +1,7 @@
<template>
<div class="coupon-center">
<BaseHeader></BaseHeader>
<SearchHeader></SearchHeader>
<div class="content">
<h2 class="coupon-page-title">领券中心</h2>
@@ -48,6 +49,7 @@ import { onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Modal } from "@/utils/message";
import storage from "@/plugins/storage";
import SearchHeader from "@/components/indexDecorate/components/SearchHeader.vue";
import { couponList, receiveCoupon } from "@/api/member.js";
const route = useRoute();

View File

@@ -200,7 +200,7 @@ function handleSubmit () { // 提交密码
Message.success('修改密码成功');
router.push('login');
}
}).catch(() => { loading.value = false; });
}).catch(() => { loading1.value = false; });
};
});
}

View File

@@ -11,7 +11,7 @@
<el-card shadow="never" v-for="(item, index) in autoCoupList" :key="index" style="margin: 10px 15px">
<span v-if="item.couponType === 'PRICE'" class="fontsize_12 global_color" style="font-size: 15px;">¥<span class="price">{{ $filters.unitPrice(item.price) }}</span></span>
<span v-if="item.couponType === 'DISCOUNT'" class="fontsize_12 global_color" style="font-size: 15px;"><span class="price">{{item.discount}}</span>折</span>
<span class="describe" style="font-size: 15px;margin-left: 5px;">满{{item.consumeThreshold}}元可用</span>
<span class="describe" style="font-size: 15px;margin-left: 5px;">{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '元可用' : '无门槛使用' }}</span>
<p style="font-size: 14px;">使用范围:{{useScope(item.scopeType, item.storeName)}}</p>
<p style="font-size: 14px;">有效期:{{item.endTime}}</p>
</el-card>

View File

@@ -8,6 +8,11 @@
<div class="layoutAll">
<el-aside class="side-bar" width="220px">
<div class="article-cate">文章分类</div>
<p v-if="categoryError" role="alert" class="load-message">
分类加载失败,可先浏览全部文章。
<el-button link type="primary" :loading="categoryLoading" @click="getCateList">重试分类</el-button>
</p>
<el-button link type="primary" @click="onSelect('')">全部文章</el-button>
<el-menu
class="side-menu"
:key="menuKey"
@@ -31,14 +36,22 @@
</el-menu>
</el-aside>
<el-main class="content">
<ul class="article-list" v-show="showList">
<div v-if="contentError" role="alert" class="load-message">
{{ contentError }}
<el-button link type="primary" @click="retryContent">重新加载</el-button>
</div>
<el-skeleton v-if="contentLoading" :rows="5" animated />
<ul class="article-list" v-show="showList && !contentLoading && !contentError">
<li v-if="!articleList.length" class="article-empty">暂无文章</li>
<li v-for="(article, index) in articleList" :key="article.id || index" @click="getDetail(article.id)">
{{ article.title }}
</li>
</ul>
<div v-show="!showList" class="article-detail">
<button type="button" class="back-btn" @click="showList = true">返回列表</button>
<el-pagination v-if="showList && !contentLoading && !contentError && total > params.pageSize"
:current-page="params.pageNumber" :page-size="params.pageSize" :total="total"
layout="prev, pager, next" @current-change="changePage" />
<div v-show="!showList && !contentLoading && !contentError" class="article-detail">
<button type="button" class="back-btn" @click="getList()">返回列表</button>
<h2>{{ detail.title }}</h2>
<div class="article-html" v-html="detail.content"></div>
</div>
@@ -60,82 +73,111 @@ defineOptions({ name: 'ArticleIndex' })
const route = useRoute()
const list = ref([])
const detail = ref('')
const detail = ref({})
const articleList = ref([])
const activeName = ref('1347456734864367616')
const activeName = ref('')
const openName = ref([])
const menuKey = ref(0)
const params = reactive({
pageNumber: 1,
pageSize: 100,
categoryId: '',
sort: 'sort'
})
const params = reactive({ pageNumber: 1, pageSize: 20, categoryId: '', sort: 'sort' })
const showList = ref(true)
const total = ref(0)
const categoryError = ref(false)
const categoryLoading = ref(false)
const contentError = ref('')
const contentLoading = ref(false)
const detailId = ref('')
let contentRequestId = 0
function onSelect (id) {
getList(id)
detail.value = ''
showList.value = true
/** 切换分类时重置分页,并使之前的列表或正文请求失效。 */
function onSelect(id) {
activeName.value = id
params.pageNumber = 1
return getList(id)
}
function getCateList () {
articleCateList().then(res => {
if (res.success) {
list.value = res.result || []
openName.value = []
if (route.query.id && detail.value && detail.value.categoryId) {
activeName.value = detail.value.categoryId
list.value.forEach(e => {
const children = e.children || []
const hitParent = String(e.id) === String(activeName.value)
const hitChild = children.some(i => i && String(i.id) === String(activeName.value))
if ((hitParent || hitChild) && children.length) {
openName.value.push(String(e.id))
}
})
} else if (list.value[0] && list.value[0].id) {
activeName.value = list.value[0].id
if (list.value[0].children && list.value[0].children.length) {
openName.value.push(String(list.value[0].id))
}
}
/** 独立加载分类;分类服务不可用时保留全部文章入口,不阻断正文和列表。 */
async function getCateList() {
if (categoryLoading.value) return
categoryLoading.value = true
categoryError.value = false
try {
const res = await articleCateList({ skipMessage: true })
if (!res.success || !Array.isArray(res.result)) throw new Error('分类响应无效')
list.value = res.result
openName.value = list.value.filter(item => Array.isArray(item.children) && item.children.length)
.map(item => String(item.id))
menuKey.value++
if (activeName.value) {
getList(activeName.value)
} catch {
categoryError.value = true
} finally {
categoryLoading.value = false
}
}
})
}
function getList (id) {
params.categoryId = id
fetchArticleList(params).then(res => {
if (res.success) {
/** 按分类分页查询公开文章;只接收最新一次请求结果,失败不冒充空列表。 */
async function getList(id = activeName.value) {
const requestId = ++contentRequestId
showList.value = true
contentLoading.value = true
contentError.value = ''
params.categoryId = id || ''
try {
const res = await fetchArticleList({ ...params }, { skipMessage: true })
if (!res.success || !Array.isArray(res.result?.records)) throw new Error('列表响应无效')
if (requestId !== contentRequestId) return
articleList.value = res.result.records
total.value = Number(res.result.total) || 0
} catch {
if (requestId === contentRequestId) contentError.value = '文章加载失败,请稍后重试。'
} finally {
if (requestId === contentRequestId) contentLoading.value = false
}
})
}
async function getDetail (id) {
await articleDetail(id).then(res => {
if (res.success) {
detail.value = res.result
/** 加载指定文章;失败可原地重试,过时的正文响应不得覆盖用户新选择。 */
async function getDetail(id) {
const requestId = ++contentRequestId
detailId.value = id
showList.value = false
contentLoading.value = true
contentError.value = ''
try {
const res = await articleDetail(id, { skipMessage: true })
if (!res.success || !res.result) throw new Error('文章不存在')
if (requestId !== contentRequestId) return
detail.value = res.result
} catch {
if (requestId === contentRequestId) contentError.value = '文章详情加载失败或已不存在,请稍后重试。'
} finally {
if (requestId === contentRequestId) contentLoading.value = false
}
})
}
onMounted(async () => {
const articleId = route.query.id
if (articleId) {
await getDetail(articleId)
}
/** 重试当前列表或正文,保留分类和分页条件。 */
function retryContent() {
return showList.value ? getList() : getDetail(detailId.value)
}
/** 切换文章列表页码,沿用当前分类。 */
function changePage(page) {
params.pageNumber = page
return getList()
}
onMounted(() => {
getCateList()
if (route.query.id) getDetail(route.query.id)
else getList()
})
</script>
<style scoped lang="scss">
.load-message {
padding: 12px;
color: #64748b;
font-size: 13px;
}
.container {
margin: 0 auto;
padding: 20px 0 40px;

View File

@@ -137,6 +137,7 @@
</template>
<script setup>
import { catchHandledRequestError } from "@/utils/requestError";
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "@/utils/message";
@@ -230,6 +231,8 @@ function handleSubmit() {
params.content = "";
getDetail();
}
}).catch(catchHandledRequestError).finally(() => {
submitLoading.value = false;
});
}

View File

@@ -0,0 +1,51 @@
<template>
<section class="benefits-panel">
<h3>会员权益</h3>
<div class="grade-options">
<button v-for="item in enabledGrades" :key="item.grade.id" :class="{active: selectedId === item.grade.id}" @click="selectedId = item.grade.id">
{{ item.grade.gradeName }}<small v-if="item.grade.id === currentGradeId">当前等级</small>
</button>
</div>
<p class="benefit-subtitle">{{ selected?.grade?.gradeName || '暂无等级' }} · 点击查看权益说明</p>
<el-empty v-if="!selected?.benefits?.length" description="暂无权益" />
<div v-else class="benefits-grid">
<button v-for="benefit in selected.benefits" :key="benefit.id" class="benefit-card" :class="{locked: !owned(benefit)}" @click="detail = benefit">
<img v-if="benefit.benefitLogo" :src="benefit.benefitLogo" alt="" />
<strong>{{ benefit.benefitName }}</strong>
<span>{{ owned(benefit) ? '已享有' : '该等级专享' }}</span>
</button>
</div>
<el-dialog :model-value="!!detail" :title="detail?.benefitName || '权益说明'" width="520px" @close="detail = null">
<template v-if="detail">
<p>{{ owned(detail) ? '当前等级已享有该权益' : '此权益属于所选等级,当前尚未享有' }}</p>
<p v-if="detail.benefitType === 'POINT_MULTIPLIER'">购物积分倍率:{{ config(detail).multiplier || 1 }} 倍,同类权益取最高值。</p>
<p v-if="detail.benefitType === 'GIFT_POINT'">首次达到等级赠送 {{ config(detail).giftPoint || 0 }} 积分。</p>
<div class="benefit-description" v-html="detail.benefitDesc || '暂无说明'" />
</template>
</el-dialog>
</section>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({gradeDetails:{type:Array,default:()=>[]},currentGradeId:{type:String,default:''}})
const selectedId = ref(''), detail = ref(null)
const enabledGrades = computed(()=>props.gradeDetails.filter(item=>item.grade?.gradeState === 'OPEN'))
const selected = computed(()=>enabledGrades.value.find(item=>item.grade.id === selectedId.value))
const current = computed(()=>enabledGrades.value.find(item=>item.grade.id === props.currentGradeId))
const owned = benefit => (current.value?.benefits || []).some(item=>item.id === benefit.id)
function config(benefit) { try { return JSON.parse(benefit.benefitConfig || '{}') } catch { return {} } }
watch([()=>props.currentGradeId,enabledGrades],()=>{
if(!selectedId.value || !enabledGrades.value.some(item=>item.grade.id===selectedId.value)) selectedId.value=props.currentGradeId || enabledGrades.value[0]?.grade.id || ''
},{immediate:true})
</script>
<style scoped>
.benefits-panel { margin:20px 0; padding:24px; background:#fff; border:1px solid #eee; border-radius:8px; }
h3 { margin:0 0 20px; font-size:18px; } .grade-options { display:flex; flex-wrap:wrap; gap:10px; }
.grade-options button { padding:10px 18px; border:1px solid #ddd; background:#fff; border-radius:6px; cursor:pointer; }
.grade-options button.active { background:#fff8eb; border-color:#c99a47; color:#866021; } small { margin-left:8px; }
.benefit-subtitle { color:#777; margin:20px 0; }.benefits-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr)); gap:16px; }
.benefit-card { padding:20px 10px; display:flex; align-items:center; flex-direction:column; gap:12px; border:1px solid #f0e3cb; background:#fffcf6; border-radius:8px; cursor:pointer; }
.benefit-card img { width:48px; height:48px; object-fit:contain; }.benefit-card span { font-size:12px; color:#986d2a; }
.benefit-card.locked { background:#fafafa; border-color:#eee; color:#888; }.locked img { filter:grayscale(1); opacity:.5; }
.benefit-description { line-height:1.8; overflow-wrap:anywhere; }.benefit-description :deep(img) { max-width:100%; }
</style>

View File

@@ -25,6 +25,8 @@
</div>
</div>
<MemberBenefitPanel :grade-details="gradeDetails" :current-grade-id="String(currentGradeId)" />
<div class="grade-panel">
<div class="grade-tabs">
<div class="grade-tab" :class="{ active: activeGradeTab === 'rules' }" @click="activeGradeTab = 'rules'">获取经验值方式</div>
@@ -87,6 +89,7 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import MemberBenefitPanel from './MemberBenefitPanel.vue'
import { memberGradeCurrent, memberGradeList, memberGradeRules } from '@/api/member.js'
defineOptions({ name: 'MemberGrade' })

View File

@@ -17,7 +17,9 @@
</div>
</template>
<!-- 列表 -->
<empty v-if="orderList.length === 0"/>
<el-skeleton v-if="spinShow" :rows="5" animated />
<el-empty v-else-if="queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
<empty v-else-if="orderList.length === 0" />
<div class="order-content" v-else>
<div class="order-cols">
<span>订单详情</span>
@@ -77,7 +79,7 @@
</div>
</div>
</div>
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
</div>
<!-- 分页 -->
<div class="page-size">
@@ -134,6 +136,7 @@
</template>
<script setup>
import { catchHandledRequestError } from "@/utils/requestError";
import { nextTick, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { Message, Modal } from "@/utils/message";
@@ -157,6 +160,8 @@ const params = reactive({ // 请求参数
order: 'desc'
});
const total = ref(0); // 订单总数
const queryFailed = ref(false); // 区分加载失败与暂无记录,支持重新查询
let listRequestVersion = 0; // 仅允许最后一次查询更新列表与加载状态
const spinShow = ref(false); // 加载状态
const companyList = ref([]); // 物流公司列表
const logisticsShow = ref(false); // 物流信息modal
@@ -230,14 +235,27 @@ function change(index) {
getList()
}
function getList() { // 获取售后列表
const version = ++listRequestVersion;
queryFailed.value = false;
spinShow.value = true;
let paramsData = JSON.parse(JSON.stringify(params))
afterSaleList(paramsData).then(res => {
spinShow.value = false
if (version !== listRequestVersion) return;
if (res.success) {
orderList.value = res.result.records;
total.value = res.result.total;
}
}).catch((error) => {
if (version !== listRequestVersion) {
catchHandledRequestError(error);
return;
}
queryFailed.value = true;
orderList.value = [];
total.value = 0;
catchHandledRequestError(error);
}).finally(() => {
if (version === listRequestVersion) spinShow.value = false;
});
}
function changePageNum(val) { // 修改页码

View File

@@ -90,6 +90,11 @@ const reasonList = ref([]); // 投诉原因
function getOrderDetail() { // 获取订单详情
orderDetail(route.query.sn).then(res => {
if (res.result?.order?.orderStatus === 'CANCELLED') {
Message.warning('订单已取消,无法发起投诉');
router.push({ name: 'MyOrder' });
return;
}
order.value = res.result
orderGoods.value = res.result.orderItems[route.query.index]
orderGoods.value.uploadList = []

View File

@@ -21,7 +21,9 @@
</template>
<!-- 订单列表 -->
<empty v-if="orderList.length === 0" />
<el-skeleton v-if="spinShow" :rows="5" animated />
<el-empty v-else-if="queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
<empty v-else-if="orderList.length === 0" />
<div class="order-content" v-else>
<div class="order-cols">
<span>订单详情</span>
@@ -65,10 +67,13 @@
/>
</div>
<div class="goods-info">
<div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">{{ goods.name }}</div>
<div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">
<el-tag v-if="isGiftItem(goods)" size="small" type="danger" effect="plain" class="mr_5">赠品</el-tag>
{{ goods.name }}
</div>
<div class="goods-num">共{{ goods.num }}件</div>
<div class="goods-extra">
<el-tag v-if="refundPriceList(goods.isRefund) && !isUnpaid(order)" type="warning" size="small">{{ refundPriceList(goods.isRefund) }}</el-tag>
<el-tag v-if="canShowRefundTag(order, goods)" type="warning" size="small">{{ refundPriceList(goods.isRefund) }}</el-tag>
</div>
</div>
</div>
@@ -91,7 +96,7 @@
</div>
</div>
</div>
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
</div>
<!-- 分页 -->
@@ -103,7 +108,9 @@
</UserCenterLayout>
<!-- 个人中心首页订单列表 -->
<empty v-if="homePage && orderList.length === 0" />
<el-skeleton v-if="homePage && spinShow" :rows="5" animated />
<el-empty v-else-if="homePage && queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
<empty v-else-if="homePage && orderList.length === 0" />
<div class="order-content" v-else-if="homePage">
<div class="order-cols">
<span>订单详情</span>
@@ -147,10 +154,13 @@
/>
</div>
<div class="goods-info">
<div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">{{ goods.name }}</div>
<div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">
<el-tag v-if="isGiftItem(goods)" size="small" type="danger" effect="plain" class="mr_5">赠品</el-tag>
{{ goods.name }}
</div>
<div class="goods-num">共{{ goods.num }}件</div>
<div class="goods-extra">
<el-tag v-if="refundPriceList(goods.isRefund) && !isUnpaid(order)" type="warning" size="small">{{ refundPriceList(goods.isRefund) }}</el-tag>
<el-tag v-if="canShowRefundTag(order, goods)" type="warning" size="small">{{ refundPriceList(goods.isRefund) }}</el-tag>
</div>
</div>
</div>
@@ -173,9 +183,10 @@
</div>
</div>
</div>
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
</div>
<el-dialog v-model="cancelAvail" title="请选择取消订单原因">
<el-alert v-if="cancelIsPickup" title="此订单使用提货卡兑换,取消不退还提货权益,无法再次兑换。" type="warning" :closable="false" style="margin-bottom:16px" />
<el-radio-group v-model="cancelParams.reason">
<el-radio :label="item.reason" v-for="item in cancelReason" :key="item.id">
{{ item.reason }}
@@ -192,6 +203,7 @@
</template>
<script setup>
import { catchHandledRequestError } from "@/utils/requestError";
import { onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message, Modal } from "@/utils/message";
@@ -226,11 +238,18 @@ const cancelParams = reactive({ // 取消售后参数
const changeWay = ref(['全部', '待付款', '待发货', '待收货/使用']);
const activeOrderTab = ref(0);
const total = ref(0); // 数据总数
const queryFailed = ref(false); // 区分加载失败与暂无记录,支持重新查询
let listRequestVersion = 0; // 仅允许最后一次查询更新列表与加载状态
const spinShow = ref(false); // 加载状态
const cancelIsPickup = ref(false);
const cancelAvail = ref(false); // 取消订单modal控制
const cancelReason = ref([]); // 取消订单原因
// 退款状态枚举
function isGiftItem(goods) {
if (!goods) return false;
return goods.promotionType === 'GIFT' || (Number(goods.flowPrice) === 0 && goods.promotionType === 'GIFT');
}
function refundPriceList(status) {
switch (status) {
case 'ALL_REFUND':
@@ -245,6 +264,11 @@ function refundPriceList(status) {
return "";
}
}
function canShowRefundTag (order, goods) {
// 未支付订单未发生资金交易,不展示退款标签
if (!order || order.payStatus !== 'PAID') return false;
return !!refundPriceList(goods?.isRefund);
}
function goodsDetail (skuId, goodsId) {
// 跳转商品详情
let routeUrl = router.resolve({
@@ -320,17 +344,30 @@ function delOrder (sn) { // 删除订单
});
}
function getList () {
const version = ++listRequestVersion;
queryFailed.value = false;
spinShow.value = true;
let _params = JSON.parse(JSON.stringify(params))
if (_params.orderStatus === 'ALL') {
delete _params.orderStatus
}
getOrderList(_params).then(res => {
spinShow.value = false
if (version !== listRequestVersion) return;
if (res.success) {
orderList.value = res.result.records;
total.value = res.result.total;
}
}).catch((error) => {
if (version !== listRequestVersion) {
catchHandledRequestError(error);
return;
}
queryFailed.value = true;
orderList.value = [];
total.value = 0;
catchHandledRequestError(error);
}).finally(() => {
if (version === listRequestVersion) spinShow.value = false;
});
}
function mapAfterSaleOrder (item) {
@@ -365,6 +402,7 @@ function changePageSize (val) { // 修改页数
}
function handleCancelOrder (sn) {
// 取消订单
cancelIsPickup.value = !!orderList.value.find(order => order.sn === sn)?.pickupCardId;
cancelParams.orderSn = sn;
afterSaleReason('CANCEL').then(res => {
if (res.success) {
@@ -412,13 +450,16 @@ function paymentLabel (order) {
if (order.paymentMethod && map[order.paymentMethod]) {
return map[order.paymentMethod]
}
if (order.orderStatus === 'CANCELLED') {
return ''
}
if (isUnpaid(order)) {
return '待支付'
}
return ''
}
function isUnpaid (order) {
return order.payStatus === 'UNPAID' || order.orderStatus === 'UNPAID'
return (order.payStatus === 'UNPAID' || order.orderStatus === 'UNPAID') && order.orderStatus !== 'CANCELLED'
}
watch(() => route.query.tag, () => {

View File

@@ -269,17 +269,27 @@
@click="goodsDetail(goods.skuId, goods.goodsId)"
class="hover-color goods-name"
>
<el-tag v-if="isGiftItem(goods)" size="small" type="danger" effect="plain" class="mr_5">赠品</el-tag>
{{ goods.goodsName }}
</p>
<p class="goods-sn">货号:{{ goods.id }}</p>
</div>
<div class="goods-price">
<template v-if="isGiftItem(goods)">
<span class="unit-price gift-original-price">
{{ $filters.unitPrice(goods.goodsPrice, "¥") }}
</span>
<span class="subtotal gift-subtotal">赠品 (¥0.00 × {{ goods.num }})</span>
</template>
<template v-else>
<span class="unit-price">
{{ $filters.unitPrice(goods.goodsPrice, "¥") }} × {{ goods.num }}
</span>
<span class="subtotal">{{ $filters.unitPrice((goods.goodsPrice * goods.num), "¥") }}</span>
</template>
</div>
<div class="goods-actions">
<template v-if="!isGiftItem(goods)">
<button
v-if="goods.commentStatus == 'UNFINISHED'"
type="button"
@@ -287,7 +297,11 @@
@click="comment(order.order.sn, goodsIndex)"
>评价</button>
<el-button
v-if="goods.complainStatus == 'NO_APPLY'"
v-if="
order.order?.orderStatus !== 'CANCELLED' &&
order.order?.payStatus === 'PAID' &&
goods.complainStatus == 'NO_APPLY'
"
@click="complain(order.order.sn, goodsIndex)"
type="danger"
size="small"
@@ -296,6 +310,7 @@
<el-button
v-if="
!isECouponOrder &&
order.order?.orderStatus !== 'CANCELLED' &&
(goods.afterSaleStatus.includes('NOT_APPLIED') ||
goods.afterSaleStatus.includes('PART_AFTER_SALE'))
"
@@ -303,6 +318,10 @@
size="small"
>申请售后</el-button
>
</template>
<template v-else>
<span class="gift-action-tip">随单赠送,不支持售后</span>
</template>
</div>
</div>
</div>
@@ -341,6 +360,7 @@
v-model="cancelAvail"
title="请选择取消订单原因"
>
<el-alert v-if="order.order?.pickupCardId" title="此订单使用提货卡兑换,取消不退还提货权益,无法再次兑换。" type="warning" :closable="false" style="margin-bottom:16px" />
<el-radio-group
v-model="cancelParams.reason"
>
@@ -725,6 +745,11 @@ function goPay(sn) {
});
}
function isGiftItem(goods) {
if (!goods) return false;
return goods.promotionType === 'GIFT' || (Number(goods.flowPrice) === 0 && goods.promotionType === 'GIFT');
}
function applyAfterSale(sn) {
// 申请售后
router.push({ name: "ApplyAfterSale", query: { sn: sn } });
@@ -1177,4 +1202,16 @@ onMounted(() => {
}
}
}
.gift-original-price {
text-decoration: line-through;
color: #999;
}
.gift-subtotal {
color: #e4393c;
font-weight: bold;
}
.gift-action-tip {
color: #999;
font-size: 12px;
}
</style>

View File

@@ -23,7 +23,7 @@
v-if="item.couponType === 'DISCOUNT'"
class="global_color coupon-amount"
><span class="price">{{ item.discount }}</span>折</span>
<span class="describe">满{{ item.consumeThreshold }}元可用</span>
<span class="describe">{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '元可用' : '无门槛使用' }}</span>
</div>
<p>使用范围:{{ useScope(item.scopeType, item.storeName) }}</p>
<p class="coupon-expire">有效期:{{ formatValidity(item) }}</p>

View File

@@ -1,19 +1,20 @@
<template>
<div class="wrapper my-gift-cards">
<UserCenterLayout>
<div class="gift-page-heading">
<div><h2>我的礼品卡</h2><p>查看卡片权益,兑换你的心意好礼</p></div>
</div>
<div class="bind-section mb_20">
<div class="section-title">兑换礼品卡</div>
<el-form ref="bindFormRef" :model="bindForm" :rules="bindRules" inline class="bind-form">
<el-form ref="bindFormRef" :model="bindForm" :rules="bindRules" class="bind-form">
<el-form-item label="卡号" prop="cardNo" label-width="60px">
<el-input v-model="bindForm.cardNo" placeholder="请输入卡号" maxlength="64" style="width: 220px" />
<el-input v-model="bindForm.cardNo" placeholder="请输入卡号" maxlength="64" />
</el-form-item>
<el-form-item label="兑换码" prop="cardSecret" label-width="70px">
<el-input
v-model="bindForm.cardSecret"
placeholder="请输入兑换码(卡密)"
maxlength="128"
style="width: 220px"
/>
</el-form-item>
<el-form-item label-width="0px">
@@ -25,15 +26,20 @@
<el-tabs v-model="statusTab" class="status-tabs mb_16" @tab-click="(tab) => onStatusTab(tab.paneName)">
<el-tab-pane label="可用" name="AVAILABLE" />
<el-tab-pane label="不可用" name="UNAVAILABLE" />
<el-tab-pane label="待激活" name="PENDING_ACTIVATION" />
<el-tab-pane name="PENDING_ACTIVATION">
<template #label>
<span class="pending-tab-label">待激活<span v-if="pendingCount !== null" class="pending-count">{{ pendingCount }}</span></span>
</template>
</el-tab-pane>
</el-tabs>
<div class="card-list-wrap">
<el-skeleton v-if="loading" fix />
<el-skeleton v-if="loading" :rows="5" animated />
<template v-if="!loading">
<empty v-if="list.length === 0" />
<div v-if="loadFailed" class="gift-load-error"><p>卡片加载失败,请稍后重试</p><el-button @click="getList">重新加载</el-button></div>
<el-empty v-else-if="list.length === 0" :description="emptyDescription" :image-size="100" />
<div v-else class="gift-card-grid">
<div v-for="item in list" :key="item.id || item.cardNo" class="gcc-card-item" :class="{ 'is-unavailable': statusTab === 'UNAVAILABLE' }">
<div v-for="item in list" :key="item.id || item.cardNo" class="gcc-card-item" :class="{ 'is-unavailable': statusTab === 'UNAVAILABLE', 'is-pickup': item.cardType === 'PICKUP' }">
<div class="gcc-card-header">
<div class="gcc-header-pattern" aria-hidden="true" />
<div class="gcc-header-inner">
@@ -42,17 +48,19 @@
<div class="gcc-title">{{ item.giftCardName || "礼品卡" }}</div>
<span class="gcc-status-chip">{{ statusChipLabel }}</span>
</div>
<div class="gcc-face"><span class="gcc-face-symbol">¥</span>{{ formatYuan(item.faceValue) }}</div>
<div class="gcc-cardno">{{ item.cardNo }}</div>
<div v-if="item.cardType === 'PICKUP'" class="gcc-face">兑换 <strong>{{ item.pickupCount }}</strong> 种商品</div>
<div v-else class="gcc-face"><span class="gcc-face-symbol">¥</span>{{ formatYuan(item.faceValue) }}</div>
<div class="gcc-cardno">卡号 {{ item.cardNo }}</div>
<div class="gcc-expire">{{ formatValidity(item) }}</div>
</div>
<div class="gcc-header-right">
<div class="gcc-type">现金卡</div>
<div class="gcc-expire">{{ formatValidity(item) }}</div>
<div class="gcc-type">{{ item.cardType === 'PICKUP' ? '提货卡' : '现金卡' }}</div>
</div>
</div>
</div>
<div class="gcc-card-body">
<div class="gcc-row">
<div v-if="item.cardType === 'PICKUP'" class="gcc-row">每种一件 · 一次兑完 · 免邮</div>
<div v-else class="gcc-row">
当前余额:<span class="gcc-strong" v-if="item.balance != null">¥{{ $filters.unitPrice(item.balance) }}</span><span class="gcc-strong" v-else>—</span>
</div>
@@ -112,6 +120,10 @@ const router = useRouter();
const statusTab = ref("AVAILABLE");
const loading = ref(false);
const total = ref(0);
const pendingCount = ref(null);
const loadFailed = ref(false);
let listRequestId = 0;
let pendingCountRequestId = 0;
const list = ref([]);
const params = reactive({
pageNumber: 1,
@@ -130,7 +142,6 @@ const bindRules = {
cardSecret: [{ required: true, message: "请输入兑换码", trigger: "blur" }],
};
const noticeModal = ref(false);
const serviceHotline = ref("12221111");
const activatingId = ref("");
const bindFormRef = ref(null);
@@ -153,11 +164,30 @@ const statusChipLabel = computed(() => {
const map = { AVAILABLE: "可用", UNAVAILABLE: "不可用", PENDING_ACTIVATION: "待激活" };
return map[statusTab.value] || "";
});
const emptyDescription = computed(() => ({
AVAILABLE: "暂无可用礼品卡,可输入卡号和兑换码添加",
UNAVAILABLE: "暂无不可用礼品卡",
PENDING_ACTIVATION: "暂无待激活礼品卡",
}[statusTab.value]));
onMounted(() => {
getList();
refreshPendingCount();
});
/** 复用会员分页接口的 total 获取全部待激活数量,独立于当前标签和每页条数。 */
async function refreshPendingCount() {
const requestId = ++pendingCountRequestId;
try {
const res = await getGiftCardCashMemberCardPage({ memberCardStatus: "PENDING_ACTIVATION", pageNumber: 1, pageSize: 1 });
if (requestId === pendingCountRequestId && res.success && res.result) {
pendingCount.value = Number(res.result.total) || 0;
}
} catch {
// 统计失败时保留已知数量,不将未知数量显示成零。
}
}
function formatYuan(val) {
if (val == null || val === "") {
return "—";
@@ -195,21 +225,32 @@ function onStatusTab(name) {
}
function getList() {
const requestId = ++listRequestId;
loading.value = true;
loadFailed.value = false;
const p = { ...params };
if (p.memberCardStatus) {
p.memberCardStatus = String(p.memberCardStatus);
}
getGiftCardCashMemberCardPage(p)
.then((res) => {
if (requestId !== listRequestId) return;
loading.value = false;
if (res.success && res.result) {
list.value = res.result.records || [];
total.value = res.result.total || 0;
if (p.memberCardStatus === "PENDING_ACTIVATION") {
++pendingCountRequestId;
pendingCount.value = Number(total.value);
}
} else {
loadFailed.value = true;
}
})
.catch(() => {
if (requestId !== listRequestId) return;
loading.value = false;
loadFailed.value = true;
});
}
@@ -250,6 +291,7 @@ function submitBind() {
resetBindForm();
params.pageNumber = 1;
getList();
refreshPendingCount();
}
})
.catch(() => {
@@ -274,6 +316,7 @@ function onCardPrimaryAction(item) {
if (res.success) {
Message.success(res.message || "激活成功");
getList();
refreshPendingCount();
}
})
.catch((err) => {
@@ -285,7 +328,7 @@ function onCardPrimaryAction(item) {
return;
}
if (statusTab.value === "AVAILABLE") {
router.push({ path: "/goodsList" });
router.push(item.cardType === "PICKUP" ? { name: "PickupCard", query: { id: resolveMemberCardId(item) } } : { path: "/goodsList" });
}
}
@@ -308,195 +351,125 @@ function isActivating(item) {
<style scoped lang="scss">
.my-gift-cards {
.gift-page-heading {
padding: 10px 4px 22px;
h2 { margin: 0 0 8px; font-size: 22px; font-weight: 600; color: #252833; }
p { margin: 0; font-size: 13px; color: #909399; }
}
.bind-section {
padding: 16px 18px;
border-radius: 8px;
border: 1px solid #eee;
@include white_background_color();
}
.section-title {
font-size: 15px;
font-weight: 500;
margin-bottom: 14px;
@include title_color($light_title_color);
padding: 20px 24px 24px;
border-radius: 10px;
border: 1px solid #f3e7e8;
background: #fff9f9;
}
.section-title { margin-bottom: 18px; font-size: 15px; font-weight: 600; color: #303133; }
.bind-form {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 4px 8px;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
align-items: start;
gap: 16px;
:deep(.el-form-item) { margin: 0; min-width: 0; }
:deep(.el-form-item__content) { min-width: 0; }
:deep(.el-input__wrapper) { min-height: 36px; box-sizing: border-box; background: #fff; }
:deep(.el-button) { height: 36px; min-width: 88px; border-radius: 6px; }
}
.status-tabs {
:deep(.el-tabs__header) {
margin-bottom: 0;
margin-top: 28px;
:deep(.el-tabs__header) { margin-bottom: 0; }
:deep(.el-tabs__item) { height: 48px; font-size: 15px; }
:deep(.el-tabs__nav-wrap::after) { height: 1px; background: #eceef2; }
}
.pending-tab-label { display: inline-flex; align-items: center; gap: 8px; }
.pending-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
padding: 0 6px;
height: 20px;
box-sizing: border-box;
border-radius: 10px;
color: $theme_color;
background: #fff0f1;
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.card-list-wrap {
position: relative;
min-height: 200px;
margin-top: 24px;
}
.card-list-wrap { position: relative; min-height: 290px; margin-top: 24px; }
.gift-load-error { padding: 64px 0; text-align: center; color: #909399; }
.gift-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
align-items: start;
}
.gcc-card-item {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
@include white_background_color();
}
.gcc-card-header {
position: relative;
min-height: 128px;
padding: 0;
background: linear-gradient(125deg, #ff9a4a 0%, #ff7729 42%, #ff8f3d 100%);
border: 1px solid #f0e3e4;
border-radius: 12px;
background: #fff;
transition: box-shadow .2s, border-color .2s;
&:hover { border-color: #edc9cd; box-shadow: 0 6px 20px rgba(100, 33, 40, .06); }
}
.gcc-card-header { position: relative; overflow: hidden; background: linear-gradient(120deg, #fff3f3, #fff9f7); }
.gcc-header-pattern {
position: absolute;
inset: 0;
opacity: 0.22;
width: 180px;
height: 180px;
right: -70px;
bottom: -110px;
border: 24px solid rgba(211, 134, 141, .07);
border-radius: 50%;
pointer-events: none;
background-image: repeating-linear-gradient(
-36deg,
transparent,
transparent 5px,
rgba(255, 255, 255, 0.45) 5px,
rgba(255, 255, 255, 0.45) 6px
);
}
.gcc-header-inner {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16px 14px 14px;
color: #fff;
}
.gcc-header-left {
flex: 1;
min-width: 0;
padding-right: 8px;
}
.gcc-title {
font-size: 14px;
font-weight: 600;
line-height: 1.3;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
}
.gcc-name-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.gcc-status-chip {
padding: 0 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
font-size: 11px;
line-height: 18px;
}
.gcc-face {
margin-top: 10px;
font-size: 26px;
font-weight: 700;
line-height: 1.2;
}
.gcc-face-symbol {
margin-right: 2px;
font-size: 15px;
font-weight: 500;
}
.gcc-cardno {
margin-top: 14px;
font-size: 12px;
letter-spacing: 0.02em;
opacity: 0.75;
word-break: break-all;
}
.gcc-header-right {
text-align: right;
flex-shrink: 0;
}
.gcc-type {
font-size: 18px;
font-weight: 700;
line-height: 1.2;
}
.gcc-expire {
margin-top: 10px;
font-size: 14px;
opacity: 0.9;
white-space: nowrap;
}
.gcc-card-body {
padding: 14px 14px 16px;
}
.gcc-row {
font-size: 13px;
line-height: 1.85;
@include title_color($light_title_color);
}
.gcc-muted {
color: #999;
}
.gcc-strong {
color: #ff6b22;
font-weight: 600;
}
.gcc-link {
color: $theme_color;
cursor: pointer;
}
.gcc-service {
color: #515a6e;
}
.gcc-header-inner { position: relative; display: flex; gap: 12px; padding: 22px 20px 18px; }
.gcc-header-left { flex: 1; min-width: 0; }
.gcc-name-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.gcc-title { max-width: 100%; overflow-wrap: anywhere; font-size: 15px; font-weight: 600; line-height: 1.5; color: #523338; }
.gcc-status-chip { padding: 1px 7px; border-radius: 4px; background: rgba(255,255,255,.8); color: #a9757b; font-size: 11px; line-height: 18px; }
.gcc-face { margin-top: 18px; color: #a63e4a; font-size: 30px; font-weight: 600; line-height: 1.3; }
.gcc-face strong { font-size: 32px; font-weight: 600; }
.gcc-face-symbol { margin-right: 3px; font-size: 18px; }
.gcc-cardno { margin-top: 18px; color: #96777b; font-size: 12px; overflow-wrap: anywhere; letter-spacing: .03em; }
.gcc-header-right { flex-shrink: 0; text-align: right; }
.gcc-type { padding: 3px 7px; border: 1px solid #e9cdd0; border-radius: 4px; font-size: 12px; color: #a46f75; white-space: nowrap; }
.gcc-expire { margin-top: 8px; color: #a08a8d; font-size: 12px; }
.gcc-card-body { padding: 16px 20px 20px; }
.gcc-row { min-height: 22px; color: #777e89; font-size: 13px; line-height: 22px; }
.gcc-strong { color: #a63e4a; font-weight: 600; }
.gcc-action {
display: block;
width: 100%;
margin-top: 14px;
padding: 9px 16px;
margin-top: 16px;
padding: 10px 16px;
border: none;
border-radius: 999px;
border-radius: 6px;
background: $theme_color;
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
color: #fff;
background: $theme_color;
transition: opacity 0.2s, background 0.2s;
}
.gcc-action:hover:not(.is-disabled) {
opacity: 0.9;
}
.gcc-action.is-disabled {
color: #c5c8ce;
cursor: not-allowed;
background: #f7f7f7;
transition: opacity .2s;
&:hover:not(.is-disabled) { opacity: .85; }
&:focus-visible { outline: 2px solid $theme_color; outline-offset: 3px; }
&.is-disabled { color: #aaa; background: #f3f4f6; cursor: not-allowed; }
}
.is-pickup .gcc-face { font-size: 22px; }
.gcc-card-item.is-unavailable {
.gcc-card-header {
background: linear-gradient(125deg, #c9c9c9 0%, #a8a8a8 100%);
border-color: #e8eaed;
.gcc-card-header { background: #f5f6f8; }
.gcc-title, .gcc-face, .gcc-strong { color: #92969e; }
.gcc-cardno, .gcc-expire, .gcc-status-chip, .gcc-type { color: #a1a5ad; }
.gcc-type { border-color: #dddfe4; }
}
.gcc-strong {
color: #999;
}
}
.page-size {
display: flex;
justify-content: flex-end;
margin-top: 20px;
.page-size { display: flex; justify-content: flex-end; margin-top: 28px; padding-top: 20px; border-top: 1px solid #f0f1f4; }
@media (max-width: 900px) {
.bind-form { grid-template-columns: minmax(0, 1fr); gap: 22px; }
.bind-form :deep(.el-form-item__label) { width: 70px !important; }
.bind-form :deep(.el-form-item:last-child .el-form-item__content) { padding-left: 70px; }
.gift-card-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }
}
}
.gcc-notice-text {
line-height: 1.7;
color: #515a6e;
margin-bottom: 16px;
}
.gcc-notice-footer {
text-align: right;
}
.gcc-notice-text { line-height: 1.7; color: #515a6e; margin-bottom: 16px; }
.gcc-notice-footer { text-align: right; }
</style>

View File

@@ -5,7 +5,9 @@
<el-button class="del-btn" @click="clearAll" type="primary">删除全部</el-button>
</template>
<!-- 订单列表 -->
<empty v-if="list.length === 0" />
<el-skeleton v-if="spinShow" :rows="5" animated />
<el-empty v-else-if="queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
<empty v-else-if="list.length === 0" />
<ul class="track-list" v-else>
<li
v-for="(item, index) in list"
@@ -39,6 +41,7 @@
</template>
<script setup>
import { catchHandledRequestError } from "@/utils/requestError";
import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { Message, Modal } from "@/utils/message";
@@ -50,6 +53,8 @@ defineOptions({ name: "MyTrack" });
const router = useRouter();
const list = ref([]); // 我的足迹,商品列表
const queryFailed = ref(false); // 区分加载失败与暂无记录,支持重新查询
let listRequestVersion = 0; // 仅允许最后一次查询更新列表与加载状态
const spinShow = ref(false); // 控制loading是否加载
const params = reactive({
pageNumber: 1,
@@ -118,9 +123,11 @@ function changePageSize(val) {
function getList() {
// 获取足迹列表
const version = ++listRequestVersion;
queryFailed.value = false;
spinShow.value = true;
tracksList(params).then((res) => {
spinShow.value = false;
if (version !== listRequestVersion) return;
if (res.success && res.result.records.length) {
list.value = res.result.records.filter(item =>{
return item != null
@@ -129,6 +136,17 @@ function getList() {
} else {
list.value = [];
}
}).catch((error) => {
if (version !== listRequestVersion) {
catchHandledRequestError(error);
return;
}
queryFailed.value = true;
list.value = [];
total.value = 0;
catchHandledRequestError(error);
}).finally(() => {
if (version === listRequestVersion) spinShow.value = false;
});
}

View File

@@ -0,0 +1,194 @@
<!--
提货卡选品与收货地址确认,兑换成功后进入普通订单履约。
@author Bulbasaur
@since 2026-11-05
-->
<template>
<UserCenterLayout>
<div v-loading="loading" class="pickup-page">
<header class="pickup-heading">
<div><div class="pickup-eyebrow">提货卡兑换</div><h2>{{ detail.giftCardName || '选择你的好礼' }}</h2></div>
<span v-if="detail.credentialId" class="pickup-validity">提货权益</span>
</header>
<el-result v-if="error" icon="warning" :title="error"><template #extra><el-button @click="load">重新加载</el-button></template></el-result>
<template v-else-if="detail.credentialId">
<el-result v-if="completed || detail.tradeSn" icon="success" title="该提货卡已兑换"><template #extra><el-button type="primary" @click="router.push({name:'MyOrder'})">查看订单</el-button></template></el-result>
<template v-else>
<div class="pickup-rules"><strong>{{ ruleText }}</strong><span>每种一件</span><span>一次兑完</span><span>免邮</span></div>
<section class="pickup-products">
<div class="pickup-section-title"><h3>选择兑换商品</h3><span>共 {{ detail.goods?.length || 0 }} 种商品 · 已选 <b>{{ selected.length }}</b> / {{ detail.pickupCount }} 种</span></div>
<el-checkbox-group v-model="selected" :max="detail.pickupCount" :disabled="busy || detail.pickupMode === 'ALL'" aria-label="可兑换商品">
<div class="pickup-grid">
<el-checkbox v-for="goods in detail.goods" :key="goods.id" :value="goods.id" :disabled="!goods.available" class="pickup-item" :class="{'is-selected': selected.includes(goods.id), 'is-unavailable': !goods.available}">
<div class="pickup-image"><el-image :src="goods.thumbnail" fit="contain" :alt="goods.goodsName"><template #error><span class="pickup-image-fallback">暂无图片</span></template></el-image></div>
<div class="pickup-goods-info">
<strong class="pickup-goods-name" :title="goods.goodsName">{{ goods.goodsName }}</strong>
<div class="pickup-specs" :title="specsText(goods.specs)">{{ specsText(goods.specs) }}</div>
<div class="pickup-store">{{ goods.storeName }}</div>
<div v-if="!goods.available" class="pickup-goods-unavailable">暂不可兑换</div>
</div>
</el-checkbox>
</div>
</el-checkbox-group>
</section>
<section class="pickup-address">
<div class="pickup-section-title"><h3>收货地址</h3><div><el-button link :disabled="busy" @click="router.push({name:'MyAddress'})">管理地址</el-button><el-button link :disabled="busy" @click="loadAddresses">刷新</el-button></div></div>
<el-select v-model="addressId" placeholder="请选择收货地址" :disabled="busy" aria-label="收货地址" class="pickup-address-select">
<el-option v-for="address in addresses" :key="address.id" :value="address.id" :label="addressText(address)" />
</el-select>
<p v-if="!addresses.length" class="pickup-address-empty">还没有收货地址,请先添加地址后刷新。</p>
</section>
<footer class="pickup-checkout">
<div class="pickup-selection"><strong>已选 <b>{{ selected.length }}</b> / {{ detail.pickupCount }} 种商品</strong><span>{{ selectionHint }}</span></div>
<div class="pickup-submit"><div class="pickup-payable">实付 <strong>¥0<span>.00</span></strong><small>免运费</small></div><el-button type="primary" :loading="busy" :disabled="!canSubmit" @click="submit">确认兑换</el-button></div>
</footer>
<p class="pickup-terms">兑换成功后取消不退权益,不支持退款或退货。</p>
</template>
</template>
</div>
</UserCenterLayout>
</template>
<script setup>
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { memberAddress } from '@/api/address';
import storage from '@/plugins/storage';
import { getPickupCard, previewPickupCard, redeemPickupCard } from '@/api/pickupCard';
const route = useRoute(), router = useRouter();
const detail = ref({}), selected = ref([]), addresses = ref([]), addressId = ref('');
const loading = ref(false), busy = ref(false), error = ref(''), completed = ref(false);
let active = true, requestVersion = 0;
const owner = storage.getItem('userInfo') || '';
/** 账号切换后丢弃旧请求结果,避免展示上一个账号的卡和地址。 */
function sameOwner() { return active && owner === (storage.getItem('userInfo') || ''); }
const canSubmit = computed(() => selected.value.length === detail.value.pickupCount && !!addressId.value && !loading.value && !detail.value.goods?.some(g => selected.value.includes(g.id) && !g.available));
const ruleText = computed(() => detail.value.pickupMode === 'ALL' ? '兑换以下全部商品' : `从以下商品任选 ${detail.value.pickupCount || 1} 种`);
const selectionHint = computed(() => {
const remaining = (detail.value.pickupCount || 0) - selected.value.length;
if (remaining > 0) return `还需选择 ${remaining} 种商品`;
return addressId.value ? '商品已选齐,请核对收货地址' : '请选择收货地址';
});
/** 兼容键值及旧数组规格,过滤图片元数据,避免展示原始 JSON。 */
function specsText(value) {
try {
const specs = typeof value === 'string' ? JSON.parse(value || '{}') : value;
const values = Array.isArray(specs) ? specs.filter(s => s && s.specName !== 'images').map(s => s.specValue) : Object.entries(specs || {}).filter(([key]) => key !== 'images').map(([, val]) => val);
return values.filter(v => typeof v === 'string' || typeof v === 'number').join(' / ') || '默认规格';
} catch { return typeof value === 'string' && !/^[\s]*[\[{]/.test(value) ? value : '默认规格'; }
}
/** 地址仅用于展示,提交后仍由服务端校验当前会员归属。 */
function addressText(address) { return `${address.name} ${address.mobile} ${address.consigneeAddressPath || ''} ${address.detail || ''}`; }
/** 刷新地址,保留仍存在的选择。 */
async function loadAddresses() {
const response = await memberAddress();
if (!sameOwner()) return;
if (!response.success) throw new Error(response.message || '地址加载失败');
addresses.value = response.result?.records || response.result || [];
if (!addresses.value.some(a => a.id === addressId.value)) addressId.value = (addresses.value.find(a => a.isDefault) || addresses.value[0])?.id || '';
}
/** 加载卡详情;离页或新请求发起后丢弃旧响应。 */
async function load() {
const version = ++requestVersion;
loading.value = true; error.value = '';
try {
const response = await getPickupCard(route.query.id);
if (!sameOwner() || version !== requestVersion) return;
if (!response.success) throw new Error(response.message || '提货卡加载失败');
detail.value = response.result;
selected.value = detail.value.pickupMode === 'ALL' ? detail.value.goods.map(g => g.id) : [];
await loadAddresses();
} catch (e) { if (sameOwner() && version === requestVersion) error.value = e.message || '加载失败,请重试'; }
finally { if (sameOwner() && version === requestVersion) loading.value = false; }
}
/** 先服务端试算再确认核销,网络异常保留选项,允许幂等重试。 */
async function submit() {
if (busy.value || !canSubmit.value || !sameOwner()) return;
busy.value = true;
const payload = { credentialId: detail.value.credentialId, skuIds: [...selected.value], addressId: addressId.value };
try {
const preview = await previewPickupCard(payload);
if (!sameOwner()) return;
if (!preview.success) throw new Error(preview.message || '兑换校验失败');
await ElMessageBox.confirm(`本次兑换 ${selected.value.length} 种商品,每种一件,免邮且实付为零。兑换后取消不退权益,不支持退款或退货。`, '确认兑换', { confirmButtonText: '确认兑换', cancelButtonText: '返回检查' });
if (!sameOwner()) return;
const response = await redeemPickupCard(payload);
if (!sameOwner()) return;
if (!response.success) throw new Error(response.message || '兑换失败');
completed.value = true;
} catch (e) {
if (sameOwner() && e !== 'cancel' && e !== 'close') {
// 网络超时可能发生在成功提交后,查询原卡确认结果,不重复创建订单。
try { const status = await getPickupCard(payload.credentialId); if (sameOwner() && status.success && status.result?.tradeSn) { detail.value = status.result; completed.value = true; return; } } catch { /* 保留原错误供重试 */ }
ElMessage.error(e.message || '兑换结果待确认,请重试或查看订单');
}
}
finally { if (sameOwner()) busy.value = false; }
}
onMounted(load);
onBeforeUnmount(() => { active = false; requestVersion++; });
</script>
<style scoped lang="scss">
.pickup-page { padding: 10px 4px 0; color: #303133; }
.pickup-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-bottom: 20px; }
.pickup-eyebrow { margin-bottom: 8px; color: #909399; font-size: 12px; }
.pickup-heading h2 { margin: 0; color: #252833; font-size: 22px; font-weight: 600; overflow-wrap: anywhere; }
.pickup-validity { padding: 5px 10px; border: 1px solid #eee; border-radius: 4px; font-size: 12px; color: #777e89; white-space: nowrap; }
.pickup-rules { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 20px; padding: 16px 18px; border-radius: 6px; background: #f7f8fa; font-size: 13px; color: #858b95; }
.pickup-rules strong { color: #434953; font-weight: 500; }
.pickup-products { margin-top: 28px; }
.pickup-section-title { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; }
.pickup-section-title h3 { margin: 0; font-size: 16px; font-weight: 600; }
.pickup-section-title > span { color: #909399; font-size: 13px; }
.pickup-section-title b { color: $theme_color; font-weight: 500; }
.pickup-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; max-height: 570px; overflow-y: auto; padding: 2px 4px 2px 2px; }
.pickup-item {
position: relative;
display: block;
width: 100%;
height: auto;
margin: 0;
padding: 0;
box-sizing: border-box;
border: 1px solid #e9ebef;
border-radius: 8px;
overflow: hidden;
background: #fff;
transition: border-color .2s, box-shadow .2s;
&:hover:not(.is-unavailable) { border-color: #e7acb1; }
&.is-selected { border-color: $theme_color; box-shadow: 0 0 0 1px $theme_color; }
&.is-unavailable { background: #f8f9fa; }
:deep(.el-checkbox__input) { position: absolute; z-index: 1; top: 12px; right: 12px; }
:deep(.el-checkbox__label) { display: block; padding: 0; width: 100%; white-space: normal; color: inherit; }
}
.pickup-image { display: flex; justify-content: center; align-items: center; height: 140px; margin: 14px 20px 0; background: #fff; }
.pickup-image :deep(.el-image) { width: 100%; height: 100%; }
.pickup-image-fallback { display: flex; height: 100%; align-items: center; justify-content: center; color: #a8abb2; background: #f7f8fa; font-size: 12px; }
.pickup-goods-info { padding: 14px; }
.pickup-goods-name { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; min-height: 40px; line-height: 20px; font-size: 14px; font-weight: 500; color: #303133; }
.pickup-specs { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 8px; color: #6b7280; font-size: 12px; line-height: 18px; }
.pickup-store { margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #a0a5ae; font-size: 12px; line-height: 18px; }
.pickup-goods-unavailable { margin-top: 8px; color: #909399; font-size: 12px; }
.is-unavailable .pickup-image { opacity: .5; }
.pickup-address { margin-top: 28px; padding-top: 24px; border-top: 1px solid #eee; }
.pickup-address-select { width: 100%; }
.pickup-address-select :deep(.el-select__wrapper) { min-height: 42px; }
.pickup-address-empty { color: #909399; font-size: 13px; }
.pickup-checkout { position: sticky; bottom: 0; z-index: 2; display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-top: 24px; padding: 20px 0; border-top: 1px solid #eee; background: #fff; }
.pickup-selection { display: flex; flex-direction: column; gap: 8px; font-size: 14px; }
.pickup-selection strong { font-weight: 500; }
.pickup-selection b { color: $theme_color; font-weight: 600; }
.pickup-selection > span { color: #909399; font-size: 12px; }
.pickup-submit { display: flex; align-items: center; gap: 24px; }
.pickup-payable { font-size: 13px; color: #606266; }
.pickup-payable strong { margin-left: 6px; color: $theme_color; font-size: 26px; font-weight: 600; }
.pickup-payable strong span { font-size: 16px; }
.pickup-payable small { display: block; margin-top: 4px; text-align: right; font-size: 12px; color: #909399; }
.pickup-submit :deep(.el-button) { min-width: 136px; height: 42px; border-radius: 6px; font-size: 15px; }
.pickup-terms { margin: 0; padding-bottom: 6px; color: #a0a5ae; font-size: 12px; line-height: 1.6; }
@media (max-width: 900px) { .pickup-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 600px) { .pickup-grid { grid-template-columns: minmax(0, 1fr); } .pickup-checkout { flex-wrap: wrap; } .pickup-submit { width: 100%; justify-content: space-between; } .pickup-section-title { flex-wrap: wrap; } }
</style>

View File

@@ -147,6 +147,24 @@
<span>{{ goods.goodsSku?.quantity > 0 ? "有货" : "无货" }}</span>
<span class="goods-price">{{ $filters.unitPrice(goods.subTotal, "¥") }}</span>
</div>
<!-- 随单实物赠品行 -->
<div
class="goods-item gift-item"
v-for="(gift, giftIndex) in getShopGifts(shop)"
:key="'gift-' + (gift.skuId || giftIndex)"
>
<span class="goods-main hover-color" @click="goGoodsDetail(gift.skuId, gift.goodsId)">
<img :src="gift.thumbnail || gift.image" alt="" />
<span class="goods-name">
<el-tag size="small" type="danger" effect="plain" class="mr_5">赠品</el-tag>
{{ gift.goodsName || gift.name || '随单实物赠品' }}
</span>
</span>
<span class="goods-price gift-zero-price">¥0.00</span>
<span>{{ gift.num || 1 }}</span>
<span>有货</span>
<span class="goods-price gift-zero-price">¥0.00</span>
</div>
</div>
<div class="order-mark">
<div v-if="isRemarkOpen(shop)" class="mark-field">
@@ -202,7 +220,7 @@
<template v-else>{{ item.discount }}折</template>
</div>
<div class="pay-coupon-info">
<p>满{{ item.consumeThreshold }}元可用</p>
<p>{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '元可用' : '无门槛使用' }}</p>
<p>使用范围:{{ useScope(item.scopeType, item.storeName) }}</p>
<p>有效期:{{ item.endTime }}</p>
</div>
@@ -876,6 +894,13 @@ function delAddress(item) {
onCancel: () => { },
});
}
function getShopGifts(shop) {
if (!shop) return [];
if (Array.isArray(shop.giftItemList) && shop.giftItemList.length > 0) {
return shop.giftItemList;
}
return [];
}
function goGoodsDetail(skuId, goodsId) {
// 跳转商品详情
let routeUrl = router.resolve({
@@ -1800,4 +1825,8 @@ function useScope(type, storeName) {
.pay-gcc-notice-foot {
text-align: right;
}
.gift-zero-price {
color: #e4393c !important;
font-weight: 500;
}
</style>

View File

@@ -163,6 +163,7 @@ function getGoodsDetail() {
}
})
.catch(() => {
isLoading.value = false;
router.push("/");
});
}

View File

@@ -1,70 +1,134 @@
<template>
<div class="seckill-list">
<BaseHeader></BaseHeader>
<Search />
<!-- 秒杀时间段 -->
<div class="promotion-decorate">限时秒杀</div>
<ul class="time-line">
<template v-for="(time, index) in list">
<li v-if="index < 5" @click="currIndex = index" :key="index" :class="{ currTimeline: currIndex === index }">
<div>{{ time.timeLine + ":00" }}</div>
<div v-if="currIndex === index">
<p>{{ nowHour >= time.timeLine ? "秒杀中" : "即将开始" }}</p>
<p>
{{ nowHour >= time.timeLine ? "距结束" : "距开始" }}&nbsp;{{
currTime
}}
</p>
<SearchHeader></SearchHeader>
<!-- 秒杀顶部氛围横幅 -->
<div class="seckill-hero">
<div class="hero-inner">
<div class="hero-left">
<div class="hero-tag">
<span class="lightning">⚡</span>
<span>限时特惠 · 爆款直降</span>
</div>
<div v-else class="not-curr">
{{ nowHour >= time.timeLine ? "秒杀中" : "即将开始" }}
<h1 class="hero-title">限时秒杀</h1>
<p class="hero-subtitle">正品严选 · 限量低价 · 抢完即止</p>
</div>
<div class="hero-countdown" v-if="list.length && list[currIndex]">
<div class="countdown-badge">
<span class="status-dot" :class="{ 'is-live': isCurrentSessionActive }"></span>
<span>{{ isCurrentSessionActive ? "正在抢购中" : "即将准点开抢" }}</span>
</div>
<div class="countdown-label">
{{ isCurrentSessionActive ? "距本场结束仅剩" : "距本场开始仅剩" }}
</div>
<div class="countdown-boxes">
<span class="time-block">{{ countdownHours }}</span>
<span class="time-sep">:</span>
<span class="time-block">{{ countdownMinutes }}</span>
<span class="time-sep">:</span>
<span class="time-block">{{ countdownSeconds }}</span>
</div>
</div>
</div>
</div>
<!-- 秒杀时间段导航 -->
<div class="time-line-wrap" v-if="list.length">
<ul class="time-line" :class="{ 'single-session': list.length === 1 }">
<template v-for="(time, index) in list">
<li
v-if="index < 5"
:key="index"
@click="currIndex = index"
:class="{ currTimeline: currIndex === index }"
>
<div class="timeline-hour">{{ time.timeLine + ":00" }}</div>
<div class="timeline-status">
<span class="status-pill">{{ nowHour >= time.timeLine ? "秒杀中" : "即将开始" }}</span>
</div>
</li>
</template>
</ul>
</div>
<!-- 秒杀商品列表 -->
<div class="seckill-goods-wrap">
<div class="goods-list">
<empty v-if="goodsList.length === 0" />
<div v-else class="goods-show-info1" v-for="(item, index) in goodsList" :key="index"
@click="goGoodsDetail(item.skuId, item.goodsId)">
<div class="goods-show-img">
<div v-if="goodsList.length === 0" class="empty-wrap">
<empty :_Title="list.length ? '当前场次暂无秒杀商品,去其他场次看看吧' : '今日暂无秒杀活动,去首页逛逛吧'" />
</div>
<div v-else class="goods-grid">
<div
class="goods-card"
v-for="(item, index) in goodsList"
:key="index"
@click="goGoodsDetail(item.skuId, item.goodsId)"
>
<div class="goods-img-box">
<img :src="item.goodsImage" loading="lazy" decoding="async" :alt="item.goodsName" />
<div v-if="isSoldOut(item)" class="sold-out-overlay">
<span>已抢光</span>
</div>
<div class="goods-show-price">
<span class="seckill-price text-danger">{{ $filters.unitPrice(item.price, "¥") }}</span>
<span class="origin-price">{{ $filters.unitPrice(item.originalPrice, "¥") }}</span>
</div>
<div class="goods-show-detail">
<span>{{ item.goodsName }}</span>
<div class="goods-price-row">
<div class="price-main">
<span class="price-symbol">¥</span>
<span class="price-val">{{ $filters.unitPrice(item.price, "") }}</span>
</div>
<div v-if="nowHour >= list[currIndex].timeLine" class="goods-show-num1">
<span>已售{{ salePercent(item) }}%</span>
<div class="price-origin">
{{ $filters.unitPrice(item.originalPrice, "¥") }}
</div>
</div>
<div class="goods-name-row" :title="item.goodsName">
{{ item.goodsName }}
</div>
<div class="goods-progress-row">
<template v-if="nowHour >= list[currIndex].timeLine">
<span class="progress-label">已抢 {{ salePercent(item) }}%</span>
<el-progress
:show-text="false"
:stroke-width="6"
:color="'var(--theme-color, #ff3c2a)'"
:percentage="salePercent(item)"
/>
</template>
<template v-else>
<span class="upcoming-tip">限量抢购 · 准点开抢</span>
</template>
</div>
<div class="goods-show-seller">
<span>{{ item.storeName }}</span>
<div class="goods-store-row" :title="item.storeName">
<span class="store-name">{{ item.storeName }}</span>
</div>
<div
class="goods-seckill-btn"
:class="{ 'is-disabled': !canBuy(item) }"
class="goods-action-btn"
:class="{
'btn-disabled': !canBuy(item),
'btn-upcoming': nowHour < list[currIndex].timeLine,
'btn-active': canBuy(item)
}"
>
{{ buyText(item) }}
</div>
</div>
</div>
</div>
<BaseFooter />
</div>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { seckillByDay } from "@/api/promotion";
import SearchHeader from "@/components/indexDecorate/components/SearchHeader.vue";
const router = useRouter();
@@ -72,47 +136,52 @@ const list = ref([]); // 秒杀时段列表
const goodsList = ref([]); // 商品列表
let interval = null; // 定时器
const currIndex = ref(0); // 当前时间段的下标
const currTime = ref(0); // 当前显示的倒计时
const diffSeconds = ref(0); // 倒计时时间戳
const currTime = ref("00:00:00"); // 当前显示的倒计时格式字符串
const countdownHours = ref("00");
const countdownMinutes = ref("00");
const countdownSeconds = ref("00");
const diffSeconds = ref(0); // 倒计时时间戳差值
const nowHour = ref(new Date().getHours()); // 当前小时数
// 当前选中场次是否处于开抢中
const isCurrentSessionActive = computed(() => {
if (!list.value.length || !list.value[currIndex.value]) return false;
return nowHour.value >= list.value[currIndex.value].timeLine;
});
watch(currIndex, (val) => {
clearInterval(interval);
interval = null;
nowHour.value = new Date().getHours();
countDown(val);
goodsList.value = list.value[val].seckillGoodsList;
goodsList.value = list.value[val]?.seckillGoodsList || [];
});
watch(diffSeconds, (val) => {
const hours = Math.floor(val / 3600);
// 当前秒数 / 60,向下取整
// 获取到所有分钟数 3600 / 60 = 60分钟
// 对60取模,超过小时数的分钟数
const minutes = Math.floor(val / 60) % 60;
// 当前的秒数 % 60,获取到 超过小时数、分钟数的秒数(秒数)
const seconds = val % 60;
currTime.value =
filteTime(hours) + ":" + filteTime(minutes) + ":" + filteTime(seconds);
if (val <= 0) {
clearInterval(interval);
interval = null;
countdownHours.value = "00";
countdownMinutes.value = "00";
countdownSeconds.value = "00";
currTime.value = "00:00:00";
return;
}
function filteTime (time) {
if (time < 10) {
return "0" + time;
} else {
return time;
}
}
const hours = Math.floor(val / 3600);
const minutes = Math.floor(val / 60) % 60;
const seconds = val % 60;
countdownHours.value = hours < 10 ? "0" + hours : String(hours);
countdownMinutes.value = minutes < 10 ? "0" + minutes : String(minutes);
countdownSeconds.value = seconds < 10 ? "0" + seconds : String(seconds);
currTime.value = `${countdownHours.value}:${countdownMinutes.value}:${countdownSeconds.value}`;
});
function getListByDay () {
// 当天秒杀活动
// 当天秒杀活动;result 可能为空数组(当天无活动),需判空避免取下标 0 报错
seckillByDay().then((res) => {
if (res.success) {
if (res.success && res.result && res.result.length) {
list.value = res.result;
goodsList.value = list.value[0].seckillGoodsList;
goodsList.value = list.value[0].seckillGoodsList || [];
countDown(currIndex.value);
}
});
@@ -120,7 +189,7 @@ function getListByDay () {
function goGoodsDetail (skuId, goodsId) {
// 跳转商品详情
let routeUrl = router.resolve({
const routeUrl = router.resolve({
path: "/goodsDetail",
query: { skuId, goodsId },
});
@@ -135,41 +204,46 @@ function salePercent (item) {
// 场次已开始,且销量达到库存
function isSoldOut (item) {
if (!list.value[currIndex.value]) return false;
return nowHour.value >= list.value[currIndex.value].timeLine && item.quantity <= item.salesNum;
}
// 场次已开始且未售罄才可抢
function canBuy (item) {
if (!list.value[currIndex.value]) return false;
return nowHour.value >= list.value[currIndex.value].timeLine && !isSoldOut(item);
}
// 按钮文案:未开始 / 已售罄 / 立即抢购
function buyText (item) {
if (!list.value[currIndex.value]) return "即将开始";
if (nowHour.value < list.value[currIndex.value].timeLine) return "即将开始";
if (isSoldOut(item)) return "已售罄";
return "立即抢购";
}
function countDown (currIndex) {
// 倒计时
// 0点时间戳
let zeroTime = new Date(new Date().toLocaleDateString()).getTime();
let currTime = new Date().getTime();
if (!list.value[currIndex]) return;
// 倒计时:0点时间戳
const zeroTime = new Date(new Date().toLocaleDateString()).getTime();
const currTimeMs = new Date().getTime();
let actTime = 0;
let nowHour = new Date().getHours(); // 当前小时数
if (list.value[currIndex].timeLine > nowHour) {
// 活动未开始
const currentHour = new Date().getHours();
nowHour.value = currentHour;
if (list.value[currIndex].timeLine > currentHour) {
// 活动未开始:距开始时间
actTime = zeroTime + list.value[currIndex].timeLine * 3600 * 1000;
} else if (list.value[currIndex].timeLine <= nowHour) {
// 活动进行中
} else {
// 活动进行中:距结束时间
if (currIndex === list.value.length - 1) {
// 如果是最后一个活动,直到24点结束
actTime = zeroTime + 24 * 3600 * 1000;
} else {
actTime = zeroTime + list.value[currIndex + 1].timeLine * 3600 * 1000;
}
}
diffSeconds.value = Math.floor((actTime - currTime) / 1000);
diffSeconds.value = Math.max(0, Math.floor((actTime - currTimeMs) / 1000));
interval = setInterval(() => {
diffSeconds.value--;
}, 1000);
@@ -183,216 +257,434 @@ onBeforeUnmount(() => {
clearInterval(interval);
});
</script>
<style lang="scss" scoped>
@import "../../assets/styles/goodsList.scss";
<style lang="scss" scoped>
.seckill-list {
background: #f5f5f5;
background: #f7f8fa;
min-height: 100vh;
}
.promotion-decorate {
width: auto;
margin: 28px auto 16px;
color: #171A26;
font-size: 22px;
font-weight: 600;
/* 顶部氛围 Hero 横幅 */
.seckill-hero {
background: linear-gradient(135deg, #e52d27 0%, #b31217 60%, #790c10 100%);
color: #fff;
padding: 30px 0 38px;
box-shadow: inset 0 -4px 12px rgba(0, 0, 0, 0.15);
&::before,
&::after {
display: none;
.hero-inner {
width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.hero-left {
display: flex;
flex-direction: column;
gap: 8px;
.hero-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
background: rgba(255, 255, 255, 0.2);
border-radius: 20px;
font-size: 13px;
width: fit-content;
backdrop-filter: blur(4px);
.lightning {
color: #ffeb3b;
font-weight: bold;
}
}
.hero-title {
margin: 0;
font-size: 34px;
font-weight: 800;
letter-spacing: 2px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
}
.hero-subtitle {
margin: 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.85);
letter-spacing: 1px;
}
}
.hero-countdown {
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 16px 24px;
backdrop-filter: blur(8px);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);
.countdown-badge {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #fff;
font-weight: 500;
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ffc107;
&.is-live {
background: #4caf50;
box-shadow: 0 0 8px #4caf50;
}
}
}
.countdown-label {
font-size: 13px;
color: rgba(255, 255, 255, 0.9);
}
.countdown-boxes {
display: flex;
align-items: center;
gap: 6px;
.time-block {
display: inline-block;
min-width: 38px;
height: 38px;
line-height: 38px;
text-align: center;
background: #1e1e1e;
color: #ffeb3b;
font-size: 20px;
font-weight: 700;
font-family: Consolas, Monaco, monospace;
border-radius: 6px;
padding: 0 4px;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
}
.time-sep {
font-size: 20px;
font-weight: bold;
color: #fff;
}
}
}
}
.time-line {
/* 时间轴导航 */
.time-line-wrap {
width: 1200px;
max-width: 1200px;
height: auto;
margin: 0 auto;
margin: -18px auto 0;
position: relative;
z-index: 10;
.time-line {
list-style: none;
padding: 0;
margin: 0;
background: #fff;
border-radius: 10px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.06);
display: flex;
box-sizing: border-box;
border-radius: 8px;
overflow: hidden;
&.single-session {
justify-content: center;
li {
max-width: 300px;
}
}
li {
flex: 1;
width: auto;
min-height: 78px;
padding: 12px 20px;
min-width: 160px;
max-width: 240px;
height: 68px;
padding: 10px 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 4px;
font-size: 18px;
font-weight: 600;
color: #171A26;
cursor: pointer;
background: #fff;
color: #333;
transition: all 0.2s ease;
position: relative;
box-sizing: border-box;
& + li {
box-shadow: inset 1px 0 0 #f2f2f2;
box-shadow: inset 1px 0 0 #f0f0f0;
}
&:hover {
cursor: pointer;
&:hover:not(.currTimeline) {
background: #fafafa;
color: var(--theme-color, #e52d27);
}
p {
margin: 0;
font-size: 13px;
font-weight: 400;
line-height: 18px;
.timeline-hour {
font-size: 19px;
font-weight: 700;
line-height: 22px;
}
.not-curr {
border: none;
border-radius: 0;
padding: 0;
margin: 0;
font-size: 13px;
font-weight: 400;
color: #999;
.timeline-status {
.status-pill {
display: inline-block;
font-size: 12px;
padding: 1px 8px;
border-radius: 10px;
background: #f5f5f5;
color: #888;
line-height: 16px;
}
}
}
.currTimeline {
background: $theme_color;
background: linear-gradient(135deg, var(--theme-color, #e52d27) 0%, #ff5252 100%);
color: #fff;
box-shadow: 0 4px 12px rgba(229, 45, 39, 0.3);
&:hover {
background: $theme_color;
background: linear-gradient(135deg, var(--theme-color, #e52d27) 0%, #ff5252 100%);
}
> div:nth-child(1) {
font-size: 22px;
line-height: 28px;
.timeline-hour {
font-size: 21px;
}
> div:nth-child(2) {
margin-left: 0;
}
.not-curr {
.timeline-status {
.status-pill {
background: rgba(255, 255, 255, 0.25);
color: #fff;
font-weight: 500;
}
}
}
}
}
/* 秒杀商品容器 */
.seckill-goods-wrap {
width: 1200px;
max-width: 1200px;
margin: 16px auto 40px;
padding: 16px;
box-sizing: border-box;
margin: 20px auto 50px;
background: #fff;
border-radius: 8px;
min-height: 320px;
border-radius: 10px;
padding: 24px;
box-sizing: border-box;
min-height: 380px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
.goods-list {
width: 100%;
margin: 0;
column-gap: 16px;
.empty-wrap {
padding: 60px 0;
}
:deep(.empty) {
margin: 48px 0;
color: #999;
.goods-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
}
.goods-show-info1 {
width: calc(25% - 12px);
min-width: 0;
padding: 12px;
margin: 0 0 16px;
position: relative;
border: 1px solid #f0f0f0;
/* 商品卡片 */
.goods-card {
background: #fff;
border: 1px solid #ebebeb;
border-radius: 8px;
cursor: pointer;
background-color: #fff;
padding: 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.25, 0.8, 0.25, 1);
position: relative;
&:hover {
border-color: #eee;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
border-color: #ffd0d0;
transform: translateY(-4px);
box-shadow: 0 10px 24px rgba(229, 45, 39, 0.12);
}
}
.goods-show-img {
display: flex;
justify-content: center;
.goods-img-box {
position: relative;
width: 100%;
aspect-ratio: 1;
border-radius: 6px;
overflow: hidden;
background: #fafafa;
background: #f9f9f9;
img {
width: 100%;
height: auto;
aspect-ratio: 1;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
}
.goods-show-price {
&:hover img {
transform: scale(1.03);
}
.sold-out-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
span {
display: inline-block;
padding: 6px 14px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.9);
color: #333;
font-size: 13px;
font-weight: 600;
}
}
}
.goods-price-row {
display: flex;
align-items: baseline;
gap: 8px;
margin-top: 10px;
}
margin-top: 12px;
.origin-price {
color: #999;
.price-main {
color: var(--theme-color, #e52d27);
font-weight: 800;
display: flex;
align-items: baseline;
.price-symbol {
font-size: 14px;
}
.price-val {
font-size: 22px;
font-family: Arial, Helvetica, sans-serif;
}
}
.price-origin {
font-size: 13px;
color: #aaa;
text-decoration: line-through;
}
}
}
.goods-show-detail {
.goods-name-row {
margin-top: 8px;
height: 40px;
margin: 8px 0;
color: #333;
font-size: 14px;
line-height: 20px;
}
color: #333;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-all;
font-weight: 500;
}
.goods-show-num1 {
.goods-progress-row {
margin-top: 10px;
height: 20px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
margin-bottom: 8px;
color: #666;
color: #777;
span {
.progress-label {
flex-shrink: 0;
}
:deep(.el-progress) {
flex: 1;
margin: 0;
}
}
.goods-show-seller {
margin-bottom: 10px;
color: #999;
font-size: 13px;
}
.goods-seckill-btn {
margin-top: auto;
height: 34px;
.upcoming-tip {
color: #e67e22;
font-size: 12px;
background: #fff8f0;
padding: 2px 8px;
border-radius: 4px;
color: #fff;
text-align: center;
line-height: 34px;
font-size: 14px;
background-color: $theme_color;
}
}
&.is-disabled {
background: #c8c8c8;
.goods-store-row {
margin-top: 8px;
margin-bottom: 12px;
font-size: 12px;
color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.store-name {
&:hover {
color: var(--theme-color, #e52d27);
}
}
}
.goods-action-btn {
margin-top: auto;
height: 36px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
transition: all 0.2s ease;
user-select: none;
&.btn-active {
background: linear-gradient(135deg, var(--theme-color, #e52d27) 0%, #ff5252 100%);
color: #fff;
box-shadow: 0 4px 10px rgba(229, 45, 39, 0.25);
&:hover {
opacity: 0.92;
box-shadow: 0 6px 14px rgba(229, 45, 39, 0.35);
}
}
&.btn-upcoming {
background: #ff9800;
color: #fff;
&:hover {
opacity: 0.92;
}
}
&.btn-disabled {
background: #e0e0e0;
color: #888;
cursor: not-allowed;
box-shadow: none;
}
}
}
</style>

View File

@@ -1,4 +1,5 @@
import axios from "axios";
import { markHandledRequestError, isHandledRequestError } from "@/utils/requestError";
import { ElLoading } from "element-plus";
import { Message, Modal } from "@/utils/message";
import Storage from "./storage";
@@ -14,7 +15,7 @@ export const buyerUrl =
? BASE.API_DEV.buyer
: BASE.API_PROD.buyer;
let isRefreshToken = 0;
let loginPrompt = null;
const refreshToken = getTokenDebounce();
/** 会员中心(/home/*)页面使用局部 loading,不启用全屏遮罩 */
@@ -37,20 +38,22 @@ const service = axios.create({
});
service.interceptors.request.use(
(config) => {
async (config) => {
const { loading } = config;
const isPutPost = config.method === "put" || config.method === "post";
const isJson = config.headers["Content-Type"] === "application/json";
const isFile = config.headers["Content-Type"] === "multipart/form-data";
if (isPutPost && isJson) {
if (!config._dataSerialized && isPutPost && isJson) {
config.data = JSON.stringify(config.data);
}
if (isPutPost && !isFile && !isJson) {
if (!config._dataSerialized && isPutPost && !isFile && !isJson) {
config.data = qs.stringify(config.data, {
arrayFormat: "repeat",
});
}
config._dataSerialized = true;
const method = (config.method || "get").toLowerCase();
const isReadRequest = method === "get" || method === "head";
const shouldShowLoading =
@@ -58,7 +61,7 @@ service.interceptors.request.use(
(!isReadRequest && loading !== false && !isMemberCenterRoute());
if (typeof window !== "undefined" && shouldShowLoading) {
config.loading = ElLoading.service({ fullscreen: true });
config._loadingInstance = ElLoading.service({ fullscreen: true });
}
let uuid = Storage.getItem("uuid");
@@ -82,7 +85,7 @@ service.interceptors.request.use(
const expired = jwtData.exp < Math.round(new Date() / 1000);
if (expired) {
if (config.needToken) {
return refresh({ response: { config } });
return refresh(config);
}
// 公开接口:丢弃过期 token,匿名继续请求,避免浏览商品时被引导登录
Storage.removeItem("accessToken");
@@ -94,7 +97,7 @@ service.interceptors.request.use(
Storage.removeItem("accessToken");
Storage.removeItem("refreshToken");
if (config.needToken) {
return refresh({ response: { config } });
return refresh(config);
}
}
}
@@ -104,59 +107,64 @@ service.interceptors.request.use(
(error) => Promise.reject(error)
);
/** 保留请求配置、状态与错误码,供页面判断业务失败并定位原因。 */
function buildRejectError(error, errorData = {}) {
const message =
errorData.message ||
(error?.code === "ECONNABORTED" ? "连接超时,请稍候再试!" : "网络错误,请稍后再试!");
return Object.assign(new Error(message), { data: errorData });
return Object.assign(new Error(message), { data: errorData, config: error.config, response: error.response, code: error.code });
}
async function refresh(error) {
const getTokenRes = await refreshToken();
if (getTokenRes === "success") {
if (error?.response?.config) {
error.response.config.headers.accessToken = Storage.getItem("accessToken");
return service(error.response.config);
/**
* 刷新凭证并返回请求配置;请求拦截器不能返回业务响应。每个业务请求最多刷新一次。
* 刷新失败时保持 Promise 拒绝,阻止后续支付、订单等成功回调继续执行。
*/
async function refresh(config) {
if (!config._retryAfterRefresh) {
config._retryAfterRefresh = true;
const result = await refreshToken();
if (result === "success") {
config.headers.accessToken = Storage.getItem("accessToken");
return config;
}
router.go(0);
return new Promise(() => {});
}
Storage.removeItem("accessToken");
Storage.removeItem("refreshToken");
Storage.removeItem("userInfo");
Storage.setItem("cartNum", 0);
useAppStore().setCartNum(0);
// 多个受保护接口同时失败时共用一个弹窗,关闭后允许再次提示。
if (!loginPrompt) {
const currentRoute = router.currentRoute.value;
Modal.confirm({
loginPrompt = Modal.confirm({
title: "请登录",
content: "请登录后执行此操作",
okText: "立即登录",
cancelText: "继续浏览",
onOk: () => {
router.push({
onOk: () => router.push({
path: "/login",
query: {
rePath: currentRoute.path,
query: JSON.stringify(currentRoute.query),
},
});
},
onCancel: () => {
router.push("/");
Modal.remove();
},
});
return Promise.reject(new Error("登录已过期"));
query: { rePath: currentRoute.path, query: JSON.stringify(currentRoute.query) },
}),
onCancel: () => router.push("/"),
}).finally(() => { loginPrompt = null; });
}
const error = Object.assign(new Error("登录已过期"), { code: "AUTH_EXPIRED", config });
throw markHandledRequestError(error);
}
service.interceptors.response.use(
async (response) => {
await closeLoading(response);
const config = response.config;
if (!config.skipAuthHandler && [401, 403, 20004].includes(response.data?.code)) {
return service(await refresh(config));
}
return response.data;
},
async (error) => {
if (typeof window === "undefined") return Promise.reject(error);
await closeLoading(error);
if (isHandledRequestError(error)) return Promise.reject(error);
const errorResponse = error.response || {};
const errorData = errorResponse.data || {};
@@ -166,32 +174,26 @@ service.interceptors.response.use(
errorResponse.status === 403 ||
error.response?.data?.code === 20004)
) {
isRefreshToken++;
if (isRefreshToken === 1) {
isRefreshToken = 0;
return refresh(error);
return service(await refresh(error.config || errorResponse.config));
}
return Promise.reject(buildRejectError(error, errorData));
if (!axios.isAxiosError(error) || axios.isCancel(error)) return Promise.reject(error);
const rejected = buildRejectError(error, errorResponse.status === 404
? { ...errorData, message: "请求的资源不存在" }
: errorData);
if (!error.config?.skipMessage && axios.isAxiosError(error) && !axios.isCancel(error)) {
Message.error(rejected.message);
markHandledRequestError(rejected);
}
if (errorResponse.status === 404) {
return Promise.reject(buildRejectError(error, { message: "请求的资源不存在" }));
}
if (error.message && !error.config?.skipMessage) {
const _message =
error.code === "ECONNABORTED"
? "连接超时,请稍候再试!"
: "网络错误,请稍后再试!";
Message.error(errorData.message || _message);
}
return Promise.reject(buildRejectError(error, errorData));
return Promise.reject(rejected);
}
);
const closeLoading = (target) => {
if (!target.config || !target.config.loading) return true;
if (typeof target.config?._loadingInstance?.close !== "function") return true;
return new Promise((resolve) => {
setTimeout(() => {
target.config.loading.close();
target.config._loadingInstance.close();
delete target.config._loadingInstance;
resolve();
}, 200);
});
@@ -208,42 +210,22 @@ export default function request(options) {
return service(options);
}
/** 并发刷新共用同一个 Promise;不轮询,不保留上次刷新结果,失败后允许下次重试。 */
function getTokenDebounce() {
let lock = false;
let success = false;
let pending = null;
return function () {
if (!lock) {
const oldRefreshToken = Storage.getItem("refreshToken");
// 无 refreshToken(未登录)时不发起刷新请求,直接判失败,避免请求 /refresh/undefined 造成拦截器死锁
if (!oldRefreshToken) {
return Promise.resolve("fail");
}
lock = true;
handleRefreshToken(oldRefreshToken)
if (pending) return pending;
const token = Storage.getItem("refreshToken");
if (!token) return Promise.resolve("fail");
pending = handleRefreshToken(token)
.then((res) => {
if (res.success) {
const { accessToken, refreshToken: newRefreshToken } = res.result;
Storage.setItem("accessToken", accessToken);
Storage.setItem("refreshToken", newRefreshToken);
success = true;
lock = false;
} else {
success = false;
lock = false;
}
if (!res.success || !res.result?.accessToken || !res.result?.refreshToken) return "fail";
Storage.setItem("accessToken", res.result.accessToken);
Storage.setItem("refreshToken", res.result.refreshToken);
return "success";
})
.catch(() => {
success = false;
lock = false;
});
}
return new Promise((resolve) => {
const timer = setInterval(() => {
if (!lock) {
clearInterval(timer);
resolve(success ? "success" : "fail");
}
}, 500);
});
.catch(() => "fail")
.finally(() => { pending = null; });
return pending;
};
}

View File

@@ -200,6 +200,12 @@ export const router = createRouter({
component: MoneyManagement,
meta: { title: "我的余额" },
},
{
path: "PickupCard",
name: "PickupCard",
component: () => import("@/pages/home/userCenter/PickupCard.vue"),
meta: { title: "提货卡兑换" },
},
{
path: "MyGiftCards",
name: "MyGiftCards",
@@ -273,6 +279,10 @@ export const router = createRouter({
component: MemberGrade,
meta: { title: "我的等级" },
},
{
path: "UserInfo",
redirect: { name: "Profile" },
},
{
path: "Profile",
name: "Profile",
@@ -301,6 +311,11 @@ export const router = createRouter({
name: "AddAddress",
component: AddAddress,
},
{
// 兼容已保存的装修顶部菜单中的旧消息地址,统一进入现有消息列表。
path: "Msg",
redirect: { name: "MsgList" },
},
{
path: "MsgList",
name: "MsgList",

View File

@@ -0,0 +1,40 @@
/**
* 已向用户提示的请求异常兜底;只识别本模块登记的错误,不屏蔽代码异常。
* @author Bulbasaur
* @since 2026-11-09
*/
const handledErrors = new WeakSet();
/** 登记已由请求层提示的错误,仍保持原请求的拒绝语义。 */
export function markHandledRequestError(error) {
handledErrors.add(error);
// 开发服务器的错误遮罩有独立监听器,通过同一 Symbol 识别已提示的异常。
Object.defineProperty(error, Symbol.for("lilishop.buyer.handledRequestError"), { value: true });
return error;
}
/** 判断错误是否已由请求层向用户展示。 */
export function isHandledRequestError(error) {
return !!error && typeof error === "object" && handledErrors.has(error);
}
/** 页面处理预期请求失败;程序错误继续向上抛出,避免被静默吞掉。 */
export function catchHandledRequestError(error) {
if (!isHandledRequestError(error)) throw error;
}
/**
* 覆盖未返回 Promise 的事件回调及 Vue 异步钩子;只有已提示的请求异常免于重复报错。
* 未登记异常沿用已有 Vue 错误处理,无处理器时重新抛出供开发环境定位。
*/
export function setupRequestErrorHandling(app, target = window) {
target.addEventListener("unhandledrejection", (event) => {
if (isHandledRequestError(event.reason)) event.preventDefault();
});
const previous = app.config.errorHandler;
app.config.errorHandler = (error, instance, info) => {
if (isHandledRequestError(error)) return;
if (previous) return previous(error, instance, info);
throw error;
};
}

View File

@@ -69,12 +69,19 @@ export function normalizeTheme(data = {}) {
};
}
let pendingThemeRequest = null;
export async function fetchAndApplyTheme(requestFn) {
const cachedTheme = loadCachedTheme();
if (cachedTheme) {
applyThemeToDocument(normalizeTheme(cachedTheme));
}
if (pendingThemeRequest) {
return pendingThemeRequest;
}
pendingThemeRequest = (async () => {
try {
const res = await requestFn();
if (res?.success && res?.result?.settingValue) {
@@ -89,7 +96,12 @@ export async function fetchAndApplyTheme(requestFn) {
"[theme] 主题色加载失败,请确认 buyer-api 已启动且站点主题配置可用",
error
);
} finally {
pendingThemeRequest = null;
}
return cachedTheme ? normalizeTheme(cachedTheme) : normalizeTheme();
})();
return pendingThemeRequest;
}

View File

@@ -0,0 +1,194 @@
/**
* 买家端请求认证回归:验证真实 Axios 拦截链、并发刷新和已提示异常边界。
* @author Bulbasaur
* @since 2026-11-09
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const axios = require('axios');
/** 加载业务源码,替换浏览器与界面依赖;请求拦截和重试仍使用真实 Axios。 */
function harness({ expired = false, refresh = async () => ({ success: false }), adapter } = {}) {
const values = new Map();
const jwt = (exp) => 'header.' + Buffer.from(JSON.stringify({ exp })).toString('base64url') + '.sig';
if (expired) values.set('accessToken', jwt(1));
values.set('refreshToken', 'refresh-old');
const Storage = { getItem: k => values.get(k), setItem: (k,v) => values.set(k,v), removeItem: k => values.delete(k) };
const helperSource = fs.readFileSync(path.join(__dirname, '../src/utils/requestError.js'), 'utf8').replace(/export /g, '');
const helper = new Function(helperSource + '; return { markHandledRequestError, isHandledRequestError, catchHandledRequestError, setupRequestErrorHandling };')();
let prompts = 0, refreshCalls = 0, closes = 0, opens = 0;
const messages = [], sent = [];
let dismiss, promptOptions;
const Modal = { confirm(options) { prompts++; promptOptions = options; return new Promise(resolve => { dismiss = resolve; }); } };
const router = { currentRoute: { value: { path: '/home/MyOrder', query: { tag: 'ALL' } } }, push: async () => {} };
const axiosProxy = { ...axios, create(options) { return axios.create({ ...options, adapter: async config => {
sent.push(config);
return adapter ? adapter(config, sent.length) : { data: { success: true }, status: 200, config, headers: {} };
} }); } };
const source = fs.readFileSync(path.join(__dirname, '../src/plugins/request.js'), 'utf8')
.replace(/^import .*;\r?\n/gm, '').replace(/export default function request/, 'function request').replace(/export const /g, 'const ');
const names = ['axios','markHandledRequestError','isHandledRequestError','ElLoading','Message','Modal','Storage','router','useAppStore','handleRefreshToken','uuidv4','require','process','BASE','window','setTimeout'];
const request = new Function(...names, source + '; return request;')(
axiosProxy, helper.markHandledRequestError, helper.isHandledRequestError,
{ service() { opens++; return { close() { closes++; } }; } }, { error: m => messages.push(m) }, Modal, Storage, router,
() => ({ setCartNum() {} }), async () => { refreshCalls++; return refresh(jwt); }, () => 'uuid', require,
{ env: { NODE_ENV: 'development' } }, { API_DEV: { buyer: '/api' } }, { atob: v => Buffer.from(v,'base64').toString('binary') }, fn => fn()
);
return { request, values, helper, sent, messages, get prompts() { return prompts; }, get refreshCalls() { return refreshCalls; }, get opens() { return opens; }, get closes() { return closes; }, dismiss: () => dismiss(), get promptOptions() { return promptOptions; } };
}
/** 构造后端拒绝登录的 HTTP 响应。 */
function unauthorized(config) {
throw new axios.AxiosError('unauthorized', 'ERR_BAD_REQUEST', config, null, { status: 401, data: {}, config });
}
test('并发过期请求只刷新一次,返回配置并各发送一次业务请求', async () => {
const h = harness({ expired: true, refresh: async jwt => {
await new Promise(resolve => setImmediate(resolve));
return { success: true, result: { accessToken: jwt(4000000000), refreshToken: 'new-refresh' } };
} });
const results = await Promise.all([1,2,3].map(() => h.request({ url: '/orders', needToken: true })));
assert.equal(h.refreshCalls, 1);
assert.equal(h.sent.length, 3);
assert.ok(results.every(r => r.success));
assert.equal(h.prompts, 0);
});
test('刷新失败保持拒绝、只弹一次登录框并清理身份与加载', async () => {
const h = harness({ expired: true });
let successes = 0;
const results = await Promise.allSettled([1,2,3].map(() => h.request({ url: '/orders', needToken: true, loading: true }).then(() => { successes++; })));
assert.equal(successes, 0);
assert.equal(h.sent.length, 0);
assert.equal(h.prompts, 1);
assert.equal(h.closes, h.opens);
assert.equal(h.values.get('accessToken'), undefined);
assert.ok(results.every(r => r.status === 'rejected' && h.helper.isHandledRequestError(r.reason)));
assert.equal(h.messages.length, 0);
h.dismiss();
await new Promise(resolve => setImmediate(resolve));
await assert.rejects(h.request({ url: '/orders', needToken: true, adapter: unauthorized }));
assert.equal(h.prompts, 2);
assert.equal(h.refreshCalls, 1);
});
test('401 重试上限防止刷新死循环,POST 请求体保持一致', async () => {
const h = harness({ adapter: unauthorized, refresh: async jwt => ({ success: true, result: { accessToken: jwt(4000000000), refreshToken: 'new' } }) });
await assert.rejects(h.request({ url: '/orders', method: 'post', data: { amount: 12 }, headers: { 'Content-Type': 'application/json' }, needToken: true, loading: true }), e => e.code === 'AUTH_EXPIRED');
assert.equal(h.refreshCalls, 1);
assert.equal(h.sent.length, 2);
assert.equal(h.sent[0].data, '{"amount":12}');
assert.equal(h.sent[1].data, h.sent[0].data);
assert.equal(h.opens, h.closes);
assert.equal(h.prompts, 1);
});
test('HTTP 200 内认证失败进入同一处理,刷新接口不递归', async () => {
const h = harness({ adapter: async config => ({ config, status: 200, data: { code: 20004 }, headers: {} }) });
await assert.rejects(h.request({ url: '/orders' }), e => e.code === 'AUTH_EXPIRED');
const skipped = await h.request({ url: '/refresh', skipAuthHandler: true, skipMessage: true });
assert.equal(skipped.code, 20004);
assert.equal(h.refreshCalls, 1);
});
test('网络失败只提示一次,skipMessage 与程序错误不登记为已处理', async () => {
const h = harness({ adapter: config => { throw new axios.AxiosError('timeout', 'ECONNABORTED', config); } });
await assert.rejects(h.request({ url: '/orders' }), e => h.helper.isHandledRequestError(e));
assert.deepEqual(h.messages, ['连接超时,请稍候再试!']);
await assert.rejects(h.request({ url: '/refresh', skipMessage: true }), e => !h.helper.isHandledRequestError(e));
const bug = new TypeError('bug');
const b = harness({ adapter: () => { throw bug; } });
await assert.rejects(b.request({ url: '/orders' }), e => e === bug);
});
test('全局兜底和开发遮罩仅过滤已提示错误,未知错误仍上报', () => {
const h = harness();
const handled = h.helper.markHandledRequestError(new Error('登录已过期'));
const bug = new TypeError('bug');
const listeners = {};
const app = { config: {} };
h.helper.setupRequestErrorHandling(app, { addEventListener: (name, fn) => { listeners[name] = fn; } });
let prevented = 0;
listeners.unhandledrejection({ reason: handled, preventDefault: () => prevented++ });
listeners.unhandledrejection({ reason: bug, preventDefault: () => prevented++ });
assert.equal(prevented, 1);
assert.doesNotThrow(() => app.config.errorHandler(handled));
assert.throws(() => app.config.errorHandler(bug), e => e === bug);
assert.throws(() => h.helper.catchHandledRequestError(bug), e => e === bug);
const filter = require('../vue.config.js').devServer.client.overlay.runtimeErrors;
assert.equal(filter(handled), false);
assert.equal(filter(bug), true);
});
test('未登录不刷新 undefined,公开接口保持匿名可访问', async () => {
const h = harness({ adapter: config => {
if (config.needToken) return unauthorized(config);
return { config, status: 200, data: { success: true }, headers: {} };
} });
h.values.delete('refreshToken');
await assert.rejects(h.request({ url: '/orders', needToken: true }), e => e.code === 'AUTH_EXPIRED');
const result = await h.request({ url: '/goods', optionalToken: true });
assert.equal(result.success, true);
assert.equal(h.refreshCalls, 0);
assert.equal(h.prompts, 1);
});
test('HTTP 401 并发共用刷新,表单 POST 重试不重复编码', async () => {
const h = harness({
adapter: config => {
if (!config.headers.accessToken) return unauthorized(config);
return { config, status: 200, data: { success: true }, headers: {} };
},
refresh: async jwt => {
await new Promise(resolve => setImmediate(resolve));
return { success: true, result: { accessToken: jwt(4000000000), refreshToken: 'new' } };
},
});
await Promise.all([1,2].map(() => h.request({ url: '/orders', method: 'post', data: { name: 'a b' }, needToken: true })));
assert.equal(h.refreshCalls, 1);
assert.equal(h.sent.length, 4);
assert.ok(h.sent.every(c => c.data === 'name=a%20b'));
});
/** 从真实 Vue 页面提取查询函数,验证失败收尾与请求乱序,界面依赖用响应式状态桩替换。 */
function listHarness(file, apiName) {
const { parse } = require('@vue/compiler-sfc');
const parser = require('@babel/parser');
const source = parse(fs.readFileSync(path.join(__dirname, '../src/pages/home', file), 'utf8')).descriptor.scriptSetup.content;
const declaration = parser.parse(source, { sourceType: 'module' }).program.body.find(n => n.type === 'FunctionDeclaration' && n.id.name === 'getList');
const state = { spinShow: { value: false }, queryFailed: { value: false }, orderList: { value: [] }, list: { value: [] }, total: { value: 0 } };
const calls = [];
const api = () => new Promise((resolve, reject) => calls.push({ resolve, reject }));
const getList = new Function(...Object.keys(state), 'params', apiName, 'catchHandledRequestError',
'let listRequestVersion = 0; ' + source.slice(declaration.start, declaration.end) + '; return getList;')(
...Object.values(state), { pageNumber: 1 }, api, () => {}
);
return { ...state, calls, getList };
}
for (const [file, api] of [['orderCenter/MyOrder.vue','getOrderList'], ['orderCenter/AfterSale.vue','afterSaleList'], ['userCenter/MyTracks.vue','tracksList']]) {
test(file + ' 失败结束加载并可重试,旧查询不能覆盖新列表', async () => {
const h = listHarness(file, api);
h.getList();
assert.equal(h.spinShow.value, true);
h.calls[0].reject(new Error('network'));
await new Promise(resolve => setImmediate(resolve));
assert.equal(h.spinShow.value, false);
assert.equal(h.queryFailed.value, true);
h.getList();
h.getList();
h.calls[2].resolve({ success: true, result: { records: [{ id: 'new' }], total: 1 } });
await new Promise(resolve => setImmediate(resolve));
h.calls[1].resolve({ success: true, result: { records: [{ id: 'old' }], total: 20 } });
await new Promise(resolve => setImmediate(resolve));
assert.equal(h.spinShow.value, false);
assert.equal(h.queryFailed.value, false);
assert.equal(h.total.value, 1);
const list = file.includes('MyTracks') ? h.list : h.orderList;
assert.deepEqual(list.value, [{ id: 'new' }]);
});
}

View File

@@ -33,6 +33,12 @@ module.exports = {
},
devServer: {
port: configs.port,
client: {
overlay: {
// 保留编译错误和程序异常遮罩,仅跳过请求层已向用户提示的错误。
runtimeErrors: (error) => error?.[Symbol.for("lilishop.buyer.handledRequestError")] !== true,
},
},
},
productionSourceMap: false,
configureWebpack: {

View File

@@ -1,42 +0,0 @@
# IM 客服端 Vue 3 迁移说明
## 技术栈
- Vue 3.5 + Vue Router 4 + Pinia 4
- Element Plus(替代 element-ui)
- 自研右键菜单 `src/plugins/contextmenu.js`(替代 vue-contextmenujs)
- `vue-virtual-scroller@3`、`vue-cropper@1.x`、`vue-prism-editor@2.0.0-alpha.2`
## 组合式 API 迁移(2026-10 完成)
全部 53 个 `.vue` 组件已从 Options API 转换为 `<script setup>`,并完成 vuex → pinia:
- **状态管理**:`src/stores/` 下 8 个 store(app/user/talk/notify/settings/emoticon/dialogue/note),对应原 vuex 根 state + 7 个模块;mutation 全部转为同名 SCREAMING_CASE action。原 `src/store/` 已删除。
- **全局 mixin 保留**:`main.js` 的 `app.mixin(MainMixin)` 依然生效(对 `<script setup>` 组件同样注入),mixin 内部已改用 pinia。**注意**:mixin 经 `this` 回调组件成员时,`<script setup>` 的绑定不会出现在公共实例上——`views/message/index.vue` 采用了 ctx 桥接(`Object.defineProperties(instance.ctx, ...)`)方案,后续若有组件被全局 mixin 回调需用同样方案。
- **socket 事件层**(`im-server/event/*`、`plugins/ws-socket.js`、`utils/talk.js` 等纯 JS):vuex 引用全部改为函数内部 `useXxxStore()`。
- **$user() 名片弹层**(`components/user/user-card/index.js`):独立子 app 改用独立 `createPinia()`。
- **app-bridge.js**:保留了 `$store.state.talks.items` 兼容门面(惰性 getter),供旧调用形态使用。
## 启动
```bash
cd im
yarn install
yarn dev # 端口 8000
yarn build
```
## 迁移要点
1. **入口** `src/main.js` — `createApp`,全局过滤器 `$filters`
2. **路由** `createRouter` + `createWebHistory`,404 路由 `/:pathMatch(.*)*`
3. **通知** `im-server/event/talk.js` 通过 `app-bridge` 发 `ElNotification`,避免循环引用
4. **用户卡片** `$user()` 插件用 `createApp` 挂载
5. **指令** `v-focus` / `v-paste` / `v-drag` / `v-outside` 已改为 Vue 3 钩子
6. **SVG** `svg-sprite-loader` + `svg-icon` 全局组件
7. **生命周期** `destroyed` → `beforeUnmount`;`$set` / `$delete` 已移除
## 上线前检查
统一清单见仓库根目录:[../MIGRATION-VUE3.md](../MIGRATION-VUE3.md)
IM 专项见该文档 **「IM 客服端」** 一节。

View File

@@ -1,4 +1,5 @@
import { post, get, upload, del } from "@/utils/request";
import config from "@/config/config";
// 获取聊天列表服务接口
export const ServeGetTalkList = (data) => {
@@ -69,9 +70,9 @@ export const ServeSendTalkFile = (data) => {
return post("/im/talk/message/file", data);
};
// 发送聊天图片服务接口
// 上传客服图片到独立 OSS;FormData 必须包含会话 ID,店铺及目录由后端推导。
export const ServeSendTalkImage = (data) => {
return upload("/buyer/common/upload/file", data);
return upload("/im/message/image", data, { apiBase: config.BASE_API_URL });
};
// 发送表情包服务接口

View File

@@ -1,11 +1,11 @@
<template>
<div class="image-message no-select">
<el-image
fit="cover"
fit="contain"
:src="src"
:lazy="true"
:style="getImgStyle(src)"
:preview-src-list="[src]"
preview-teleported
>
<template #error>
<div class="image-slot">图片加载失败...</div>
@@ -29,6 +29,10 @@ defineProps({
})
function getImgStyle(url) {
// 商城上传地址不携带尺寸,提供可见的预览区域;原尺寸后缀仍沿用旧展示规则。
if (!/\d+x\d+/.test(url.split('_').pop())) {
return { width: '200px', height: '200px', maxWidth: '100%' }
}
return imgZoom(url, 200)
}
</script>

View File

@@ -295,7 +295,7 @@ function formatDateToString(date) {
// margin-left: 12px;
}
:deep(.el-tabs__nav-scroll) {
min-width: 362px;
min-width: 0;
}
:deep(.el-tabs__item) {
height: 100%;

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex">
<el-container class="ov-hidden flex-10 full-height ">
<el-container class="ov-hidden flex-10 full-height chat-main">
<PanelHeader ref="panelHeader" :data="params" :online="isOnline" :keyboard="inputEvent"
@event="handleHeaderEvent" />
<!-- 主体信息 -->
@@ -65,7 +65,7 @@
<pre v-if="!emojistwo.includes(item.text)" v-html="item.text" />
<pre v-if="emojistwo.includes(item.text)" v-html="textReplaceEmoji(item.text)" />
</div>
<ImageMessage v-if="item.messageType === 'PICTURE'" :src="item.text" />
</div>
<div v-if="item.webSocketStatus" class="tips">网络异常发送失败,请重新发送。</div>
@@ -163,7 +163,7 @@
<!-- 页脚信息 -->
<el-footer class="footer-box" height="180">
<template v-if="multiSelect.isOpen === false">
<MeEditor @send="submitSendMessage" @keyboard-event="onKeyboardEvent" />
<MeEditor :talk-id="params.talkId" @send="submitSendMessage" @send-image="submitSendImage" @keyboard-event="onKeyboardEvent" />
</template>
<template v-else>
<PanelToolbar v-model="multiSelect.items.length" @event="handleMultiMode" />
@@ -185,7 +185,7 @@
</transition>
<!-- 链接信息 -->
<OtherLink :toUser="toUser" :id="id" :goodsParams="goodsParams" class="flex-4" />
<OtherLink :toUser="toUser" :id="id" :goodsParams="goodsParams" class="chat-sidebar" />
</div>
</template>
<script setup>
@@ -202,6 +202,7 @@ import { textReplaceEmoji, emojistwo } from "@/utils/emojis";
import OtherLink from "@/components/chat/panel/OtherLink.vue";
import TalkSearchRecord from "@/components/chat/TalkSearchRecord";
import MeEditor from "@/components/editor/MeEditor";
import ImageMessage from "@/components/chat/messaege/ImageMessage.vue";
import PanelHeader from "./PanelHeader";
import PanelToolbar from "./PanelToolbar";
import SocketInstance from "@/im-server/socket-instance";
@@ -414,16 +415,21 @@ function formateDateAndTimeToString(date) {
// 重新发送消息
function againSendMessage(val) {
submitSendMessage(val.text)
submitSendMessage(val.text, val.messageType)
}
// 回车键发送消息回调事件
function submitSendMessage(content) {
// 发送已上传图片,复用消息发送和失败重发流程。
function submitSendImage(url) {
submitSendMessage(url, "PICTURE");
}
// 发送文本或图片,重发保持原类型,发送图片时保留文字草稿。
function submitSendMessage(content, messageType = "MESSAGE") {
const record = {
operation_type: "MESSAGE",
to: props.params.receiver_id,
from: id.value,
message_type: "MESSAGE",
message_type: messageType,
context: content,
talk_id: props.params.talkId,
};
@@ -434,7 +440,7 @@ function submitSendMessage(content) {
talkStore.UPDATE_TALK_ITEM({
index_name: index_name.value,
draft_text: "",
...(messageType === "MESSAGE" ? { draft_text: "" } : {}),
});
/**
@@ -445,7 +451,8 @@ function submitSendMessage(content) {
fromUser: id.value,
toUser: record.to,
isRead: false,
messageType: "MESSAGE",
messageType,
talkId: record.talk_id,
text: content,
float: "right",
};
@@ -465,7 +472,7 @@ function submitSendMessage(content) {
});
} else {
talkStore.SET_TLAK_UNREAD_MESSAGE({
content: content,
content: messageType === "PICTURE" ? "[图片]" : content,
nickname: record.name,
});
}
@@ -889,6 +896,17 @@ function talkPanelScrollBottom() {
}
</script>
<style lang="less" scoped>
/* 固定侧栏宽度,防止 tab 内容最小宽度挤压聊天区域。 */
.chat-main {
flex: 1 1 0;
min-width: 0;
}
.chat-sidebar {
flex: 0 0 362px;
width: 362px;
min-width: 0;
overflow: hidden;
}
.flex-a-c{
align-items: center;
>div{

View File

@@ -21,11 +21,11 @@
<legacy-el-icon name="el-icon-headset" />
<p class="tip-title">语音消息</p>
</li> -->
<!-- #TODO 发图片功能暂时隐藏 此处会涉及到token过期 -->
<!-- <li @click="$refs.restFile.click()">
<li role="button" tabindex="0" aria-label="发送图片" @click="restFile.click()"
@keydown.enter="restFile.click()" @keydown.space.prevent="restFile.click()">
<legacy-el-icon name="el-icon-picture-outline-round" />
<p class="tip-title">图片</p>
</li> -->
</li>
<!-- <li @click="$refs.restFile2.click()">
<legacy-el-icon name="el-icon-folder" />
<p class="tip-title">附件</p>
@@ -117,8 +117,12 @@ import {
} from "@/api/chat";
defineOptions({ name: "MeEditor" });
const props = defineProps({
// 后端 li_im_talk.id;不使用接收方 ID 代替会话 ID,避免图片存入错误会话。
talkId: { type: String, default: "" },
});
const emit = defineEmits(["keyboard-event", "send"]);
const emit = defineEmits(["keyboard-event", "send", "send-image"]);
const dialogueStore = useDialogueStore();
@@ -141,6 +145,10 @@ const imageViewer = reactive({
isShow: false,
file: null,
});
// 预览绑定原会话,上传期间切换会话后不发送,避免图片误发。
let imageTalk = null;
let imageTalkId = null;
let uploadingImage = false;
const codeBlock = reactive({
isShow: false,
@@ -165,6 +173,8 @@ const sendtime = ref(0);
const interval = ref(1000);
watch(talkUser, (n_index_name) => {
imageViewer.isShow = false;
imageViewer.file = null;
fileManageRef.value?.clear();
editorText.value = getDraftText(n_index_name);
});
@@ -253,6 +263,21 @@ function uploadFileChange(e) {
// 打开图片查看器
function openImageViewer(file) {
if (!file || uploadingImage) return;
if (!props.talkId) {
ElNotification.warning({ message: "会话尚未就绪,请稍后重试" });
return;
}
if (!/^image\/(png|jpeg|gif|webp)$/i.test(file.type) || !file.size) {
ElNotification.warning({ message: "请选择有效的 PNG、JPG、GIF 或 WebP 图片" });
return;
}
if (file.size > 10 * 1024 * 1024) {
ElNotification.warning({ message: "图片不能大于10MB" });
return;
}
imageTalk = talkUser.value;
imageTalkId = props.talkId;
imageViewer.isShow = true;
imageViewer.file = file;
}
@@ -278,29 +303,34 @@ function confirmCodeBlock(data) {
});
}
// 确认上传图片消息回调事件
function confirmUploadImage() {
let fileData = new FormData();
// 上传返回地址后通过现有消息通道发送;失败保留预览以便重试。
async function confirmUploadImage() {
if (uploadingImage) return;
const imageView = imageViewerRef.value;
const targetTalk = imageTalk;
const targetTalkId = imageTalkId;
uploadingImage = true;
try {
const fileData = new FormData();
fileData.append("file", imageViewer.file);
let imageView = imageViewerRef.value;
ServeSendTalkImage(fileData)
.then((res) => {
imageView.loading = false;
if (res.code == 200) {
imageView.closeBox();
} else {
ElNotification({
title: "友情提示",
message: res.message,
type: "warning",
});
fileData.append("talkId", targetTalkId);
const res = await ServeSendTalkImage(fileData);
if (res.code !== 200 || typeof res.result !== "string" || !/^https?:\/\//i.test(res.result)) {
throw new Error(res.message || "图片上传失败,请重试");
}
})
.finally(() => {
if (targetTalk !== talkUser.value || targetTalkId !== props.talkId || !imageViewer.isShow) {
ElNotification.warning({ message: "会话已切换,图片未发送,请在目标会话重新选择" });
return;
}
emit("send-image", res.result);
imageView.loading = false;
});
imageView.closeBox();
} catch (error) {
ElNotification.error({ message: error.message || "图片上传失败,请重试" });
} finally {
uploadingImage = false;
imageView.loading = false;
}
}
// 选中表情包回调事件

View File

@@ -62,7 +62,10 @@ function closeBox() {
emit('close', false)
}
// 清理切换会话后的预览,仅对有效文件读取内容。
function loadFile(file) {
src.value = ''
if (!file) return
let reader = new FileReader()
fileSize.value = Math.ceil(file.size / 1024)
fileName.value = file.name
@@ -75,6 +78,7 @@ function loadFile(file) {
// 确认按钮事件
function uploadImage() {
if (loading.value || !src.value) return
loading.value = true
emit('confirm')
}

View File

@@ -43,7 +43,8 @@ class Talk extends Base {
super();
this.sender_id = resource.fromUser; //发送
this.receiver_id = resource.toUser; //接收
this.talk_type = resource.messageType; //类型
// 后端推送的 messageType 是消息内容类型(MESSAGE/PICTURE/GOODS/ORDERS),私聊会话类型固定为 1
this.talk_type = Number(resource.talk_type) || 1;
this.resource = resource;
@@ -93,12 +94,22 @@ class Talk extends Base {
return userId == this.getAccountId() ? "right" : "left";
}
/**
* 获取消息内容类型(MESSAGE/PICTURE/GOODS/ORDERS)
*/
getMessageType () {
return this.resource.messageType || this.resource.msg_type || "MESSAGE";
}
/**
* 获取聊天列表左侧的对话信息
*/
getTalkText () {
let text = this.resource.content || this.resource.text;
switch (this.resource.msg_type) {
switch (this.getMessageType()) {
case 'PICTURE':
text = "[图片]";
break;
case 'GOODS':
text = "[商品链接]";
break;
@@ -224,10 +235,15 @@ class Talk extends Base {
}
// console.log("%c 准备更新...UPDATE_TALK_ITEM ", "color:red");
const nowTime = parseTime(new Date());
const msgText = this.getTalkText() || record.text;
talkStore.UPDATE_TALK_ITEM({
index_name: this.getIndexName(),
msg_text: this.getTalkText() || record.text,
updated_at: parseTime(new Date()),
msg_text: msgText,
lastTalkMessage: msgText,
lastTalkTime: nowTime,
lastMessageType: this.getMessageType(),
updated_at: nowTime,
});
if (this.talk_type == 1 && this.getAccountId() !== this.sender_id) {
@@ -248,6 +264,7 @@ class Talk extends Base {
useTalkStore().UPDATE_TALK_MESSAGE({
index_name: this.getIndexName(),
msg_text: this.getTalkText(),
lastMessageType: this.getMessageType(),
updated_at: parseTime(new Date()),
});
}

View File

@@ -1,4 +1,5 @@
import { useAppStore } from "@/stores/app";
import { useDialogueStore } from "@/stores/dialogue";
import config from "@/config/config";
import WsSocket from "@/plugins/ws-socket";
import { getToken } from "@/utils/auth";
@@ -86,6 +87,15 @@ class SocketInstance {
});
this.socket.on("event_error", (data) => {
// 将被服务端拒绝的图片标记为失败,避免乐观展示被误认为发送成功。
if (data.pictureUrl) {
useDialogueStore().records.forEach((record) => {
if (record.messageType === "PICTURE" && record.float === "right"
&& record.talkId === data.talkId && record.text === data.pictureUrl) {
record.webSocketStatus = true;
}
});
}
ElNotification({
title: "友情提示",
message: data.message,

View File

@@ -38,6 +38,8 @@ export default {
await this.createTalk(this.$route.query.id);
} else if (typeof this.loadChatList === "function") {
await this.loadChatList();
} else if (typeof this.loadChatListInJs === "function") {
await this.loadChatListInJs();
}
}
if(this.$route.query.goodsId && this.$route.query.skuId && this.goodsParams){
@@ -104,8 +106,8 @@ export default {
items: result.map((item) => formatTalkItem(item)),
});
// 判断
if (this.$route.query.id) {
// 判断(仅在挂载了 clickTab 的页面组件实例上自动切换会话)
if (this.$route.query.id && typeof this.clickTab === "function") {
let takeData, takeIndex;
result.forEach((take, index) => {
if (take.id == this.$route.query.id) {
@@ -113,9 +115,13 @@ export default {
takeIndex = index;
}
});
if (typeof this.$nextTick === "function") {
this.$nextTick(() =>
this.clickTab(this.$route.query.id, takeData, takeIndex)
);
} else {
this.clickTab(this.$route.query.id, takeData, takeIndex);
}
}
}).finally(() => {
this.loadStatus = 1;

View File

@@ -272,6 +272,12 @@ class WsSocket {
* @param {Object} evt Websocket 消息
*/
onMessage(evt) {
// 图片权限校验失败属于系统反馈,不能当成聊天消息插入记录。
const envelope = JSON.parse(evt.data);
if (envelope.messageResultType === "SYSTEM_TIPS") {
this.onCallBacks["event_error"]?.(envelope.result);
return;
}
let result = this.onParse(evt);
// 部分推送(在线状态、系统通知等)没有 text 字段,不做商品/群名解析直接透传
const text = result.text;

View File

@@ -71,11 +71,17 @@ export const useTalkStore = defineStore("talks", {
UPDATE_TALK_MESSAGE(resource) {
let enableGetTalkList = true;
this.items.forEach((item) => {
if (item.userId == resource.index_name.split("_")[1]) {
if (
item.index_name === resource.index_name ||
item.userId == resource.index_name.split("_")[1]
) {
item.unread++;
item.msg_text = resource.msg_text;
item.lastTalkTime = resource.updated_at;
item.lastTalkMessage = resource.msg_text;
if (resource.lastMessageType) {
item.lastMessageType = resource.lastMessageType;
}
item.updated_at = resource.updated_at;
enableGetTalkList = false;
}

View File

@@ -28,6 +28,8 @@ const mixinCtx = {
return router.currentRoute.value;
},
};
mixinCtx.loadChatListInJs = MainMixin.methods.loadChatListInJs.bind(mixinCtx);
mixinCtx.loadStoreChatList = MainMixin.methods.loadStoreChatList.bind(mixinCtx);
export function setRefreshView(fn) {
refreshViewFn = fn;

View File

@@ -153,10 +153,12 @@ export const del = (url, data = {}, options = {}) => {
* @returns {Promise<any>}
*/
export const upload = (url, data = {}, options = {}) => {
// 默认沿用买家上传地址;客服图片显式选择 IM 服务,不将文件发往通用上传入口。
const { apiBase = config.BASE_BUYER, ...requestOptions } = options;
return request({
url: config.BASE_BUYER + url,
url: apiBase + url,
method: "post",
data: data,
...options,
...requestOptions,
});
};

View File

@@ -113,8 +113,9 @@
</div>
<div class="talk-message">
<span v-if="item.lastMessageType === 'MESSAGE'" v-html="textReplaceEmoji(item.lastTalkMessage)"></span>
<span v-if="item.lastMessageType === 'GOODS'">[商品链接]</span>
<span v-if="item.lastMessageType === 'ORDER'">[订单链接]</span>
<span v-else-if="item.lastMessageType === 'PICTURE'">[图片]</span>
<span v-else-if="item.lastMessageType === 'GOODS'">[商品链接]</span>
<span v-else-if="item.lastMessageType === 'ORDERS' || item.lastMessageType === 'ORDER'">[订单链接]</span>
</div>
<div class="content">
<template v-if="index_name != item.index_name && item.draft_text">
@@ -428,7 +429,7 @@ function clickTab(id, val, index) {
if (id == index_name.value.split("_")[1]) {
talkStore.UPDATE_TALK_ITEM({
userId: id,
index_name: "MESSAGE_" + index_name.value.split("_")[1],
index_name: index_name.value,
unread: 0,
});
}

View File

@@ -239,3 +239,6 @@ export const deleteMemberBenefit = (id) => {
export const getMemberBenefitTypes = () => {
return getRequest("/member/benefit/types");
};
export const getMemberBenefitDeliveries = params => getRequest('/member/benefit/deliveries', params);
export const retryMemberBenefitDelivery = id => postRequest('/member/benefit/deliveries/' + id + '/retry');

View File

@@ -78,6 +78,15 @@ export const getOrderList = (params) => {
return getRequest(`/order/order`, params)
}
// 小程序专用接口由服务端固定来源与微信支付范围,并回填本地上报结果。
export const getWechatOrderList = (params) => getRequest('/order/order/wechat', params)
// 状态统计与专用列表共用上报状态筛选。
export const getWechatOrderNum = (params) => getRequest('/order/order/wechat/orderNum', params)
// 人工补报复用支付流水锁和真实履约校验,不受自动上报开关限制。
export const retryWechatOrder = (sn) => postRequest(`/order/order/wechat/${encodeURIComponent(sn)}/retry`, {}, undefined, 120000)
// 查询同笔微信支付下的操作日志,不返回原始支付和物流凭据。
export const getWechatOrderLogs = (sn, params) => getRequest(`/order/order/wechat/${encodeURIComponent(sn)}/logs`, params)
// 导出订单列表
export const exportOrder = (params) => {
return getRequest(`/order/order/queryExportOrder`, params,'blob')

View File

@@ -0,0 +1,6 @@
import { getRequest, putRequestWithNoForm } from '@/libs/axios';
export const getPaidConfig = () => getRequest('/member/paid/config');
export const savePaidConfig = data => putRequestWithNoForm('/member/paid/config', data);
export const getPaidOrders = params => getRequest('/member/paid/orders', params);
export const getPaidResources = (type, ids) => getRequest('/member/paid/resources', { type, ids: ids.join(',') });
export const getPaidDiscountSkus = params => getRequest('/member/paid/discount-skus', params);

View File

@@ -147,6 +147,11 @@ export const getCouponActivityList = params => {
return getRequest("/promotion/couponActivity", params);
};
// 关闭优惠券活动
// 获取优惠券礼包H5推广二维码及链接
export const getCouponPackageQrCode = (id, params) => {
return getRequest(`/promotion/couponActivity/package/qrcode/${id}`, params);
};
export const closeActivity = id => {
return deleteRequest(`/promotion/couponActivity/${id}`);
};
@@ -319,6 +324,11 @@ export const giftCardCashBatchCreate = (activityId, params) => {
);
};
/** 导出指定制卡批次的全部卡号、卡密,不受页面筛选和分页限制。 */
export const exportGiftCardCashCreateBatch = (createBatchId) => {
return getRequest(`/promotion/giftCardCash/createBatch/${encodeURIComponent(createBatchId)}/export`, {}, "blob");
};
// 下载批量发卡 Excel 模板
export const downloadGiftCardCashIssueTemplate = () => {
return getRequest("/promotion/giftCardCash/issue/template", {}, "blob");

View File

@@ -5,14 +5,10 @@ import {
postRequest
} from '@/libs/axios';
// 查询短信站内信信息
// 查询站内信信息
export const getMessageData = params => {
return getRequest("/other/message", params);
};
//修改短信模板
export const editSmsMessageTemplate = (id, params) => {
return putRequest(`/sms/sms/${id}`, params)
}
//修改站内信模板
export const editNoticeMessage = (id, params) => {
return putRequest(`/setting/noticeMessage/${id}`, params)
@@ -45,14 +41,6 @@ export const openNoticeMessage = (id) => {
export const updateMessageStatus = (id,status) => {
return putRequest(`/setting/noticeMessage/${id}/${status}`);
};
//获取短信模板
export const getSmsTemplatePage = (params) => {
return getRequest("/sms/sms/template", params);
};
//获取短信签名
export const getSmsSignPage = (params) => {
return getRequest("/sms/sms/sign", params);
};
//获取设置
export const getSetting = (key) => {
return getRequest(`/setting/setting/get/${key}`);
@@ -66,42 +54,6 @@ export const setSetting = (key, params) => {
return postRequest(`/setting/setting/put/${key}`, params);
};
//查看短信签名
export const getSmsSign = () => {
return getRequest(`/sms/sms/sign`);
};
//添加短信签名
export const addSmsSign = (params) => {
return postRequest(`/sms/sms/sign`, params);
};
//修改短信签名
export const editSmsSign = (params) => {
return putRequest(`/sms/sms/sign`, params);
};
//删除短信签名
export const delSmsSign = (id) => {
return deleteRequest(`/sms/sms/sign/${id}`);
};
//添加短信模板
export const addSmsTemplate = (params) => {
return postRequest(`/sms/sms/template`, params);
};
//修改短信模板
export const editSmsTemplate = (params) => {
return putRequest(`/sms/sms/template`, params);
};
//删除短信模板
export const delSmsTemplate = (id) => {
return deleteRequest(`/sms/sms/template/${id}`);
};
//查询短信发送记录
export const getSmsReachPage = (params) => {
return getRequest(`/sms/sms`, params);
};
//重新发送
export const repeatSendSms = (id) => {
return postRequest(`/sms/sms/repeat/${id}`);
};
//获取设置
export const getExperienceData = (key) => {
return getRequest(`/setting/experience/get/${key}`);
@@ -218,41 +170,11 @@ export const updateWechatMpMessageTemplate = params => {
// -------------------------------------------------------------
// 补充缺失的系统配置、短信、版本及验证码 API
// 补充缺失的系统配置、版本及验证码 API
// -------------------------------------------------------------
// 短信任务发送与记录 (sms.vue)
export const getSmsPage = (params) => {
return getRequest(`/sms/sms`, params);
};
export const sendSms = (params) => {
return postRequest(`/sms/sms`, params);
};
// 短信签名同步与详细 (sms.vue, smsSign.vue)
export const syncSign = () => {
return putRequest(`/sms/sign/querySmsSign`);
};
export const deleteSign = (id) => {
return deleteRequest(`/sms/sign/${id}`);
};
export const smsSignDetail = (id) => {
return getRequest(`/sms/sign/${id}`);
};
// 短信模板管理与同步 (sms.vue)
export const syncTemplate = () => {
return putRequest(`/sms/template/querySmsSign`);
};
export const addSmsTemplatePage = (params) => {
return postRequest(`/sms/template`, params);
};
export const editSmsTemplatePage = (params) => {
return putRequest(`/sms/template/modifySmsTemplate`, params);
};
export const deleteSmsTemplatePage = (params) => {
return deleteRequest(`/sms/template`, params);
};
// 支付支持方式表单 (PAYMENT_SUPPORT.vue)
export const getPaymentSupportForm = () => {

View File

@@ -3,7 +3,7 @@
<!-- 使用 fullscreen 类来控制是否全屏显示 -->
<div :class="{ fullscreen: fullscreen }" class="tinymce-container">
<!-- 使用 tinymce-textarea 类作为编辑器的文本区域 -->
<uploadImage @callback="insertImage" />
<uploadImage :use-image-picker="imagePicker" @callback="insertImage" />
<textarea :id="tinymceId" class="tinymce-textarea" />
</div>
</div>
@@ -21,10 +21,15 @@ import {
} from "vue";
import { initEditor } from "@/components/editor/config";
import uploadImage from "@/components/editor/upload-image.vue";
import { useFormItem } from "element-plus";
defineOptions({ name: "Tinymce" });
const props = defineProps({
imagePicker: {
type: Boolean,
default: false,
},
modelValue: {
type: String,
default: "",
@@ -39,6 +44,7 @@ const props = defineProps({
},
});
const emit = defineEmits(["update:modelValue", "input"]);
const { formItem } = useFormItem();
// 引入编辑器的配置(data 中的 initEditor 与 import 同名,直接使用 import)
const hasChange = ref(false); // 标记内容是否有更改
@@ -66,6 +72,9 @@ watch(editorContent, (val) => {
function emitContent(content) {
emit("update:modelValue", content);
emit("input", content);
if (props.imagePicker && content) {
nextTick(() => formItem?.validate("change").catch(() => {}));
}
}
// 数据返回并给富文本框插入图片
function insertImage(arr) {

View File

@@ -57,6 +57,7 @@
<el-dialog
v-model="showOssManager"
title="选择图片"
width="1000px"
append-to-body
:z-index="3600"
@@ -94,6 +95,7 @@ import { Message } from "@/utils/message";
import OssManage from "@/views/sys/oss-manage/ossManage.vue";
defineOptions({ name: "upload-image" });
const props = defineProps({ useImagePicker: { type: Boolean, default: false } });
const emit = defineEmits(["callback"]);
@@ -120,6 +122,10 @@ function draggableItemKey(item) {
return item?.url || item;
}
function handleClickUploadImage() {
if (props.useImagePicker) {
importOSS();
return;
}
show.value = true;
}
function handleView(url) {
@@ -158,7 +164,16 @@ function handleUploadError(err) {
Message.error(err?.message || String(err));
}
function confirmUrls() {
if (props.useImagePicker) {
const urls = [...new Set(selectedImage.value.map(parseOssSelectionUrl).filter(Boolean))];
if (!urls.length) {
Message.warning("请选择图片");
return;
}
emit("callback", urls);
} else {
applySelectedImages();
}
showOssManager.value = false;
}
function cancelOssImport() {

View File

@@ -92,6 +92,9 @@
<span>{{ row.simpleSpecs || "默认规格" }}</span>
</template>
</el-table-column>
<el-table-column label="店铺名称" prop="storeName" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.storeName || '—' }}</template>
</el-table-column>
<el-table-column label="销售价" width="120" align="right">
<template #default="{ row }">
<span class="goods-price font-semibold">
@@ -111,7 +114,7 @@
</el-table-column>
<el-table-column label="商品类型" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ goodsTypeLabel(row.goodsType) }}</el-tag>
<span>{{ goodsTypeLabel(row.goodsType) }}</span>
</template>
</el-table-column>
</el-table>
@@ -136,11 +139,13 @@
</template>
<script setup>
import { nextTick, onMounted, reactive, ref, watch } from "vue";
import { nextTick, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import * as API_Goods from "@/api/goods";
import { goodsTypeLabel } from "@/constants/goodsType";
const props = defineProps({
goodsLoader: { type: Function, default: null },
queryParams: { type: Object, default: () => ({}) },
selectedWay: {
type: Array,
default: () => [],
@@ -167,6 +172,8 @@ const category = ref([]);
const goodsData = ref([]);
const loading = ref(false);
const tableRef = ref(null);
let syncingSelection = false;
let querySequence = 0;
watch(category, (val) => {
goodsParams.categoryPath = val && val.length ? val[2] : "";
@@ -194,6 +201,8 @@ watch(
onMounted(() => {
init();
});
watch(() => props.queryParams, onSearchGoods, { deep: true });
onUnmounted(() => { querySequence++; });
function onSearchGoods() {
goodsData.value = [];
@@ -222,13 +231,14 @@ function onPageSizeChange(val) {
}
function getQueryGoodsList() {
const sequence = ++querySequence;
loading.value = true;
API_Goods.getGoodsSkuData(goodsParams)
(props.goodsLoader || API_Goods.getGoodsSkuData)({ ...goodsParams, ...props.queryParams })
.then((res) => {
initGoods(res);
if (sequence === querySequence) initGoods(res);
})
.finally(() => {
loading.value = false;
if (sequence === querySequence) loading.value = false;
});
}
@@ -245,16 +255,18 @@ function initGoods(res) {
function syncTableSelection() {
const table = tableRef.value;
if (!table) return;
if (!selectedList.value.length) {
// 将不在当前页的已选 SKU 也加入保留选择,避免翻页回显时丢失。
syncingSelection = true;
try {
table.clearSelection();
return;
}
goodsData.value.forEach((item) => {
const selected = selectedList.value.some(
(e) => (e.id || e.skuId) && String(e.id || e.skuId) === String(item.id || item.skuId)
);
table.toggleRowSelection(item, selected);
selectedList.value.forEach((item) => {
const id = String(item.id || item.skuId);
const current = goodsData.value.find(row => String(row.id || row.skuId) === id);
table.toggleRowSelection(current || item, true);
});
} finally {
syncingSelection = false;
}
}
function emitSelected(list) {
@@ -263,6 +275,7 @@ function emitSelected(list) {
}
function onSelectionChange(rows) {
if (syncingSelection) return;
if (type.value !== "multiple") {
if (!rows.length) {
emitSelected([]);

View File

@@ -16,6 +16,8 @@
v-if="flag"
ref="goodsDialogRef"
:selectedWay="goodsData"
:goods-loader="goodsLoader"
:query-params="queryParams"
@selected="
(val) => {
goodsData = val;
@@ -69,6 +71,7 @@ import goodsDialog from "./goods-dialog";
import linkDialog from "./link-dialog";
const emit = defineEmits(["closeFlag", "selectedGoodsData", "selectedLink"]);
defineProps({ goodsLoader: { type: Function, default: null }, queryParams: { type: Object, default: () => ({}) } });
const goodsFlag = ref(false); // 是否商品选择器
const goodsData = ref([]); // 选择的商品

View File

@@ -1,8 +1,13 @@
<!--
楼层装修链接选择器-营销活动选择面板(满额活动拆分为满减、满折、满赠三项)
@author Bulbasaur
@since 2026-11-09
-->
<template>
<div class="wrapper">
<div class="list">
<div
v-for="(item, index) in Object.keys(promotionList)"
v-for="(item, index) in promotionKeys"
:key="index"
class="list-item"
:class="{ active: selectedIndex == index }"
@@ -76,7 +81,7 @@ const emit = defineEmits(["selected"]);
const totals = ref("");
const loading = ref(true);
const promotionList = ref("");
const promotionList = ref({});
const selectedIndex = ref(0);
const promotions = ref("");
const index = ref(999);
@@ -86,6 +91,12 @@ const params = ref({
});
const showPromotionList = ref([]);
const promotionKeys = computed(() => {
return Object.keys(promotionList.value || {}).filter(
(k) => k !== "FULL_DISCOUNT" && Boolean(typeOption(k).title)
);
});
const isSeckillMode = computed(() => {
return promotions.value === "SECKILL";
});
@@ -106,12 +117,18 @@ function seckillStatusText(v) {
}
function sortGoods(type) {
loading.value = false;
showPromotionList.value = promotionList.value[type];
showPromotionList.value = promotionList.value[type] || [];
}
function typeOption(type) {
switch (type) {
case "FULL_MINUS":
return { title: "满减活动", methodsed: () => { showPromotionList.value = []; sortGoods("FULL_MINUS"); } };
case "FULL_RATE":
return { title: "满折活动", methodsed: () => { showPromotionList.value = []; sortGoods("FULL_RATE"); } };
case "FULL_GIFT":
return { title: "满赠活动", methodsed: () => { showPromotionList.value = []; sortGoods("FULL_GIFT"); } };
case "FULL_DISCOUNT":
return { title: "满减", methodsed: () => { showPromotionList.value = []; sortGoods("FULL_DISCOUNT"); } };
return { title: "满减活动", methodsed: () => { showPromotionList.value = []; sortGoods("FULL_DISCOUNT"); } };
case "PINTUAN":
return { title: "拼团", methodsed: () => { showPromotionList.value = []; sortGoods("PINTUAN"); } };
case "KANJIA":
@@ -143,15 +160,18 @@ function getPromotion(res) {
if (res.result) {
promotionList.value = res.result;
delete promotionList.value.COUPON;
Object.keys(res.result)[0] && typeOption(Object.keys(res.result)[0]).methodsed();
promotions.value = Object.keys(res.result)[0];
const keys = promotionKeys.value;
if (keys.length) {
promotions.value = keys[0];
typeOption(keys[0]) && typeOption(keys[0]).methodsed();
}
}
}
function clickPromotion(val, i) {
promotions.value = val;
selectedIndex.value = i;
params.value.pageNumber = 1;
typeOption(val) && typeOption(val).methodsed(promotionList.value[val].id);
typeOption(val) && typeOption(val).methodsed(promotionList.value[val]?.id);
}
onMounted(() => {

View File

@@ -1,174 +1,10 @@
<template>
<div class="shop">
<div class="wap-content">
<div class="query-wrapper">
<div class="query-item">
<div>店铺名称</div>
<el-input
v-model="params.storeName"
placeholder="请输入店铺名称"
clearable
style="width: 150px"
@clear="resetSearch"
@keyup.enter="resetSearch"
/>
</div>
<div class="query-item">
<el-button type="primary" @click="resetSearch">搜索</el-button>
</div>
</div>
<div>
<el-table
ref="tableRef"
v-loading="loading"
class="shops-select-table"
:data="shopsData"
row-key="id"
height="340"
empty-text="暂无店铺信息"
@selection-change="onSelectionChange"
>
<el-table-column
type="selection"
label="选择"
width="60"
align="center"
reserve-selection
/>
<el-table-column label="店铺图片" width="100" align="center">
<template #default="{ row }">
<img class="shop-logo" :src="row.storeLogo" alt="" />
</template>
</el-table-column>
<el-table-column
prop="storeName"
label="店铺名称"
min-width="240"
show-overflow-tooltip
/>
<el-table-column
prop="createTime"
label="创建时间"
width="180"
show-overflow-tooltip
/>
<el-table-column label="店铺类型" width="120">
<template #default="{ row }">
<span :class="{ theme_color: row.selfOperated }">
{{ row.selfOperated ? "自营" : "非自营" }}
</span>
</template>
</el-table-column>
<el-table-column label="店铺状态" width="120">
<template #default="{ row }">
<span :class="{ theme_color: row.storeDisable === 'OPEN' }">
{{ row.storeDisable === "OPEN" ? "开启中" : "未开启" }}
</span>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pageration"
size="small"
layout="total, prev, pager, next, jumper"
:total="total"
:page-size="params.pageSize"
:current-page="params.pageNumber"
@current-change="changePageSize"
/>
</div>
</div>
</div>
<StoreSelector embedded :multiple="false" :query-params="shopQuery" @selected="selectShop" />
</template>
<script setup>
import { nextTick, reactive, ref } from "vue";
import { getShopListData } from "@/api/shops.js";
const emit = defineEmits(["selected"]);
const loading = ref(false);
const total = ref(0);
const params = reactive({
pageNumber: 1,
pageSize: 12,
storeDisable: "OPEN",
storeName: "",
});
const shopsData = ref([]);
const tableRef = ref(null);
const selectedShop = ref(null);
// 无 defineExpose:grep manager/src 全量确认无外部 ref/$refs/$parent 访问本组件成员
// (link-dialog.vue 的 $refs["lili-component"][0].type 写入指向首个 tab 的 goods-dialog,与本组件无关)
// created
init();
function resetSearch() {
shopsData.value = [];
params.pageNumber = 1;
init();
}
function changePageSize(v) {
params.pageNumber = v;
init();
}
function init() {
loading.value = true;
getShopListData(params)
.then((res) => {
if (res.success) {
total.value = res.result.total;
shopsData.value = res.result.records;
nextTick(syncTableSelection);
}
})
.finally(() => {
loading.value = false;
});
}
function syncTableSelection() {
const table = tableRef.value;
if (!table || !selectedShop.value) return;
shopsData.value.forEach((row) => {
table.toggleRowSelection(row, row.id === selectedShop.value.id);
});
}
function onSelectionChange(rows) {
// 选择链接只能保留一个店铺,勾选新店铺时自动取消上一个
if (!rows.length) {
selectedShop.value = null;
emit("selected", []);
return;
}
const row = rows[rows.length - 1];
if (rows.length > 1) {
tableRef.value?.clearSelection();
tableRef.value?.toggleRowSelection(row, true);
}
selectedShop.value = row;
emit("selected", [{ ...row, ___type: "shops" }]);
}
import StoreSelector from '@/components/store-selector/index.vue';
// Author: Bulbasaur; 2026-11-05. 装修链接复用统一店铺选择组件,保留单选及启用店铺范围。
const emit = defineEmits(['selected']);
const shopQuery = { storeDisable: 'OPEN' };
function selectShop(rows) { emit('selected', rows.map(row => ({ ...row, ___type: 'shops' }))); }
</script>
<style lang="scss" scoped>
@import "../style.scss";
.shop {
display: flex;
}
.shop-logo {
display: block;
width: 48px;
height: 48px;
margin: 0 auto;
border-radius: 4px;
object-fit: contain;
}
.pageration {
margin-top: 12px;
justify-content: flex-end;
}
</style>

View File

@@ -105,6 +105,7 @@ const props = defineProps({
default: false,
},
maxlength: Number,
beforeSelect: Function,
});
const emit = defineEmits(["update:modelValue", "input", "on-change"]);
const attrs = useAttrs();
@@ -131,6 +132,7 @@ function handleCLickImg(val, index) {
});
}
function handleOssCallback(item) {
if (item?.url && props.beforeSelect && props.beforeSelect(item) === false) return;
picModalFlag.value = false;
if (!item?.url) return;
currentValue.value = item.url;

View File

@@ -0,0 +1,25 @@
# 统一店铺选择器
作者:Bulbasaur;2026-11-05。
默认从右侧打开抽屉,支持搜索、分页、多选和已选回显。取消不提交,确认通过 `selected` 返回完整已选店铺数组。店铺类型、状态均为普通文字,无右侧预览区。
```vue
<StoreSelector ref="selector" @selected="onSelected" />
```
导入 `@/components/store-selector/index.vue`,通过 `selector.value.open(selectedRows)` 打开。`selectedRows` 为包含 `id` 的店铺对象数组;每次打开建立独立选择草稿。
| 参数 | 默认值 | 用途 |
| --- | --- | --- |
| multiple | true | false 时单选,返回数组最多一项 |
| embedded | false | true 时在父容器展示列表,并在选择时立即通知;适用于装修链接面板 |
| selectedWay | [] | 初始店铺数组;内嵌模式监听外部选择变化 |
| queryParams | {} | 店铺查询限制,例如 `{ storeDisable: 'OPEN' }` |
| title | 选择店铺 | 抽屉标题 |
| size | 1080px | 抽屉宽度,最大为视口宽度 |
| tableHeight | 自动 | 可选表格高度 |
已接入付费会员、装修链接单选、移动端装修店铺列表配置。查询复用 `getShopListData`(`/store/store`),不新增后端接口。
验证:在工作区根目录运行 `node scripts/test-store-selector.cjs`。

View File

@@ -0,0 +1,115 @@
<template>
<component :is="embedded ? 'div' : ElDrawer" v-bind="embedded ? {} : { modelValue: visible, title, size, direction: 'rtl', appendToBody: true, destroyOnClose: true }" :class="embedded ? 'store-selector-inline' : 'store-picker-drawer'" @update:model-value="visible = $event" @close="request++">
<el-form class="picker-search" inline @submit.prevent="search">
<el-form-item label="店铺名称"><el-input v-model="keyword" clearable placeholder="请输入店铺名称" /></el-form-item>
<el-form-item><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form>
<div class="picker-content">
<div class="picker-results">
<el-alert v-if="error" title="加载失败,请重试" type="error" :closable="false"><el-button link type="primary" @click="load">重新加载</el-button></el-alert>
<el-table ref="table" v-loading="loading" :data="rows" row-key="id" border :height="tableHeight || (embedded ? '340px' : 'max(300px, calc(100vh - 270px))')" :empty-text="error ? '加载失败' : '暂无匹配结果'" @select="selectStore" @select-all="selectPage" @row-click="toggleStore">
<el-table-column v-if="multiple" type="selection" :reserve-selection="false" width="48" align="center" />
<el-table-column v-else label="选择" width="60" align="center"><template #default="{row}"><el-radio :model-value="selectedStores[0]?.id" :value="row.id" :aria-label="'选择' + row.storeName" @click.stop @change="chooseSingle(row)" /></template></el-table-column>
<el-table-column label="店铺信息" min-width="265"><template #default="{row}"><div class="store-info"><el-avatar :size="40" shape="square" :src="row.storeLogo || ''">店</el-avatar><div class="store-copy"><div class="store-name" :title="row.storeName">{{ row.storeName || '未命名店铺' }}</div><div class="store-id">ID:{{ row.id }}</div></div></div></template></el-table-column>
<el-table-column label="店铺类型" width="96" align="center"><template #default="{row}">{{ selfOperated(row) ? '自营' : '非自营' }}</template></el-table-column>
<el-table-column label="状态" width="100" align="center"><template #default="{row}">{{ storeStatus(row.storeDisable) }}</template></el-table-column>
</el-table>
<el-pagination v-model:current-page="page" :total="total" :page-size="10" layout="total, prev, pager, next" @current-change="load" />
</div>
</div>
<template v-if="!embedded" #footer><div class="picker-footer"><span class="selected-count">已选择 <b>{{ selectedStores.length }}</b> 家店铺</span><div><el-button @click="visible = false">取消</el-button><el-button type="primary" :disabled="loading" @click="confirm">确定</el-button></div></div></template>
</component>
</template>
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { ElDrawer } from 'element-plus';
import { getShopListData } from '@/api/shops';
// Author: Bulbasaur; 2026-11-05.
const props = defineProps({
multiple: { type: Boolean, default: true },
embedded: { type: Boolean, default: false },
selectedWay: { type: Array, default: () => [] },
queryParams: { type: Object, default: () => ({}) },
title: { type: String, default: '选择店铺' },
size: { type: [String, Number], default: '1080px' },
tableHeight: { type: String, default: '' },
});
const emit = defineEmits(['selected']);
const visible = ref(false), keyword = ref(''), rows = ref([]), total = ref(0), page = ref(1), loading = ref(false), error = ref(false), table = ref();
const storeSelection = ref({});
const selectedStores = computed(() => Object.values(storeSelection.value));
let request = 0;
const selfOperated = row => [true, 1, 'true', '1'].includes(row.selfOperated);
const storeStatus = value => ({OPEN:'营业中',CLOSED:'已关闭',APPLY:'待审核',APPLYING:'待审核',REFUSED:'审核拒绝',EXPIRED:'已过期'}[value] || value || '—');
async function syncStores() {
await nextTick();
if (!table.value) return;
table.value.clearSelection();
rows.value.forEach(row => { if (storeSelection.value[String(row.id)]) table.value.toggleRowSelection(row, true); });
}
function notifyEmbedded() { if (props.embedded) emit('selected', selectedStores.value.map(row => ({...row}))); }
function chooseSingle(row) { storeSelection.value = { [String(row.id)]: {...row} }; notifyEmbedded(); }
function selectStore(selected, row) {
const key = String(row.id);
if (selected.some(item => String(item.id) === key)) storeSelection.value[key] = row;
else delete storeSelection.value[key];
notifyEmbedded();
}
function selectPage(selected) {
const ids = new Set(selected.map(row => String(row.id)));
rows.value.forEach(row => { if (ids.has(String(row.id))) storeSelection.value[String(row.id)] = row; else delete storeSelection.value[String(row.id)]; });
notifyEmbedded();
}
function toggleStore(row, column) {
if (column?.type === 'selection') return;
if (!props.multiple) { chooseSingle(row); return; }
const key = String(row.id);
if (storeSelection.value[key]) delete storeSelection.value[key]; else storeSelection.value[key] = row;
syncStores();
notifyEmbedded();
}
async function load() {
const id = ++request; loading.value = true; error.value = false;
try {
const res = await getShopListData({ ...props.queryParams, pageNumber: page.value, pageSize: 10, storeName: keyword.value.trim() });
if (id !== request || !visible.value) return;
if (!res.success) throw new Error('load');
rows.value = res.result.records || []; total.value = res.result.total || 0;
rows.value.forEach(row => { if (storeSelection.value[String(row.id)]) storeSelection.value[String(row.id)] = row; });
await syncStores();
} catch { if (id === request) { error.value = true; rows.value = []; total.value = 0; } }
finally { if (id === request) loading.value = false; }
}
function search() { page.value = 1; load(); }
function reset() { keyword.value = ''; search(); }
async function open(selected = props.selectedWay) {
request++; keyword.value = ''; rows.value = []; total.value = 0; page.value = 1; error.value = false;
setSelection(selected);
visible.value = true;
await nextTick(); table.value?.clearSelection();
await load();
}
function setSelection(selected) {
const valid = (Array.isArray(selected) ? selected : []).filter(row => row && row.id != null);
storeSelection.value = Object.fromEntries((props.multiple ? valid : valid.slice(0,1)).map(row => [String(row.id), {...row}]));
}
function confirm() { emit('selected', selectedStores.value.map(row => ({...row}))); visible.value = false; }
watch(() => props.selectedWay, selected => { if (props.embedded) { setSelection(selected); syncStores(); } }, { deep: true });
watch(() => props.queryParams, () => { if (visible.value) search(); }, { deep: true });
onMounted(() => { if (props.embedded) open(); });
defineExpose({ open });
</script>
<style scoped>
:global(.store-picker-drawer) { max-width: 100vw; }
:global(.store-picker-drawer .el-drawer__header) { margin-bottom: 0; padding: 20px; border-bottom: 1px solid #ebeef5; }
:global(.store-picker-drawer .el-drawer__body) { padding: 20px; }
:global(.store-picker-drawer .el-drawer__footer) { padding: 0 20px 20px; }
.picker-search { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 16px 18px 0; margin-bottom: 18px; background: #f7f8fa; border-radius: 6px; }
.picker-search .el-form-item { margin-right: 0; margin-bottom: 16px; }.picker-search .el-input { width: 240px; }
.picker-content { min-width: 0; }.picker-results { min-width: 0; }
.el-pagination { margin-top: 18px; }.store-info { display: flex; align-items: center; gap: 12px; min-height: 50px; }.el-avatar { flex-shrink: 0; background: #edf2f8; color: #7b8da6; }
.store-copy { min-width: 0; flex: 1; }.store-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; color: #303133; }.store-id { margin-top: 3px; font-size: 12px; color: #909399; overflow-wrap: anywhere; }
.selected-count b { color: var(--el-color-primary); }
.picker-footer { display: flex; justify-content: space-between; align-items: center; border-top: 1px solid #ebeef5; padding-top: 16px; }.selected-count { color: #909399; }
@media(max-width: 800px) { .picker-search .el-input { width: 180px; } }
</style>

View File

@@ -182,10 +182,12 @@ export const postBlobRequest = (url, data) => {
});
};
export const postRequest = (url, params, headers) => {
// 个别需等待外部平台的操作可传超时毫秒数,现有调用保持 8 秒默认值。
export const postRequest = (url, params, headers, timeout = 8000) => {
let accessToken = getStore("accessToken");
return service({
method: "post",
timeout,
url: `${url}`,
data: params,
transformRequest: headers

View File

@@ -7,6 +7,8 @@ let util = {};
/** 已弃用、前端未实现或已移除的菜单 frontRoute,注册路由时跳过 */
const DEPRECATED_FRONT_ROUTES = new Set([
"sys/message/sms",
"sys/message/smsSign",
"goods/goods-manage/spec",
"member/list/memberRecycle",
"order/order/fictitiousOrderList",
@@ -406,6 +408,15 @@ util.initRouterNode = function(routers, data) {
}
const menu = Object.assign({}, item);
const hasChildren = item.children && item.children.length > 0;
if (
!hasChildren &&
(menu.frontRoute === "promotions/full-discount/full-discount" ||
menu.name === "full-discount" ||
menu.name === "full-cut-activity") &&
menu.title === "满额活动"
) {
menu.title = "满减活动";
}
if (hasChildren) {
menu.children = [];

View File

@@ -169,6 +169,12 @@ export const otherRouter = {
name: "order-detail",
component: () => import("@/views/order/order/orderDetail.vue")
},
{
path: "wechat-order-list",
title: "小程序订单",
name: "wechat-order-list",
component: () => import("@/views/order/wechat/orderList.vue")
},
{
path: "appointment/work-orders",
title: "预约工单",
@@ -218,6 +224,20 @@ export const otherRouter = {
name: "member-benefit",
component: () => import("@/views/member/benefit/index.vue")
},
{
path: "paid-member-settings", title: "付费会员", name: "paid-member-settings",
component: () => import("@/views/member/paid/settings.vue")
},
{
path: "paid-member-records", title: "付费记录", name: "paid-member-records",
component: () => import("@/views/member/paid/records.vue")
},
{
path: "member-benefit-deliveries",
title: "权益发放明细",
name: "member-benefit-deliveries",
component: () => import("@/views/member/benefit/BenefitDeliveryRecords.vue")
},
{
path: "member-grade-experience",
title: "等级权益设置",
@@ -379,12 +399,6 @@ export const otherRouter = {
component: () => import("@/views/promotions/seckill/seckill-goods.vue")
},
{
path: "add-sms-sign",
title: "短信签名",
name: "add-sms-sign",
component: () => import("@/views/sys/message/smsSign.vue")
},
{
path: "live-add",
title: "创建直播",

View File

@@ -0,0 +1,44 @@
/**
* 商品规格展示:兼容简要规格、键值 JSON 和旧数组结构,过滤图片数据,不展示原始 JSON。
* @author Bulbasaur
* @since 2026-11-06
*/
/** 将规格中的简单值转为文字,图片对象等复杂数据不参与规格展示。 */
function specValueText(value) {
if (typeof value === "string") return value.trim();
if (typeof value === "number" && Number.isFinite(value)) return String(value);
if (typeof value === "boolean") return String(value);
if (Array.isArray(value)) return value.filter(item => !item || typeof item !== "object").map(specValueText).filter(Boolean).join("、");
return "";
}
/**
* 生成可读的 SKU 规格,优先复用接口已有的 simpleSpecs;无有效规格时显示默认规格。
* @param {string|object|Array|null} specs SKU 原始规格
* @param {string|null} simpleSpecs 接口提供的简要规格
* @returns {string} 排除 images 后的规格值,以斜线分隔
*/
export function formatSkuSpecs(specs, simpleSpecs) {
if (typeof simpleSpecs === "string" && simpleSpecs.trim()) return simpleSpecs.trim();
let parsed = specs;
if (typeof parsed === "string") {
const text = parsed.trim();
if (!text) return "默认规格";
try {
parsed = JSON.parse(text);
} catch {
return /^[\[{]/.test(text) ? "默认规格" : text;
}
}
let values;
if (Array.isArray(parsed)) {
values = parsed.filter(item => item && typeof item === "object" && item.specName !== "images")
.map(item => specValueText(item.specValue));
} else if (parsed && typeof parsed === "object") {
values = Object.entries(parsed).filter(([name]) => name !== "images").map(([, value]) => specValueText(value));
} else {
values = [specValueText(parsed)];
}
return values.filter(Boolean).join(" / ") || "默认规格";
}

View File

@@ -0,0 +1,23 @@
/**
* 小程序订单上报状态及日志显示口径,成功仅表示发货接口受理。
* @author Bulbasaur
* @since 2026-11-09
*/
export const wechatSyncOptions = [
{ value: "UNCHECKED", label: "待核查", type: "info" },
{ value: "PENDING", label: "待上报", type: "warning" },
{ value: "PROCESSING", label: "处理中", type: "warning" },
{ value: "SUCCESS", label: "上报成功", type: "success" },
{ value: "FAILED", label: "上报失败", type: "danger" },
];
/** 查找页面和日志的统一状态标签,重复请求跳过使用独立说明。 */
export function wechatSyncLabel(value) {
if (value === "SKIPPED") return "已跳过重复";
return wechatSyncOptions.find((item) => item.value === value)?.label || value || "—";
}
/** 返回 Element Plus 标签类型,未知状态使用中性色。 */
export function wechatSyncTag(value) {
return wechatSyncOptions.find((item) => item.value === value)?.type || "info";
}

View File

@@ -49,6 +49,24 @@ const menuList = computed(() => appStore.menuList);
const navList = computed(() => appStore.navList);
const currNav = computed(() => appStore.currNav);
const activeMenuName = computed(() => {
if (route.name === "full-discount-detail") {
const type = route.query?.activityType;
const candidates =
type === "FULL_GIFT"
? ["manager-full-gift", "full-gift", "full-discount", "full-cut-activity"]
: type === "FULL_RATE"
? ["manager-full-rate", "full-rate", "full-discount", "full-cut-activity"]
: ["full-discount", "full-cut-activity"];
for (const candidate of candidates) {
for (const item of menuList.value) {
for (const menu of item.children || []) {
if (menu.name === candidate || menu.path === candidate) {
return menu.name;
}
}
}
}
}
const routePath = util.normalizeRoutePath(route.path);
for (const item of menuList.value) {
for (const menu of item.children || []) {

View File

@@ -0,0 +1,40 @@
<template>
<el-form-item v-if="form.benefitType === 'POINT_MULTIPLIER'" label="积分倍率" required>
<div class="number-field"><el-input-number v-model="form.multiplier" :min="1" :max="100" :precision="2" controls-position="right" /><span>倍</span></div>
<div class="hint">购物积分基数 × 倍率,同类权益取最高倍率。</div>
</el-form-item>
<div v-if="['CUSTOM', 'GRADE_BADGE', 'EXCLUSIVE_CUSTOMER_SERVICE'].includes(form.benefitType)" class="rule-description">{{ typeDescription }}</div>
<template v-if="form.benefitType === 'COUPON_PACKAGE'">
<el-form-item label="发券规则" required>
<el-radio-group v-model="form.grantRule">
<el-radio value="ONCE">发放一次</el-radio>
<el-radio value="MONTHLY">每月发放</el-radio>
<el-radio value="PERIOD">按周期发放</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.grantRule === 'MONTHLY'" label="发放日期" required>
<div class="number-field"><el-input-number v-model="form.grantDay" :min="1" :max="31" :precision="0" controls-position="right" /><span>号</span></div>
<div class="hint">当月不存在指定日期时,取当月最后一天。</div>
</el-form-item>
<el-form-item v-if="form.grantRule === 'PERIOD'" label="发放周期" required>
<div class="number-field"><el-input-number v-model="form.periodMonths" :min="1" :max="12" :precision="0" controls-position="right" /><span>个月</span></div>
<div class="hint">以首次达到等级的日期为起点,首期立即发放。</div>
</el-form-item>
<div class="rule-description coupon-description">最多选择 10 张平台优惠券,每券赠送 1~10 张。仅发放当前周期,暂停期间未生成的历史周期不追发。</div>
</template>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({ form: { type: Object, required: true } });
const typeDescription = computed(() => ({
GRADE_BADGE: '展示会员的等级徽章。请在下方配置权益图标和介绍,并在客户等级中关联使用。',
EXCLUSIVE_CUSTOMER_SERVICE: '展示会员专属客服权益。会员可通过商城现有客服入口联系,具体服务内容请在权益介绍中说明。',
CUSTOM: '展示自定义会员权益。请在权益介绍中说明适用条件、参与方式及服务内容。',
}[props.form.benefitType] || ''));
</script>
<style scoped>
.number-field { display: flex; align-items: center; gap: 10px; }
.hint { width: 100%; margin-top: 6px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 20px; }
.rule-description { padding: 12px 16px; color: var(--el-text-color-regular); background: #f6f8fb; border-radius: 6px; font-size: 13px; line-height: 22px; }
.coupon-description { margin-bottom: 20px; }
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div class="search benefit-delivery-page">
<el-card shadow="never">
<el-form :model="query" inline class="search-form" label-width="70px" @submit.prevent @keyup.enter="search">
<el-form-item label="会员ID"><el-input v-model="query.memberId" placeholder="请输入会员ID" clearable style="width:240px" /></el-form-item>
<el-form-item label="状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:240px">
<el-option label="成功" value="SUCCESS" /><el-option label="失败" value="FAILED" /><el-option label="待发放" value="PENDING" />
</el-select></el-form-item>
<el-form-item><el-button type="primary" class="search-btn" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="mt_10">
<template #header><div class="records-header"><span>权益发放明细</span><el-button @click="router.push({ name: 'member-benefit' })">返回客户权益</el-button></div></template>
<el-table :data="rows" v-loading="loading" border>
<el-table-column prop="memberId" label="会员ID" min-width="160" show-overflow-tooltip />
<el-table-column prop="benefitName" label="权益" min-width="130" />
<el-table-column prop="sourceRef" label="来源等级ID" min-width="150" show-overflow-tooltip />
<el-table-column prop="cycleKey" label="发放周期" min-width="130" />
<el-table-column label="状态" width="100"><template #default="{row}">{{ statuses[row.status] || row.status }}</template></el-table-column>
<el-table-column prop="attempts" label="尝试次数" width="90" />
<el-table-column prop="errorMessage" label="失败原因" min-width="200" show-overflow-tooltip />
<el-table-column prop="updateTime" label="更新时间" width="170" />
<el-table-column label="操作" width="90"><template #default="{row}"><el-button v-if="row.status === 'FAILED'" link type="primary" :loading="retrying === row.id" @click="retry(row)">重试</el-button></template></el-table-column>
</el-table>
<el-pagination v-model:current-page="query.pageNumber" :total="total" :page-size="query.pageSize" layout="total, prev, pager, next" @current-change="load" />
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { getMemberBenefitDeliveries, retryMemberBenefitDelivery } from '@/api/member';
defineOptions({ name: 'memberBenefitDeliveries' });
const router = useRouter();
const loading = ref(false), rows = ref([]), total = ref(0), retrying = ref('');
const query = reactive({memberId:'',status:'',pageNumber:1,pageSize:20});
const statuses = {SUCCESS:'成功',FAILED:'失败',PENDING:'待发放'};
async function load() {
loading.value = true;
try { const res = await getMemberBenefitDeliveries({ ...query }); if(res.success) { rows.value = res.result.records || []; total.value = res.result.total || 0; } }
catch { ElMessage.error('发放明细加载失败,请重新搜索'); }
finally { loading.value = false; }
}
function search() { query.pageNumber = 1; load(); }
function reset() { query.memberId = ''; query.status = ''; search(); }
async function retry(row) {
try { await ElMessageBox.confirm('按原发放配置补发此权益,系统会检查会员当前资格。', '重试权益发放'); }
catch { return; }
retrying.value = row.id;
try { const res = await retryMemberBenefitDelivery(row.id); if(res.success) ElMessage.success('补发成功'); await load(); }
catch { ElMessage.error('补发失败,请稍后重试'); }
finally { retrying.value = ''; }
}
onMounted(load);
</script>
<style scoped>
.records-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.el-pagination { margin-top: 20px; justify-content: flex-end; }
</style>

View File

@@ -0,0 +1,129 @@
<template>
<section class="benefit-section">
<div class="benefit-section-heading"><h3>基本信息</h3></div>
<div class="benefit-form-grid">
<el-form-item label="权益名称" prop="benefitName">
<el-input v-model="form.benefitName" maxlength="50" show-word-limit placeholder="请输入权益名称" />
</el-form-item>
<el-form-item label="权益类型" prop="benefitType">
<el-select v-model="form.benefitType" :disabled="editing" :clearable="!editing" placeholder="请选择权益类型" @change="$emit('type-change', $event)">
<el-option v-for="item in typeOptions" :key="item.value" :value="item.value" :label="item.description" />
</el-select>
<div v-if="editing" class="benefit-field-hint">权益创建后不支持修改类型</div>
</el-form-item>
</div>
</section>
<section v-if="form.benefitType" class="benefit-section">
<div class="benefit-section-heading"><h3>权益规则</h3><span>{{ typeLabel }}</span></div>
<el-form-item v-if="form.benefitType === 'GIFT_POINT'" label="赠送积分" prop="giftPoint" required>
<div class="benefit-number-field">
<el-input-number v-model="form.giftPoint" :min="0" :max="99999" :precision="0" controls-position="right" placeholder="请输入积分数量" />
<span class="benefit-input-unit">积分</span>
</div>
<div class="benefit-field-hint">首次达到关联等级时赠送,范围为 0~99999 积分。</div>
</el-form-item>
<BenefitConfigFields :form="form" />
<el-form-item v-if="form.benefitType === 'COUPON_PACKAGE'" label="赠送优惠券" prop="couponPackageRows" required class="benefit-coupon-form-item">
<div class="benefit-coupon-field">
<div class="benefit-coupon-toolbar">
<span>已选择 <strong>{{ form.couponPackageRows.length }}</strong> / 10 张优惠券</span>
<el-button type="primary" plain @click="$emit('select-coupons', which)">添加优惠券</el-button>
</div>
<el-table border size="small" class="benefit-coupon-table" :data="form.couponPackageRows" empty-text="暂未选择优惠券,请点击右上方添加">
<el-table-column prop="couponName" label="优惠券名称" min-width="160" show-overflow-tooltip />
<el-table-column label="有效期" min-width="200"><template #default="{ row }"><span v-if="row" v-html="row.validRange || '-'" /></template></el-table-column>
<el-table-column prop="faceValueLabel" label="面额" min-width="95" />
<el-table-column label="赠送张数" width="135"><template #default="{ row, $index }">
<el-input-number v-if="row" :model-value="row.quantity" :min="1" :max="10" :precision="0" controls-position="right" style="width: 104px" @change="value => $emit('quantity-change', which, $index, value)" />
</template></el-table-column>
<el-table-column label="操作" width="70" align="center"><template #default="{ row, $index }">
<el-button v-if="row" link type="danger" @click="$emit('remove-coupon', which, $index)">删除</el-button>
</template></el-table-column>
</el-table>
</div>
</el-form-item>
</section>
<section class="benefit-section">
<div class="benefit-section-heading"><h3>展示内容</h3><span>用于会员端权益展示</span></div>
<el-form-item label="权益图标" prop="benefitLogo">
<div class="benefit-logo-field">
<div class="benefit-logo-preview">
<el-image v-if="form.benefitLogo" :src="form.benefitLogo" fit="contain" :preview-src-list="[form.benefitLogo]" preview-teleported>
<template #error><span class="benefit-logo-placeholder">图片不可用</span></template>
</el-image>
<span v-else class="benefit-logo-placeholder">图标预览</span>
</div>
<BenefitLogoInput v-model="form.benefitLogo" class="benefit-logo-upload" />
</div>
</el-form-item>
<el-form-item label="权益介绍" prop="benefitDesc">
<RichEditor v-model="form.benefitDesc" image-picker height="300px" class="benefit-rich-editor" />
</el-form-item>
</section>
<section class="benefit-section">
<div class="benefit-section-heading"><h3>发布设置</h3></div>
<div class="benefit-form-grid">
<el-form-item label="启用状态" prop="benefitState">
<el-radio-group v-model="form.benefitState"><el-radio value="OPEN">开启</el-radio><el-radio value="CLOSE">关闭</el-radio></el-radio-group>
<div class="benefit-field-hint">关闭后,会员端隐藏该权益并停止后续发放。</div>
</el-form-item>
<el-form-item label="显示排序" prop="benefitSort">
<el-input-number v-model="form.benefitSort" :min="1" :max="9999" :precision="0" controls-position="right" />
<div class="benefit-field-hint">数字越小越靠前,范围为 1~9999。</div>
</el-form-item>
</div>
</section>
</template>
<script setup>
import { computed } from 'vue';
import BenefitConfigFields from './BenefitConfigFields.vue';
import BenefitLogoInput from './BenefitLogoInput.vue';
import RichEditor from '@/components/editor/index.vue';
const props = defineProps({
form: { type: Object, required: true },
typeOptions: { type: Array, default: () => [] },
editing: { type: Boolean, default: false },
which: { type: String, required: true },
});
defineEmits(['type-change', 'select-coupons', 'quantity-change', 'remove-coupon']);
const typeLabel = computed(() => props.typeOptions.find(item => item.value === props.form.benefitType)?.description || '');
</script>
<style lang="scss" scoped>
.benefit-section { padding: 24px; border: 1px solid var(--el-border-color-lighter); border-radius: 8px; background: #fff; }
.benefit-section + .benefit-section { margin-top: 16px; }
.benefit-section-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 22px; }
.benefit-section-heading h3 { margin: 0; color: var(--el-text-color-primary); font-size: 15px; font-weight: 600; line-height: 22px; }
.benefit-section-heading span { color: var(--el-text-color-secondary); font-size: 12px; line-height: 20px; }
.benefit-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
.benefit-field-hint { width: 100%; margin-top: 6px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 20px; }
.benefit-section :deep(.el-form-item) { margin-bottom: 22px; }
.benefit-section > :deep(.el-form-item:last-child), .benefit-form-grid > :deep(.el-form-item) { margin-bottom: 0; }
.benefit-section :deep(.el-form-item__label) { padding: 0; margin-bottom: 8px; font-weight: 500; line-height: 22px; }
.benefit-section :deep(.el-form-item__content) { min-width: 0; }
.benefit-section :deep(.el-select) { width: 100%; }
.benefit-section :deep(.el-input-number) { width: 180px; max-width: 100%; }
.benefit-number-field { display: flex; gap: 10px; align-items: center; }
.benefit-input-unit { color: var(--el-text-color-regular); }
.benefit-coupon-field, .benefit-rich-editor { width: 100%; min-width: 0; }
.benefit-coupon-toolbar { display: flex; gap: 12px; flex-wrap: wrap; justify-content: space-between; align-items: center; margin-bottom: 12px; color: var(--el-text-color-secondary); font-size: 12px; }
.benefit-coupon-toolbar strong { color: var(--el-color-primary); font-weight: 500; }
.benefit-coupon-table { border-radius: 6px; overflow: hidden; }
.benefit-logo-field { display: flex; align-items: center; gap: 20px; width: 100%; }
.benefit-logo-preview { width: 88px; height: 88px; flex: 0 0 88px; display: flex; align-items: center; justify-content: center; border: 1px dashed var(--el-border-color); border-radius: 8px; background-color: #fafbfc; overflow: hidden; }
.benefit-logo-preview :deep(.el-image) { width: 64px; height: 64px; }
.benefit-logo-placeholder { color: var(--el-text-color-placeholder); font-size: 12px; }
.benefit-logo-upload { flex: 1; min-width: 0; }
@media (max-width: 640px) {
.benefit-section { padding: 18px 16px; }
.benefit-form-grid { grid-template-columns: minmax(0, 1fr); gap: 22px; }
.benefit-logo-field { flex-direction: column; align-items: flex-start; gap: 12px; }
.benefit-logo-upload { width: 100%; }
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<div class="logo-input">
<UploadPicInput :model-value="modelValue" :before-select="validateSelection" placeholder="请选择图标或粘贴图片地址" @update:model-value="updateLogo" />
<span class="hint">透明PNG,建议100×100像素,上传大小不超过10KB。</span>
</div>
</template>
<script setup>
import UploadPicInput from '@/components/lili/upload-pic-input.vue';
import { nextTick } from 'vue';
import { ElMessage, useFormItem } from 'element-plus';
defineProps({modelValue:{type:String,default:''}});
const emit = defineEmits(['update:modelValue']);
const { formItem } = useFormItem();
function validateSelection({ file }) {
if (!file || file.fileType !== 'image/png' || Number(file.fileSize) > 10240) {
ElMessage.error('请选择不超过10KB的PNG图标');
return false;
}
return true;
}
async function updateLogo(value) {
emit('update:modelValue', value);
await nextTick();
formItem?.validate('change').catch(() => {});
}
</script>
<style scoped>
.logo-input { width: 100%; min-width: 0; }
.hint { display: block; margin-top: 8px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 20px; }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -76,9 +76,11 @@
</el-form-item>
<el-form-item label="等级图标" prop="gradeImage">
<upload-pic-input v-model="formAdd.gradeImage" />
<div class="image-size-hint">推荐 100 × 100 像素,使用透明背景 PNG。</div>
</el-form-item>
<el-form-item label="等级背景图" prop="gradeBackground">
<upload-pic-input v-model="formAdd.gradeBackground" />
<div class="image-size-hint">推荐 750 × 460 像素</div>
</el-form-item>
<el-form-item label="字体颜色" prop="gradeFontColor">
<el-input v-model="formAdd.gradeFontColor" maxlength="20" placeholder="如:#333333" />
@@ -133,9 +135,11 @@
</el-form-item>
<el-form-item label="等级图标" prop="gradeImage">
<upload-pic-input v-model="formEdit.gradeImage" />
<div class="image-size-hint">推荐 100 × 100 像素,使用透明背景 PNG。</div>
</el-form-item>
<el-form-item label="等级背景图" prop="gradeBackground">
<upload-pic-input v-model="formEdit.gradeBackground" />
<div class="image-size-hint">推荐 750 × 460 像素</div>
</el-form-item>
<el-form-item label="字体颜色" prop="gradeFontColor">
<el-input v-model="formEdit.gradeFontColor" maxlength="20" placeholder="如:#333333" />
@@ -456,6 +460,13 @@ onMounted(() => {
});
</script>
<style scoped>
.image-size-hint {
width: 100%;
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 20px;
}
.ops a {
color: #2d8cf0;
cursor: pointer;

View File

@@ -0,0 +1,47 @@
<template>
<div class="search">
<el-card shadow="never"><el-form inline :model="query" class="search-form" @submit.prevent @keyup.enter="search">
<el-form-item label="客户名称"><el-input v-model="query.keyword" placeholder="请输入客户昵称" clearable /></el-form-item><el-form-item label="会员ID"><el-input v-model="query.memberId" placeholder="请输入会员ID" clearable /></el-form-item>
<el-form-item label="支付状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:160px"><el-option label="未支付" value="UNPAID" /><el-option label="已支付" value="PAID" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item>
<el-form-item><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form></el-card>
<el-card shadow="never" class="mt_10"><div class="actions"><el-button :loading="exporting" @click="exportRecords">导出记录</el-button><el-button @click="router.push({name:'paid-member-settings'})">付费会员</el-button></div>
<el-table v-loading="loading" :data="rows" border>
<el-table-column prop="sn" label="会员费订单号" min-width="200" /><el-table-column prop="memberName" label="客户" width="130" /><el-table-column prop="memberId" label="会员ID" min-width="180" />
<el-table-column label="会员名称" min-width="140"><template #default="{row}">{{ name(row) }}</template></el-table-column>
<el-table-column prop="amount" label="付费金额(元)" width="115" /><el-table-column prop="months" label="有效月数" width="90" /><el-table-column label="支付状态" width="110"><template #default="{row}">{{ status(row) }}</template></el-table-column>
<el-table-column label="会员状态" width="110"><template #default="{row}">{{ membership(row) }}</template></el-table-column><el-table-column prop="payTime" label="支付时间" width="175" /><el-table-column prop="startTime" label="生效时间" width="175" /><el-table-column prop="endTime" label="到期时间" width="175" />
<el-table-column prop="paymentMethod" label="支付方式" width="115" /><el-table-column prop="createTime" label="创建时间" width="175" />
</el-table>
<el-pagination v-model:current-page="query.pageNumber" :page-size="20" :total="total" layout="total, prev, pager, next" @current-change="load" />
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { getPaidOrders } from '@/api/paid-member';
defineOptions({name:'paidMemberRecords'});
const router=useRouter(), query=reactive({keyword:'',memberId:'',status:'',pageNumber:1,pageSize:20});
const rows=ref([]), total=ref(0), loading=ref(false), exporting=ref(false);
let request=0;
const time=value => value ? new Date(String(value).replace(/-/g,'/')).getTime() : 0;
function status(row) { return row.status === 'UNPAID' && time(row.payDeadline)<Date.now() ? '已超时' : ({UNPAID:'未支付',PAID:'已支付',CANCELLED:'已取消'}[row.status] || row.status); }
function membership(row) { return row.status !== 'PAID' ? '未开通' : time(row.startTime)>Date.now() ? '待生效' : time(row.endTime)<=Date.now() ? '已到期' : '有效'; }
function name(row) { try { return JSON.parse(row.configSnapshot).name; } catch { return '付费会员'; } }
async function load() { const id=++request;loading.value=true;try {const res=await getPaidOrders({...query});if(id===request && res.success){rows.value=res.result.records||[];total.value=res.result.total||0;}} finally {if(id===request)loading.value=false;} }
function search(){query.pageNumber=1;load();} function reset(){query.keyword='';query.memberId='';query.status='';search();}
async function exportRecords() {
exporting.value=true;
try {
const list=[], filters={...query}; let page=1;
while(true){const res=await getPaidOrders({...filters,pageNumber:page,pageSize:100});if(!res.success)return;const records=res.result.records||[];if(!records.length)break;list.push(...records);if(list.length>=res.result.total)break;if(list.length>=10000){ElMessage.warning('一次最多导出10000条,请缩小筛选范围');return;}page++;}
const quote=value=>{let text=String(value??'');if(/^[=+\-@]/.test(text))text="'"+text;return '"'+text.replace(/"/g,'""')+'"';};
const csv=[['会员费订单号','客户','会员ID','会员名称','付费金额','有效月数','支付状态','会员状态','支付时间','生效时间','到期时间','支付方式'],...list.map(r=>[r.sn,r.memberName,r.memberId,name(r),r.amount,r.months,status(r),membership(r),r.payTime,r.startTime,r.endTime,r.paymentMethod])].map(row=>row.map(quote).join(',')).join('\r\n');
const url=URL.createObjectURL(new Blob(['\uFEFF'+csv],{type:'text/csv;charset=utf-8;'}));const a=document.createElement('a');a.href=url;a.download='付费记录.csv';a.click();URL.revokeObjectURL(url);
} finally {exporting.value=false;}
}
onMounted(load);
</script>
<style scoped>.actions { display:flex; gap:12px; margin-bottom:18px; }.el-pagination { margin-top:20px;justify-content:flex-end; }</style>

View File

@@ -0,0 +1,159 @@
<template>
<div class="paid-settings" v-loading="loading">
<el-alert v-if="loadError" title="设置加载失败,请点击重试" type="error" :closable="false"><el-button link @click="loadSettings">重新加载</el-button></el-alert>
<el-form :model="form" label-width="150px" @submit.prevent>
<el-card shadow="never"><template #header>基本信息</template>
<el-form-item label="开放购买/续费"><el-switch v-model="form.enabled" /><span class="hint inline">关闭后,已支付会员仍有效至到期</span></el-form-item>
<el-form-item label="会员名称" required><el-input v-model="form.name" maxlength="50" style="width:360px" /></el-form-item>
<el-form-item label="会员图标"><UploadPicInput v-model="form.icon" /><div class="hint">推荐100 × 100像素,透明PNG</div></el-form-item>
<el-form-item label="会员背景"><el-radio-group v-model="form.backgroundType"><el-radio value="COLOR">背景色</el-radio><el-radio value="IMAGE">背景图</el-radio></el-radio-group><el-color-picker v-if="form.backgroundType === 'COLOR'" v-model="form.backgroundColor" class="inline" /></el-form-item>
<el-form-item v-if="form.backgroundType === 'IMAGE'" label="背景图"><UploadPicInput v-model="form.backgroundImage" /></el-form-item>
<el-form-item label="字体颜色"><el-color-picker v-model="form.fontColor" /></el-form-item>
<el-form-item label="开通价格" required><el-input-number v-model="form.price" :min="0.01" :max="1000000" :precision="2" :controls="false" /><span class="inline">元</span></el-form-item>
<el-form-item label="会员有效期" required><el-input-number v-model="form.months" :min="1" :max="120" :precision="0" :controls="false" /><span class="inline">个月</span><div class="hint">按自然月计算;未到期续费从原到期时间顺延。</div></el-form-item>
<el-form-item label="关联客户权益"><el-select v-model="form.benefitIds" multiple filterable style="width:480px;max-width:100%" placeholder="选择权益"><el-option v-for="b in benefitOptions" :key="b.id" :label="b.benefitName" :value="String(b.id)" /></el-select></el-form-item>
</el-card>
<el-card shadow="never"><template #header>商家设置</template>
<el-form-item label="商家范围"><el-radio-group v-model="form.storeScope"><el-radio value="SELF">自营商家</el-radio><el-radio value="CUSTOM">自定义选择</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="form.storeScope === 'CUSTOM'" label="选择商家"><el-button @click="openPicker('stores')">添加商家</el-button><div class="picked-tags"><el-tag v-for="id in form.storeIds" :key="id" closable @close="remove('storeIds', id)">{{ stores[id]?.storeName || id }}</el-tag></div></el-form-item>
</el-card>
<el-card shadow="never"><template #header>商品折扣</template>
<el-form-item label="商品会员折扣"><el-radio-group v-model="form.discountMode"><el-radio value="ALL">所有适用商品统一折扣</el-radio><el-radio value="CUSTOM">自定义商品折扣</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="form.discountMode === 'ALL'" label="统一折扣"><el-input-number v-model="form.discount" :min="0.01" :max="10" :precision="2" :controls="false" /><span class="inline">折</span></el-form-item>
<el-form-item v-else label="折扣商品"><div class="full"><el-button @click="openPicker('discount')">添加商品</el-button><el-table :data="filteredDiscountRules" border class="mt_10"><el-table-column label="商品" min-width="230"><template #default="{row}">{{ goods[row.skuId]?.goodsName || row.skuId }}</template></el-table-column><el-table-column label="SKU编码" width="180"><template #default="{row}">{{ goods[row.skuId]?.sn || row.skuId }}</template></el-table-column><el-table-column label="规格" min-width="160" show-overflow-tooltip><template #default="{row}">{{ goods[row.skuId]?.simpleSpecs?.trim() || '—' }}</template></el-table-column><el-table-column label="分类" min-width="180" show-overflow-tooltip><template #default="{row}">{{ goods[row.skuId]?.categoryName || '—' }}</template></el-table-column><el-table-column label="品牌" min-width="120" show-overflow-tooltip><template #default="{row}">{{ goods[row.skuId]?.brandName || '—' }}</template></el-table-column><el-table-column label="售价" width="120" align="right"><template #default="{row}">{{ goods[row.skuId]?.price == null ? '—' : '¥' + Number(goods[row.skuId].price).toFixed(2) }}</template></el-table-column><el-table-column label="折扣" width="190"><template #default="{row}"><el-input-number v-model="row.discount" :min="0.01" :max="10" :precision="2" style="width:140px" :controls="false" /> 折</template></el-table-column><el-table-column label="操作" width="80"><template #default="{row}"><el-button link type="danger" @click="form.goodsRules = form.goodsRules.filter(r => r.skuId !== row.skuId)">移除</el-button></template></el-table-column></el-table></div></el-form-item>
</el-card>
<el-card shadow="never"><template #header>会员中心推荐商品</template>
<el-button @click="openPicker('recommended')">添加商品</el-button>
<el-table :data="form.recommendedSkuIds.map(id => goods[id] || {id})" border class="mt_10"><el-table-column prop="sn" label="SKU编码" width="180" /><el-table-column label="商品名称" min-width="240"><template #default="{row}">{{ row.goodsName || row.id }}</template></el-table-column><el-table-column label="规格" min-width="160" show-overflow-tooltip><template #default="{row}">{{ row.simpleSpecs?.trim() || '—' }}</template></el-table-column><el-table-column label="分类" min-width="180" show-overflow-tooltip><template #default="{row}">{{ row.categoryName || '—' }}</template></el-table-column><el-table-column label="品牌" min-width="120" show-overflow-tooltip><template #default="{row}">{{ row.brandName || '—' }}</template></el-table-column><el-table-column label="售价" width="120" align="right"><template #default="{row}">{{ row.price == null ? '—' : '¥' + Number(row.price).toFixed(2) }}</template></el-table-column><el-table-column label="操作" width="90"><template #default="{row}"><el-button link type="danger" @click="remove('recommendedSkuIds', String(row.id))">移除</el-button></template></el-table-column></el-table>
</el-card>
<el-card shadow="never">
<template #header>展示设置</template>
<div class="entry-settings-grid">
<section class="entry-settings-block">
<h4>未开通会员入口</h4>
<el-form-item label="入口文案" required><el-input v-model="form.unopenedText" maxlength="50" show-word-limit class="entry-text-input" /></el-form-item>
<el-form-item label="字体颜色" required><el-color-picker v-model="form.unopenedFontColor" /><span class="entry-color-value">{{ form.unopenedFontColor }}</span></el-form-item>
<el-form-item label="背景图"><UploadPicInput v-model="form.unopenedBackground" /><div class="hint">用于个人中心未开通及已到期入口,推荐 750 × 160 像素,支持 PNG/JPG;不设置时使用默认渐变背景。</div></el-form-item>
<el-form-item label="效果预览"><div class="entry-preview" :style="{color: form.unopenedFontColor}"><img v-if="form.unopenedBackground" :src="form.unopenedBackground" alt="未开通入口背景" /><div class="entry-preview-content"><div><strong>{{ form.unopenedText || '开通付费会员,享专属权益' }}</strong><small>开通享商品优惠与专属权益</small></div><span>›</span></div></div></el-form-item>
</section>
<section class="entry-settings-block">
<h4>已开通会员入口</h4>
<el-form-item label="入口文案" required><el-input v-model="form.openedText" maxlength="50" show-word-limit class="entry-text-input" /></el-form-item>
<el-form-item label="字体颜色" required><el-color-picker v-model="form.openedFontColor" /><span class="entry-color-value">{{ form.openedFontColor }}</span></el-form-item>
<el-form-item label="背景图"><UploadPicInput v-model="form.openedBackground" /><div class="hint">用于个人中心有效会员入口,推荐 750 × 160 像素,支持 PNG/JPG;不设置时使用默认渐变背景。</div></el-form-item>
<el-form-item label="效果预览"><div class="entry-preview" :style="{color: form.openedFontColor}"><img v-if="form.openedBackground" :src="form.openedBackground" alt="已开通入口背景" /><div class="entry-preview-content"><div><strong>{{ form.openedText || '我的付费会员' }}</strong><small>有效期至 2027-11-05(示例)</small></div><span>›</span></div></div></el-form-item>
</section>
</div>
<el-form-item label="会员价格标签"><UploadPicInput v-model="form.priceLabel" /><div class="hint">展示在商品列表、商品详情的会员价前,推荐 48 × 48 像素、透明 PNG;不设置时只显示会员价文字。</div></el-form-item>
<el-form-item label="标签预览"><div class="price-label-preview"><img v-if="form.priceLabel" :src="form.priceLabel" alt="会员价格标签" /><span>会员价 ¥88.00</span></div></el-form-item>
</el-card>
<el-card shadow="never" class="document-card">
<template #header><div class="document-heading"><div><span>购买说明</span><div class="document-hint">展示在移动端付费会员页面,可填写权益介绍和使用说明。</div></div><el-button @click="fullscreenEditor(descriptionEditor)">全屏编辑</el-button></div></template>
<el-form-item label-width="0" class="document-field"><RichEditor ref="descriptionEditor" v-model="form.description" image-picker height="520px" class="full" /></el-form-item>
</el-card>
<el-card shadow="never" class="document-card">
<template #header><div class="document-heading"><div><span>会员协议</span><div class="document-hint">用户购买前阅读并同意的协议,开启购买前请完善内容。</div></div><el-button @click="fullscreenEditor(agreementEditor)">全屏编辑</el-button></div></template>
<el-form-item label-width="0" class="document-field"><RichEditor ref="agreementEditor" v-model="form.agreement" image-picker height="600px" class="full" /></el-form-item>
</el-card>
<div class="save-bar"><el-button type="primary" :loading="saving" :disabled="loading || !loaded" @click="save">保存设置</el-button></div>
</el-form>
<StoreSelector ref="picker" @selected="selected" />
<GoodsSelector ref="goodsPicker" :goods-loader="discountPicking ? getPaidDiscountSkus : undefined" :query-params="discountQuery" @selectedGoodsData="selected" />
</div>
</template>
<script setup>
import { reactive, ref, onMounted, watch, computed } from 'vue';
import { ElMessage } from 'element-plus';
import UploadPicInput from '@/components/lili/upload-pic-input.vue';
import RichEditor from '@/components/editor/index.vue';
import StoreSelector from '@/components/store-selector/index.vue';
import GoodsSelector from '@/components/lili-dialog/index.vue';
import { getPaidConfig, savePaidConfig, getPaidResources, getPaidDiscountSkus } from '@/api/paid-member';
import { getMemberBenefitByPage } from '@/api/member';
defineOptions({name:'paidMemberSettings'});
const form = reactive({ goodsRules: [], storeIds: [], recommendedSkuIds: [], benefitIds: [] });
const loading = ref(false), loaded = ref(false), loadError = ref(false), saving = ref(false), picker = ref(), benefitOptions = ref([]), stores = reactive({}), goods = reactive({});
const descriptionEditor = ref(), agreementEditor = ref(), goodsPicker = ref(), discountPicking = ref(false);
const discountQuery = computed(() => discountPicking.value ? { storeScope: form.storeScope, storeIds: form.storeIds.join(',') } : {});
const filteredDiscountRules = computed(() => form.goodsRules.filter(rule => storeAllowed(goods[rule.skuId])));
function fullscreenEditor(editor) { editor?.getEditor()?.execCommand('mceFullScreen'); }
let target = '';
function remember(rows, type) { rows.forEach(row => { const cache = type === 'STORE' ? stores : goods; cache[String(row.id)] = { ...cache[String(row.id)], ...row }; }); }
function remove(field, id) { form[field] = form[field].filter(value => value !== id); }
async function openPicker(value) {
target = value;
if (value === 'stores') {
picker.value.open(form.storeIds.map(id => ({ ...stores[id], id: String(id) })));
return;
}
discountPicking.value = value === 'discount';
if (discountPicking.value) {
if (form.storeScope === 'CUSTOM' && !form.storeIds.length) { ElMessage.warning('请先选择参与商家'); return; }
try { await reconcileDiscounts(); } catch { ElMessage.error('商品商家信息加载失败,请重试'); return; }
}
const ids = value === 'discount' ? form.goodsRules.map(rule => rule.skuId) : form.recommendedSkuIds;
goodsPicker.value.goodsData = ids.map(id => ({ ...goods[id], id: String(id) }));
goodsPicker.value.open('goods');
}
async function selected(rows) {
remember(rows, target === 'stores' ? 'STORE' : 'SKU');
if (target === 'stores') { form.storeIds = rows.map(row => String(row.id)); return; }
if (target === 'discount') {
try { await hydrate('SKU', rows.map(row => String(row.id))); } catch { ElMessage.error('商品商家信息加载失败,请重试'); return; }
const validRows = rows.filter(row => storeAllowed(goods[String(row.id)]));
if (validRows.length !== rows.length) ElMessage.warning('已排除不属于参与商家的商品');
const discounts = new Map(form.goodsRules.map(rule => [String(rule.skuId), rule.discount]));
form.goodsRules = validRows.map(row => ({ skuId: String(row.id), discount: discounts.get(String(row.id)) ?? form.discount ?? 9.5 }));
} else { form.recommendedSkuIds = rows.map(row => String(row.id)); }
const missing = rows.filter(row => !Object.hasOwn(row, 'categoryName') || !Object.hasOwn(row, 'brandName'));
if (missing.length) { try { await hydrate('SKU', missing.map(row => String(row.id))); } catch { ElMessage.warning('商品分类和品牌加载失败,可刷新页面重试'); } }
}
async function save() { saving.value = true; try { await reconcileDiscounts(); const res = await savePaidConfig({...form}); if (res.success) ElMessage.success('付费会员设置已保存'); } finally { saving.value = false; } }
function storeAllowed(sku) {
return !!sku && (form.storeScope === 'SELF' ? sku.storeSelfOperated === true : form.storeIds.map(String).includes(String(sku.storeId)));
}
async function reconcileDiscounts() {
const missing = form.goodsRules.filter(rule => !Object.hasOwn(goods[rule.skuId] || {}, 'storeSelfOperated'));
if (missing.length) await hydrate('SKU', missing.map(rule => rule.skuId));
const remaining = form.goodsRules.filter(rule => storeAllowed(goods[rule.skuId]));
if (remaining.length !== form.goodsRules.length) { form.goodsRules = remaining; ElMessage.warning('已移除不属于当前参与商家的折扣商品'); }
}
watch(() => [form.storeScope, ...form.storeIds], () => {
if (loading.value || !loaded.value) return;
reconcileDiscounts().catch(() => ElMessage.error('商品商家信息加载失败,请重试'));
}, { flush: 'sync' });
async function hydrate(type, ids) {
for (let i=0; i<ids.length; i+=100) { const res = await getPaidResources(type, ids.slice(i,i+100)); if (!res.success) throw new Error('resources'); remember(res.result, type); }
}
async function loadSettings() {
loading.value = true; loaded.value = false; loadError.value = false;
try {
const [config, rights] = await Promise.all([getPaidConfig(), getMemberBenefitByPage({pageNumber:1,pageSize:200})]);
if (!config.success) { loadError.value = true; return; }
Object.assign(form, config.result); loaded.value = true; benefitOptions.value = rights.result?.records || [];
const ids = [...new Set([...form.recommendedSkuIds, ...form.goodsRules.map(r => r.skuId)])];
await Promise.all([hydrate('SKU',ids), hydrate('STORE',form.storeIds)]);
} catch { loadError.value = true; } finally { loading.value = false; }
}
onMounted(loadSettings);
</script>
<style scoped>
.entry-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; margin-bottom: 24px; }
.entry-settings-block { min-width: 0; border: 1px solid #ebeef5; border-radius: 6px; padding: 20px 16px 0; }
.entry-settings-block h4 { margin: 0 0 24px; color: #303133; font-size: 14px; }
.entry-text-input { width: 480px; max-width: 100%; }
.entry-color-value { margin-left: 12px; color: #909399; font-size: 12px; }
.entry-preview { position: relative; overflow: hidden; width: 375px; max-width: 100%; min-height: 80px; border-radius: 10px; background: linear-gradient(110deg, #423c36, #756149); }
.entry-preview > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.entry-preview-content { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px; min-height: 80px; box-sizing: border-box; line-height: 1.5; }
.entry-preview-content strong { font-size: 14px; }.entry-preview-content small { display: block; margin-top: 4px; opacity: .75; font-size: 11px; }.entry-preview-content > span { font-size: 24px; }
.price-label-preview { display: flex; align-items: center; gap: 6px; color: #a77c3e; font-size: 13px; }.price-label-preview img { width: 24px; height: 24px; object-fit: contain; }
@media (max-width: 1400px) { .entry-settings-grid { grid-template-columns: minmax(0, 1fr); } }
.document-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.document-heading > div { min-width: 0; }
.document-hint { margin-top: 8px; color: #909399; font-size: 12px; font-weight: normal; line-height: 20px; }
.document-field { margin-bottom: 0; }
.document-field :deep(.el-form-item__content) { min-width: 0; }
.document-card :deep(.tox-tinymce) { border-color: #dcdfe6; }
.paid-settings { padding-bottom: 70px; }.el-card { margin-top: 16px; }.hint { width: 100%; font-size: 12px; line-height: 20px; margin-top: 6px; color: #909399; }.inline { margin-left: 12px; }.hint.inline { width: auto; margin-top: 0; }.full { width: 100%; min-width: 0; }.picked-tags { width: 100%; display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }.save-bar { position: sticky; bottom: 0; padding: 16px; background: #fff; border-top: 1px solid #ebeef5; display: flex; justify-content: flex-end; z-index: 10; }
</style>

View File

@@ -368,6 +368,7 @@
/>
<div class="goods-info">
<div class="goods-name">
<el-tag v-if="isGiftItem(row)" size="small" type="danger" effect="plain" class="mr_5">赠品</el-tag>
<span>{{ row.goodsName }}</span>
</div>
<div v-if="row.specs" class="goods-specs">
@@ -391,7 +392,11 @@
<el-table-column label="单价" width="150" align="center">
<template #default="{ row }">
<span class="font-mono font-bold">¥ {{ Number(row.unitPrice || 0).toFixed(2) }}</span>
<template v-if="isGiftItem(row)">
<div class="font-mono text-gray" style="text-decoration: line-through;">¥ {{ Number(row.unitPrice || 0).toFixed(2) }}</div>
<div class="text-danger font-bold">赠品 ¥ 0.00</div>
</template>
<span v-else class="font-mono font-bold">¥ {{ Number(row.unitPrice || 0).toFixed(2) }}</span>
</template>
</el-table-column>
@@ -1159,10 +1164,16 @@ function formatOperatorType(type) {
return map[type] || type || "系统";
}
function isGiftItem(row) {
if (!row) return false;
return row.promotionType === 'GIFT' || (Number(row.flowPrice) === 0 && row.promotionType === 'GIFT');
}
function getPromotionText(row) {
let resultText = "";
if (row && row.promotionType) {
const type = row.promotionType.split(",");
if (type.indexOf("GIFT") !== -1) resultText += "满赠赠品 ";
if (type.indexOf("PINTUAN") !== -1) resultText += "拼团 ";
if (type.indexOf("SECKILL") !== -1) resultText += "秒杀 ";
if (type.indexOf("COUPON") !== -1) resultText += "优惠券 ";

View File

@@ -37,7 +37,7 @@
</el-select>
</el-form-item>
<el-form-item label="支付方式" prop="paymentMethod">
<el-select v-model="searchForm.paymentMethod" placeholder="请选择支付方式" clearable>
<el-select v-model="searchForm.paymentMethod" placeholder="请选择支付方式" :disabled="!!fixedPaymentMethod" :clearable="!fixedPaymentMethod">
<el-option v-for="item in paymentOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
@@ -52,6 +52,7 @@
@change="selectDateRange"
/>
</el-form-item>
<slot name="extra-filters" :form="searchForm" />
<el-form-item class="order-search-action">
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
@@ -70,10 +71,13 @@
/>
</el-tabs>
</div>
<div>
<div v-if="allowExport">
<el-button type="warning" class="export" :loading="exporting" @click="exportOrder">导出订单</el-button>
</div>
<el-alert v-if="loadError" title="订单加载失败,请重试" type="error" :closable="false" show-icon>
<el-button link type="primary" @click="init">重新加载</el-button>
</el-alert>
<el-table v-loading="loading" :data="data" ref="tableRef" class="mt_10" style="width: 100%">
<el-table-column prop="sn" label="订单号" min-width="200" show-overflow-tooltip />
<el-table-column label="商品" min-width="280">
@@ -126,9 +130,11 @@
</template>
</el-table-column>
<el-table-column prop="createTime" label="下单时间" width="170" />
<el-table-column label="操作" width="80" align="center" fixed="right">
<slot name="extra-columns" />
<el-table-column label="操作" :width="actionWidth" align="center" fixed="right">
<template #default="{ row }">
<a class="link-text" @click="detail(row)">查看</a>
<slot name="extra-actions" :row="row" />
</template>
</el-table-column>
</el-table>
@@ -157,6 +163,15 @@ import { ElMessage, ElMessageBox } from "element-plus";
import { unitPrice, customRouterPush } from "@/utils/filters";
const PAGE_SIZES = [20, 50, 100];
// 独立渠道页面复用列表,来源限制不从地址栏或可重置表单中读取。
const props = defineProps({
fixedClientType: { type: String, default: "" },
fixedPaymentMethod: { type: String, default: "" },
allowExport: { type: Boolean, default: true },
wechatSync: { type: Boolean, default: false },
actionWidth: { type: Number, default: 80 },
});
const PROMOTION_OPTIONS = [
{ label: "普通订单", value: "NORMAL" },
{ label: "拼团订单", value: "PINTUAN" },
@@ -195,8 +210,9 @@ function emptySearchForm(pageSize = 20) {
consigneeMobile: "",
storeName: "",
orderStatus: "",
paymentMethod: "",
paymentMethod: props.fixedPaymentMethod,
orderPromotionType: "",
...(props.wechatSync ? { wechatSyncStatus: "" } : {}),
};
}
@@ -209,6 +225,9 @@ const searchFormRef = ref(null);
const tableRef = ref(null);
const loading = ref(true);
const loadError = ref(false);
let listRequestSeq = 0;
let countRequestSeq = 0;
const exporting = ref(false);
const pageSizes = ref(PAGE_SIZES);
const promotionOptions = ref(PROMOTION_OPTIONS);
@@ -367,7 +386,7 @@ function selectDateRange(v) {
}
}
function countParams() {
const params = { ...searchForm.value };
const params = listParams();
delete params.orderStatus;
delete params.pageNumber;
delete params.pageSize;
@@ -375,17 +394,35 @@ function countParams() {
delete params.order;
return params;
}
function listParams() {
const params = { ...searchForm.value };
if (props.fixedClientType) params.clientType = props.fixedClientType;
if (props.fixedPaymentMethod) params.paymentMethod = props.fixedPaymentMethod;
return params;
}
function getDataList() {
const requestId = ++listRequestSeq;
loading.value = true;
API_Order.getOrderList(searchForm.value)
loadError.value = false;
const fetchList = props.wechatSync ? API_Order.getWechatOrderList : API_Order.getOrderList;
fetchList(listParams())
.then((res) => {
if (requestId !== listRequestSeq) return;
if (res.success) {
data.value = res.result.records;
total.value = res.result.total;
} else {
throw new Error("订单查询失败");
}
})
.catch(() => {
if (requestId !== listRequestSeq) return;
data.value = [];
total.value = 0;
loadError.value = true;
})
.finally(() => {
loading.value = false;
if (requestId === listRequestSeq) loading.value = false;
});
}
function detail(v) {
@@ -395,6 +432,7 @@ function detail(v) {
});
}
async function exportOrder() {
if (!props.allowExport) return;
if (!searchForm.value.startDate || !searchForm.value.endDate) {
try {
await ElMessageBox.confirm(
@@ -407,7 +445,7 @@ async function exportOrder() {
}
}
exporting.value = true;
API_Order.exportOrder(searchForm.value)
API_Order.exportOrder(listParams())
.then((res) => {
const blob = new Blob([res], {
type: "application/vnd.ms-excel;charset=utf-8",
@@ -440,14 +478,19 @@ function orderStatusClick(name) {
getDataList();
}
function getOrderNumData() {
API_Order.getOrderNum(countParams())
const requestId = ++countRequestSeq;
const fetchCount = props.wechatSync ? API_Order.getWechatOrderNum : API_Order.getOrderNum;
fetchCount(countParams())
.then((res) => {
if (requestId !== countRequestSeq) return;
if (res.success) {
orderNumData.value = res.result;
} else {
orderNumData.value = {};
}
})
.catch(() => {
orderNumData.value = {};
if (requestId === countRequestSeq) orderNumData.value = {};
});
}
@@ -462,6 +505,9 @@ watch(
}
);
// 补报完成后刷新当前筛选和页码,不清空运营人员已选条件。
defineExpose({ reload: init });
onMounted(() => {
try {
sessionStorage.removeItem("manager-order-list-query");

View File

@@ -0,0 +1,88 @@
<!--
小程序订单同步日志抽屉:按支付流水展示审计记录,不显示原始身份或支付凭据。
@author Bulbasaur
@since 2026-11-09
-->
<template>
<el-drawer :model-value="modelValue" title="微信订单同步日志" size="min(1100px, 95vw)" @update:model-value="emit('update:modelValue', $event)">
<p>订单:{{ orderSn }}。日志包含同笔微信支付下的关联子订单操作;上报成功仅代表微信接口已受理。</p>
<el-button :loading="loading" @click="load">刷新</el-button>
<el-alert v-if="failed" title="日志加载失败,请点击刷新重试" type="error" :closable="false" class="mt_10" />
<el-table v-loading="loading" :data="records" class="mt_10" empty-text="暂无本地同步记录,历史上报结果需核查">
<el-table-column type="expand">
<template #default="{ row }">
<div class="log-detail">
<p>触发订单:{{ row.orderSn }}</p>
<p>请求摘要:{{ row.summary || '—' }}</p>
<p>错误码:{{ row.errorCode || '—' }}</p>
<p>处理原因:{{ row.errorMessage || '—' }}</p>
<p>结束时间:{{ row.finishTime || '—' }};耗时:{{ row.durationMs == null ? '—' : `${row.durationMs} 毫秒` }}</p>
</div>
</template>
</el-table-column>
<el-table-column prop="createTime" label="操作时间" width="175" />
<el-table-column label="操作" width="110">
<template #default="{ row }">{{ row.operation === 'RECEIVE' ? '收货通知' : '发货上报' }}</template>
</el-table-column>
<el-table-column label="来源" width="110">
<template #default="{ row }">{{ row.triggerType === 'MANUAL' ? '人工补报' : '自动事件' }}</template>
</el-table-column>
<el-table-column prop="operatorName" label="操作人" min-width="100" />
<el-table-column label="结果" min-width="120">
<template #default="{ row }"><el-tag :type="wechatSyncTag(row.status)">{{ wechatSyncLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="errorMessage" label="处理原因" min-width="220" show-overflow-tooltip />
</el-table>
<el-pagination v-model:current-page="pageNumber" :page-size="20" :total="total" layout="total, prev, pager, next" class="mt_10" @current-change="load" />
</el-drawer>
</template>
<script setup>
import { ref, watch } from "vue";
import { getWechatOrderLogs } from "@/api/order";
import { wechatSyncLabel, wechatSyncTag } from "@/utils/wechatOrderSync";
const props = defineProps({ modelValue: Boolean, orderSn: { type: String, default: "" } });
const emit = defineEmits(["update:modelValue"]);
const records = ref([]);
const loading = ref(false);
const failed = ref(false);
const total = ref(0);
const pageNumber = ref(1);
let requestSeq = 0;
/** 请求序号隔离不同订单及页码,关闭抽屉后不接受过期响应。 */
async function load() {
if (!props.modelValue || !props.orderSn) return;
const id = ++requestSeq;
loading.value = true;
failed.value = false;
try {
const res = await getWechatOrderLogs(props.orderSn, { pageNumber: pageNumber.value, pageSize: 20 });
if (id !== requestSeq) return;
if (!res.success) throw new Error("日志加载失败");
records.value = res.result.records;
total.value = res.result.total;
} catch (e) {
if (id !== requestSeq) return;
failed.value = true;
records.value = [];
total.value = 0;
} finally {
if (id === requestSeq) loading.value = false;
}
}
watch(() => [props.modelValue, props.orderSn], () => {
requestSeq++;
records.value = [];
total.value = 0;
pageNumber.value = 1;
loading.value = false;
if (props.modelValue) load();
}, { immediate: true });
</script>
<style scoped>
.log-detail { padding: 0 20px; overflow-wrap: anywhere; }
</style>

View File

@@ -0,0 +1,87 @@
<template>
<div>
<el-alert
title="小程序订单"
type="info"
:closable="false"
show-icon
class="wechat-order-notice"
description="仅展示微信小程序下单且使用微信支付的订单。自动上报开关开启且微信侧已开通发货管理时,按现有履约流程上报。"
/>
<OrderList ref="orderListRef" fixed-client-type="WECHAT_MP" fixed-payment-method="WECHAT" :allow-export="false" wechat-sync :action-width="220">
<template #extra-filters="{ form }">
<el-form-item label="上报状态" prop="wechatSyncStatus">
<el-select v-model="form.wechatSyncStatus" placeholder="全部上报状态" clearable>
<el-option v-for="item in wechatSyncOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</template>
<template #extra-columns>
<el-table-column label="微信上报状态" width="130">
<template #default="{ row }"><el-tag :type="wechatSyncTag(row.wechatSyncStatus)">{{ wechatSyncLabel(row.wechatSyncStatus) }}</el-tag></template>
</el-table-column>
<el-table-column prop="wechatSyncTime" label="最近上报时间" width="175" />
<el-table-column prop="wechatSyncError" label="上报处理原因" min-width="230" show-overflow-tooltip />
</template>
<template #extra-actions="{ row }">
<el-button link type="primary" @click="showLogs(row)">同步日志</el-button>
<el-button link type="warning" :loading="retryingSn === row.sn" :disabled="!!retryingSn" @click="retry(row)">补报</el-button>
</template>
</OrderList>
<SyncLogDrawer v-model="logVisible" :order-sn="logOrderSn" />
</div>
</template>
<script setup>
import OrderList from "../order/orderList.vue";
import SyncLogDrawer from "./SyncLogDrawer.vue";
import { ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { retryWechatOrder } from "@/api/order";
import { wechatSyncOptions, wechatSyncLabel, wechatSyncTag } from "@/utils/wechatOrderSync";
defineOptions({ name: "wechatOrderList" });
const orderListRef = ref(null);
const retryingSn = ref("");
const logVisible = ref(false);
const logOrderSn = ref("");
/** 展示当前订单所在微信支付流水的审计日志。 */
function showLogs(row) {
logOrderSn.value = row.sn;
logVisible.value = true;
}
/** 人工补报需要明确确认范围;同笔支付共用锁,已成功且内容未变时由后端跳过。 */
async function retry(row) {
if (retryingSn.value) return;
try {
const historyNotice = row.wechatSyncStatus === "UNCHECKED" ? "该订单没有本地上报记录,请先核查微信后台,确认需要补报后继续。" : "";
await ElMessageBox.confirm(historyNotice + "将按该笔微信支付的全部关联订单归集补报。人工补报不受自动上报开关限制;已成功且内容未变化会跳过。", "补报微信发货信息", { type: "warning", confirmButtonText: "确认补报", cancelButtonText: "取消" });
} catch (e) { return; }
if (retryingSn.value) return;
retryingSn.value = row.sn;
try {
const res = await retryWechatOrder(row.sn);
if (!res.success) throw new Error(res.message || "补报失败");
if (res.result === "SUCCESS") ElMessage.success("微信接口已受理发货信息");
else if (res.result === "SKIPPED") ElMessage.success("相同发货信息已成功上报,本次已跳过");
else {
ElMessage.warning("本次未完成上报,请查看同步日志中的处理原因");
showLogs(row);
}
} catch (e) {
ElMessage.error("补报未完成,请刷新状态并查看日志后再处理");
} finally {
retryingSn.value = "";
orderListRef.value?.reload();
}
}
</script>
<style scoped>
.wechat-order-notice {
margin-bottom: 16px;
}
</style>

View File

@@ -320,10 +320,10 @@ if (props.options.couponUrl === undefined) props.options.couponUrl = '/member/co
if (props.options.orderUrl === undefined) props.options.orderUrl = '/member/order';
const defaultServices = [
{ name: '收藏', url: '/member/favorites', icon: 'Star' },
{ name: '足迹', url: '/member/footprint', icon: 'Clock' },
{ name: '红包', url: '/couponCenter', icon: 'Present' },
{ name: '客服', url: '/article', icon: 'Service' }
{ name: '收藏', url: '/home/Favorites', icon: 'Star' },
{ name: '足迹', url: '/home/MyTracks', icon: 'Clock' },
{ name: '领券', url: '/coupon', icon: 'Present' },
{ name: '帮助', url: '/article', icon: 'Service' }
];
const ensureServices = computed(() => {

View File

@@ -443,7 +443,7 @@ export const COMPONENT_DEFINITIONS = [
{ title: '数码潮玩 · 爆款立减500', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_1.png', url: '/goodsList?category=1' },
{ title: '美妆奢宠 · 每满300减50', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_2.png', url: '/goodsList?category=2' },
{ title: '运动潮品 · 爆款低至5折', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_3.png', url: '/goodsList?category=3' },
{ title: '品质生活 · 限时第2件半价', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_4.png', url: '/goodsList?category=4' }
{ title: '品质生活 · 精选好物', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_4.png', url: '/goodsList?category=4' }
]
},
defaultComponentStyle: {

View File

@@ -49,7 +49,7 @@ const brandList = computed(() => {
{ title: '数码潮玩 · 爆款立减500', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_1.png', url: '/goodsList?category=1' },
{ title: '美妆奢宠 · 每满300减50', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_2.png', url: '/goodsList?category=2' },
{ title: '运动潮品 · 爆款低至5折', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_3.png', url: '/goodsList?category=3' },
{ title: '品质生活 · 限时第2件半价', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_4.png', url: '/goodsList?category=4' }
{ title: '品质生活 · 精选好物', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_4.png', url: '/goodsList?category=4' }
];
});

View File

@@ -10,7 +10,7 @@
<span class="val">{{ cp.price }}</span>
</div>
<div class="right-info">
<span class="title">满{{ cp.consumeThreshold }}可用</span>
<span class="title">{{ cp.consumeThreshold > 0 ? '满' + cp.consumeThreshold + '可用' : '无门槛使用' }}</span>
<span class="scope" v-if="cp.title">{{ cp.title }}</span>
<span class="btn">立即领取</span>
</div>

View File

@@ -78,10 +78,10 @@ const props = defineProps({
});
const defaultServices = [
{ name: '收藏', url: '/member/favorites', icon: 'Star' },
{ name: '足迹', url: '/member/footprint', icon: 'Clock' },
{ name: '红包', url: '/couponCenter', icon: 'Present' },
{ name: '客服', url: '/article', icon: 'Service' }
{ name: '收藏', url: '/home/Favorites', icon: 'Star' },
{ name: '足迹', url: '/home/MyTracks', icon: 'Clock' },
{ name: '领券', url: '/coupon', icon: 'Present' },
{ name: '帮助', url: '/article', icon: 'Service' }
];
const iconMap = {

View File

@@ -80,7 +80,7 @@
<div class="ctrl-row">
<div class="ctrl-label">选择店铺</div>
<div class="ctrl-body">
<el-button plain type="primary" @click="shopDialogVisible = true">选择店铺</el-button>
<el-button plain type="primary" @click="openShops">选择店铺</el-button>
</div>
</div>
<div class="shop-chips" v-if="firstItem.shopList && firstItem.shopList.length">
@@ -176,38 +176,7 @@
/>
</el-dialog>
<el-dialog
v-model="shopDialogVisible"
title="选择店铺"
width="720px"
append-to-body
destroy-on-close
@open="loadShops"
>
<el-table
:data="shopOptions"
v-loading="shopLoading"
max-height="400"
@selection-change="onShopSelection"
>
<el-table-column type="selection" width="50" />
<el-table-column label="店铺" min-width="220">
<template #default="{ row }">
<div class="shop-cell">
<img :src="row.storeLogo" class="shop-cell-logo" alt="" />
<span>{{ row.storeName }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="类型" width="100">
<template #default="{ row }">{{ row.selfOperated ? "自营" : "旗舰店" }}</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="shopDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmShops">确定</el-button>
</template>
</el-dialog>
<StoreSelector ref="storeSelector" :query-params="{ storeDisable: 'OPEN' }" @selected="confirmShops" />
</div>
</template>
@@ -217,7 +186,7 @@ import ColorField from "../controls/ColorField.vue";
import SliderField from "../controls/SliderField.vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
import ways from "@/components/lili-dialog/wap.js";
import { getShopListData } from "@/api/shops";
import StoreSelector from "@/components/store-selector/index.vue";
const DEFAULTS = {
bgColor1: "#FFFFFF",
@@ -247,7 +216,7 @@ const DEFAULT_ITEM = {
export default {
name: "PanelShop",
hideDecorateTitle: true,
components: { GradientField, ColorField, SliderField, ossManage },
components: { GradientField, ColorField, SliderField, ossManage, StoreSelector },
props: {
res: { type: Object, required: true },
},
@@ -260,10 +229,6 @@ export default {
{ key: "style", title: "样式" },
],
picModelFlag: false,
shopDialogVisible: false,
shopLoading: false,
shopOptions: [],
selectedShops: [],
};
},
computed: {
@@ -284,30 +249,15 @@ export default {
if (!Array.isArray(item.shopList)) item.shopList = [];
},
methods: {
// 加载店铺列表供选择
loadShops() {
this.shopLoading = true;
this.selectedShops = [];
getShopListData({ pageNumber: 1, pageSize: 100, status: "OPEN" })
.then((res) => {
this.shopOptions = (res && res.result && res.result.records) || [];
})
.finally(() => {
this.shopLoading = false;
});
},
onShopSelection(rows) {
this.selectedShops = rows;
},
confirmShops() {
this.firstItem.shopList = this.selectedShops.map((s) => ({
openShops() { this.$refs.storeSelector.open(this.firstItem.shopList); },
confirmShops(rows) {
this.firstItem.shopList = rows.map((s) => ({
id: s.id,
storeName: s.storeName,
storeLogo: s.storeLogo,
selfOperated: s.selfOperated,
storeBanner: s.storeBanner || "",
}));
this.shopDialogVisible = false;
},
removeShop(index) {
this.firstItem.shopList.splice(index, 1);

View File

@@ -433,9 +433,15 @@
<!-- 当选择完活动之后的其他回调 -->
<span v-if="item.url.___type == 'marketing'">
<span v-if="item.url.___promotion == 'SECKILL'"> 秒杀 </span>
<span v-if="item.url.___promotion == 'FULL_DISCOUNT'">
<span v-if="item.url.___promotion == 'FULL_DISCOUNT' || item.url.___promotion == 'FULL_MINUS'">
满减
</span>
<span v-if="item.url.___promotion == 'FULL_RATE'">
满折
</span>
<span v-if="item.url.___promotion == 'FULL_GIFT'">
满赠
</span>
<span v-if="item.url.___promotion == 'PINTUAN'"> 拼团 </span>
{{ item.url.title || item.url.goodsName }}</span>
<!-- 当选择完活动之后的其他回调 -->

View File

@@ -91,7 +91,7 @@
<el-table-column label="优惠券类型" width="130" align="center">
<template #default="{ row }">
<el-tag v-if="row" size="small" effect="light" :type="row.couponType === 'DISCOUNT' ? 'warning' : 'danger'">
{{ couponTypeText(row.couponType) }}
{{ couponTypeText(row) }}
</el-tag>
</template>
</el-table-column>
@@ -181,9 +181,9 @@ onMounted(() => {
getCouponActivity();
});
function couponTypeText(v) {
if (v === "DISCOUNT") return "打折券";
if (v === "PRICE") return "减免现金券";
function couponTypeText(row) {
if (row.couponType === "DISCOUNT") return "满折优惠券";
if (row.couponType === "PRICE") return Number(row.consumeThreshold) === 0 ? "立减优惠券" : "满减优惠券";
return "未知类型";
}

View File

@@ -25,13 +25,6 @@
/>
</el-form-item>
<el-form-item label="优惠券活动类型" prop="couponActivityType">
<el-radio-group v-model="form.couponActivityType">
<el-radio-button value="REGISTERED">新人发券</el-radio-button>
<el-radio-button value="SPECIFY">精确发券</el-radio-button>
<el-radio-button value="AUTO_COUPON">自动赠券</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
label="领取频率"
v-if="form.couponActivityType === 'AUTO_COUPON'"
@@ -170,8 +163,8 @@
</template>
<script setup>
import { ref, reactive } from "vue";
import { useRouter } from "vue-router";
import { ref, reactive, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import couponTemplate from "@/views/promotions/coupon/coupon";
import userList from "@/views/member/list/index";
import { saveActivityCoupon } from "@/api/promotion";
@@ -182,6 +175,7 @@ import { unixToDate } from "@/utils/filters";
defineOptions({ name: "add-coupon-activity" });
const router = useRouter();
const route = useRoute();
const appStore = useAppStore();
const options = reactive({
@@ -205,6 +199,19 @@ const form = reactive({
couponActivityItems: [],
couponFrequencyEnum: "",
});
watch(
() => route.query.couponActivityType,
(type) => {
if (["REGISTERED", "SPECIFY", "AUTO_COUPON", "COUPON_PACKAGE"].includes(type)) {
if (type === "COUPON_PACKAGE") {
form.couponFrequencyEnum = "DAY";
form.activityScope = "ALL";
}
form.couponActivityType = type;
}
},
{ immediate: true }
);
const submitLoading = ref(false);
const selectCouponList = ref([]);
const formRule = reactive({

View File

@@ -2,7 +2,10 @@
<div class="search">
<el-card>
<div class="operation padding-row">
<el-button type="primary" @click="add">添加活动</el-button>
<el-button type="primary" plain @click="addRegistered">注册赠券</el-button>
<el-button type="primary" plain @click="addSpecify">精准发券</el-button>
<el-button type="primary" plain @click="addAutoCoupon">自动赠券</el-button>
<el-button type="success" plain @click="addCouponPackage">创建券礼包</el-button>
</div>
<el-table
@@ -45,6 +48,8 @@
<template #default="{ row }">
<template v-if="row">
<a class="link-text" @click="info(row)">查看</a>
<span class="op-split">|</span>
<a class="link-text" @click="openQrCode(row)">推广码</a>
<template
v-if="(!checked && row.promotionStatus === 'START') || row.promotionStatus === 'NEW'"
>
@@ -68,6 +73,22 @@
@size-change="changePageSize"
/>
</div>
<!-- 推广二维码弹窗 -->
<el-dialog v-model="qrCodeDialogVisible" title="券礼包 H5 推广二维码" width="440px" align-center>
<div style="text-align: center; padding: 10px 0;">
<div v-if="qrCodeLoading" style="padding: 30px 0;">
<div style="color: #999;">正在生成推广二维码...</div>
</div>
<div v-else-if="qrCodeInfo.qrCodeBase64">
<img :src="qrCodeInfo.qrCodeBase64" alt="H5推广二维码" style="width: 220px; height: 220px; border: 1px solid #ebeef5; padding: 6px; border-radius: 8px;" />
<div style="margin-top: 15px; font-size: 13px; color: #555; word-break: break-all; text-align: left; background: #f8f9fa; padding: 10px; border-radius: 6px;">
<div style="font-weight: bold; margin-bottom: 4px;">H5 访问链接:</div>
<div style="color: #409eff;">{{ qrCodeInfo.h5Url }}</div>
</div>
<el-button type="primary" style="margin-top: 15px; width: 100%;" @click="copyH5Url">复制 H5 访问链接</el-button>
</div>
</div>
</el-dialog>
</el-card>
</div>
</template>
@@ -75,7 +96,7 @@
<script setup>
import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { getCouponActivityList, closeActivity } from "@/api/promotion";
import { getCouponActivityList, closeActivity, getCouponPackageQrCode } from "@/api/promotion";
import { Message, Modal } from "@/utils/message";
defineOptions({ name: "coupon-activity" });
@@ -103,9 +124,10 @@ const selectDate = ref([]);
function activityTypeText(type) {
const map = {
REGISTERED: "注册赠券",
COUPON_PACKAGE: "优惠券礼包",
AUTO_COUPON: "自动发券",
};
return map[type] || "精确发券";
return map[type] || "精准发券";
}
function activityScopeText(scope) {
return scope === "DESIGNATED" ? "指定会员" : "全部会员";
@@ -145,8 +167,73 @@ function promotionStatusTagType(status) {
function init() {
getDataList();
}
function add() {
router.push({ name: "add-coupon-activity" });
const qrCodeDialogVisible = ref(false);
const qrCodeLoading = ref(false);
const qrCodeInfo = reactive({
activityId: "",
h5Url: "",
qrCodeBase64: "",
});
function addCouponPackage() {
router.push({
name: "add-coupon-activity",
query: { couponActivityType: "COUPON_PACKAGE" },
});
}
function openQrCode(row) {
qrCodeDialogVisible.value = true;
qrCodeLoading.value = true;
qrCodeInfo.activityId = row.id;
qrCodeInfo.h5Url = "";
qrCodeInfo.qrCodeBase64 = "";
getCouponPackageQrCode(row.id)
.then((res) => {
if (res.success && res.result) {
qrCodeInfo.h5Url = res.result.h5Url;
qrCodeInfo.qrCodeBase64 = res.result.qrCodeBase64;
} else {
Message.error(res.message || "生成推广二维码失败");
}
})
.catch(() => {
Message.error("获取二维码异常");
})
.finally(() => {
qrCodeLoading.value = false;
});
}
function copyH5Url() {
if (!qrCodeInfo.h5Url) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(qrCodeInfo.h5Url).then(() => {
Message.success("H5 访问链接已复制到剪贴板!");
});
} else {
Message.success("链接:" + qrCodeInfo.h5Url);
}
}
function addRegistered() {
router.push({
name: "add-coupon-activity",
query: { couponActivityType: "REGISTERED" },
});
}
function addSpecify() {
router.push({
name: "add-coupon-activity",
query: { couponActivityType: "SPECIFY" },
});
}
function addAutoCoupon() {
router.push({
name: "add-coupon-activity",
query: { couponActivityType: "AUTO_COUPON" },
});
}
function info(v) {
router.push({ name: "coupon-activity-info", query: { id: v.id } });

File diff suppressed because it is too large Load Diff

View File

@@ -72,7 +72,7 @@
<el-table-column label="面额/折扣" width="100">
<template #default="{ row }">
<priceColorScheme
v-if="row && row.price"
v-if="row && row.couponType === 'PRICE'"
:value="row.price"
:color="$mainColor"
/>

View File

@@ -93,7 +93,7 @@
<el-table-column label="面额/折扣" width="150">
<template #default="{ row }">
<span v-if="row">
<span v-if="row.price" :style="{ color: $mainColor }">
<span v-if="row.couponType === 'PRICE'" :style="{ color: $mainColor }">
{{ $filters.unitPrice(row.price, "¥") }}</span>
<span v-else>{{ row.couponDiscount }}折</span>
</span>
@@ -118,7 +118,7 @@
<el-table-column label="优惠券类型" width="120">
<template #default="{ row }">
<el-tag v-if="row" :type="couponTypeTagType(row.couponType)">
{{ couponTypeText(row.couponType) }}
{{ couponTypeText(row) }}
</el-tag>
</template>
</el-table-column>
@@ -362,12 +362,10 @@ function getTypeTagType(type) {
};
return map[type] || "";
}
function couponTypeText(type) {
const map = {
DISCOUNT: "打折",
PRICE: "减免现金",
};
return map[type] || "未知";
function couponTypeText(row) {
if (row.couponType === "DISCOUNT") return "满折优惠券";
if (row.couponType === "PRICE") return Number(row.consumeThreshold) === 0 ? "立减优惠券" : "满减优惠券";
return "未知";
}
function couponTypeTagType(type) {
const map = {

View File

@@ -1,3 +1,8 @@
<!--
管理端-满减/满折/满赠活动详情只读视图(适配 1~3 级阶梯及满赠多赠品展示)
@author Bulbasaur
@since 2026-11-09
-->
<template>
<div class="full-discount-detail-page">
<!-- 顶部标题与返回栏 -->
@@ -5,7 +10,7 @@
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>满优惠活动详情</span>
<span>{{ activityTypeTitle }}详情</span>
<el-tag type="info" size="small" effect="plain" class="mode-tag">
只读模式
</el-tag>
@@ -30,13 +35,17 @@
<span class="font-semibold text-dark">{{ form.promotionName || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="活动类型">
<el-tag size="small" :type="activityTagType">{{ activityTypeTitle }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="活动范围">
<el-tag size="small" effect="light" :type="form.scopeType === 'ALL' ? 'success' : 'primary'">
{{ form.scopeType === "ALL" ? "全品类通用" : "指定商品适用" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="活动时间" :span="2">
<el-descriptions-item label="活动时间">
<span class="font-mono text-dark">
{{ form.startTime || "-" }} 至 {{ form.endTime || "-" }}
</span>
@@ -48,66 +57,49 @@
</el-descriptions>
</el-card>
<!-- 优惠规则与赠品权益卡片 -->
<!-- 阶梯优惠规则卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">优惠规则与权益</span>
<span class="section-title">阶梯优惠规则</span>
<span class="section-badge">共 {{ (form.ruleList || []).length }} 级阶梯(仅生效满足的最高一级)</span>
</div>
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="优惠门槛">
<el-descriptions :column="1" border class="custom-descriptions">
<el-descriptions-item
v-for="(tier, idx) in form.ruleList || []"
:key="idx"
:label="`第 ${idx + 1} 级阶梯`"
>
<span class="money-highlight font-mono font-semibold">
满 ¥ {{ Number(form.fullMoney || 0).toFixed(2) }}
满 ¥ {{ Number(tier.fullMoney || 0).toFixed(2) }}
</span>
<span class="text-secondary ml_10" style="font-size: 12px">(达到当前金额可享优惠)</span>
</el-descriptions-item>
<el-descriptions-item label="优惠形式">
<template v-if="form.discountType === 'fullMinusFlag'">
<el-tag size="small" effect="light" type="danger">减现金</el-tag>
<template v-if="form.activityType === 'FULL_MINUS'">
<el-tag size="small" effect="light" type="danger" class="ml_10">减现金</el-tag>
<span class="money-highlight font-mono font-semibold ml_10">
减 ¥ {{ Number(form.fullMinus || 0).toFixed(2) }}
立减 ¥ {{ Number(tier.fullMinus || 0).toFixed(2) }}
</span>
</template>
<template v-else-if="form.discountType === 'fullRateFlag'">
<el-tag size="small" effect="light" type="warning">打折</el-tag>
<template v-else-if="form.activityType === 'FULL_RATE'">
<el-tag size="small" effect="light" type="warning" class="ml_10">打折</el-tag>
<span class="money-highlight font-mono font-semibold ml_10">
打 {{ form.fullRate }} 折
打 {{ tier.fullRate }} 折
</span>
</template>
<template v-else>
<span class="text-secondary">无直接立减/打折</span>
</template>
</el-descriptions-item>
<el-descriptions-item label="附加赠送权益" :span="2">
<div class="extra-rights-row">
<el-tag :type="form.freeFreightFlag ? 'success' : 'info'" effect="plain" size="small">
{{ form.freeFreightFlag ? "✓ 包邮(免运费)" : "不包邮" }}
</el-tag>
<el-tag :type="form.couponFlag ? 'success' : 'info'" effect="plain" size="small">
{{ form.couponFlag ? "✓ 赠送优惠券" : "不送券" }}
</el-tag>
<el-tag :type="form.giftFlag ? 'success' : 'info'" effect="plain" size="small">
{{ form.giftFlag ? "✓ 赠送赠品" : "不送赠品" }}
</el-tag>
<el-tag :type="form.pointFlag ? 'success' : 'info'" effect="plain" size="small">
{{ form.pointFlag ? "✓ 赠送积分" : "不送积分" }}
<template v-else-if="form.activityType === 'FULL_GIFT'">
<el-tag size="small" effect="light" type="success" class="ml_10">满赠</el-tag>
<div class="tier-gift-tags">
<el-tag
v-for="(gift, gIdx) in tier.giftItems || []"
:key="gIdx"
size="small"
type="success"
effect="plain"
>
{{ formatGiftName(gift) }} × {{ gift.num || 1 }}件
</el-tag>
</div>
</el-descriptions-item>
<el-descriptions-item v-if="form.couponFlag" label="赠送优惠券" :span="2">
<span class="font-semibold text-dark">{{ couponNameText }}</span>
</el-descriptions-item>
<el-descriptions-item v-if="form.giftFlag" label="赠品信息" :span="2">
<span class="font-semibold text-dark">{{ giftNameText }}</span>
</el-descriptions-item>
<el-descriptions-item v-if="form.pointFlag" label="赠送积分数" :span="2">
<span class="font-mono font-semibold text-dark">{{ form.point ?? 0 }} 积分</span>
</template>
</el-descriptions-item>
</el-descriptions>
</el-card>
@@ -186,7 +178,7 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { getPlatformCouponList, getFullDiscountById } from "@/api/promotion";
import { getFullDiscountById } from "@/api/promotion";
import { getGoodsSkuData } from "@/api/goods";
import { useAppStore } from "@/stores/app";
@@ -198,64 +190,83 @@ const appStore = useAppStore();
const id = ref(route.query.id);
const form = ref({
discountType: "fullMinusFlag",
activityType: "FULL_MINUS",
scopeType: "ALL",
promotionGoodsList: [],
ruleList: [],
});
const couponList = ref([]);
const giftList = ref([]);
const couponNameText = computed(() => {
if (!form.value.couponId) return "未指定优惠券";
const found = couponList.value.find((c) => String(c.id) === String(form.value.couponId));
return found ? found.couponName : `优惠券 ID: ${form.value.couponId}`;
const activityTypeTitle = computed(() => {
if (form.value.activityType === "FULL_RATE") return "满折活动";
if (form.value.activityType === "FULL_GIFT") return "满赠活动";
return "满减活动";
});
const giftNameText = computed(() => {
if (!form.value.giftId) return "未指定赠品";
const found = giftList.value.find((g) => String(g.id) === String(form.value.giftId));
return found ? found.goodsName : `赠品 ID: ${form.value.giftId}`;
const activityTagType = computed(() => {
if (form.value.activityType === "FULL_RATE") return "warning";
if (form.value.activityType === "FULL_GIFT") return "success";
return "danger";
});
onMounted(() => {
if (id.value) {
getDetail();
}
getCouponList();
getGiftList();
});
function formatGiftName(gift) {
if (gift?.goodsName) return gift.goodsName;
const found = giftList.value.find((g) => String(g.id) === String(gift?.skuId));
return found ? found.goodsName : `赠品 ID: ${gift?.skuId || "-"}`;
}
function getDetail() {
getFullDiscountById(id.value).then((res) => {
if (res && res.success && res.result) {
let data = res.result;
const data = res.result;
if (data.scopeType !== "PORTION_GOODS") {
data.promotionGoodsList = [];
}
if (!data.activityType) {
if (data.fullRateFlag) {
data.discountType = "fullRateFlag";
data.activityType = "FULL_RATE";
} else if (!data.fullMinusFlag && data.giftFlag) {
data.activityType = "FULL_GIFT";
} else {
data.discountType = "fullMinusFlag";
data.activityType = "FULL_MINUS";
}
}
if (!Array.isArray(data.ruleList) || !data.ruleList.length) {
if (data.rulesJson) {
try {
data.ruleList = JSON.parse(data.rulesJson);
} catch (e) {
data.ruleList = [];
}
}
if (!data.ruleList || !data.ruleList.length) {
data.ruleList = [
{
fullMoney: data.fullMoney,
fullMinus: data.fullMinus,
fullRate: data.fullRate,
giftItems: data.giftId
? String(data.giftId)
.split(",")
.filter(Boolean)
.map((skuId) => ({ skuId, goodsName: data.giftSkuName, num: 1 }))
: [],
},
];
}
}
form.value = data;
}
});
}
function getCouponList() {
const params = {
pageSize: 50,
pageNumber: 1,
getType: "ACTIVITY",
promotionStatus: "START",
};
getPlatformCouponList(params).then((res) => {
if (res && res.success && res.result) {
couponList.value = res.result.records || [];
}
});
}
function getGiftList() {
const params = {
pageSize: 50,
@@ -357,11 +368,11 @@ function back() {
font-size: 14px;
}
.extra-rights-row {
.tier-gift-tags {
margin-top: 8px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
gap: 8px;
}
.custom-table {

Some files were not shown because too many files have changed in this diff Show More