32 Commits

Author SHA1 Message Date
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
pikachu1995@126.com
cbaf89c835 feat(goods): 完善商品定时上下架功能
- 新增定时下架支持,商品列表同时展示上架和下架计划
- 添加定时计划的编辑和删除操作入口
- 新增取消定时上下架API接口
- 商品选择对话框支持商品模式,适配SPU维度选择
- 商品选择对话框增加高级筛选折叠和品牌联动逻辑
- 优化商品列表筛选布局为弹性自适应宽度
- 管理端定时计划页面增加定时下架标签页
- 商家端定时计划页面重构为完整CRUD抽屉交互
- 修改楼层列表新建逻辑改为路由跳转装修页
2026-11-04 11:07:37 +08:00
pikachu1995@126.com
f6f106e12b chore(license): 将项目许可证 更换为 Apache-2.0
- 将 LICENSE 文件内容替换为 Apache License 2.0 完整文本
2026-11-03 17:47:37 +08:00
pikachu1995@126.com
484b6a6dad feat: 优化商品促销页面并独立展示定时上架
- 新增平台、商家定时上架独立页面,平台列表仅供查看
- 虚拟销量支持状态筛选及右侧抽屉设置,修复编码显示问题
- 统一列表操作栏,优化商品选择器、砍价、积分及优惠券页面
- 两端新增活动优惠券入口,隐藏创建表单中的活动类型字段
- 移除限时直降、第 N 件优惠页面、路由、接口调用及缓存菜单入口

验证:26 个改动前端文件编译或语法检查通过,暂存差异格式检查通过。
2026-11-03 16:29:13 +08:00
pikachu1995@126.com
d64dd08b42 refactor(goods-setting): 移除图片尺寸配置并新增自营店商品审核开关
- 移除缩略图、小图、原图宽高配置项及相关校验规则
- 新增自营店商品审核独立开关(selfOperatedGoodsCheck)
- 统一商品审核开关值为字符串类型 true/false
- 更新商品审核描述文案为非自营店商品审核说明
- 清理README中已移除功能的描述条目
2026-11-03 11:16:06 +08:00
pikachu1995@126.com
5300988643 feat(seller): align PC decoration editor and remove legacy components 2026-11-03 11:11:57 +08:00
pikachu1995@126.com
fb99824a3a refactor(site-settings): 统一站点配置管理并清理废弃页面
- 新增 useSiteSettings composable 替代各端硬编码的配置引用
- 移除 memberRecycle 和 fictitiousOrderList 废弃页面并增加缓存过滤
- 版权信息改为通过接口动态获取,后台新增版权归属配置字段
- 全局禁止 textarea 拖拽缩放并统一三端样式规则
- 会员列表增加状态 tab 筛选及启用/禁用双向切换功能
- 删除未使用的 getSpecificationList API 方法
2026-11-03 09:14:05 +08:00
pikachu1995@126.com
22f3e9b5e9 refactor(after-sale): 重构售后单详情页面为现代化布局
- 将页面从简单的 dl/dd 结构重构为基于 Element Plus 的卡片式两栏布局
- 新增顶部导航栏与状态看板,集成可视化流程步骤条
- 使用 el-descriptions 和 el-form 替代原有手写表单结构
- 添加动态步骤计算逻辑,支持仅退款和退货退款两种流程
- 新增复制单号、卡号格式化、跳转关联订单等辅助功能
- 物流弹窗改用 el-timeline 展示轨迹,优化请求时机为数据加载后打开
- 完善表单校验与操作 loading 状态,增加异常捕获处理
- 样式改为 scoped 并全面升级视觉设计
2026-11-02 16:53:49 +08:00
pikachu1995@126.com
7db1ee4fb7 feat(manager): 表单弹窗统一改为右抽屉,文章添加/编辑改为独立页面,支付方式汇总显示中文名称 2026-11-02 16:29:19 +08:00
pikachu1995@126.com
4e33ab19b6 feat(manager): 短信模板配置弹窗补充变量命名约束提示 2026-11-02 15:49:01 +08:00
pikachu1995@126.com
ce11f50942 feat(pc-decorate): 优化PC楼层装修组件自适应高度、支持可视化热区图绘制及编辑器交互修复
- fix(editor): 修复点击返回页面列表 autoSaveTimer is not defined 运行时错误,优化回退逻辑
- feat(hotzone): PC 端热区图接入可视化组件,支持拖拽画框、拉伸缩放与百分比自适应
- fix(floor-height): 商品宫格、宫格楼层、横幅楼层、商品条等默认行高改为自适应撑开,防止商品溢出截断
- fix(video): 修正视频组件默认行高与播放器高度对齐为 450px,避免底部遮挡
- fix(freelayout): 统一自由排版默认高度与后台画布高度为 450px,消除底部空白
- feat(adaptive): 频道宫格、品牌墙、优惠券等集合类组件支持动态行高与 autoCompTypes 弹性包裹
- refactor(preset): 优化双端 double11Preset 预设模板,清理遗留固定行高
2026-10-30 09:27:21 +08:00
pikachu1995@126.com
ccf243f31a feat(order): 优化订单详情页布局并重构标准A4出库发货单 2026-10-30 07:22:16 +08:00
pikachu1995@126.com
c0cc8d8598 feat(manager): 优化列表操作列规范、会员分组统计、漏斗图重构与订单交互
1. 订单交互:移除 orderList 缓存机制修复默认带入订单号问题,支持 query 联动;

2. 会员模块:会员分组新增人数统计与联动,全量规范会员提现、充值、日志等操作列样式;

3. 商品与销量:虚拟销量优化首列缩略图与对齐方式,规范品牌与商品分组操作;

4. 数据统计:重构交易转化漏斗图为自适应 SVG 渐变图与指标卡片;

5. 系统与营销:统一菜单/部门操作列样式,优化 OSS 模块、营销活动与首页待办对齐。
2026-10-30 06:51:48 +08:00
296 changed files with 45763 additions and 21408 deletions

216
LICENSE
View File

@@ -1,21 +1,205 @@
MIT License
Apache License, Version 2.0
Copyright (c) 2020-2026 Beijing Hongye Huicheng Technology Co., Ltd. (北京宏业汇成科技有限公司)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

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

@@ -5,7 +5,7 @@
<p>
<a href="https://github.com/lilishop/lilishop"><img src="https://img.shields.io/github/stars/lilishop/lilishop.svg?style=social&logo=github" alt="GitHub Stars" /></a>
<a href="https://gitee.com/beijing_hongye_huicheng/lilishop"><img src="https://gitee.com/beijing_hongye_huicheng/lilishop/badge/star.svg?theme=dark" alt="Gitee Stars" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License" /></a>
<a href="https://vuejs.org/"><img src="https://img.shields.io/badge/Vue-3.5-brightgreen.svg" alt="Vue 3" /></a>
<a href="https://element-plus.org/"><img src="https://img.shields.io/badge/Element%20Plus-2.x-409EFF.svg" alt="Element Plus" /></a>
<a href="https://pinia.vuejs.org/"><img src="https://img.shields.io/badge/Pinia-4.x-yellow.svg" alt="Pinia" /></a>
@@ -28,7 +28,7 @@
**LILISHOP** is an open-source e-commerce system built with Spring Boot, Vue 3, and uni-app. It supports B2B2C multi-vendor marketplaces, WeChat Mini Programs, H5, Android/iOS Apps, live-streaming e-commerce, affiliate distribution, and flash sales.
### ✨ Why Choose Lilishop
- 🟢 **True MIT License**: Free for commercial use and private secondary development without licensing concerns.
- 🟢 **True Apache-2.0 License**: Free for commercial use and private secondary development without licensing concerns.
- 🚀 **Modern Vue 3 Stack**: Built on `Vue 3.5 + Pinia 4 + Vue Router 4 + Element Plus`, leaving legacy Vue 2 templates behind.
- 🧩 **4-in-1 Production Suite**: Covers **PC Buyer Mall (`buyer`)**, **Seller Portal (`seller`)**, **Platform Admin Panel (`manager`)**, and **IM Customer Service Web (`im`)**, complete with drag-and-drop visual storefront decoration and rich analytics.
@@ -141,4 +141,4 @@ lilishop-ui
## 6. License
Lilishop is licensed under the [MIT](./LICENSE) License. Copyright © 2020-2026 **Beijing Hongye Huicheng Technology Co., Ltd.**
Lilishop is licensed under the [Apache-2.0](./LICENSE) License. Copyright © 2020-2026 **Beijing Hongye Huicheng Technology Co., Ltd.**

View File

@@ -5,7 +5,7 @@
<p>
<a href="https://github.com/lilishop/lilishop"><img src="https://img.shields.io/github/stars/lilishop/lilishop.svg?style=social&logo=github" alt="GitHub Stars" /></a>
<a href="https://gitee.com/beijing_hongye_huicheng/lilishop"><img src="https://gitee.com/beijing_hongye_huicheng/lilishop/badge/star.svg?theme=dark" alt="Gitee Stars" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License" /></a>
<a href="https://vuejs.org/"><img src="https://img.shields.io/badge/Vue-3.5-brightgreen.svg" alt="Vue 3" /></a>
<a href="https://element-plus.org/"><img src="https://img.shields.io/badge/Element%20Plus-2.x-409EFF.svg" alt="Element Plus" /></a>
<a href="https://pinia.vuejs.org/"><img src="https://img.shields.io/badge/Pinia-4.x-yellow.svg" alt="Pinia" /></a>
@@ -28,7 +28,7 @@
**LILISHOP** 是基于 Spring Boot / Vue 3 / uni-app 开发的开源商城系统,支持 B2B2C 多商户商城、微信小程序商城、H5 商城、APP 商城、直播电商、分销返佣、秒杀活动。
### ✨ 为什么选择 Lilishop
- 🟢 **真 MIT 协议**:支持免费商用与私有化闭源二开,无版权后顾之忧。
- 🟢 **真 Apache-2.0 协议**:支持免费商用与私有化闭源二开,无版权后顾之忧。
- 🚀 **Vue 3 全新栈**:全面基于 `Vue 3.5 + Pinia 4 + Vue Router 4 + Element Plus` 构建,告别老旧 Vue 2 架构。
- 🧩 **四端合一生产级闭环**:完整覆盖 **PC 买家端 (`buyer`)**、**商家端 (`seller`)**、**平台运营管理端 (`manager`)** 与 **在线客服端 (`im`)**,支持拖拽式可视化楼层装修与多维数据统计报表。
@@ -146,7 +146,7 @@ lilishop-ui
## 6. 开源与授权
本项目遵循 [MIT](./LICENSE) 开源协议,版权归 **北京宏业汇成科技有限公司** 所有。允许个人及企业免费用于学习、研究、修改和商业用途(需保留原始版权和许可声明)。
本项目遵循 [Apache-2.0](./LICENSE) 开源协议,版权归 **北京宏业汇成科技有限公司** 所有。允许个人及企业免费用于学习、研究、修改和商业用途(需保留原始版权和许可声明)。
---
@@ -162,20 +162,46 @@ lilishop-ui
## 8. 升级计划
### ✅ 已完成特性
| 功能模块 | 功能模块 |
| :--- | :--- |
| ✅ 商品定时上下架 | ✅ 商品分组 / 会员分组 |
| ✅ 商品虚拟销量 | ✅ 专用发票(发票上传) |
| ✅ 置顶评论 | ✅ 客户等级体系 |
| ✅ 购物卡 | ✅ 腾讯云-云直播 |
| ✅ 卡密商品 | ✅ 二级分销 |
| ✅ 定时任务管理 | ✅ 预约服务商品 |
| ✅ 主题色管理 | ✅ 商家发货改造(整单/拆单/无需配送) |
| ✅ 多维统计(营业/商品/会员/积分/储值/营销/分销) | ✅ 经营报表(店铺业绩/商品同比环比/订单明细/销售汇总) |
### ✅ 已完成功能
按可核实的实现或完善时间倒序记录;日期表示代码完成或功能更新时间,不代表生产上线时间。
| 时间 | 功能模块 | 完成内容 |
| :--- | :--- | :--- |
| 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 | 会员分组 | 新增会员分组管理。 |
### 🔜 近期规划中
- 🔜 店铺结算汇总 & 结算台账
- 🔜 支付方式汇总 & 钱包流水 & 财务汇总
- 🔜 微信小店对接
- 🔜 抖店对接
- 🔜 微信小程序原生重构与体验对齐(持续完善;已接入预约交易、分类多模板与首页楼层装修)
- 🔜 OpenAPI 开放平台与开发者中心

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

@@ -1,3 +1,8 @@
/* 全局禁止多行输入框拖动缩放,覆盖组件生成的内联 resize 样式。 */
.el-textarea__inner {
resize: none !important;
}
:root {
--el-color-primary: var(--theme-color, #ff3c2a);
--el-color-danger: var(--theme-color, #ff3c2a);

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

@@ -43,25 +43,20 @@
</div>
<div class="information footer-bottmom">
<div class="icp">
<li v-if="config.icpCard">
<a :href='"https://tsm.miit.gov.cn/dxxzsp/xkz/xkzgl/resource/qiyesearch.jsp?num="+config.icpCard+"&type=xuke"' target="_blank">
{{config.icpCard}}
</a>
</li>
<li v-if="config.icpMessage" class="footer-bottmom">
<li v-if="icp" class="footer-bottmom">
<a href="https://beian.miit.gov.cn/" target="_blank">
{{config.icpMessage}}
{{icp}}
</a>
</li>
</div>
<a class="flex " :href="config.company.href">
<img class="zhizhao" src="@/assets/images/zhizhao.jpg" mode="" />{{config.company.name}}
<a v-if="companyName" class="flex " :href="companyUrl || undefined">
<img class="zhizhao" src="@/assets/images/zhizhao.jpg" mode="" />{{companyName}}
</a>
</div>
<div class="clearfix"></div>
<div class="copyright">
<p>Copyright © {{year}} {{config.title}}</p>
<p>Copyright © {{year}} {{copyrightOwner}}</p>
</div>
</div>
</footer>
@@ -71,11 +66,12 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import config from '@/config'
import { useSiteSettings } from '@/composables/useSiteSettings'
defineOptions({ name: 'Footer' })
const router = useRouter()
const { companyName, companyUrl, icp, copyrightOwner } = useSiteSettings()
const guideArr = ref([
// 导航链接

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

@@ -12,7 +12,7 @@
class="component-cell"
:style="getComponentCellStyle(comp.componentStyle, row.rowStyle, row.components ? row.components.length : 1)"
:style="getComponentCellStyle(comp.componentStyle, row.rowStyle, row.components ? row.components.length : 1, comp.type)"
>
@@ -69,6 +69,10 @@ const props = defineProps({
const hasAutoComp = computed(() => {
return Array.isArray(props.row.components) && props.row.components.some(c => ['goodsGrid', 'gridFloor', 'bannerFloor', 'goodsBar', 'channelFloor', 'goodsList', 'recommendFeed', 'articleContent', 'brandWall', 'channelGrid', 'couponList'].includes(c?.type));
});
const rowWrapperStyle = computed(() => {
const s = props.row.rowStyle || {};
const style = {};
@@ -96,10 +100,14 @@ const rowWrapperStyle = computed(() => {
const h = String(s.height).trim();
if (h && Number(h) > 0) {
const hVal = /^\d+$/.test(h) ? `${h}px` : h;
if (hasAutoComp.value) {
style.minHeight = hVal;
} else {
style.height = hVal;
style.minHeight = hVal;
}
}
}
// 外边距 (margin)
const isNarrow = style.width !== '100%';
@@ -164,10 +172,14 @@ const rowInnerFlexStyle = computed(() => {
const h = String(s.height).trim();
if (h && Number(h) > 0) {
const hVal = /^\d+$/.test(h) ? `${h}px` : h;
if (hasAutoComp.value) {
styleObj.minHeight = hVal;
} else {
styleObj.height = hVal;
styleObj.minHeight = hVal;
}
}
}
return styleObj;
@@ -175,7 +187,8 @@ const rowInnerFlexStyle = computed(() => {
function getComponentCellStyle(style, rowStyle = {}, totalComps = 1) {
function getComponentCellStyle(style, rowStyle = {}, totalComps = 1, compType = '') {
const isAutoHeightComp = compType && ['goodsGrid', 'gridFloor', 'bannerFloor', 'goodsBar', 'channelFloor', 'goodsList', 'recommendFeed', 'articleContent', 'brandWall', 'channelGrid', 'couponList'].includes(compType);
if (!style) return { flex: '1 1 0%' };
@@ -185,10 +198,14 @@ function getComponentCellStyle(style, rowStyle = {}, totalComps = 1) {
const rh = String(rowStyle.height).trim();
if (rh && Number(rh) > 0) {
const rhVal = /^\d+$/.test(rh) ? `${rh}px` : rh;
if (isAutoHeightComp) {
s.minHeight = rhVal;
} else {
s.height = rhVal;
s.minHeight = rhVal;
}
}
}
const gap = Number(rowStyle?.gap) || 0;
const totalGap = (totalComps > 1 && gap > 0) ? gap * (totalComps - 1) : 0;

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

@@ -7,10 +7,10 @@
:key="idx"
class="zone-link"
:style="{
top: `${zone.top || 0}%`,
left: `${zone.left || 0}%`,
width: `${zone.width || 10}%`,
height: `${zone.height || 10}%`
top: `${zone.top !== undefined ? zone.top : ((zone.topPer || 0) * 100)}%`,
left: `${zone.left !== undefined ? zone.left : ((zone.leftPer || 0) * 100)}%`,
width: `${zone.width !== undefined ? zone.width : ((zone.widthPer || 0.1) * 100)}%`,
height: `${zone.height !== undefined ? zone.height : ((zone.heightPer || 0.1) * 100)}%`
}"
:title="zone.title"
@click="handleClickZone(zone)"
@@ -68,7 +68,7 @@ const customStyle = computed(() => {
});
function handleClickZone(zone) {
const url = zone?.url ? String(zone.url).trim() : '';
const url = (zone?.url || zone?.link) ? String(zone.url || zone.link).trim() : '';
if (!url) return;
if (/^https?:\/\//i.test(url)) {
window.open(url, '_blank');

View File

@@ -50,10 +50,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(() => {

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

@@ -724,7 +724,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
0,
0
],
"height": 180
"height": 220
},
"components": [
{
@@ -901,6 +901,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
"backgroundColor": "transparent"
},
"options": {
"height": 450,
"elements": [
{
"type": "badge",
@@ -979,7 +980,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16
],
"borderRadius": "8px",
"height": 560
"height": ""
},
"components": [
{
@@ -1061,7 +1062,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16
],
"borderRadius": "8px",
"height": 560
"height": ""
},
"components": [
{
@@ -1140,7 +1141,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16
],
"borderRadius": "8px",
"height": 620
"height": ""
},
"components": [
{
@@ -1249,7 +1250,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16
],
"borderRadius": "8px",
"height": 420
"height": ""
},
"components": [
{
@@ -1404,7 +1405,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16
],
"borderRadius": "8px",
"height": 360
"height": 450
},
"components": [
{
@@ -1417,6 +1418,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
},
"options": {
"videoUrl": "https://vjs.zencdn.net/v/oceans.mp4",
"height": 450,
"coverUrl": "https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/video/video_cover.png",
"url": "/goodsList?brand=1",
"title": "双11全球狂欢发布盛典 · 年度旗舰好物真实开箱测评",
@@ -1481,7 +1483,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16
],
"borderRadius": "8px",
"height": 220
"height": ""
},
"components": [
{
@@ -1656,7 +1658,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16
],
"borderRadius": "8px",
"height": 980
"height": ""
},
"components": [
{
@@ -1810,7 +1812,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16
],
"borderRadius": "8px",
"height": 1270
"height": ""
},
"components": [
{

View File

@@ -0,0 +1,33 @@
import { computed, onMounted, ref } from "vue";
import { getBaseSite } from "@/api/common.js";
import config from "@/config";
// 同页的页头、页脚共享请求;每次重新进入页面时刷新,避免旧缓存覆盖后台设置。
const settings = ref(null);
let pendingRequest;
export function loadSiteSettings() {
if (!pendingRequest) {
pendingRequest = getBaseSite()
.then((res) => {
if (res.success && res.result?.settingValue) {
settings.value = JSON.parse(res.result.settingValue);
}
return settings.value;
})
.catch(() => settings.value)
.finally(() => { pendingRequest = null; });
}
return pendingRequest;
}
export function useSiteSettings() {
onMounted(loadSiteSettings);
const siteName = computed(() => settings.value?.siteName || config.title);
const companyName = computed(() => settings.value ? settings.value.companyName || "" : config.company?.name || "");
const companyUrl = computed(() => settings.value ? settings.value.companyUrl || "" : config.company?.href || "");
const website = computed(() => settings.value ? settings.value.website || "" : config.website || "");
const icp = computed(() => settings.value ? settings.value.icp || "" : config.icpMessage || "");
const copyrightOwner = computed(() => settings.value?.copyrightOwner?.trim() || siteName.value);
return { settings, siteName, companyName, companyUrl, website, icp, copyrightOwner };
}

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

@@ -206,7 +206,11 @@ function getIndexData() {
}
async function parsePageData(pageData) {
const dataJson = JSON.parse(pageData);
const dataJson = typeof pageData === 'string' ? JSON.parse(pageData) : pageData;
if (dataJson.version === 2 || dataJson.version === '2.0') {
modelForm.value = dataJson;
return;
}
for (let i = 0; i < dataJson.list.length; i++) {
const type = dataJson.list[i].type;
if (type === "carousel2") {

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

@@ -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,7 +67,10 @@
/>
</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>
@@ -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,7 +154,10 @@
/>
</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>
@@ -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':
@@ -320,17 +339,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 +397,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) {

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"
@@ -303,6 +313,10 @@
size="small"
>申请售后</el-button
>
</template>
<template v-else>
<span class="gift-action-tip">随单赠送,不支持售后</span>
</template>
</div>
</div>
</div>
@@ -341,6 +355,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 +740,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 +1197,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: 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)); }
}
}
.page-size {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
}
.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";
.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-wrap {
width: 1200px;
margin: -18px auto 0;
position: relative;
z-index: 10;
.time-line {
width: 1200px;
max-width: 1200px;
height: auto;
margin: 0 auto;
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: 10px;
padding: 24px;
box-sizing: border-box;
min-height: 380px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
.empty-wrap {
padding: 60px 0;
}
.goods-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
}
/* 商品卡片 */
.goods-card {
background: #fff;
border: 1px solid #ebebeb;
border-radius: 8px;
min-height: 320px;
.goods-list {
width: 100%;
margin: 0;
column-gap: 16px;
}
:deep(.empty) {
margin: 48px 0;
color: #999;
}
}
.goods-show-info1 {
width: calc(25% - 12px);
min-width: 0;
padding: 12px;
margin: 0 0 16px;
position: relative;
border: 1px solid #f0f0f0;
border-radius: 8px;
cursor: pointer;
background-color: #fff;
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;
}
&: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-show-price {
.goods-price-row {
display: flex;
align-items: baseline;
gap: 8px;
margin-top: 10px;
margin-top: 12px;
.price-main {
color: var(--theme-color, #e52d27);
font-weight: 800;
display: flex;
align-items: baseline;
.price-symbol {
font-size: 14px;
}
.origin-price {
color: #999;
.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 {
height: 40px;
margin: 8px 0;
color: #333;
font-size: 14px;
line-height: 20px;
}
.goods-show-num1 {
.goods-name-row {
margin-top: 8px;
height: 40px;
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-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

@@ -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,21 +1,205 @@
MIT License
Apache License, Version 2.0
Copyright (c) 2020 YuanDong
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

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

@@ -37,7 +37,7 @@ a {
}
textarea {
resize: none;
resize: none !important;
outline: 0;
white-space: pre-wrap;
word-wrap: break-word;

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

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

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

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

@@ -1,6 +1,10 @@
// 统一请求路径前缀在libs/axios.js中修改
import { getRequest, postRequest, putRequest, deleteRequest} from '@/libs/axios';
export const cancelGoodsMarketSchedule = (goodsId, status) => {
return deleteRequest(`/goods/goods/schedule/market/${goodsId}`, { status });
};
// 获取商品品牌分页列表
export const getManagerBrandPage = (params) => {
return getRequest('/goods/brand/getByPage', params)

View File

@@ -170,6 +170,10 @@ export const deleteMemberGroup = (id) => {
return deleteRequest(`/member/memberGroup/delete/${id}`);
};
export const getMemberGroupUsers = (groupId, params) => {
return getRequest(`/member/memberGroup/${groupId}/users`, params);
};
export const addMemberGroupUsers = (groupId, memberIds) => {
return postRequest(`/member/memberGroup/${groupId}/users`, {
memberIds: Array.isArray(memberIds) ? memberIds.join(",") : memberIds,
@@ -235,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

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

@@ -283,11 +283,6 @@ export const getCouponReceiveList = (params) => {
return getRequest("/promotion/coupon/received", params);
};
export const getFlashDiscountList = (params) => getRequest("/promotion/flashDiscount", params);
export const getFlashDiscountDetail = (id) => getRequest(`/promotion/flashDiscount/${id}`);
export const updateFlashDiscountStatus = (id, params) => putRequest(`/promotion/flashDiscount/status/${id}`, params);
export const deleteFlashDiscount = (id) => deleteRequest(`/promotion/flashDiscount/${id}`);
export const getNthItemDiscountList = (params) => getRequest("/promotion/nthItemDiscount", params);
// 现金礼品卡活动分页
export const getGiftCardCashActivityPage = (params) => {
@@ -324,6 +319,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)
@@ -22,37 +18,11 @@ export const editNoticeMessage = (id, params) => {
export const wechatMessageSync = (params) => {
return getRequest("/wechat/wechatMessage/init", params)
}
//分页获取微信消息
export const getWechatMessagePage = (params) => {
return getRequest("/wechat/wechatMessage", params)
}
//编辑微信消息模版
export const editWechatMessageTemplate = (id, params) => {
return putRequest(`/wechat/wechatMessage/${id}`, params)
}
//删除微信消息模版
export const delWechatMessageTemplate = (id, params) => {
return deleteRequest(`/wechat/wechatMessage/${id}`, params)
}
//微信消息订阅同步
//微信消息码同步
export const wechatMPMessageSync = (params) => {
return getRequest("/wechat/wechatMPMessage/init", params)
}
//分页获取微信消息订阅
export const getWechatMPMessagePage = (params) => {
return getRequest("/wechat/wechatMPMessage", params)
}
//编辑微信消息订阅模版
export const editWechatMPMessageTemplate = (id, params) => {
return putRequest(`/wechat/wechatMPMessage/${id}`, params)
}
//删除微信消息订阅模版
export const delWechatMPMessageTemplate = (id, params) => {
return deleteRequest(`/wechat/wechatMPMessage/${id}`, params)
}
// 查询站内信模板信息
@@ -71,122 +41,93 @@ export const openNoticeMessage = (id) => {
export const updateMessageStatus = (id,status) => {
return putRequest(`/setting/noticeMessage/${id}/${status}`);
};
//获取短信模板
export const getSmsTemplatePage = (params) => {
return getRequest(`sms/template/querySmsTemplatePage`, params);
//获取设置
export const getSetting = (key) => {
return getRequest(`/setting/setting/get/${key}`);
};
//添加短信模板
export const addSmsTemplatePage = (params) => {
return postRequest(`sms/template`, params);
//根据类型查看配置信息
export const getSettingList = (params) => {
return getRequest(`/setting/setting/all`, params);
};
//删除短信模板
export const deleteSmsTemplatePage = (params) => {
return deleteRequest(`sms/template`, params);
};
//删除短信模板
export const editSmsTemplatePage = (params) => {
return putRequest(`sms/template/modifySmsTemplate`, params);
};
//分页查询短信签名
export const getSmsSignPage = (params) => {
return getRequest(`sms/sign/querySmsSignPage`, params);
};
//更新短信模板
export const syncTemplate = () => {
return putRequest(`sms/template/querySmsSign`);
};
//更新短信模板
export const addSmsSign = (params) => {
return postRequest(`sms/sign`, params);
};
//同步短信签名
export const syncSign = () => {
return putRequest(`sms/sign/querySmsSign`);
};
//删除短信签名
export const deleteSign = (id) => {
return deleteRequest(`sms/sign/${id}`);
};
//更新短信签名
export const editSmsSign = (params) => {
return putRequest(`sms/sign/modifySmsSign`, params);
};
//查询签名详情
export const smsSignDetail = (id) => {
return getRequest(`sms/sign/${id}`);
//设置配置信息
export const setSetting = (key, params) => {
return postRequest(`/setting/setting/put/${key}`, params);
};
//分页查询app版本信息
export const appVersionPage = (params) => {
return getRequest(`/other/appVersion`, params);
//获取设置
export const getExperienceData = (key) => {
return getRequest(`/setting/experience/get/${key}`);
};
//添加app版本信息
export const addVersion = (params) => {
return postRequest(`/other/appVersion`, params);
};
//修改app版本信息
export const editVersion = (params, id) => {
return putRequest(`/other/appVersion/${id}`, params);
};
//删除app版本信息
export const deleteVersion = (id) => {
return deleteRequest(`/other/appVersion/${id}`);
};
//查询同城配送列表
export const getInstantDelivery = (params) => {
return getRequest(`instant-delivery/getByPage`, params);
//保存
export const setExperienceData = (key, params) => {
return postRequest(`/setting/experience/put/${key}`, params);
};
//添加同城配送信息
export const editInstantDelivery = (bean, params, headers) => {
return putRequest(`instant-delivery/${bean}/config`, params, headers);
//根据类型获取积分设置
export const getPointData = (key) => {
return getRequest(`/setting/point/get/${key}`);
};
//启用配送方案
export const openInstantDelivery = (bean) => {
return putRequest(`instant-delivery/${bean}/open`);
//保存
export const setPointData = (key, params) => {
return postRequest(`/setting/point/put/${key}`, params);
};
//修改封面图片
export const editInstantDeliveryImage = (bean, params) => {
return putRequest(`instant-delivery/${bean}/image`, params);
//根据类型获取积分设置
export const getKuaidiSetting = (key) => {
return getRequest(`/setting/kuaidi/get/${key}`);
};
//保存
export const setKuaidiSetting = (key, params) => {
return postRequest(`/setting/kuaidi/put/${key}`, params);
};
//查询短信发送记录
export const getSmsPage = (params) => {
return getRequest(`sms/sms`, params);
//获取所有的物流公司
export const getLogisticsList = () => {
return getRequest(`/setting/logistics/all`);
};
//分页获取物流公司
export const getLogisticsPage = (params) => {
return getRequest(`/setting/logistics/getByPage`, params);
};
//添加物流公司
export const addLogistics = (params) => {
return postRequest(`/setting/logistics`, params);
};
//获取物流公司详情
export const getLogisticsDetail = (id) => {
return getRequest(`/setting/logistics/get/${id}`);
};
//编辑物流公司
export const editLogistics = (id, params) => {
return putRequest(`/setting/logistics/${id}`, params);
};
//删除物流公司
export const delLogistics = (id) => {
return deleteRequest(`/setting/logistics/delete/${id}`);
};
//发送短信
export const sendSms = (params) => {
return postRequest(`sms/sms`, params);
//禁用启用物流公司
export const openLogistics = (id) => {
return putRequest(`/setting/logistics/open/${id}`);
};
/*
* 获取 支付支持设置开关
*/
export const getPaymentSupportForm = () => {
return getRequest(`setting/settingx/paymentSupport`);
}
// 验证码源文件删除
export const delVerification = (id) => {
return deleteRequest(`/other/verificationSource/${id}`)
}
//获取所有的物流公司
export const getLogisticsAll = () => {
return getRequest(`/setting/logistics/all`);
};
// 添加验证码源文件
export const addVerification = (params) => {
// 验证码源文件添加
export const createVerification = (params) => {
return postRequest(`/other/verificationSource`, params)
}
// 修改验证码源文件
export const editVerification = (id, params) => {
// 验证码源文件修改
export const updateVerification = (id, params) => {
return putRequest(`/other/verificationSource/${id}`, params)
}
// 验证码源文件详情
export const verificationDetail = (id) => {
return getRequest(`/other/verificationSource/${id}`)
// 验证码源文件删除
export const deleteVerification = (id) => {
return deleteRequest(`/other/verificationSource/${id}`)
}
// 验证码源文件列表
export const verificationPage = (params) => {
return getRequest(`/other/verificationSource`, params)
@@ -197,12 +138,64 @@ export const getNoticeMessageDetail = (id, params) => {
return getRequest(`/setting/noticeMessage/${id}`, params)
}
// 消息模板聚合分页(站内信 + 微信服务号 + 小程序)
// 统一消息模板聚合分页(站内信 + 短信 + 微信服务号 + 小程序订阅)
export const getMessageTemplateAggregatePage = params => {
return getRequest("/setting/noticeMessage/aggregate", params);
return getRequest("/setting/messageTemplate/page", params);
};
// 修改通知类站内信邮箱渠道开关
export const updateNoticeMessageEmailStatus = (id, status) => {
return putRequest(`/setting/noticeMessage/${id}/email/${status}`);
// 快速切换渠道开关 (channel: NOTICE / SMS / WECHAT_OA / WECHAT_MP, status: OPEN / CLOSE)
export const updateMessageChannelSwitch = params => {
return putRequest("/setting/messageTemplate/switch", params);
};
// 保存站内信配置 (title, content, status)
export const updateNoticeMessageTemplate = params => {
return putRequest("/setting/messageTemplate/notice", params);
};
// 保存短信配置 (smsTemplateCode, status; 内容只读不可编辑)
export const updateSmsMessageTemplateConfig = params => {
return putRequest("/setting/messageTemplate/sms", params);
};
// 保存微信公众号服务号配置 (code, enable; 内容只读)
export const updateWechatOaMessageTemplate = params => {
return putRequest("/setting/messageTemplate/wechat-oa", params);
};
// 保存微信小程序订阅消息配置 (code, enable; 内容只读)
export const updateWechatMpMessageTemplate = params => {
return putRequest("/setting/messageTemplate/wechat-mp", params);
};
// -------------------------------------------------------------
// 补充缺失的系统配置、版本及验证码 API
// -------------------------------------------------------------
// 支付支持方式表单 (PAYMENT_SUPPORT.vue)
export const getPaymentSupportForm = () => {
return getRequest(`/setting/settingx/paymentSupport`);
};
// 验证码源管理别名 (slider.vue)
export const addVerification = createVerification;
export const editVerification = updateVerification;
export const delVerification = deleteVerification;
// APP版本管理 (appVersion.vue)
export const appVersionPage = (params) => {
return getRequest(`/other/appVersion`, params);
};
export const addVersion = (params) => {
return postRequest(`/other/appVersion`, params);
};
export const editVersion = (params, id) => {
return putRequest(`/other/appVersion/${id}`, params);
};
export const deleteVersion = (id) => {
return deleteRequest(`/other/appVersion/${id}`);
};

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

@@ -1,7 +1,7 @@
<template>
<el-dialog
v-model="flag"
width="800px"
:width="width"
:close-on-click-modal="false"
title="绘制热区"
top="120px"
@@ -26,6 +26,13 @@
import { ref } from "vue";
import hotzone from "./components/Hotzone.vue";
const props = defineProps({
width: {
type: String,
default: "800px",
},
});
const emit = defineEmits(["changeZone", "closeFlag"]);
const flag = ref(false);

View File

@@ -1,106 +1,151 @@
<template>
<div class="wrapper">
<div class="wap-content">
<div class="query-wrapper">
<div class="goods-dialog-wrapper">
<!-- 顶部搜索筛选栏 -->
<div class="goods-query-bar">
<div class="query-item">
<div>搜索范围</div>
<span class="query-label">商品名称:</span>
<el-input
v-model="goodsParams.goodsName"
placeholder="商品名称"
placeholder="请输入商品名称"
clearable
style="width: 150px"
style="width: 220px"
@clear="onSearchGoods"
@keyup.enter="onSearchGoods"
/>
</div>
<div class="query-item">
<span class="query-label">商品编号:</span>
<el-input
v-model="goodsParams.sn"
placeholder="请输入商品编号/货号"
clearable
style="width: 190px"
@clear="onSearchGoods"
@keyup.enter="onSearchGoods"
/>
</div>
<div class="query-item">
<span class="query-label">商品分类:</span>
<el-cascader
v-model="category"
:options="skuList"
placeholder="请选择商品分类"
popper-class="goods-dialog-cascader-popper"
style="width: 250px"
style="width: 240px"
clearable
/>
</div>
<div class="query-item">
<div class="query-item query-actions">
<el-button type="primary" @click="onSearchGoods">搜索</el-button>
<el-button @click="onResetGoods">重置</el-button>
</div>
</div>
<div>
<!-- 表格展示区域 (自适应填充剩余高度) -->
<div class="goods-table-wrap">
<el-table
ref="tableRef"
v-loading="loading"
class="goods-select-table"
:data="goodsData"
row-key="id"
height="340"
empty-text="暂无商品信息"
height="100%"
stripe
border
empty-text="暂无匹配商品信息"
@selection-change="onSelectionChange"
>
<el-table-column
type="selection"
label="选择"
width="60"
width="50"
align="center"
reserve-selection
/>
<el-table-column label="商品图片" width="90" align="center">
<el-table-column label="图片" width="76" align="center">
<template #default="{ row }">
<img class="goods-thumb" :src="row.thumbnail" alt="" />
<el-image
class="goods-thumb"
:src="row.thumbnail"
fit="cover"
preview-teleported
:preview-src-list="row.thumbnail ? [row.thumbnail] : []"
>
<template #error>
<div class="thumb-slot">无图</div>
</template>
</el-image>
</template>
</el-table-column>
<el-table-column
prop="goodsName"
label="商品名称"
min-width="200"
min-width="260"
show-overflow-tooltip
/>
>
<template #default="{ row }">
<div class="goods-title-line">{{ row.goodsName || "-" }}</div>
<div v-if="row.sn" class="goods-sn-line font-mono">货号: {{ row.sn }}</div>
</template>
</el-table-column>
<el-table-column label="商品规格" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
{{ row.simpleSpecs || "默认规格" }}
<span>{{ row.simpleSpecs || "默认规格" }}</span>
</template>
</el-table-column>
<el-table-column label="价格" width="110">
<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">¥{{ $filters.unitPrice(row.price) }}</span>
<span class="goods-price font-semibold">
¥ {{ Number(row.price || 0).toFixed(2) }}
</span>
</template>
</el-table-column>
<el-table-column label="库存" width="90">
<el-table-column label="可用库存" width="100" align="right">
<template #default="{ row }">
{{ row.quantity != null ? row.quantity : "—" }}
<span>{{ row.quantity != null ? row.quantity : "—" }}</span>
</template>
</el-table-column>
<el-table-column label="销量" width="90">
<el-table-column label="累计销量" width="100" align="right">
<template #default="{ row }">
{{ row.buyCount != null ? row.buyCount : 0 }}
<span>{{ row.buyCount != null ? row.buyCount : 0 }}</span>
</template>
</el-table-column>
<el-table-column label="商品类型" width="110">
<el-table-column label="商品类型" width="100" align="center">
<template #default="{ row }">
{{ goodsTypeLabel(row.goodsType) }}
<span>{{ goodsTypeLabel(row.goodsType) }}</span>
</template>
</el-table-column>
</el-table>
</div>
<!-- 底部翻页栏 -->
<div class="goods-pagination-bar">
<el-pagination
v-model:current-page="goodsParams.pageNumber"
class="pageration"
v-model:page-size="goodsParams.pageSize"
:total="total"
:page-size="goodsParams.pageSize"
layout="total, prev, pager, next"
:page-sizes="[15, 30, 50, 100]"
popper-class="goods-dialog-pagination-popper"
:teleported="true"
layout="total, sizes, prev, pager, next, jumper"
size="small"
@current-change="changePageSize"
@current-change="changePage"
@size-change="onPageSizeChange"
/>
</div>
</div>
</div>
</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: () => [],
@@ -127,10 +172,13 @@ 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] : "";
});
watch(
() => props.selectedWay,
(val) => {
@@ -139,6 +187,7 @@ watch(
},
{ deep: true, immediate: true }
);
watch(
() => goodsParams.categoryPath,
() => {
@@ -152,26 +201,47 @@ watch(
onMounted(() => {
init();
});
watch(() => props.queryParams, onSearchGoods, { deep: true });
onUnmounted(() => { querySequence++; });
function onSearchGoods() {
goodsData.value = [];
goodsParams.pageNumber = 1;
getQueryGoodsList();
}
function changePageSize(v) {
function onResetGoods() {
goodsParams.goodsName = "";
goodsParams.sn = "";
category.value = [];
goodsParams.categoryPath = "";
goodsParams.pageNumber = 1;
getQueryGoodsList();
}
function changePage(v) {
goodsParams.pageNumber = v;
getQueryGoodsList();
}
function onPageSizeChange(val) {
goodsParams.pageSize = val;
goodsParams.pageNumber = 1;
getQueryGoodsList();
}
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;
});
}
function initGoods(res) {
const records = res?.result?.records || [];
records.forEach((item) => {
@@ -181,24 +251,31 @@ function initGoods(res) {
goodsData.value = records;
nextTick(syncTableSelection);
}
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) {
selectedList.value = list;
emit("selected", selectedList.value);
}
function onSelectionChange(rows) {
// 选择链接时为单选:保留最后勾选的一项,多选框交互不变
if (syncingSelection) return;
if (type.value !== "multiple") {
if (!rows.length) {
emitSelected([]);
@@ -214,17 +291,18 @@ function onSelectionChange(rows) {
}
emitSelected(rows);
}
function init() {
API_Goods.getGoodsSkuData(goodsParams).then((res) => {
initGoods(res);
});
getQueryGoodsList();
API_Goods.getCategoryTree({ deleteFlag: false }).then((res) => {
if (res.success) {
if (res.success && res.result) {
deepGroup(res.result);
}
});
}
function deepGroup(val) {
skuList.value = [];
val.forEach((item) => {
let childWay = [];
if (item.children) {
@@ -252,34 +330,102 @@ function deepGroup(val) {
});
}
// 父组件 lili-dialog/index.vue 通过 $refs.goodsDialog.type = "single" 切换单选;
// expose 的 ref 外部读写等价于原 data 字段
defineExpose({ type });
</script>
<style scoped lang="scss">
@import "./style.scss";
.wap-content {
.goods-dialog-wrapper {
width: 100%;
flex: 1;
padding: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
/* 顶部搜索栏 */
.goods-query-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 0 0 12px;
flex-shrink: 0;
.query-item {
display: flex;
align-items: center;
.query-label {
font-size: 13px;
color: #606266;
white-space: nowrap;
margin-right: 4px;
}
}
.query-actions {
margin-left: auto;
display: flex;
gap: 8px;
}
}
/* 表格容器 */
.goods-table-wrap {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
}
.goods-thumb {
display: block;
width: 48px;
height: 48px;
margin: 0 auto;
border-radius: 4px;
object-fit: cover;
border: 1px solid #ebeef5;
}
.thumb-slot {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
font-size: 12px;
color: #c0c4cc;
}
.goods-title-line {
font-size: 13px;
color: #303133;
line-height: 1.4;
}
.goods-sn-line {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.goods-price {
color: $theme_color;
color: #f56c6c;
}
.pageration {
/* 底部翻页栏 */
.goods-pagination-bar {
display: flex;
justify-content: flex-end;
width: 100%;
margin-top: 16px;
padding-right: 8px;
box-sizing: border-box;
align-items: center;
padding-top: 12px;
flex-shrink: 0;
}
</style>

View File

@@ -2,18 +2,22 @@
<el-dialog
v-if="goodsFlag"
v-model="flag"
width="1160px"
top="120px"
title="选择商品"
width="85%"
top="5vh"
:z-index="10000"
:close-on-click-modal="false"
append-to-body
destroy-on-close
class="goods-selector-dialog"
@close="clickClose"
>
<goodsDialog
v-if="flag"
ref="goodsDialogRef"
:selectedWay="goodsData"
:goods-loader="goodsLoader"
:query-params="queryParams"
@selected="
(val) => {
goodsData = val;
@@ -21,8 +25,15 @@
"
/>
<template #footer>
<div class="goods-dialog-footer">
<span class="selected-count-tip">
已选择 <strong class="text-primary">{{ goodsData.length }}</strong> 件商品
</span>
<div class="footer-actions">
<el-button @click="clickClose">取消</el-button>
<el-button type="primary" @click="clickOK">确定</el-button>
</div>
</div>
</template>
</el-dialog>
@@ -53,12 +64,14 @@
</template>
</el-dialog>
</template>
<script setup>
import { ref } from "vue";
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([]); // 选择的商品
@@ -112,14 +125,7 @@ function close() {
flag.value = false;
}
// 外部通过 ref 访问的成员(grep manager/src 全量确认):
// - open:page-decoration 各 modelList、modelForm/modelFormItem、hotzone/Zone、wap/advertising、alertAdvertising 等打开弹窗(goods/link)
// - singleGoods:modelList/forYour.vue、mixModel.vue 直接调用
// - clearGoodsSelected:wap/decorate.vue、modelList/goodsAndType.vue 调用
// - goodsData:modelList/newGoodsSort.vue、notEnough.vue 回写已选商品
// - goodsFlag:wap/decorate.vue 直接写入
// - flag:wap/decorate.vue 直接写入 true 打开弹窗
// - close:未被 grep 到外部调用,原 Options API 实例天然暴露,保守保留
// 外部通过 ref 访问的成员:
defineExpose({
open,
close,
@@ -130,14 +136,73 @@ defineExpose({
flag,
});
</script>
<style scoped lang="scss">
:deep(.el-dialog__body) {
width: 100%;
height: 500px;
overflow: hidden;
}
.goods-dialog-footer {
display: flex;
justify-content: space-between;
align-items: center;
.selected-count-tip {
font-size: 13px;
color: #606266;
.text-primary {
color: #409eff;
font-weight: 600;
font-size: 15px;
margin: 0 2px;
}
}
.footer-actions {
display: flex;
gap: 10px;
}
}
</style>
<style lang="scss">
/* 商品选择器弹窗全局尺寸与布局增强 */
.goods-selector-dialog {
max-width: 1360px;
min-width: 900px;
border-radius: 8px;
.el-dialog__header {
padding: 16px 20px;
margin-right: 0;
border-bottom: 1px solid #ebeef5;
.el-dialog__title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
.el-dialog__body {
width: 100% !important;
height: calc(84vh - 120px) !important;
min-height: 520px !important;
max-height: 760px !important;
padding: 16px 20px !important;
box-sizing: border-box !important;
overflow: hidden !important;
}
.el-dialog__footer {
padding: 12px 20px;
border-top: 1px solid #ebeef5;
}
}
// 抽屉 append-to-body 后不在当前组件 DOM 内,不能使用 scoped 样式
.link-selector-drawer {
max-width: 96vw;
@@ -152,7 +217,8 @@ defineExpose({
}
}
// 商品分类下拉挂载到 body,需要高于抽屉的 10000 层级
.goods-dialog-cascader-popper {
.goods-dialog-cascader-popper,
.goods-dialog-pagination-popper {
z-index: 10001 !important;
}
</style>

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,30 @@
<template>
<div class="list-action-bar" role="group" aria-label="列表操作">
<div class="list-action-bar__primary"><slot /></div>
<div v-if="$slots.right" class="list-action-bar__secondary"><slot name="right" /></div>
</div>
</template>
<style lang="scss" scoped>
.list-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
width: 100%;
margin-bottom: 16px;
&__primary,
&__secondary {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
:deep(.el-button + .el-button) {
margin-left: 0;
}
}
</style>

View File

@@ -119,7 +119,7 @@ function finished() {
});
}
// 父组件(member/index、memberRecycle、memberDetail、orderDetail、shopOperation)通过 ref 调用 open() 打开弹窗
// 父组件(member/index、memberDetail、orderDetail、shopOperation)通过 ref 调用 open() 打开弹窗
defineExpose({ open });
</script>

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

@@ -0,0 +1,33 @@
import { computed, onMounted, ref } from "vue";
import { getBaseSite } from "@/api/common.js";
import config from "@/config";
// 同页的页头、页脚共享请求;每次重新进入页面时刷新,避免旧缓存覆盖后台设置。
const settings = ref(null);
let pendingRequest;
export function loadSiteSettings() {
if (!pendingRequest) {
pendingRequest = getBaseSite()
.then((res) => {
if (res.success && res.result?.settingValue) {
settings.value = JSON.parse(res.result.settingValue);
}
return settings.value;
})
.catch(() => settings.value)
.finally(() => { pendingRequest = null; });
}
return pendingRequest;
}
export function useSiteSettings() {
onMounted(loadSiteSettings);
const siteName = computed(() => settings.value?.siteName || config.title);
const companyName = computed(() => settings.value ? settings.value.companyName || "" : config.company?.name || "");
const companyUrl = computed(() => settings.value ? settings.value.companyUrl || "" : config.company?.href || "");
const website = computed(() => settings.value ? settings.value.website || "" : config.website || "");
const icp = computed(() => settings.value ? settings.value.icp || "" : config.icpMessage || "");
const copyrightOwner = computed(() => settings.value?.copyrightOwner?.trim() || siteName.value);
return { settings, siteName, companyName, companyUrl, website, icp, copyrightOwner };
}

View File

@@ -4,6 +4,7 @@ const viewContext = require.context("@/views", true, /\.vue$/);
const VIEW_ALIASES = {
"customWords/index": "custom-words/index",
"sensitiveWords/index": "sensitive-words/index",
"sys/message/noticeMessageTemplate": "sys/message/messageTemplate",
};
function resolveViewPath(url) {

View File

@@ -7,7 +7,13 @@ 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",
"promotions/flash-discount",
"promotions/nth-item-discount",
]);
function isDeprecatedMenu(menu) {
@@ -402,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

@@ -38,6 +38,20 @@ export const otherRouter = {
redirect: "/home",
component: Main,
children: [
{
path: "messageTemplate",
title: "消息模板",
name: "messageTemplate",
meta: { title: "消息模板" },
component: () => import("@/views/sys/message/messageTemplate.vue")
},
{
path: "noticeMessageTemplate",
title: "消息模板",
name: "noticeMessageTemplate",
meta: { title: "消息模板" },
component: () => import("@/views/sys/message/messageTemplate.vue")
},
{
path: "home",
title: '首页',
@@ -112,6 +126,12 @@ export const otherRouter = {
name: "goods-category",
component: () => import("@/views/goods/goods-manage/category.vue")
},
{
path: "goods-scheduled-upper",
title: "定时上架",
name: "goods-scheduled-upper",
component: () => import("@/views/goods/scheduled-upper/index.vue")
},
{
path: "goods-group",
title: "商品分组",
@@ -149,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: "预约工单",
@@ -198,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: "等级权益设置",
@@ -346,18 +386,6 @@ export const otherRouter = {
name: "full-discount-detail",
component: () => import("@/views/promotions/full-discount/full-discount-detail.vue")
},
{
path: "promotions/flash-discount",
title: "限时直降",
name: "flash-discount",
component: () => import("@/views/promotions/flash-discount/flash-discount.vue")
},
{
path: "promotions/nth-item-discount",
title: "第N件优惠",
name: "nth-item-discount",
component: () => import("@/views/promotions/nth-item-discount/nth-item-discount.vue")
},
{
path: "promotions/seckill/manager-seckill-add",
title: "编辑秒杀活动",
@@ -371,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: "创建直播",
@@ -471,6 +493,13 @@ export const otherRouter = {
meta: { title: "商品销售汇总报表" },
component: () => import("@/views/statistics/goods-sales-summary.vue")
},
{
path: "article-edit",
title: "文章编辑",
name: "article-edit",
meta: { title: "文章编辑" },
component: () => import("@/views/page/article-manage/articleEdit.vue")
},
{
path: "distributionImportRecord",
title: "分销员导入记录",

View File

@@ -2,6 +2,8 @@ import { defineStore } from "pinia";
import { otherRouter } from "@/router/router";
import i18n from "@/locale";
const removedPageNames = new Set(["memberRecycle", "fictitiousOrderList"]);
// 原 vuex app 模块 + 根 state(openStyleStore/openStoreId/notices)合并
// mutation 全部转为同名 action;两个例外:
// - pageOpenedList mutation 与 state 同名,改名 updateOpenedList
@@ -73,7 +75,8 @@ export const useAppStore = defineStore("app", {
},
initCachePage() {
if (localStorage.cachePage) {
this.cachePage = JSON.parse(localStorage.cachePage);
this.cachePage = JSON.parse(localStorage.cachePage).filter((name) => !removedPageNames.has(name));
localStorage.cachePage = JSON.stringify(this.cachePage);
}
},
removeTag(name) {
@@ -120,8 +123,9 @@ export const useAppStore = defineStore("app", {
},
setOpenedList() {
this.pageOpenedList = localStorage.pageOpenedList
? JSON.parse(localStorage.pageOpenedList)
? JSON.parse(localStorage.pageOpenedList).filter((page) => !removedPageNames.has(page.name))
: [otherRouter.children[0]];
localStorage.pageOpenedList = JSON.stringify(this.pageOpenedList);
},
setAvatarPath(path) {
localStorage.avatorImgPath = path;

View File

@@ -1,3 +1,8 @@
/* 全局禁止多行输入框拖动缩放,覆盖组件生成的内联 resize 样式。 */
.el-textarea__inner {
resize: none !important;
}
/* Element Plus 主题覆盖(对齐原 iView 主色) */
:root {
// --el-color-primary: #ff5c58;

View File

@@ -1,7 +1,7 @@
/**
* 打印指定 DOM 区域(替代 vue-print-nb)
* 打印指定 DOM 区域(支持标准 A4 纵向与完整打印样式隔离)
*/
export function printElement(elementId, title = "打印") {
export function printElement(elementId, title = "打印发货单") {
const el = document.getElementById(elementId);
if (!el) {
return;
@@ -13,22 +13,196 @@ export function printElement(elementId, title = "打印") {
doc.open();
doc.write(
`<!DOCTYPE html><html><head><title>${title}</title><style>
body{font-family:Arial,sans-serif;padding:12px;color:#333}
table{width:100%;border-collapse:collapse}
td,th{border:1px solid #ddd;padding:6px}
.lineH30{line-height:30px}
.f14{font-size:14px;color:#333}
.printgoodtitle{font-size:14px;line-height:1.5;margin-top:15px;color:#333}
.printgoodinfo{padding:10px}
.printgooditem{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #eee}
.printgoodname{flex:1}
.printgoodguid{margin-top:4px;color:#666;font-size:12px}
.printgoodguiditem{margin-right:8px}
.printgoodnumber{white-space:nowrap;margin-left:12px}
@page {
size: portrait;
margin: 8mm 10mm;
}
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
box-sizing: border-box !important;
}
html, body {
margin: 0 !important;
padding: 0 !important;
background: #ffffff !important;
width: 100% !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
color: #000000;
}
.invoice-wrap {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
color: #000;
padding: 4px 6px;
background: #fff;
line-height: 1.4;
font-size: 12px;
width: 100%;
}
.inv-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding-bottom: 10px;
border-bottom: 2px solid #000;
margin-bottom: 10px;
}
.inv-title-box h2 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 700;
color: #000;
letter-spacing: 1px;
}
.inv-sub-title {
font-size: 10px;
color: #555;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.inv-meta-right {
text-align: right;
}
.barcode-svg {
display: inline-block;
}
.barcode-text {
font-family: monospace;
font-size: 13px;
font-weight: 700;
margin-top: 2px;
letter-spacing: 1px;
color: #000;
}
.inv-meta-table {
width: 100%;
border-collapse: collapse;
border: 1px solid #000;
margin-bottom: 10px;
}
.inv-meta-table td {
border: 1px solid #000;
padding: 5px 8px;
font-size: 12px;
line-height: 16px;
}
.inv-meta-table .m-k {
width: 75px;
background: #f3f4f6;
font-weight: 600;
color: #111;
text-align: center;
white-space: nowrap;
}
.inv-meta-table .m-v {
color: #000;
}
.inv-table {
width: 100% !important;
table-layout: fixed !important;
border-collapse: collapse;
border: 1px solid #000;
margin-bottom: 0 !important;
font-size: 12px;
}
.inv-table th {
background: #f3f4f6;
color: #000;
font-weight: 700;
text-align: center;
padding: 6px 4px;
border: 1px solid #000;
}
.inv-table td {
padding: 6px 6px;
border: 1px solid #000;
color: #000;
vertical-align: middle;
word-break: break-all;
overflow-wrap: break-word;
}
.inv-table .goods-title {
font-weight: 600;
line-height: 1.3;
}
.inv-table .goods-specs {
font-size: 11px;
color: #444;
margin-top: 2px;
}
.check-box-square {
display: inline-block;
width: 14px;
height: 14px;
border: 1.5px solid #000;
border-radius: 2px;
}
.inv-summary-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 10px;
background: #f9fafb;
border: 1px solid #000;
border-top: none;
margin-top: -1px !important;
margin-bottom: 14px;
font-size: 12px;
}
.inv-summary-left {
color: #111;
font-weight: 600;
}
.inv-summary-right {
display: flex;
gap: 16px;
align-items: baseline;
}
.inv-summary-right .sum-item {
display: flex;
align-items: baseline;
gap: 4px;
}
.inv-summary-right .sum-total {
font-size: 15px;
font-weight: 700;
color: #000;
}
.inv-sign-area {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
padding: 10px 4px 6px;
border-top: 1px dashed #666;
font-size: 12px;
color: #222;
margin-bottom: 6px;
}
.sign-line {
border-bottom: 1px solid #000;
display: inline-block;
width: 80px;
height: 14px;
vertical-align: middle;
margin-left: 4px;
}
.inv-footer-note {
text-align: center;
font-size: 11px;
color: #666;
padding-top: 6px;
border-top: 1px solid #e5e7eb;
}
</style></head><body>${el.innerHTML}</body></html>`
);
doc.close();
iframe.contentWindow.focus();
setTimeout(() => {
iframe.contentWindow.print();
setTimeout(() => document.body.removeChild(iframe), 1000);
setTimeout(() => {
if (document.body.contains(iframe)) {
document.body.removeChild(iframe);
}
}, 1500);
}, 100);
}

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

@@ -45,12 +45,13 @@
</div>
</el-card>
<el-dialog
<el-drawer
v-model="modalVisible"
:title="modalTitle"
width="500px"
size="500px"
:close-on-click-modal="false"
destroy-on-close
append-to-body
>
<el-form ref="formRef" :model="form" label-width="100px" :rules="formValidate">
<el-form-item label="自定义分词" prop="name">
@@ -61,7 +62,7 @@
<el-button @click="modalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</el-drawer>
</div>
</template>

View File

@@ -127,9 +127,9 @@
</template>
</el-table-column>
<el-table-column prop="joinTime" label="加入时间" min-width="160" />
<el-table-column label="操作" width="270" align="center" fixed="right">
<el-table-column label="操作" width="270" align="right" fixed="right">
<template #default="{ row }">
<template v-if="row">
<div v-if="row" class="ops-row">
<a class="link-text" @click="openEdit(row)">编辑</a>
<span class="op-split">|</span>
<a class="link-text" @click="openDetail(row)">详情</a>
@@ -144,7 +144,7 @@
<a v-if="row.distributionStatus != 'RETREAT'" class="link-text" @click="retreat(row)">清退</a>
<a v-if="row.distributionStatus == 'RETREAT'" class="link-text" @click="resume(row)">恢复</a>
</template>
</template>
</div>
</template>
</el-table-column>
</el-table>
@@ -260,10 +260,10 @@
</div>
</el-drawer>
<el-dialog
<el-drawer
v-model="editVisible"
title="编辑分销员"
width="520px"
size="520px"
:close-on-click-modal="false"
destroy-on-close
append-to-body
@@ -304,12 +304,12 @@
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editLoading" @click="submitEdit">保存</el-button>
</template>
</el-dialog>
</el-drawer>
<el-dialog
<el-drawer
v-model="createVisible"
title="新增分销员"
width="520px"
size="520px"
:close-on-click-modal="false"
destroy-on-close
append-to-body
@@ -345,12 +345,12 @@
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">确定</el-button>
</template>
</el-dialog>
</el-drawer>
<el-dialog
<el-drawer
v-model="batchGradeVisible"
title="批量设置等级"
width="480px"
size="480px"
:close-on-click-modal="false"
destroy-on-close
append-to-body
@@ -380,12 +380,12 @@
<el-button @click="batchGradeVisible = false">取消</el-button>
<el-button type="primary" :loading="batchGradeLoading" @click="submitBatchGrade">确定</el-button>
</template>
</el-dialog>
</el-drawer>
<el-dialog
<el-drawer
v-model="importVisible"
title="批量导入分销员"
width="520px"
size="520px"
:close-on-click-modal="false"
destroy-on-close
append-to-body
@@ -416,7 +416,7 @@
开始导入
</el-button>
</template>
</el-dialog>
</el-drawer>
</div>
</template>
@@ -923,6 +923,9 @@ onMounted(() => {
margin: 0 8px;
color: #dcdee2;
}
.ops-row {
white-space: nowrap;
}
.status-tabs {
margin-bottom: 4px;
}

View File

@@ -243,12 +243,13 @@
</div>
</el-drawer>
<el-dialog
<el-drawer
v-model="rebindVisible"
title="换绑客户关系"
width="520px"
size="520px"
:close-on-click-modal="false"
destroy-on-close
append-to-body
>
<el-form ref="rebindFormRef" :model="rebindForm" :rules="rebindRules" label-width="110px">
<el-form-item label="客户">
@@ -273,7 +274,7 @@
<el-button @click="rebindVisible = false">取消</el-button>
<el-button type="primary" :loading="rebindLoading" @click="submitRebind">确定</el-button>
</template>
</el-dialog>
</el-drawer>
<distribution-selector
v-model="distributorSelectorVisible"
@@ -281,12 +282,13 @@
@confirm="onDistributorSelected"
/>
<el-dialog
<el-drawer
v-model="unbindVisible"
title="解绑客户关系"
width="520px"
size="520px"
:close-on-click-modal="false"
destroy-on-close
append-to-body
>
<el-form ref="unbindFormRef" :model="unbindForm" label-width="110px">
<el-form-item label="客户">
@@ -300,7 +302,7 @@
<el-button @click="unbindVisible = false">取消</el-button>
<el-button type="primary" :loading="unbindLoading" @click="submitUnbind">确定</el-button>
</template>
</el-dialog>
</el-drawer>
</div>
</template>

View File

@@ -14,7 +14,11 @@
<el-card class="mt_10">
<el-table v-loading="loading" border :data="data">
<el-table-column prop="period" label="日期" width="120" />
<el-table-column prop="paymentName" label="支付方式" width="140" />
<el-table-column prop="paymentName" label="支付方式" width="140">
<template #default="{ row }">
{{ formatPaymentName(row.paymentName) }}
</template>
</el-table-column>
<el-table-column prop="payCount" label="支付笔数" width="100" />
<el-table-column prop="payAmount" label="支付金额" width="120" />
<el-table-column prop="refundCount" label="退款笔数" width="100" />
@@ -35,6 +39,17 @@ const loading = ref(false);
const dateRange = ref([]);
const data = ref([]);
const paymentNameMap = {
WECHAT: "微信支付",
ALIPAY: "支付宝",
WALLET: "余额支付",
BANK_TRANSFER: "线下转账",
};
function formatPaymentName(name) {
return paymentNameMap[name] || name || "-";
}
onMounted(() => {
loadData();
});

View File

@@ -1,28 +1,56 @@
<template>
<div class="search">
<el-card>
<el-form inline>
<el-form-item label="会员">
<el-input v-model="searchForm.memberName" clearable style="width: 160px" />
<div class="finance-wallet-log-page search">
<el-card shadow="never" class="filter-card">
<el-form inline class="search-form" @keyup.enter="handleSearch">
<el-form-item label="客户ID">
<el-input v-model="searchForm.memberId" placeholder="请输入客户ID" clearable style="width: 180px" />
</el-form-item>
<el-form-item label="日期">
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" />
<el-form-item label="会员名称">
<el-input v-model="searchForm.memberName" placeholder="请输入会员名称" clearable style="width: 160px" />
</el-form-item>
<el-form-item>
<el-form-item label="日期范围">
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" style="width: 240px" />
</el-form-item>
<el-form-item class="form-actions">
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button @click="handleExport">导出</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="mt_10">
<el-table v-loading="loading" border :data="data">
<el-table-column prop="memberName" label="会员" width="140" />
<el-table-column prop="money" label="金额" width="110" />
<el-table-column prop="serviceType" label="业务类型" width="140" />
<el-table-column prop="detail" label="明细" min-width="200" show-overflow-tooltip />
<el-table-column prop="createTime" label="时间" width="170" />
<el-card shadow="never" class="table-card mt_10">
<template #header>
<div class="card-header-bar">
<div class="header-title">
<span>钱包流水明细</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getDataList">刷新</el-button>
</div>
</div>
</template>
<el-table v-loading="loading" border stripe :data="data">
<el-table-column prop="memberId" label="客户ID" min-width="190" show-overflow-tooltip />
<el-table-column prop="memberName" label="会员名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="money" label="金额" width="130" align="right">
<template #default="{ row }">
<span v-if="row" :class="row.money >= 0 ? 'money-increase' : 'money-decrease'">
{{ row.money >= 0 ? '+' : '' }}¥ {{ Number(row.money || 0).toFixed(2) }}
</span>
</template>
</el-table-column>
<el-table-column prop="serviceType" label="业务类型" width="130" align="center">
<template #default="{ row }">
{{ row ? row.serviceType || "-" : "-" }}
</template>
</el-table-column>
<el-table-column prop="detail" label="明细" min-width="240" show-overflow-tooltip />
<el-table-column prop="createTime" label="时间" width="170" align="center" />
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
@@ -42,15 +70,16 @@ import { useTable } from "@/utils/useTable";
import * as API_Finance from "@/api/finance";
import { getRequest } from "@/libs/axios";
import { downloadBlob } from "@/utils/downloadBlob";
import { ElMessage } from "element-plus";
defineOptions({ name: "finance-wallet-log" });
const dateRange = ref([]);
const { loading, data, total, searchForm, getDataList, handleSearch } = useTable(
const { loading, data, total, searchForm, getDataList, handleSearch, resetSearch } = useTable(
(p) => getRequest("/wallet/log", p),
{
pageSize: 20,
form: { memberName: "" },
form: { memberId: "", memberName: "" },
beforeRequest: (p) => {
const params = { ...p };
if (dateRange.value?.length === 2) {
@@ -62,8 +91,18 @@ const { loading, data, total, searchForm, getDataList, handleSearch } = useTable
}
);
function handleReset() {
dateRange.value = [];
searchForm.memberId = "";
searchForm.memberName = "";
resetSearch();
}
function handleExport() {
const params = { memberName: searchForm.memberName };
const params = {
memberId: searchForm.memberId || undefined,
memberName: searchForm.memberName || undefined,
};
if (dateRange.value?.length === 2) {
params.startDate = dateRange.value[0];
params.endDate = dateRange.value[1];
@@ -73,3 +112,78 @@ function handleExport() {
});
}
</script>
<style lang="scss" scoped>
.finance-wallet-log-page {
padding: 16px 20px;
background-color: #f6f8fa;
min-height: calc(100vh - 110px);
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.filter-card {
border-radius: 8px;
border: 1px solid #ebeef5;
:deep(.el-card__body) {
padding: 16px 16px 2px;
}
.search-form {
display: flex;
flex-wrap: wrap;
align-items: center;
.el-form-item {
margin-bottom: 14px;
margin-right: 16px;
}
.form-actions {
margin-left: auto;
}
}
}
.table-card {
border-radius: 8px;
border: 1px solid #ebeef5;
.card-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
.header-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
}
.money-increase {
color: #67c23a;
}
.money-decrease {
color: #f56c6c;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding-top: 6px;
}
</style>

View File

@@ -1,10 +1,10 @@
<template>
<div class="search">
<el-card>
<div class="operation">
<list-action-bar>
<el-button type="primary" @click="add">添加</el-button>
<el-button @click="delAll">批量删除</el-button>
</div>
</list-action-bar>
<el-table
ref="table"
@@ -66,6 +66,7 @@
</template>
<script setup>
import ListActionBar from "@/components/list-action-bar.vue";
import { ref, reactive, onMounted } from "vue";
import {
addGoodsUnit,

View File

@@ -70,7 +70,8 @@
</el-card>
<el-card>
<div class="goods-tab">
<slot v-if="scheduledOnly" name="scheduled-controls" />
<div v-if="!scheduledOnly" class="goods-tab">
<el-tabs v-model="currentStatus" @tab-click="onStatusTabClick">
<el-tab-pane
v-for="item in goodsStatusWithCount"
@@ -81,7 +82,7 @@
</el-tabs>
</div>
<div class="batch-operations" style="margin: 10px 0">
<div v-if="!readOnly" class="batch-operations" style="margin: 10px 0">
<el-button
type="primary"
:disabled="selectedRows.length === 0"
@@ -125,7 +126,7 @@
row-key="id"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="100" align="center" />
<el-table-column v-if="!readOnly" type="selection" width="100" align="center" />
<el-table-column prop="id" label="SPU编码" width="200" show-overflow-tooltip />
<el-table-column label="商品图片" width="100" align="center">
<template #default="{ row }">
@@ -141,6 +142,8 @@
<a class="link-text" @click="linkTo(row.id, row.skuId)">{{ row.goodsName }}</a>
</template>
</el-table-column>
<el-table-column v-if="currentStatus === 'SCHEDULED_UPPER'" prop="scheduledUpperTime" label="计划上架时间" width="190" />
<el-table-column v-if="currentStatus === 'SCHEDULED_DOWN'" prop="scheduledDownTime" label="计划下架时间" width="190" />
<el-table-column label="价格" width="200">
<template #default="{ row }">
<span :style="{ color: $mainColor }">{{ unitPrice(row.price, '¥') }}</span>
@@ -153,7 +156,7 @@
<span v-if="row">{{ goodsTypeText(row.goodsType) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="150">
<el-table-column v-if="!scheduledOnly" label="状态" width="150">
<template #default="{ row }">
<span v-if="row">{{ marketEnableText(row.marketEnable) }}</span>
</template>
@@ -164,7 +167,12 @@
</template>
</el-table-column>
<el-table-column prop="storeName" label="店铺名称" width="200" show-overflow-tooltip />
<el-table-column label="操作" width="200" align="center" fixed="right">
<el-table-column v-if="scheduledOnly" label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<a class="link-text" @click="emit('delete-schedule', row)">删除</a>
</template>
</el-table-column>
<el-table-column v-else-if="!readOnly" label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row.authFlag === 'TOBEAUDITED'">
<a class="link-text" @click="openAuditModal(row)">审核</a>
@@ -298,6 +306,12 @@ import { ElMessage, ElMessageBox } from "element-plus";
import { unitPrice, customRouterPush } from "@/utils/filters";
defineOptions({ name: "goods" });
const emit = defineEmits(["delete-schedule"]);
const props = defineProps({
scheduledStatus: { type: String, default: "SCHEDULED_UPPER" },
scheduledOnly: { type: Boolean, default: false },
readOnly: { type: Boolean, default: false },
});
// 模板 ref(searchFormRef/underFormRef/batchAuditFormRef/goodsGroupFormRef 因与 data 同名改为 xxxRef)
const searchFormRef = ref(null);
@@ -315,7 +329,7 @@ const auditModalVisible = ref(false);
const auditSubmitLoading = ref(false);
const currentAuditGoods = ref(null);
const submitLoading = ref(false);
const currentStatus = ref("ALL");
const currentStatus = ref(props.scheduledOnly ? props.scheduledStatus : "ALL");
const { loading, data, total, searchForm, getDataList, handleSearch, resetSearch } = useTable(
getGoodsListData,
@@ -333,8 +347,13 @@ const { loading, data, total, searchForm, getDataList, handleSearch, resetSearch
},
beforeRequest: (p) => {
const params = { ...p };
if (currentStatus.value && currentStatus.value !== "ALL") {
params.goodsStatus = currentStatus.value;
const status = props.scheduledOnly ? props.scheduledStatus : currentStatus.value;
if (status && status !== "ALL") {
params.goodsStatus = status;
if (["SCHEDULED_UPPER", "SCHEDULED_DOWN"].includes(status)) {
params.sort = status === "SCHEDULED_UPPER" ? "scheduled_upper_time" : "scheduled_down_time";
params.order = "asc";
}
} else {
delete params.goodsStatus;
}
@@ -347,7 +366,7 @@ const { loading, data, total, searchForm, getDataList, handleSearch, resetSearch
);
function handleReset() {
currentStatus.value = "ALL";
currentStatus.value = props.scheduledOnly ? props.scheduledStatus : "ALL";
resetSearch();
}
const goodsNumerData = ref({});
@@ -415,6 +434,7 @@ function init() {
// 搜索
// 获取数据
function getNumberData() {
if (props.scheduledOnly) return;
// 创建一个不包含goodsStatus字段的搜索参数
const { goodsStatus, ...searchParams } = searchForm;
getGoodsNumerData(searchParams).then((res) => {
@@ -488,6 +508,7 @@ function goodsStatusClick(name) {
}
selectedRows.value = [];
clearTableSelection();
searchForm.pageNumber = 1;
getDataList();
}
function examine(v, authFlag) {

View File

@@ -1,47 +1,77 @@
<template>
<div class="search">
<el-card>
<div class="brand-manage-page search">
<!-- 顶部筛选表单 -->
<el-card shadow="never" class="filter-card">
<el-form
ref="searchFormRef"
:model="searchForm"
inline
label-width="70px"
label-width="76px"
class="search-form"
@keyup.enter="handleSearch"
>
<el-form-item label="品牌名称">
<el-form-item label="品牌名称" prop="name">
<el-input
v-model="searchForm.name"
placeholder="请输入品牌名称"
clearable
style="width: 240px"
style="width: 220px"
@clear="handleSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-form-item class="form-actions">
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="mt_10">
<div class="operation padding-row" style="margin-bottom: 10px;">
<el-button type="primary" @click="add">添加</el-button>
</div>
<!-- 品牌数据表格卡片 -->
<el-card shadow="never" class="table-card mt_10">
<list-action-bar>
<el-button type="primary" @click="add">添加品牌</el-button>
<template #right>
<el-button @click="getDataList">刷新</el-button>
</template>
</list-action-bar>
<el-table ref="table" v-loading="loading" border :data="data" style="width: 100%">
<el-table-column prop="name" label="品牌名称" width="200" />
<el-table-column label="品牌图标" align="left">
<el-table
ref="table"
v-loading="loading"
border
stripe
:data="data"
style="width: 100%"
class="custom-brand-table"
>
<!-- 品牌ID -->
<el-table-column prop="id" label="品牌ID" min-width="190" show-overflow-tooltip />
<!-- 品牌图标 -->
<el-table-column label="品牌图标" width="96" align="center">
<template #default="{ row }">
<img
v-if="row"
:src="row.logo || ''"
alt="加载图片失败"
style="cursor: pointer; width: 80px; height: 60px; margin: 10px 0; object-fit: contain"
/>
<div v-if="row && row.logo" class="logo-box">
<el-image
:src="row.logo"
fit="contain"
preview-teleported
:preview-src-list="[row.logo]"
class="brand-logo-img"
>
<template #error>
<div class="logo-slot">无图</div>
</template>
</el-image>
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" align="left" width="150">
<!-- 品牌名称 -->
<el-table-column prop="name" label="品牌名称" min-width="180" show-overflow-tooltip />
<!-- 状态 -->
<el-table-column label="状态" width="110" align="center">
<template #default="{ row }">
<el-switch
v-if="row"
@@ -53,8 +83,12 @@
/>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="170" show-overflow-tooltip />
<el-table-column label="操作" width="230" align="center" fixed="right">
<!-- 创建时间 -->
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center" show-overflow-tooltip />
<!-- 操作栏 -->
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row">
<a class="link-text" @click="edit(row)">编辑</a>
@@ -67,7 +101,8 @@
</el-table-column>
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
@@ -81,24 +116,31 @@
</div>
</el-card>
<!-- 添加 / 编辑品牌弹窗 -->
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="500px"
width="520px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form ref="formRef" :model="form" label-width="100px" :rules="formValidate">
<el-form ref="formRef" :model="form" label-width="90px" :rules="formValidate">
<el-form-item v-if="modalType === 1 && form.id" label="品牌ID">
<span class="font-mono text-dark">{{ form.id }}</span>
</el-form-item>
<el-form-item label="品牌名称" prop="name">
<el-input v-model="form.name" style="width: 100%" />
<el-input v-model="form.name" placeholder="请输入品牌名称" style="width: 100%" />
</el-form-item>
<el-form-item label="品牌图标" prop="logo">
<div style="display: flex; align-items: center; gap: 8px">
<div class="dialog-logo-picker">
<div class="logo-preview-box">
<img
:src="form.logo || defaultPic"
alt=""
style="width: 60px; height: 60px; object-fit: contain; border: 1px solid #dcdfe6; border-radius: 4px"
alt="品牌图标"
class="dialog-logo-img"
/>
<el-button type="primary" link @click="openLogoPicker">修改</el-button>
</div>
<el-button type="primary" link @click="openLogoPicker">上传/选择图标</el-button>
</div>
</el-form-item>
</el-form>
@@ -108,7 +150,8 @@
</template>
</el-dialog>
<el-dialog v-model="picModelFlag" width="1200px" :show-close="true">
<!-- OSS 图片选择组件弹窗 -->
<el-dialog v-model="picModelFlag" width="1200px" title="选择品牌图标" :show-close="true">
<ossManage
ref="ossManageRef"
:is-component="true"
@@ -117,13 +160,15 @@
/>
</el-dialog>
<!-- 关联分类弹窗 -->
<el-dialog
v-model="categoryModalVisible"
:title="categoryModalTitle"
width="700px"
width="680px"
:close-on-click-modal="false"
destroy-on-close
>
<div v-loading="categoryTreeLoading" style="position: relative; max-height: 520px; overflow: auto">
<div v-loading="categoryTreeLoading" class="category-tree-wrap">
<el-tree
ref="categoryTree"
:key="categoryTreeKey"
@@ -139,7 +184,7 @@
<template #footer>
<el-button @click="categoryModalVisible = false">取消</el-button>
<el-button type="primary" :loading="categorySubmitLoading" @click="submitBrandCategory">
提交
提交关联
</el-button>
</template>
</el-dialog>
@@ -147,6 +192,7 @@
</template>
<script setup>
import ListActionBar from "@/components/list-action-bar.vue";
import { ref, reactive, nextTick, getCurrentInstance } from "vue";
import { useTable } from "@/utils/useTable";
import {
@@ -299,7 +345,7 @@ function handleStatusSwitchChange(row, checked) {
}
function delBrand(row) {
ElMessageBox.confirm("您确认要删除 " + row.name + " ?", "确认删除", { type: "warning" }).then(() => {
ElMessageBox.confirm("您确认要删除品牌 " + row.name + " ?", "确认删除", { type: "warning" }).then(() => {
return delBrandApi(row.id).then((res) => {
if (res.success) {
ElMessage.success("品牌删除成功!");
@@ -339,7 +385,7 @@ function handleSubmit() {
function add() {
modalType.value = 0;
modalTitle.value = "添加";
modalTitle.value = "添加品牌";
formRef.value?.resetFields();
delete form.value.id;
modalVisible.value = true;
@@ -347,7 +393,7 @@ function add() {
function edit(v) {
modalType.value = 1;
modalTitle.value = "编辑";
modalTitle.value = "编辑品牌";
formRef.value?.resetFields();
for (const attr in v) {
if (v[attr] === null) {
@@ -360,13 +406,108 @@ function edit(v) {
</script>
<style lang="scss" scoped>
.link-text {
color: #409eff;
cursor: pointer;
text-decoration: none;
.brand-manage-page {
.filter-card {
.search-form {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0;
:deep(.el-form-item) {
margin-bottom: 0;
margin-right: 16px;
}
.op-split {
margin: 0 8px;
color: #dcdee2;
.form-actions {
margin-left: auto;
}
}
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-placeholder {
color: #c0c4cc;
}
.time-text {
font-size: 12px;
color: #909399;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.custom-brand-table {
.logo-box {
width: 52px;
height: 40px;
margin: 0 auto;
border: 1px solid #ebeef5;
border-radius: 4px;
background: #fafafa;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.brand-logo-img {
width: 48px;
height: 36px;
}
.logo-slot {
font-size: 11px;
color: #c0c4cc;
}
}
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
}
}
.dialog-logo-picker {
display: flex;
align-items: center;
gap: 12px;
.logo-preview-box {
width: 64px;
height: 48px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fafafa;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.dialog-logo-img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
}
.category-tree-wrap {
position: relative;
max-height: 520px;
overflow: auto;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 10px;
}
</style>

View File

@@ -1,9 +1,9 @@
<template>
<div>
<el-card>
<div class="mb_10">
<list-action-bar>
<el-button type="primary" @click="addParent">添加一级分类</el-button>
</div>
</list-action-bar>
<el-table
v-loading="loading"
@@ -121,6 +121,7 @@
</template>
<script setup>
import ListActionBar from "@/components/list-action-bar.vue";
import { ref, reactive, onMounted } from "vue";
import {
delCategory,

View File

@@ -1,7 +1,7 @@
<template>
<div class="search">
<el-card>
<el-form ref="searchFormRef" :model="searchForm" inline>
<el-card class="parameter-filter-card">
<el-form ref="searchFormRef" :model="searchForm" inline class="search-form" @keyup.enter="handleSearch">
<el-form-item label="参数名称" prop="paramName">
<el-input
v-model="searchForm.paramName"
@@ -16,9 +16,12 @@
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<el-row class="operation" style="margin-bottom: 10px;">
</el-card>
<el-card class="parameter-list-card" style="margin-top: 10px">
<list-action-bar>
<el-button type="primary" @click="goAdd">添加</el-button>
</el-row>
</list-action-bar>
<el-table
ref="tableRef"
v-loading="loading"
@@ -72,6 +75,7 @@
</div>
</template>
<script setup>
import ListActionBar from "@/components/list-action-bar.vue";
import { ref } from "vue";
import { useTable } from "@/utils/useTable";
import { useRouter } from "vue-router";

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,77 @@
<template>
<div class="search">
<el-card>
<div class="operation padding-row">
<el-button type="primary" @click="openAdd">添加分组</el-button>
<div class="goods-group-page search">
<!-- 顶部筛选与操作栏 -->
<el-card shadow="never" class="filter-card">
<div class="card-toolbar">
<el-form inline :model="searchForm" class="filter-form" @keyup.enter="handleSearch">
<el-form-item label="分组名称">
<el-input
v-model="searchForm.groupName"
placeholder="请输入分组名称"
clearable
style="width: 220px"
@clear="handleSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
<el-table ref="table" v-loading="loading" :data="data" border class="mt_10" style="width: 100%">
<!-- 分组数据表格卡片 -->
<el-card shadow="never" class="table-card mt_10">
<list-action-bar>
<el-button type="primary" @click="openAdd">添加分组</el-button>
<template #right>
<el-button @click="getData">刷新</el-button>
</template>
</list-action-bar>
<el-table
ref="table"
v-loading="loading"
:data="data"
border
stripe
style="width: 100%"
class="custom-group-table"
>
<!-- 分组ID -->
<el-table-column prop="id" label="分组ID" min-width="190" show-overflow-tooltip />
<!-- 分组名称 -->
<el-table-column prop="groupName" label="分组名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="description" label="分组描述" min-width="240" show-overflow-tooltip />
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column prop="updateTime" label="更新时间" width="180" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<!-- 分组描述 -->
<el-table-column prop="description" label="分组描述" min-width="240" show-overflow-tooltip>
<template #default="{ row }">
<div v-if="row" class="ops" style="display: flex; justify-content: center">
{{ row ? row.description || "-" : "-" }}
</template>
</el-table-column>
<!-- 创建时间 -->
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center" />
<!-- 更新时间 -->
<el-table-column prop="updateTime" label="更新时间" min-width="170" align="center" />
<!-- 操作栏 -->
<el-table-column label="操作" width="140" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row">
<a class="link-text" @click="openEdit(row)">编辑</a>
<span class="op-split">|</span>
<a class="link-text" @click="remove(row)">删除</a>
</div>
</template>
</template>
</el-table-column>
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
@@ -35,13 +85,32 @@
</div>
</el-card>
<el-dialog v-model="addFlag" title="添加商品分组" width="500px" :close-on-click-modal="false" destroy-on-close>
<!-- 添加商品分组弹窗 -->
<el-dialog
v-model="addFlag"
title="添加商品分组"
width="520px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form ref="addFormRef" :model="formAdd" :rules="rulesAdd" label-width="90px">
<el-form-item label="分组名称" prop="groupName" style="width: 90%">
<el-input v-model="formAdd.groupName" maxlength="30" placeholder="请输入分组名称" />
<el-form-item label="分组名称" prop="groupName">
<el-input
v-model="formAdd.groupName"
maxlength="30"
show-word-limit
placeholder="请输入分组名称"
/>
</el-form-item>
<el-form-item label="分组描述" prop="description" style="width: 90%">
<el-input v-model="formAdd.description" maxlength="200" placeholder="请输入分组描述" />
<el-form-item label="分组描述" prop="description">
<el-input
v-model="formAdd.description"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入分组描述信息"
/>
</el-form-item>
</el-form>
<template #footer>
@@ -50,14 +119,35 @@
</template>
</el-dialog>
<el-dialog v-model="editFlag" title="编辑商品分组" width="500px" :close-on-click-modal="false" destroy-on-close>
<!-- 编辑商品分组弹窗 -->
<el-dialog
v-model="editFlag"
title="编辑商品分组"
width="520px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form ref="editFormRef" :model="formEdit" :rules="rulesEdit" label-width="90px">
<el-input v-model="formEdit.id" style="display: none" />
<el-form-item label="分组名称" prop="groupName" style="width: 90%">
<el-input v-model="formEdit.groupName" maxlength="30" placeholder="请输入分组名称" />
<el-form-item label="分组ID">
<span>{{ formEdit.id }}</span>
</el-form-item>
<el-form-item label="分组描述" prop="description" style="width: 90%">
<el-input v-model="formEdit.description" maxlength="200" placeholder="请输入分组描述" />
<el-form-item label="分组名称" prop="groupName">
<el-input
v-model="formEdit.groupName"
maxlength="30"
show-word-limit
placeholder="请输入分组名称"
/>
</el-form-item>
<el-form-item label="分组描述" prop="description">
<el-input
v-model="formEdit.description"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入分组描述信息"
/>
</el-form-item>
</el-form>
<template #footer>
@@ -69,6 +159,7 @@
</template>
<script setup>
import ListActionBar from "@/components/list-action-bar.vue";
import { ref, reactive, nextTick, onMounted } from "vue";
import * as API_Goods from "@/api/goods.js";
import { ElMessage, ElMessageBox } from "element-plus";
@@ -79,6 +170,7 @@ const loading = ref(true);
const searchForm = reactive({
pageNumber: 1,
pageSize: 20,
groupName: "",
});
const data = ref([]);
const total = ref(0);
@@ -86,18 +178,22 @@ const addFlag = ref(false);
const editFlag = ref(false);
const submitAddLoading = ref(false);
const submitEditLoading = ref(false);
const formAdd = ref({
groupName: "",
description: "",
});
const formEdit = ref({
id: "",
groupName: "",
description: "",
});
const rulesAdd = reactive({
groupName: [{ required: true, message: "请输入分组名称", trigger: "blur" }],
});
const rulesEdit = reactive({
groupName: [{ required: true, message: "请输入分组名称", trigger: "blur" }],
});
@@ -105,7 +201,14 @@ const rulesEdit = reactive({
const addFormRef = ref(null);
const editFormRef = ref(null);
function init() {
function handleSearch() {
searchForm.pageNumber = 1;
getData();
}
function handleReset() {
searchForm.pageNumber = 1;
searchForm.groupName = "";
getData();
}
@@ -216,7 +319,12 @@ function submitEdit() {
}
function remove(row) {
ElMessageBox.confirm("确定删除该分组?", "提示", { type: "warning" }).then(() => {
ElMessageBox.confirm(`确定删除分组 "${row.groupName || "该分组"}" 吗?`, "提示", {
type: "warning",
confirmButtonText: "确定删除",
cancelButtonText: "取消",
})
.then(() => {
return API_Goods.deleteGoodsGroup(row.id)
.then((res) => {
if (res && res.success) {
@@ -229,23 +337,41 @@ function remove(row) {
.catch(() => {
ElMessage.error("删除失败,请检查权限或后端接口");
});
}).catch(() => {});
})
.catch(() => {});
}
onMounted(() => {
init();
getData();
});
</script>
<style scoped>
.ops a {
color: #2d8cf0;
cursor: pointer;
text-decoration: none;
<style scoped lang="scss">
.goods-group-page {
.card-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
.filter-form {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0;
:deep(.el-form-item) {
margin-bottom: 0;
margin-right: 16px;
}
}
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
}
.ops span {
display: inline-block;
margin: 0 8px;
color: #dcdee2;
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<goods-list :key="`${activeStatus}-${refreshKey}`" scheduled-only read-only :scheduled-status="activeStatus" @delete-schedule="deleteSchedule">
<template #scheduled-controls>
<el-tabs v-model="activeStatus">
<el-tab-pane label="定时上架" name="SCHEDULED_UPPER" />
<el-tab-pane label="定时下架" name="SCHEDULED_DOWN" />
</el-tabs>
</template>
</goods-list>
</template>
<script setup>
/**
* 平台商品定时上架列表。
* @author Bulbasaur
* @since 2026-11-03
*/
import GoodsList from "@/views/goods/goods-info/goods.vue";
import { ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { cancelGoodsMarketSchedule } from "@/api/goods";
defineOptions({ name: "goodsScheduledUpper" });
const activeStatus = ref("SCHEDULED_UPPER");
const refreshKey = ref(0);
async function deleteSchedule(row) {
const status = activeStatus.value === "SCHEDULED_UPPER" ? "UPPER" : "DOWN";
try {
await ElMessageBox.confirm(`确认删除“${row.goodsName}”的定时${status === 'UPPER' ? '上架' : '下架'}计划?`, "删除定时计划", { confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" });
} catch {
return;
}
try {
const res = await cancelGoodsMarketSchedule(row.id, status);
if (res.success) {
ElMessage.success("定时计划已删除");
refreshKey.value++;
}
} catch {
// 接口错误由统一拦截器提示。
}
}
</script>

View File

@@ -49,9 +49,13 @@
</el-card>
<el-card class="mt_10">
<el-tabs v-model="salesTab" @tab-change="handleSalesTabChange">
<el-tab-pane label="全部" name="all" />
<el-tab-pane label="已设置" name="configured" />
<el-tab-pane label="未设置" name="unconfigured" />
</el-tabs>
<el-alert
type="info"
show-icon
:closable="false"
title="支持按规格逐条设置虚拟销量,列表总销量 = 真实销量 + 虚拟销量。"
style="margin-bottom: 10px"
@@ -71,55 +75,65 @@
ref="tableRef"
v-loading="loading"
:data="data"
border
class="mt_10"
style="width: 100%"
@sort-change="changeSort"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column prop="id" label="SKU ID" width="180" show-overflow-tooltip />
<el-table-column prop="goodsId" label="SPU编码" width="180" show-overflow-tooltip />
<el-table-column label="商品信息" min-width="360">
<el-table-column label="商品信息" min-width="320">
<template #default="{ row }">
<div v-if="row" class="goods-info">
<img
<div class="goods-thumb-box">
<el-image
v-if="row.thumbnail"
:src="row.thumbnail"
fit="cover"
preview-teleported
:preview-src-list="[row.thumbnail]"
class="goods-thumbnail"
alt=""
/>
>
<template #error>
<div class="thumb-slot">无图</div>
</template>
</el-image>
<div v-else class="thumb-slot">无图</div>
</div>
<div class="goods-text">
<div class="div-zoom">{{ row.goodsName }}</div>
<div v-if="row.simpleSpecs" class="sub-title">
<div class="goods-title" :title="row.goodsName">{{ row.goodsName || "-" }}</div>
<div v-if="row.simpleSpecs" class="goods-specs">
规格:{{ row.simpleSpecs.trim() || "-" }}
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="真实销量" width="110">
<el-table-column prop="goodsId" label="SPU编码" width="220" show-overflow-tooltip />
<el-table-column prop="id" label="SKU ID" width="220" show-overflow-tooltip />
<el-table-column label="真实销量" width="110" align="right">
<template #default="{ row }">
<span v-if="row">{{ row.buyCount || 0 }}</span>
</template>
</el-table-column>
<el-table-column label="虚拟销量" width="110">
<el-table-column label="虚拟销量" width="110" align="right">
<template #default="{ row }">
<span v-if="row">{{ row.virtualSalesInput || 0 }}</span>
</template>
</el-table-column>
<el-table-column label="总销量" width="110">
<el-table-column label="总销量" width="110" align="right">
<template #default="{ row }">
<span v-if="row">{{ getTotalSales(row) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row" :type="row.marketEnable === 'UPPER' ? 'success' : 'warning'">
<span v-if="row">
{{ row.marketEnable === "UPPER" ? "上架" : "下架" }}
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center" fixed="right">
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row, $index }">
<a
v-if="row"
@@ -147,14 +161,19 @@
</div>
</el-card>
<el-dialog
<el-drawer
v-model="editModalVisible"
:title="modalMode === 'batch' ? '批量设置虚拟销量' : '设置虚拟销量'"
width="480px"
class="virtual-sales-drawer"
direction="rtl"
size="min(480px, 100vw)"
append-to-body
:before-close="handleBeforeCloseVirtualSales"
:close-on-click-modal="false"
:close-on-press-escape="!modalSubmitting"
:show-close="!modalSubmitting"
destroy-on-close
@closed="resetVirtualSalesModal"
>
<div v-if="modalMode === 'single' && currentSku" class="virtual-sales-modal">
<div class="sku-info-item">
@@ -199,12 +218,14 @@
</el-form>
</div>
<template #footer>
<div class="drawer-actions">
<el-button :disabled="modalSubmitting" @click="handleCancelVirtualSales">取消</el-button>
<el-button type="primary" :loading="modalSubmitting" @click="handleSubmitVirtualSales">
提交
</el-button>
</div>
</template>
</el-dialog>
</el-drawer>
</div>
</template>
@@ -239,6 +260,7 @@ const modalMode = ref("single");
const currentSku = ref(null);
const currentIndex = ref(-1);
const selectedRows = ref([]);
const salesTab = ref("all");
const editForm = reactive({
virtualSales: 0,
});
@@ -254,6 +276,7 @@ const searchForm = ref({
});
const data = ref([]);
const total = ref(0);
let dataRequestId = 0;
function init() {
getDataList();
@@ -271,6 +294,12 @@ function handleSearch() {
searchForm.value.pageNumber = 1;
getDataList();
}
function handleSalesTabChange() {
searchForm.value.pageNumber = 1;
selectedRows.value = [];
tableRef.value?.clearSelection();
getDataList();
}
function handleReset() {
searchForm.value = {
pageNumber: 1,
@@ -298,9 +327,15 @@ function handleSelectionChange(selection) {
selectedRows.value = selection;
}
function getDataList() {
const requestId = ++dataRequestId;
loading.value = true;
getGoodsSkuData(searchForm.value)
const params = { ...searchForm.value };
if (salesTab.value !== "all") {
params.virtualSalesSet = salesTab.value === "configured";
}
getGoodsSkuData(params)
.then((res) => {
if (requestId !== dataRequestId) return;
if (res && res.success && res.result) {
data.value = (res.result.records || []).map((item) => ({
...item,
@@ -308,6 +343,10 @@ function getDataList() {
saving: false,
}));
total.value = res.result.total || 0;
if (data.value.length === 0 && total.value > 0 && searchForm.value.pageNumber > 1) {
searchForm.value.pageNumber = Math.max(1, Math.ceil(total.value / searchForm.value.pageSize));
getDataList();
}
selectedRows.value = [];
} else {
data.value = [];
@@ -317,13 +356,14 @@ function getDataList() {
}
})
.catch(() => {
if (requestId !== dataRequestId) return;
data.value = [];
total.value = 0;
selectedRows.value = [];
ElMessage.error("加载规格列表失败");
})
.finally(() => {
loading.value = false;
if (requestId === dataRequestId) loading.value = false;
});
}
function getVirtualSalesValue(row) {
@@ -354,6 +394,10 @@ function handleCancelVirtualSales() {
if (modalSubmitting.value) return;
resetVirtualSalesModal();
}
function handleBeforeCloseVirtualSales(done) {
if (modalSubmitting.value) return;
done();
}
function resetVirtualSalesModal() {
editModalVisible.value = false;
modalMode.value = "single";
@@ -388,6 +432,7 @@ function handleSubmitVirtualSales() {
}
ElMessage.success("虚拟销量设置成功");
resetVirtualSalesModal();
getDataList();
} else {
ElMessage.error((res && res.message) || "规格虚拟销量设置失败");
}
@@ -424,6 +469,7 @@ function handleBatchSubmitVirtualSales(virtualSales) {
tableRef.value?.clearSelection();
ElMessage.success("虚拟销量设置成功");
resetVirtualSalesModal();
getDataList();
} else {
ElMessage.error((res && res.message) || "虚拟销量设置失败");
}
@@ -445,32 +491,85 @@ onMounted(() => {
</script>
<style lang="scss" scoped>
.sub-title {
margin-top: 6px;
color: #808695;
font-size: 12px;
.virtual-sales-drawer {
:deep(.el-drawer__header) {
margin-bottom: 0;
padding-bottom: 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
:deep(.el-drawer__footer) {
border-top: 1px solid var(--el-border-color-lighter);
}
}
.drawer-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
:deep(.el-button + .el-button) {
margin-left: 0;
}
}
.goods-info {
display: flex;
align-items: center;
margin: 5px 0;
padding: 10px 0;
padding: 4px 0;
}
.goods-thumb-box {
width: 44px;
height: 44px;
min-width: 44px;
margin-right: 12px;
border-radius: 4px;
overflow: hidden;
border: 1px solid #ebeef5;
background-color: #f8f9fa;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.goods-thumbnail {
width: 50px;
height: 50px;
margin-right: 12px;
object-fit: cover;
flex-shrink: 0;
width: 44px;
height: 44px;
display: block;
}
.thumb-slot {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #909399;
background-color: #f5f7fa;
}
.goods-text {
min-width: 0;
flex: 1;
}
.div-zoom {
.goods-title {
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-all;
font-weight: normal;
}
.goods-specs {
margin-top: 4px;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -493,11 +592,28 @@ onMounted(() => {
.sku-info-label {
width: 90px;
color: #808695;
flex-shrink: 0;
}
.mt_10 {
margin-top: 10px;
}
.link-text {
color: #2d8cf0;
cursor: pointer;
text-decoration: none;
font-size: 14px;
line-height: 1;
display: inline-block;
&:hover {
color: #57a3f3;
}
&.disabled {
color: #c5c8ce;
cursor: not-allowed;
}
}
</style>

View File

@@ -77,9 +77,9 @@
<div class="counts">{{ notices.appointmentWorkOrder || 0 }}</div>
<div>未完成预约工单</div>
</div>
<div class="todo-item" @click="goProcurementAudit">
<div class="counts">{{ notices.procurementAudit || 0 }}</div>
<div>待审核采购单</div>
<div class="todo-item" @click="goMemberWithdrawApply">
<div class="counts">{{ notices.balanceCash || 0 }}</div>
<div>待审核会员提现</div>
</div>
</div>
</div>
@@ -321,10 +321,10 @@ function goAppointmentWorkOrders() {
});
}
function goProcurementAudit() {
function goMemberWithdrawApply() {
router.push({
name: "manager-procurement-order-list",
query: { status: "SUBMITTED" },
name: "withdrawApply",
query: { applyStatus: "APPLY" },
});
}

View File

@@ -11,7 +11,7 @@
<li>多店铺管理与经营数据分析</li>
</ul>
</div>
<p class="brand-copyright">Copyright © {{ year }} Lilishop</p>
<p class="brand-copyright">Copyright © {{ year }} {{ copyrightOwner }}</p>
</div>
<div class="login-panel" @keyup.enter="submitLogin">
@@ -94,11 +94,13 @@ import verify from "@/components/verify";
import { setStore } from "@/libs/storage";
import { md5 } from "@/utils/md5.js";
import { useAppStore } from "@/stores/app";
import { useSiteSettings } from "@/composables/useSiteSettings";
const router = useRouter();
const appStore = useAppStore();
const year = ref(new Date().getFullYear());
const { copyrightOwner } = useSiteSettings();
const loading = ref(false);
const form = reactive({
username: "",

View File

@@ -52,10 +52,11 @@
</div>
</el-card>
<el-dialog
<el-drawer
v-model="modalVisible"
:title="modalTitle"
width="500px"
size="500px"
append-to-body
:close-on-click-modal="false"
destroy-on-close
>
@@ -93,7 +94,7 @@
提交
</el-button>
</template>
</el-dialog>
</el-drawer>
</div>
</template>

View File

@@ -1,24 +1,23 @@
<template>
<div class="foot">
<el-row justify="space-around" class="help">
<el-row v-if="website" justify="space-around" class="help">
<a class="item" :href="website" target="_blank">帮助</a>
<a class="item" :href="website" target="_blank">隐私</a>
<a class="item" :href="website" target="_blank">条款</a>
</el-row>
<el-row justify="center" class="copyright">
Copyright © {{ year }} - Present
<a :href="website" class="href" target="_blank" style="margin: 0 5px">{{ config.title }}</a>
Copyright © {{ year }}
<a :href="website || undefined" class="href" target="_blank" style="margin: 0 5px">{{ copyrightOwner }}</a>
</el-row>
</div>
</template>
<script setup>
import { ref } from "vue";
import config from "@/config/index";
import { useSiteSettings } from "@/composables/useSiteSettings";
const year = ref(new Date().getFullYear());
// 官网地址优先取接口下发缓存,兜底用本地配置
const website = ref(localStorage.getItem("website") || config.website);
const { website, copyrightOwner } = useSiteSettings();
</script>
<style lang="scss" scoped>

View File

@@ -8,7 +8,7 @@
<script setup>
import { ref, onMounted } from "vue";
import { getBaseSite } from "@/api/common.js";
import { loadSiteSettings } from "@/composables/useSiteSettings";
const domainLogo = ref("");
@@ -24,11 +24,9 @@ function applyFavicon(href) {
}
function getSite() {
getBaseSite().then((res) => {
if (res.success && res.result && res.result.settingValue) {
const { domainLogo: logo, domainIcon, siteName, website } = JSON.parse(
res.result.settingValue
);
loadSiteSettings().then((data) => {
if (data) {
const { domainLogo: logo, domainIcon, siteName, website } = data;
domainLogo.value = logo || "";
const expirationTime = new Date().setHours(new Date().getHours() + 1);
localStorage.setItem("icontitle_expiration_time", expirationTime);

View File

@@ -7,8 +7,8 @@
</a>
<template #dropdown>
<el-dropdown-menu v-if="value != 0">
<el-dropdown-item v-if="res.balanceCash" command="deposit">
<el-badge :value="res.balanceCash">待处理预存款提现申请</el-badge>
<el-dropdown-item v-if="res.balanceCash" command="withdrawApply">
<el-badge :value="res.balanceCash">待处理会员提现申请</el-badge>
</el-dropdown-item>
<el-dropdown-item v-if="res.complain" command="orderComplaint">
<el-badge :value="res.complain">待处理投诉审核</el-badge>
@@ -28,9 +28,6 @@
<el-dropdown-item v-if="res.appointmentWorkOrder" command="manager-appointment-work-orders">
<el-badge :value="res.appointmentWorkOrder">未完成预约工单</el-badge>
</el-dropdown-item>
<el-dropdown-item v-if="res.procurementAudit" command="manager-procurement-order-list">
<el-badge :value="res.procurementAudit">待审核采购单</el-badge>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -63,12 +60,12 @@ const value = computed(() => {
});
function navigateTo(name) {
if (name === "manager-appointment-work-orders") {
router.push({ name, query: { serviceStatus: "OPEN" } });
if (name === "withdrawApply") {
router.push({ name: "withdrawApply", query: { applyStatus: "APPLY" } });
return;
}
if (name === "manager-procurement-order-list") {
router.push({ name, query: { status: "SUBMITTED" } });
if (name === "manager-appointment-work-orders") {
router.push({ name, query: { serviceStatus: "OPEN" } });
return;
}
router.push({ name });

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

@@ -1,20 +1,29 @@
<template>
<div class="search">
<el-card>
<div class="member-recharge-page search">
<!-- 顶部筛选表单 -->
<el-card shadow="never" class="filter-card">
<el-form
ref="searchFormRef"
:model="searchForm"
inline
label-width="70px"
label-width="76px"
class="search-form"
@keyup.enter="handleSearch"
>
<el-form-item label="客户ID" prop="memberId">
<el-input
v-model="searchForm.memberId"
placeholder="请输入客户ID"
clearable
style="width: 180px"
/>
</el-form-item>
<el-form-item label="客户名称" prop="memberName">
<el-input
v-model="searchForm.memberName"
placeholder="请输入客户名称"
clearable
style="width: 240px"
style="width: 160px"
/>
</el-form-item>
<el-form-item label="充值单号" prop="rechargeSn">
@@ -22,59 +31,106 @@
v-model="searchForm.rechargeSn"
placeholder="请输入充值单号"
clearable
style="width: 240px"
style="width: 190px"
/>
</el-form-item>
<el-form-item label="支付时间">
<el-form-item label="充值时间">
<el-date-picker
v-model="selectDate"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
clearable
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
placeholder="选择起始时间"
style="width: 360px"
style="width: 320px"
@change="selectDateRange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
<el-form-item class="form-actions">
<el-button type="primary" class="search-btn" @click="handleSearch">
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<!-- 充值数据表格 -->
<el-card shadow="never" class="table-card mt_10">
<template #header>
<div class="card-header-bar">
<div class="header-title">
<span>会员充值记录</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getDataList">刷新</el-button>
</div>
</div>
</template>
<el-table
ref="table"
v-loading="loading"
border
stripe
:data="data"
ref="table"
class="mt_10"
style="width: 100%"
>
<el-table-column prop="memberName" label="客户名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="rechargeSn" label="订单号" min-width="180" show-overflow-tooltip />
<el-table-column label="充值金额" width="160" sortable>
<!-- 客户ID -->
<el-table-column prop="memberId" label="客户ID" min-width="170" show-overflow-tooltip />
<!-- 客户名称 -->
<el-table-column prop="memberName" label="客户名称" min-width="140" show-overflow-tooltip />
<!-- 充值单号 -->
<el-table-column prop="rechargeSn" label="充值单号" min-width="180" show-overflow-tooltip />
<!-- 充值金额 -->
<el-table-column label="充值金额" width="140" align="right" sortable prop="rechargeMoney">
<template #default="{ row }">
<priceColorScheme v-if="row" :value="row.rechargeMoney" :color="$mainColor" unit="+" />
<span v-if="row" class="recharge-money-text">
+¥ {{ formatMoney(row.rechargeMoney) }}
</span>
</template>
</el-table-column>
<el-table-column label="充值方式" width="120">
<!-- 充值方式 -->
<el-table-column label="充值方式" width="120" align="center">
<template #default="{ row }">
<span v-if="row">{{ rechargeWayText(row.rechargeWay) }}</span>
{{ row ? rechargeWayText(row.rechargeWay) : "-" }}
</template>
</el-table-column>
<el-table-column label="支付状态" width="120">
<!-- 支付状态 -->
<el-table-column label="支付状态" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row" :type="row.payStatus === 'PAID' ? 'success' : 'danger'">
{{ row.payStatus === "PAID" ? "已付款" : "未付款" }}
</el-tag>
{{ row ? (row.payStatus === "PAID" ? "已付款" : "未付款") : "-" }}
</template>
</el-table-column>
<!-- 第三方流水号 -->
<el-table-column prop="receivableNo" label="第三方流水号" min-width="180" show-overflow-tooltip />
<!-- 充值时间 -->
<el-table-column prop="createTime" label="充值时间" width="170" align="center" />
<!-- 支付时间 -->
<el-table-column prop="payTime" label="支付时间" width="170" align="center" />
<!-- 操作 -->
<el-table-column label="操作" width="110" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row">
<a v-if="row.memberId" class="link-text" @click="viewMemberDetail(row)">客户详情</a>
<span v-else>-</span>
</template>
</template>
</el-table-column>
<el-table-column prop="createTime" label="充值时间" width="190" />
<el-table-column prop="payTime" label="支付时间" width="190" />
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
@@ -91,49 +147,85 @@
</template>
<script setup>
import { ref, onMounted } from "vue";
import { onMounted, ref } from "vue";
import { useTable } from "@/utils/useTable";
import { getUserRecharge } from "@/api/member";
import { customRouterPush } from "@/utils/filters";
import { ElMessage } from "element-plus";
defineOptions({ name: "recharge" });
// 模板 el-form 的 ref(原 ref="searchForm",与 data 同名会互相覆盖,改名为 searchFormRef)
const searchFormRef = ref(null);
const table = ref(null);
const selectDate = ref(null);
const { loading, data, total, searchForm, getDataList } = useTable(
const { loading, data, total, searchForm, getDataList, handleSearch, resetSearch } = useTable(
getUserRecharge,
{
pageSize: 20,
immediate: false,
form: { sort: "createTime", order: "desc", startDate: "", endDate: "", memberName: "" },
form: {
sort: "createTime",
order: "desc",
memberId: "",
memberName: "",
rechargeSn: "",
startDate: "",
endDate: "",
},
beforeRequest: (p) => {
const params = { ...p };
if (selectDate.value && selectDate.value.length === 2) {
params.startDate = selectDate.value[0];
params.endDate = selectDate.value[1];
} else {
params.startDate = "";
params.endDate = "";
}
return params;
},
}
);
function rechargeWayText(way) {
if (way === "ALIPAY") return "支付宝";
if (way === "WECHAT") return "微信";
if (way === "WECHAT") return "微信支付";
if (way === "BANK_TRANSFER") return "线下转账";
return "";
return way || "-";
}
function init() {
getDataList();
function formatMoney(val) {
const num = Number(val || 0);
return num.toFixed(2);
}
function viewMemberDetail(row) {
if (!row || !row.memberId) return;
customRouterPush({ name: "member-detail", query: { id: row.memberId } });
}
function handleReset() {
selectDate.value = null;
searchForm.memberId = "";
searchForm.memberName = "";
searchForm.rechargeSn = "";
searchForm.startDate = "";
searchForm.endDate = "";
searchForm.pageNumber = 1;
resetSearch();
}
function changePage(v) {
searchForm.pageNumber = v;
getDataList();
}
function changePageSize(v) {
searchForm.pageNumber = 1;
searchForm.pageSize = v;
getDataList();
}
function handleSearch() {
searchForm.pageNumber = 1;
searchForm.pageSize = 20;
getDataList();
}
function selectDateRange(v) {
if (v && v.length === 2) {
searchForm.startDate = v[0];
@@ -145,6 +237,84 @@ function selectDateRange(v) {
}
onMounted(() => {
init();
getDataList();
});
</script>
<style lang="scss" scoped>
.member-recharge-page {
padding: 16px 20px;
background-color: #f6f8fa;
min-height: calc(100vh - 110px);
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-muted {
color: #909399;
}
/* 筛选卡片 */
.filter-card {
border-radius: 8px;
border: 1px solid #ebeef5;
:deep(.el-card__body) {
padding: 16px 16px 2px;
}
.search-form {
display: flex;
flex-wrap: wrap;
align-items: center;
.el-form-item {
margin-bottom: 14px;
margin-right: 16px;
}
.form-actions {
margin-left: auto;
}
}
}
/* 表格卡片 */
.table-card {
border-radius: 8px;
border: 1px solid #ebeef5;
.card-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
.header-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
}
.recharge-money-text {
font-size: 14px;
color: #67c23a;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding-top: 6px;
}
</style>

View File

@@ -1,75 +1,123 @@
<template>
<div class="search">
<el-card>
<div class="member-wallet-log-page search">
<!-- 顶部筛选表单 -->
<el-card shadow="never" class="filter-card">
<el-form
ref="searchFormRef"
:model="searchForm"
inline
label-width="70px"
label-width="76px"
class="search-form"
@keyup.enter="handleSearch"
>
<el-form-item label="客户ID" prop="memberId">
<el-input
v-model="searchForm.memberId"
placeholder="请输入客户ID"
clearable
style="width: 180px"
/>
</el-form-item>
<el-form-item label="客户名称" prop="memberName">
<el-input
v-model="searchForm.memberName"
placeholder="请输入客户名称"
clearable
style="width: 240px"
style="width: 160px"
/>
</el-form-item>
<el-form-item label="支付时间">
<el-form-item label="变动时间">
<el-date-picker
v-model="selectDate"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
clearable
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
placeholder="选择起始时间"
style="width: 360px"
style="width: 320px"
@change="selectDateRange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
<el-form-item class="form-actions">
<el-button type="primary" class="search-btn" @click="handleSearch">
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<!-- 变动记录表格 -->
<el-card shadow="never" class="table-card mt_10">
<template #header>
<div class="card-header-bar">
<div class="header-title">
<span>预存款变动明细</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getDataList">刷新</el-button>
</div>
</div>
</template>
<el-table
ref="table"
v-loading="loading"
border
stripe
:data="data"
ref="table"
class="mt_10"
style="width: 100%"
>
<el-table-column prop="memberName" label="客户名称" min-width="100" />
<el-table-column label="变动金额" width="150">
<!-- 客户ID -->
<el-table-column prop="memberId" label="客户ID" min-width="190" show-overflow-tooltip />
<!-- 客户名称 -->
<el-table-column prop="memberName" label="客户名称" min-width="140" show-overflow-tooltip />
<!-- 变动金额 -->
<el-table-column label="变动金额" width="140" align="right" sortable prop="money">
<template #default="{ row }">
<template v-if="row">
<priceColorScheme
v-if="row.money > 0"
:value="row.money"
color="green"
/>
<priceColorScheme
v-else-if="row.money < 0"
:value="row.money"
:color="$mainColor"
/>
<span v-if="row.money > 0" class="money-increase">
+¥ {{ formatMoney(row.money) }}
</span>
<span v-else-if="row.money < 0" class="money-decrease">
-¥ {{ formatMoney(Math.abs(row.money)) }}
</span>
<span v-else class="money-zero">
¥ 0.00
</span>
</template>
</template>
</el-table-column>
<el-table-column prop="createTime" label="变更时间" width="200" />
<el-table-column label="业务类型" width="200">
<!-- 业务类型 -->
<el-table-column label="业务类型" width="120" align="center">
<template #default="{ row }">
<span v-if="row">{{ serviceTypeText(row.serviceType) }}</span>
{{ row ? serviceTypeText(row.serviceType) : "-" }}
</template>
</el-table-column>
<!-- 日志详细说明 -->
<el-table-column prop="detail" label="变动说明" min-width="260" show-overflow-tooltip />
<!-- 变动时间 -->
<el-table-column prop="createTime" label="变动时间" width="170" align="center" />
<!-- 操作 -->
<el-table-column label="操作" width="110" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row">
<a v-if="row.memberId" class="link-text" @click="viewMemberDetail(row)">客户详情</a>
<span v-else>-</span>
</template>
</template>
</el-table-column>
<el-table-column prop="detail" label="详细" min-width="300" show-overflow-tooltip />
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
@@ -86,23 +134,42 @@
</template>
<script setup>
import { ref, onMounted } from "vue";
import { onMounted, ref } from "vue";
import { useTable } from "@/utils/useTable";
import { getUserWallet } from "@/api/member";
import { customRouterPush } from "@/utils/filters";
import { ElMessage } from "element-plus";
defineOptions({ name: "walletLog" });
// 模板 el-form 的 ref(原 ref="searchForm",与 data 同名会互相覆盖,改名为 searchFormRef)
const searchFormRef = ref(null);
const table = ref(null);
const selectDate = ref(null);
const { loading, data, total, searchForm, getDataList } = useTable(
const { loading, data, total, searchForm, getDataList, handleSearch, resetSearch } = useTable(
getUserWallet,
{
pageSize: 20,
immediate: false,
form: { sort: "createTime", order: "desc", startDate: "", endDate: "", memberName: "" },
form: {
sort: "createTime",
order: "desc",
memberId: "",
memberName: "",
startDate: "",
endDate: "",
},
beforeRequest: (p) => {
const params = { ...p };
if (selectDate.value && selectDate.value.length === 2) {
params.startDate = selectDate.value[0];
params.endDate = selectDate.value[1];
} else {
params.startDate = "";
params.endDate = "";
}
return params;
},
}
);
@@ -113,23 +180,46 @@ function serviceTypeText(type) {
if (type === "WALLET_RECHARGE") return "余额充值";
return "佣金提成";
}
function init() {
getDataList();
function getServiceTagType(type) {
if (type === "WALLET_RECHARGE") return "success";
if (type === "WALLET_REFUND") return "primary";
if (type === "WALLET_PAY") return "warning";
if (type === "WALLET_WITHDRAWAL") return "danger";
return "info";
}
function formatMoney(val) {
const num = Number(val || 0);
return num.toFixed(2);
}
function viewMemberDetail(row) {
if (!row || !row.memberId) return;
customRouterPush({ name: "member-detail", query: { id: row.memberId } });
}
function handleReset() {
selectDate.value = null;
searchForm.memberId = "";
searchForm.memberName = "";
searchForm.startDate = "";
searchForm.endDate = "";
searchForm.pageNumber = 1;
resetSearch();
}
function changePage(v) {
searchForm.pageNumber = v;
getDataList();
}
function changePageSize(v) {
searchForm.pageNumber = 1;
searchForm.pageSize = v;
getDataList();
}
function handleSearch() {
searchForm.pageNumber = 1;
searchForm.pageSize = 20;
getDataList();
}
function selectDateRange(v) {
if (v && v.length === 2) {
searchForm.startDate = v[0];
@@ -141,6 +231,93 @@ function selectDateRange(v) {
}
onMounted(() => {
init();
getDataList();
});
</script>
<style lang="scss" scoped>
.member-wallet-log-page {
padding: 16px 20px;
background-color: #f6f8fa;
min-height: calc(100vh - 110px);
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-muted {
color: #909399;
}
/* 筛选卡片 */
.filter-card {
border-radius: 8px;
border: 1px solid #ebeef5;
:deep(.el-card__body) {
padding: 16px 16px 2px;
}
.search-form {
display: flex;
flex-wrap: wrap;
align-items: center;
.el-form-item {
margin-bottom: 14px;
margin-right: 16px;
}
.form-actions {
margin-left: auto;
}
}
}
/* 表格卡片 */
.table-card {
border-radius: 8px;
border: 1px solid #ebeef5;
.card-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
.header-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
}
.money-increase {
font-size: 14px;
color: #67c23a;
}
.money-decrease {
font-size: 14px;
color: #f56c6c;
}
.money-zero {
font-size: 14px;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding-top: 6px;
}
</style>

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