20 Commits

Author SHA1 Message Date
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
238 changed files with 41178 additions and 19485 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. (北京宏业汇成科技有限公司) Copyright (c) 2020-2026 Beijing Hongye Huicheng Technology Co., Ltd. (北京宏业汇成科技有限公司)
Permission is hereby granted, free of charge, to any person obtaining a copy Apache License
of this software and associated documentation files (the "Software"), to deal Version 2.0, January 2004
in the Software without restriction, including without limitation the rights http://www.apache.org/licenses/
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:
The above copyright notice and this permission notice shall be included in all TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 1. Definitions.
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE "License" shall mean the terms and conditions for use, reproduction,
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER and distribution as defined by Sections 1 through 9 of this document.
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 "Licensor" shall mean the copyright owner or entity authorized by
SOFTWARE. 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

@@ -5,7 +5,7 @@
<p> <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://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="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://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://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> <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. **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 ### ✨ 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. - 🚀 **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. - 🧩 **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 ## 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> <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://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="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://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://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> <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** 是基于 Spring Boot / Vue 3 / uni-app 开发的开源商城系统,支持 B2B2C 多商户商城、微信小程序商城、H5 商城、APP 商城、直播电商、分销返佣、秒杀活动。
### ✨ 为什么选择 Lilishop ### ✨ 为什么选择 Lilishop
- 🟢 **真 MIT 协议**:支持免费商用与私有化闭源二开,无版权后顾之忧。 - 🟢 **真 Apache-2.0 协议**:支持免费商用与私有化闭源二开,无版权后顾之忧。
- 🚀 **Vue 3 全新栈**:全面基于 `Vue 3.5 + Pinia 4 + Vue Router 4 + Element Plus` 构建,告别老旧 Vue 2 架构。 - 🚀 **Vue 3 全新栈**:全面基于 `Vue 3.5 + Pinia 4 + Vue Router 4 + Element Plus` 构建,告别老旧 Vue 2 架构。
- 🧩 **四端合一生产级闭环**:完整覆盖 **PC 买家端 (`buyer`)**、**商家端 (`seller`)**、**平台运营管理端 (`manager`)** 与 **在线客服端 (`im`)**,支持拖拽式可视化楼层装修与多维数据统计报表。 - 🧩 **四端合一生产级闭环**:完整覆盖 **PC 买家端 (`buyer`)**、**商家端 (`seller`)**、**平台运营管理端 (`manager`)** 与 **在线客服端 (`im`)**,支持拖拽式可视化楼层装修与多维数据统计报表。
@@ -146,7 +146,7 @@ lilishop-ui
## 6. 开源与授权 ## 6. 开源与授权
本项目遵循 [MIT](./LICENSE) 开源协议,版权归 **北京宏业汇成科技有限公司** 所有。允许个人及企业免费用于学习、研究、修改和商业用途(需保留原始版权和许可声明)。 本项目遵循 [Apache-2.0](./LICENSE) 开源协议,版权归 **北京宏业汇成科技有限公司** 所有。允许个人及企业免费用于学习、研究、修改和商业用途(需保留原始版权和许可声明)。
--- ---
@@ -163,19 +163,44 @@ lilishop-ui
## 8. 升级计划 ## 8. 升级计划
### ✅ 已完成特性 ### ✅ 已完成特性
| 功能模块 | 功能模块 |
| :--- | :--- | 按可核实的实现或完善时间倒序记录;日期表示代码完成或功能更新时间,不代表生产上线时间。
| ✅ 商品定时上下架 | ✅ 商品分组 / 会员分组 |
| ✅ 商品虚拟销量 | ✅ 专用发票(发票上传) | | 时间 | 功能模块 | 完成内容 |
| ✅ 置顶评论 | ✅ 客户等级体系 | | :--- | :--- | :--- |
| ✅ 购物卡 | ✅ 腾讯云-云直播 | | 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

@@ -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 });
/** 一次兑换;重试返回同一卡已创建的交易。 */
export const redeemPickupCard = data => request({ url: '/buyer/promotion/pickupCard/redeem', method: Method.POST, needToken: true, headers: { 'Content-Type': 'application/json' }, data });

View File

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

View File

@@ -39,7 +39,7 @@
<div> <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 === '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 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> </div>
<p>使用范围:{{useScope(coupon.scopeType, coupon.storeName)}}</p> <p>使用范围:{{useScope(coupon.scopeType, coupon.storeName)}}</p>
<p>有效期:{{coupon.endTime}}</p> <p>有效期:{{coupon.endTime}}</p>

View File

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

View File

@@ -100,7 +100,7 @@
@click="handleReceiveCoupon(item.id)" @click="handleReceiveCoupon(item.id)"
> >
<span v-if="item.couponType == 'PRICE'" <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'" <span v-if="item.couponType == 'DISCOUNT'"
>满{{ item.consumeThreshold }}打{{ >满{{ item.consumeThreshold }}打{{
item.couponDiscount item.couponDiscount
@@ -117,7 +117,7 @@
@click="handleReceiveCoupon(item.id)" @click="handleReceiveCoupon(item.id)"
> >
<span v-if="item.couponType == 'PRICE'" <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'" <span v-if="item.couponType == 'DISCOUNT'"
>满{{ item.consumeThreshold }}打{{ >满{{ item.consumeThreshold }}打{{
item.couponDiscount item.couponDiscount
@@ -127,26 +127,6 @@
</div> </div>
</div> </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>
<!-- 拼团 --> <!-- 拼团 -->
<div class="item-price-row" v-if="promotionMap['PINTUAN']"> <div class="item-price-row" v-if="promotionMap['PINTUAN']">
<p> <p>

View File

@@ -12,7 +12,7 @@
class="component-cell" 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 rowWrapperStyle = computed(() => {
const s = props.row.rowStyle || {}; const s = props.row.rowStyle || {};
const style = {}; const style = {};
@@ -96,8 +100,12 @@ const rowWrapperStyle = computed(() => {
const h = String(s.height).trim(); const h = String(s.height).trim();
if (h && Number(h) > 0) { if (h && Number(h) > 0) {
const hVal = /^\d+$/.test(h) ? `${h}px` : h; const hVal = /^\d+$/.test(h) ? `${h}px` : h;
style.height = hVal; if (hasAutoComp.value) {
style.minHeight = hVal; style.minHeight = hVal;
} else {
style.height = hVal;
style.minHeight = hVal;
}
} }
} }
@@ -164,8 +172,12 @@ const rowInnerFlexStyle = computed(() => {
const h = String(s.height).trim(); const h = String(s.height).trim();
if (h && Number(h) > 0) { if (h && Number(h) > 0) {
const hVal = /^\d+$/.test(h) ? `${h}px` : h; const hVal = /^\d+$/.test(h) ? `${h}px` : h;
styleObj.height = hVal; if (hasAutoComp.value) {
styleObj.minHeight = hVal; styleObj.minHeight = hVal;
} else {
styleObj.height = hVal;
styleObj.minHeight = hVal;
}
} }
} }
@@ -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%' }; if (!style) return { flex: '1 1 0%' };
@@ -185,8 +198,12 @@ function getComponentCellStyle(style, rowStyle = {}, totalComps = 1) {
const rh = String(rowStyle.height).trim(); const rh = String(rowStyle.height).trim();
if (rh && Number(rh) > 0) { if (rh && Number(rh) > 0) {
const rhVal = /^\d+$/.test(rh) ? `${rh}px` : rh; const rhVal = /^\d+$/.test(rh) ? `${rh}px` : rh;
s.height = rhVal; if (isAutoHeightComp) {
s.minHeight = rhVal; s.minHeight = rhVal;
} else {
s.height = rhVal;
s.minHeight = rhVal;
}
} }
} }
const gap = Number(rowStyle?.gap) || 0; const gap = Number(rowStyle?.gap) || 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: '数码潮玩 · 爆款立减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: '美妆奢宠 · 每满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: '运动潮品 · 爆款低至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" :key="idx"
class="zone-link" class="zone-link"
:style="{ :style="{
top: `${zone.top || 0}%`, top: `${zone.top !== undefined ? zone.top : ((zone.topPer || 0) * 100)}%`,
left: `${zone.left || 0}%`, left: `${zone.left !== undefined ? zone.left : ((zone.leftPer || 0) * 100)}%`,
width: `${zone.width || 10}%`, width: `${zone.width !== undefined ? zone.width : ((zone.widthPer || 0.1) * 100)}%`,
height: `${zone.height || 10}%` height: `${zone.height !== undefined ? zone.height : ((zone.heightPer || 0.1) * 100)}%`
}" }"
:title="zone.title" :title="zone.title"
@click="handleClickZone(zone)" @click="handleClickZone(zone)"
@@ -68,7 +68,7 @@ const customStyle = computed(() => {
}); });
function handleClickZone(zone) { 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 (!url) return;
if (/^https?:\/\//i.test(url)) { if (/^https?:\/\//i.test(url)) {
window.open(url, '_blank'); window.open(url, '_blank');

View File

@@ -724,7 +724,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
0, 0,
0 0
], ],
"height": 180 "height": 220
}, },
"components": [ "components": [
{ {
@@ -901,6 +901,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
"backgroundColor": "transparent" "backgroundColor": "transparent"
}, },
"options": { "options": {
"height": 450,
"elements": [ "elements": [
{ {
"type": "badge", "type": "badge",
@@ -979,7 +980,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16 16
], ],
"borderRadius": "8px", "borderRadius": "8px",
"height": 560 "height": ""
}, },
"components": [ "components": [
{ {
@@ -1061,7 +1062,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16 16
], ],
"borderRadius": "8px", "borderRadius": "8px",
"height": 560 "height": ""
}, },
"components": [ "components": [
{ {
@@ -1140,7 +1141,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16 16
], ],
"borderRadius": "8px", "borderRadius": "8px",
"height": 620 "height": ""
}, },
"components": [ "components": [
{ {
@@ -1249,7 +1250,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16 16
], ],
"borderRadius": "8px", "borderRadius": "8px",
"height": 420 "height": ""
}, },
"components": [ "components": [
{ {
@@ -1404,7 +1405,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16 16
], ],
"borderRadius": "8px", "borderRadius": "8px",
"height": 360 "height": 450
}, },
"components": [ "components": [
{ {
@@ -1417,6 +1418,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
}, },
"options": { "options": {
"videoUrl": "https://vjs.zencdn.net/v/oceans.mp4", "videoUrl": "https://vjs.zencdn.net/v/oceans.mp4",
"height": 450,
"coverUrl": "https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/video/video_cover.png", "coverUrl": "https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/video/video_cover.png",
"url": "/goodsList?brand=1", "url": "/goodsList?brand=1",
"title": "双11全球狂欢发布盛典 · 年度旗舰好物真实开箱测评", "title": "双11全球狂欢发布盛典 · 年度旗舰好物真实开箱测评",
@@ -1481,7 +1483,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16 16
], ],
"borderRadius": "8px", "borderRadius": "8px",
"height": 220 "height": ""
}, },
"components": [ "components": [
{ {
@@ -1656,7 +1658,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16 16
], ],
"borderRadius": "8px", "borderRadius": "8px",
"height": 980 "height": ""
}, },
"components": [ "components": [
{ {
@@ -1810,7 +1812,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16 16
], ],
"borderRadius": "8px", "borderRadius": "8px",
"height": 1270 "height": ""
}, },
"components": [ "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)) */ /** 单次购买数量上限(O-09 默认:min(quantity, 999)) */
export const E_COUPON_MAX_BUY_NUM = 999; export const E_COUPON_MAX_BUY_NUM = 999;
/** 常规立即购买(秒杀/满减/券/限时/第N件等) */ /** 常规立即购买(秒杀/满减/券等) */
export const E_COUPON_CART_BUY_NOW = "BUY_NOW"; export const E_COUPON_CART_BUY_NOW = "BUY_NOW";
export const E_COUPON_CART_PINTUAN = "PINTUAN"; export const E_COUPON_CART_PINTUAN = "PINTUAN";
export const E_COUPON_CART_POINTS = "POINTS"; export const E_COUPON_CART_POINTS = "POINTS";
@@ -72,8 +72,6 @@ export function createEmptyPromotionMap() {
SECKILL: null, SECKILL: null,
FULL_DISCOUNT: null, FULL_DISCOUNT: null,
COUPON: [], COUPON: [],
DISCOUNT: null,
NTH: null,
PINTUAN: null, PINTUAN: null,
KANJIA: null, KANJIA: null,
}; };

View File

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

View File

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

View File

@@ -11,7 +11,7 @@
<el-card shadow="never" v-for="(item, index) in autoCoupList" :key="index" style="margin: 10px 15px"> <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 === '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 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;">使用范围:{{useScope(item.scopeType, item.storeName)}}</p>
<p style="font-size: 14px;">有效期:{{item.endTime}}</p> <p style="font-size: 14px;">有效期:{{item.endTime}}</p>
</el-card> </el-card>

View File

@@ -206,7 +206,11 @@ function getIndexData() {
} }
async function parsePageData(pageData) { 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++) { for (let i = 0; i < dataJson.list.length; i++) {
const type = dataJson.list[i].type; const type = dataJson.list[i].type;
if (type === "carousel2") { if (type === "carousel2") {

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

View File

@@ -176,6 +176,7 @@
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton> <el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
</div> </div>
<el-dialog v-model="cancelAvail" title="请选择取消订单原因"> <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-group v-model="cancelParams.reason">
<el-radio :label="item.reason" v-for="item in cancelReason" :key="item.id"> <el-radio :label="item.reason" v-for="item in cancelReason" :key="item.id">
{{ item.reason }} {{ item.reason }}
@@ -227,6 +228,7 @@ const changeWay = ref(['全部', '待付款', '待发货', '待收货/使用']);
const activeOrderTab = ref(0); const activeOrderTab = ref(0);
const total = ref(0); // 数据总数 const total = ref(0); // 数据总数
const spinShow = ref(false); // 加载状态 const spinShow = ref(false); // 加载状态
const cancelIsPickup = ref(false);
const cancelAvail = ref(false); // 取消订单modal控制 const cancelAvail = ref(false); // 取消订单modal控制
const cancelReason = ref([]); // 取消订单原因 const cancelReason = ref([]); // 取消订单原因
@@ -365,6 +367,7 @@ function changePageSize (val) { // 修改页数
} }
function handleCancelOrder (sn) { function handleCancelOrder (sn) {
// 取消订单 // 取消订单
cancelIsPickup.value = !!orderList.value.find(order => order.sn === sn)?.pickupCardId;
cancelParams.orderSn = sn; cancelParams.orderSn = sn;
afterSaleReason('CANCEL').then(res => { afterSaleReason('CANCEL').then(res => {
if (res.success) { if (res.success) {

View File

@@ -341,6 +341,7 @@
v-model="cancelAvail" v-model="cancelAvail"
title="请选择取消订单原因" title="请选择取消订单原因"
> >
<el-alert v-if="order.order?.pickupCardId" title="此订单使用提货卡兑换,取消不退还提货权益,无法再次兑换。" type="warning" :closable="false" style="margin-bottom:16px" />
<el-radio-group <el-radio-group
v-model="cancelParams.reason" v-model="cancelParams.reason"
> >

View File

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

View File

@@ -1,19 +1,20 @@
<template> <template>
<div class="wrapper my-gift-cards"> <div class="wrapper my-gift-cards">
<UserCenterLayout> <UserCenterLayout>
<div class="gift-page-heading">
<div><h2>我的礼品卡</h2><p>查看卡片权益,兑换你的心意好礼</p></div>
</div>
<div class="bind-section mb_20"> <div class="bind-section mb_20">
<div class="section-title">兑换礼品卡</div> <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-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>
<el-form-item label="兑换码" prop="cardSecret" label-width="70px"> <el-form-item label="兑换码" prop="cardSecret" label-width="70px">
<el-input <el-input
v-model="bindForm.cardSecret" v-model="bindForm.cardSecret"
placeholder="请输入兑换码(卡密)" placeholder="请输入兑换码(卡密)"
maxlength="128" maxlength="128"
style="width: 220px"
/> />
</el-form-item> </el-form-item>
<el-form-item label-width="0px"> <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-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="AVAILABLE" />
<el-tab-pane label="不可用" name="UNAVAILABLE" /> <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> </el-tabs>
<div class="card-list-wrap"> <div class="card-list-wrap">
<el-skeleton v-if="loading" fix /> <el-skeleton v-if="loading" :rows="5" animated />
<template v-if="!loading"> <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-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-card-header">
<div class="gcc-header-pattern" aria-hidden="true" /> <div class="gcc-header-pattern" aria-hidden="true" />
<div class="gcc-header-inner"> <div class="gcc-header-inner">
@@ -42,17 +48,19 @@
<div class="gcc-title">{{ item.giftCardName || "礼品卡" }}</div> <div class="gcc-title">{{ item.giftCardName || "礼品卡" }}</div>
<span class="gcc-status-chip">{{ statusChipLabel }}</span> <span class="gcc-status-chip">{{ statusChipLabel }}</span>
</div> </div>
<div class="gcc-face"><span class="gcc-face-symbol">¥</span>{{ formatYuan(item.faceValue) }}</div> <div v-if="item.cardType === 'PICKUP'" class="gcc-face">兑换 <strong>{{ item.pickupCount }}</strong> 种商品</div>
<div class="gcc-cardno">{{ item.cardNo }}</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>
<div class="gcc-header-right"> <div class="gcc-header-right">
<div class="gcc-type">现金卡</div> <div class="gcc-type">{{ item.cardType === 'PICKUP' ? '提货卡' : '现金卡' }}</div>
<div class="gcc-expire">{{ formatValidity(item) }}</div>
</div> </div>
</div> </div>
</div> </div>
<div class="gcc-card-body"> <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> 当前余额:<span class="gcc-strong" v-if="item.balance != null">¥{{ $filters.unitPrice(item.balance) }}</span><span class="gcc-strong" v-else>—</span>
</div> </div>
@@ -112,6 +120,10 @@ const router = useRouter();
const statusTab = ref("AVAILABLE"); const statusTab = ref("AVAILABLE");
const loading = ref(false); const loading = ref(false);
const total = ref(0); const total = ref(0);
const pendingCount = ref(null);
const loadFailed = ref(false);
let listRequestId = 0;
let pendingCountRequestId = 0;
const list = ref([]); const list = ref([]);
const params = reactive({ const params = reactive({
pageNumber: 1, pageNumber: 1,
@@ -130,7 +142,6 @@ const bindRules = {
cardSecret: [{ required: true, message: "请输入兑换码", trigger: "blur" }], cardSecret: [{ required: true, message: "请输入兑换码", trigger: "blur" }],
}; };
const noticeModal = ref(false); const noticeModal = ref(false);
const serviceHotline = ref("12221111");
const activatingId = ref(""); const activatingId = ref("");
const bindFormRef = ref(null); const bindFormRef = ref(null);
@@ -153,11 +164,30 @@ const statusChipLabel = computed(() => {
const map = { AVAILABLE: "可用", UNAVAILABLE: "不可用", PENDING_ACTIVATION: "待激活" }; const map = { AVAILABLE: "可用", UNAVAILABLE: "不可用", PENDING_ACTIVATION: "待激活" };
return map[statusTab.value] || ""; return map[statusTab.value] || "";
}); });
const emptyDescription = computed(() => ({
AVAILABLE: "暂无可用礼品卡,可输入卡号和兑换码添加",
UNAVAILABLE: "暂无不可用礼品卡",
PENDING_ACTIVATION: "暂无待激活礼品卡",
}[statusTab.value]));
onMounted(() => { onMounted(() => {
getList(); 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) { function formatYuan(val) {
if (val == null || val === "") { if (val == null || val === "") {
return "—"; return "—";
@@ -195,21 +225,32 @@ function onStatusTab(name) {
} }
function getList() { function getList() {
const requestId = ++listRequestId;
loading.value = true; loading.value = true;
loadFailed.value = false;
const p = { ...params }; const p = { ...params };
if (p.memberCardStatus) { if (p.memberCardStatus) {
p.memberCardStatus = String(p.memberCardStatus); p.memberCardStatus = String(p.memberCardStatus);
} }
getGiftCardCashMemberCardPage(p) getGiftCardCashMemberCardPage(p)
.then((res) => { .then((res) => {
if (requestId !== listRequestId) return;
loading.value = false; loading.value = false;
if (res.success && res.result) { if (res.success && res.result) {
list.value = res.result.records || []; list.value = res.result.records || [];
total.value = res.result.total || 0; total.value = res.result.total || 0;
if (p.memberCardStatus === "PENDING_ACTIVATION") {
++pendingCountRequestId;
pendingCount.value = Number(total.value);
}
} else {
loadFailed.value = true;
} }
}) })
.catch(() => { .catch(() => {
if (requestId !== listRequestId) return;
loading.value = false; loading.value = false;
loadFailed.value = true;
}); });
} }
@@ -250,6 +291,7 @@ function submitBind() {
resetBindForm(); resetBindForm();
params.pageNumber = 1; params.pageNumber = 1;
getList(); getList();
refreshPendingCount();
} }
}) })
.catch(() => { .catch(() => {
@@ -274,6 +316,7 @@ function onCardPrimaryAction(item) {
if (res.success) { if (res.success) {
Message.success(res.message || "激活成功"); Message.success(res.message || "激活成功");
getList(); getList();
refreshPendingCount();
} }
}) })
.catch((err) => { .catch((err) => {
@@ -285,7 +328,7 @@ function onCardPrimaryAction(item) {
return; return;
} }
if (statusTab.value === "AVAILABLE") { 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"> <style scoped lang="scss">
.my-gift-cards { .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 { .bind-section {
padding: 16px 18px; padding: 20px 24px 24px;
border-radius: 8px; border-radius: 10px;
border: 1px solid #eee; border: 1px solid #f3e7e8;
@include white_background_color(); background: #fff9f9;
}
.section-title {
font-size: 15px;
font-weight: 500;
margin-bottom: 14px;
@include title_color($light_title_color);
} }
.section-title { margin-bottom: 18px; font-size: 15px; font-weight: 600; color: #303133; }
.bind-form { .bind-form {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
align-items: flex-end; align-items: start;
gap: 4px 8px; 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 { .status-tabs {
:deep(.el-tabs__header) { margin-top: 28px;
margin-bottom: 0; :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; }
} }
.card-list-wrap { .pending-tab-label { display: inline-flex; align-items: center; gap: 8px; }
position: relative; .pending-count {
min-height: 200px; display: inline-flex;
margin-top: 24px; 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: 290px; margin-top: 24px; }
.gift-load-error { padding: 64px 0; text-align: center; color: #909399; }
.gift-card-grid { .gift-card-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px; gap: 24px;
align-items: start; align-items: start;
} }
.gcc-card-item { .gcc-card-item {
border-radius: 12px;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); border: 1px solid #f0e3e4;
@include white_background_color(); border-radius: 12px;
} background: #fff;
.gcc-card-header { transition: box-shadow .2s, border-color .2s;
position: relative; &:hover { border-color: #edc9cd; box-shadow: 0 6px 20px rgba(100, 33, 40, .06); }
min-height: 128px;
padding: 0;
background: linear-gradient(125deg, #ff9a4a 0%, #ff7729 42%, #ff8f3d 100%);
} }
.gcc-card-header { position: relative; overflow: hidden; background: linear-gradient(120deg, #fff3f3, #fff9f7); }
.gcc-header-pattern { .gcc-header-pattern {
position: absolute; position: absolute;
inset: 0; width: 180px;
opacity: 0.22; height: 180px;
right: -70px;
bottom: -110px;
border: 24px solid rgba(211, 134, 141, .07);
border-radius: 50%;
pointer-events: none; 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 { .gcc-action {
display: block; display: block;
width: 100%; width: 100%;
margin-top: 14px; margin-top: 16px;
padding: 9px 16px; padding: 10px 16px;
border: none; border: none;
border-radius: 999px; border-radius: 6px;
background: $theme_color;
color: #fff;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
color: #fff; transition: opacity .2s;
background: $theme_color; &:hover:not(.is-disabled) { opacity: .85; }
transition: opacity 0.2s, background 0.2s; &:focus-visible { outline: 2px solid $theme_color; outline-offset: 3px; }
} &.is-disabled { color: #aaa; background: #f3f4f6; cursor: not-allowed; }
.gcc-action:hover:not(.is-disabled) {
opacity: 0.9;
}
.gcc-action.is-disabled {
color: #c5c8ce;
cursor: not-allowed;
background: #f7f7f7;
} }
.is-pickup .gcc-face { font-size: 22px; }
.gcc-card-item.is-unavailable { .gcc-card-item.is-unavailable {
.gcc-card-header { border-color: #e8eaed;
background: linear-gradient(125deg, #c9c9c9 0%, #a8a8a8 100%); .gcc-card-header { background: #f5f6f8; }
} .gcc-title, .gcc-face, .gcc-strong { color: #92969e; }
.gcc-strong { .gcc-cardno, .gcc-expire, .gcc-status-chip, .gcc-type { color: #a1a5ad; }
color: #999; .gcc-type { border-color: #dddfe4; }
}
} }
.page-size { .page-size { display: flex; justify-content: flex-end; margin-top: 28px; padding-top: 20px; border-top: 1px solid #f0f1f4; }
display: flex; @media (max-width: 900px) {
justify-content: flex-end; .bind-form { grid-template-columns: minmax(0, 1fr); gap: 22px; }
margin-top: 20px; .bind-form :deep(.el-form-item__label) { width: 70px !important; }
.bind-form :deep(.el-form-item:last-child .el-form-item__content) { padding-left: 70px; }
.gift-card-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }
} }
} }
.gcc-notice-text { .gcc-notice-text { line-height: 1.7; color: #515a6e; margin-bottom: 16px; }
line-height: 1.7; .gcc-notice-footer { text-align: right; }
color: #515a6e; </style>
margin-bottom: 16px;
}
.gcc-notice-footer {
text-align: right;
}
</style>

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

@@ -202,7 +202,7 @@
<template v-else>{{ item.discount }}折</template> <template v-else>{{ item.discount }}折</template>
</div> </div>
<div class="pay-coupon-info"> <div class="pay-coupon-info">
<p>满{{ item.consumeThreshold }}元可用</p> <p>{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '元可用' : '无门槛使用' }}</p>
<p>使用范围:{{ useScope(item.scopeType, item.storeName) }}</p> <p>使用范围:{{ useScope(item.scopeType, item.storeName) }}</p>
<p>有效期:{{ item.endTime }}</p> <p>有效期:{{ item.endTime }}</p>
</div> </div>

View File

@@ -81,7 +81,7 @@ watch(currIndex, (val) => {
interval = null; interval = null;
nowHour.value = new Date().getHours(); nowHour.value = new Date().getHours();
countDown(val); countDown(val);
goodsList.value = list.value[val].seckillGoodsList; goodsList.value = list.value[val]?.seckillGoodsList || [];
}); });
watch(diffSeconds, (val) => { watch(diffSeconds, (val) => {
@@ -108,11 +108,11 @@ watch(diffSeconds, (val) => {
}); });
function getListByDay () { function getListByDay () {
// 当天秒杀活动 // 当天秒杀活动;result 可能为空数组(当天无活动),需判空避免取下标 0 报错
seckillByDay().then((res) => { seckillByDay().then((res) => {
if (res.success) { if (res.success && res.result && res.result.length) {
list.value = res.result; list.value = res.result;
goodsList.value = list.value[0].seckillGoodsList; goodsList.value = list.value[0].seckillGoodsList || [];
countDown(currIndex.value); countDown(currIndex.value);
} }
}); });

View File

@@ -200,6 +200,12 @@ export const router = createRouter({
component: MoneyManagement, component: MoneyManagement,
meta: { title: "我的余额" }, meta: { title: "我的余额" },
}, },
{
path: "PickupCard",
name: "PickupCard",
component: () => import("@/pages/home/userCenter/PickupCard.vue"),
meta: { title: "提货卡兑换" },
},
{ {
path: "MyGiftCards", path: "MyGiftCards",
name: "MyGiftCards", name: "MyGiftCards",

View File

@@ -1,21 +1,205 @@
MIT License Apache License, Version 2.0
Copyright (c) 2020 YuanDong Copyright (c) 2020 YuanDong
Permission is hereby granted, free of charge, to any person obtaining a copy Apache License
of this software and associated documentation files (the "Software"), to deal Version 2.0, January 2004
in the Software without restriction, including without limitation the rights http://www.apache.org/licenses/
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:
The above copyright notice and this permission notice shall be included in all TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 1. Definitions.
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE "License" shall mean the terms and conditions for use, reproduction,
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER and distribution as defined by Sections 1 through 9 of this document.
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 "Licensor" shall mean the copyright owner or entity authorized by
SOFTWARE. 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

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

View File

@@ -1,6 +1,10 @@
// 统一请求路径前缀在libs/axios.js中修改 // 统一请求路径前缀在libs/axios.js中修改
import { getRequest, postRequest, putRequest, deleteRequest} from '@/libs/axios'; 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) => { export const getManagerBrandPage = (params) => {
return getRequest('/goods/brand/getByPage', params) return getRequest('/goods/brand/getByPage', params)

View File

@@ -170,6 +170,10 @@ export const deleteMemberGroup = (id) => {
return deleteRequest(`/member/memberGroup/delete/${id}`); return deleteRequest(`/member/memberGroup/delete/${id}`);
}; };
export const getMemberGroupUsers = (groupId, params) => {
return getRequest(`/member/memberGroup/${groupId}/users`, params);
};
export const addMemberGroupUsers = (groupId, memberIds) => { export const addMemberGroupUsers = (groupId, memberIds) => {
return postRequest(`/member/memberGroup/${groupId}/users`, { return postRequest(`/member/memberGroup/${groupId}/users`, {
memberIds: Array.isArray(memberIds) ? memberIds.join(",") : memberIds, memberIds: Array.isArray(memberIds) ? memberIds.join(",") : memberIds,
@@ -235,3 +239,6 @@ export const deleteMemberBenefit = (id) => {
export const getMemberBenefitTypes = () => { export const getMemberBenefitTypes = () => {
return getRequest("/member/benefit/types"); 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); 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) => { 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 模板 // 下载批量发卡 Excel 模板
export const downloadGiftCardCashIssueTemplate = () => { export const downloadGiftCardCashIssueTemplate = () => {
return getRequest("/promotion/giftCardCash/issue/template", {}, "blob"); return getRequest("/promotion/giftCardCash/issue/template", {}, "blob");
@@ -355,4 +355,4 @@ export const getGiftCardCashUsageRecordPage = (params) => {
// 卡密凭证分页(平台库存+会员资产) // 卡密凭证分页(平台库存+会员资产)
export const getGiftCardCashCredentialPage = (params) => { export const getGiftCardCashCredentialPage = (params) => {
return getRequest("/promotion/giftCardCash/credential", params); return getRequest("/promotion/giftCardCash/credential", params);
}; };

View File

@@ -5,14 +5,10 @@ import {
postRequest postRequest
} from '@/libs/axios'; } from '@/libs/axios';
// 查询短信站内信信息 // 查询站内信信息
export const getMessageData = params => { export const getMessageData = params => {
return getRequest("/other/message", params); return getRequest("/other/message", params);
}; };
//修改短信模板
export const editSmsMessageTemplate = (id, params) => {
return putRequest(`/sms/sms/${id}`, params)
}
//修改站内信模板 //修改站内信模板
export const editNoticeMessage = (id, params) => { export const editNoticeMessage = (id, params) => {
return putRequest(`/setting/noticeMessage/${id}`, params) return putRequest(`/setting/noticeMessage/${id}`, params)
@@ -22,37 +18,11 @@ export const editNoticeMessage = (id, params) => {
export const wechatMessageSync = (params) => { export const wechatMessageSync = (params) => {
return getRequest("/wechat/wechatMessage/init", 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) => { export const wechatMPMessageSync = (params) => {
return getRequest("/wechat/wechatMPMessage/init", 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) => { export const updateMessageStatus = (id,status) => {
return putRequest(`/setting/noticeMessage/${id}/${status}`); return putRequest(`/setting/noticeMessage/${id}/${status}`);
}; };
//获取短信模板 //获取设置
export const getSmsTemplatePage = (params) => { export const getSetting = (key) => {
return getRequest(`sms/template/querySmsTemplatePage`, params); return getRequest(`/setting/setting/get/${key}`);
}; };
//添加短信模板 //根据类型查看配置信息
export const addSmsTemplatePage = (params) => { export const getSettingList = (params) => {
return postRequest(`sms/template`, params); return getRequest(`/setting/setting/all`, params);
}; };
//删除短信模板 //设置配置信息
export const deleteSmsTemplatePage = (params) => { export const setSetting = (key, params) => {
return deleteRequest(`sms/template`, params); return postRequest(`/setting/setting/put/${key}`, 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}`);
}; };
//分页查询app版本信息 //获取设置
export const appVersionPage = (params) => { export const getExperienceData = (key) => {
return getRequest(`/other/appVersion`, params); return getRequest(`/setting/experience/get/${key}`);
}; };
//添加app版本信息 //保存
export const addVersion = (params) => { export const setExperienceData = (key, params) => {
return postRequest(`/other/appVersion`, params); return postRequest(`/setting/experience/put/${key}`, 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 editInstantDelivery = (bean, params, headers) => { export const getPointData = (key) => {
return putRequest(`instant-delivery/${bean}/config`, params, headers); return getRequest(`/setting/point/get/${key}`);
}; };
//启用配送方案 //保存
export const openInstantDelivery = (bean) => { export const setPointData = (key, params) => {
return putRequest(`instant-delivery/${bean}/open`); return postRequest(`/setting/point/put/${key}`, params);
}; };
//修改封面图片 //根据类型获取积分设置
export const editInstantDeliveryImage = (bean, params) => { export const getKuaidiSetting = (key) => {
return putRequest(`instant-delivery/${bean}/image`, params); return getRequest(`/setting/kuaidi/get/${key}`);
};
//保存
export const setKuaidiSetting = (key, params) => {
return postRequest(`/setting/kuaidi/put/${key}`, params);
}; };
//查询短信发送记录 //获取所有的物流公司
export const getSmsPage = (params) => { export const getLogisticsList = () => {
return getRequest(`sms/sms`, params); 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) => { export const openLogistics = (id) => {
return postRequest(`sms/sms`, params); return putRequest(`/setting/logistics/open/${id}`);
}; };
/*
* 获取 支付支持设置开关
*/
export const getPaymentSupportForm = () => {
return getRequest(`setting/settingx/paymentSupport`);
}
// 验证码源文件删除 //获取所有的物流公司
export const delVerification = (id) => { export const getLogisticsAll = () => {
return deleteRequest(`/other/verificationSource/${id}`) return getRequest(`/setting/logistics/all`);
} };
// 添加验证码源文件 // 验证码源文件添加
export const addVerification = (params) => { export const createVerification = (params) => {
return postRequest(`/other/verificationSource`, params) return postRequest(`/other/verificationSource`, params)
} }
// 验证码源文件修改
// 修改验证码源文件 export const updateVerification = (id, params) => {
export const editVerification = (id, params) => {
return putRequest(`/other/verificationSource/${id}`, params) return putRequest(`/other/verificationSource/${id}`, params)
} }
// 验证码源文件删除
// 验证码源文件详情 export const deleteVerification = (id) => {
export const verificationDetail = (id) => { return deleteRequest(`/other/verificationSource/${id}`)
return getRequest(`/other/verificationSource/${id}`)
} }
// 验证码源文件列表 // 验证码源文件列表
export const verificationPage = (params) => { export const verificationPage = (params) => {
return getRequest(`/other/verificationSource`, params) return getRequest(`/other/verificationSource`, params)
@@ -197,12 +138,64 @@ export const getNoticeMessageDetail = (id, params) => {
return getRequest(`/setting/noticeMessage/${id}`, params) return getRequest(`/setting/noticeMessage/${id}`, params)
} }
// 消息模板聚合分页(站内信 + 微信服务号 + 小程序) // 统一消息模板聚合分页(站内信 + 短信 + 微信服务号 + 小程序订阅)
export const getMessageTemplateAggregatePage = params => { export const getMessageTemplateAggregatePage = params => {
return getRequest("/setting/noticeMessage/aggregate", params); return getRequest("/setting/messageTemplate/page", params);
}; };
// 修改通知类站内信邮箱渠道开关 // 快速切换渠道开关 (channel: NOTICE / SMS / WECHAT_OA / WECHAT_MP, status: OPEN / CLOSE)
export const updateNoticeMessageEmailStatus = (id, status) => { export const updateMessageChannelSwitch = params => {
return putRequest(`/setting/noticeMessage/${id}/email/${status}`); 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 类来控制是否全屏显示 --> <!-- 使用 fullscreen 类来控制是否全屏显示 -->
<div :class="{ fullscreen: fullscreen }" class="tinymce-container"> <div :class="{ fullscreen: fullscreen }" class="tinymce-container">
<!-- 使用 tinymce-textarea 类作为编辑器的文本区域 --> <!-- 使用 tinymce-textarea 类作为编辑器的文本区域 -->
<uploadImage @callback="insertImage" /> <uploadImage :use-image-picker="imagePicker" @callback="insertImage" />
<textarea :id="tinymceId" class="tinymce-textarea" /> <textarea :id="tinymceId" class="tinymce-textarea" />
</div> </div>
</div> </div>
@@ -21,10 +21,15 @@ import {
} from "vue"; } from "vue";
import { initEditor } from "@/components/editor/config"; import { initEditor } from "@/components/editor/config";
import uploadImage from "@/components/editor/upload-image.vue"; import uploadImage from "@/components/editor/upload-image.vue";
import { useFormItem } from "element-plus";
defineOptions({ name: "Tinymce" }); defineOptions({ name: "Tinymce" });
const props = defineProps({ const props = defineProps({
imagePicker: {
type: Boolean,
default: false,
},
modelValue: { modelValue: {
type: String, type: String,
default: "", default: "",
@@ -39,6 +44,7 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(["update:modelValue", "input"]); const emit = defineEmits(["update:modelValue", "input"]);
const { formItem } = useFormItem();
// 引入编辑器的配置(data 中的 initEditor 与 import 同名,直接使用 import) // 引入编辑器的配置(data 中的 initEditor 与 import 同名,直接使用 import)
const hasChange = ref(false); // 标记内容是否有更改 const hasChange = ref(false); // 标记内容是否有更改
@@ -66,6 +72,9 @@ watch(editorContent, (val) => {
function emitContent(content) { function emitContent(content) {
emit("update:modelValue", content); emit("update:modelValue", content);
emit("input", content); emit("input", content);
if (props.imagePicker && content) {
nextTick(() => formItem?.validate("change").catch(() => {}));
}
} }
// 数据返回并给富文本框插入图片 // 数据返回并给富文本框插入图片
function insertImage(arr) { function insertImage(arr) {

View File

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

View File

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

View File

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

View File

@@ -2,18 +2,22 @@
<el-dialog <el-dialog
v-if="goodsFlag" v-if="goodsFlag"
v-model="flag" v-model="flag"
width="1160px" title="选择商品"
top="120px" width="85%"
top="5vh"
:z-index="10000" :z-index="10000"
:close-on-click-modal="false" :close-on-click-modal="false"
append-to-body append-to-body
destroy-on-close destroy-on-close
class="goods-selector-dialog"
@close="clickClose" @close="clickClose"
> >
<goodsDialog <goodsDialog
v-if="flag" v-if="flag"
ref="goodsDialogRef" ref="goodsDialogRef"
:selectedWay="goodsData" :selectedWay="goodsData"
:goods-loader="goodsLoader"
:query-params="queryParams"
@selected=" @selected="
(val) => { (val) => {
goodsData = val; goodsData = val;
@@ -21,8 +25,15 @@
" "
/> />
<template #footer> <template #footer>
<el-button @click="clickClose">取消</el-button> <div class="goods-dialog-footer">
<el-button type="primary" @click="clickOK">确定</el-button> <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> </template>
</el-dialog> </el-dialog>
@@ -53,16 +64,18 @@
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
<script setup> <script setup>
import { ref } from "vue"; import { ref } from "vue";
import goodsDialog from "./goods-dialog"; import goodsDialog from "./goods-dialog";
import linkDialog from "./link-dialog"; import linkDialog from "./link-dialog";
const emit = defineEmits(["closeFlag", "selectedGoodsData", "selectedLink"]); const emit = defineEmits(["closeFlag", "selectedGoodsData", "selectedLink"]);
defineProps({ goodsLoader: { type: Function, default: null }, queryParams: { type: Object, default: () => ({}) } });
const goodsFlag = ref(false); // 是否商品选择器 const goodsFlag = ref(false); // 是否商品选择器
const goodsData = ref([]); //选择的商品 const goodsData = ref([]); // 选择的商品
const linkData = ref(""); //选择的链接 const linkData = ref(""); // 选择的链接
const flag = ref(false); // modal显隐 const flag = ref(false); // modal显隐
// 模板 ref:原 ref="goodsDialog" 与 import 的组件同名,改名避免互相覆盖 // 模板 ref:原 ref="goodsDialog" 与 import 的组件同名,改名避免互相覆盖
@@ -112,14 +125,7 @@ function close() {
flag.value = false; flag.value = false;
} }
// 外部通过 ref 访问的成员(grep manager/src 全量确认): // 外部通过 ref 访问的成员:
// - 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 实例天然暴露,保守保留
defineExpose({ defineExpose({
open, open,
close, close,
@@ -130,14 +136,73 @@ defineExpose({
flag, flag,
}); });
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
:deep(.el-dialog__body) { :deep(.el-dialog__body) {
width: 100%; width: 100%;
height: 500px; height: 500px;
overflow: hidden; 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>
<style lang="scss"> <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 样式 // 抽屉 append-to-body 后不在当前组件 DOM 内,不能使用 scoped 样式
.link-selector-drawer { .link-selector-drawer {
max-width: 96vw; max-width: 96vw;
@@ -152,7 +217,8 @@ defineExpose({
} }
} }
// 商品分类下拉挂载到 body,需要高于抽屉的 10000 层级 // 商品分类下拉挂载到 body,需要高于抽屉的 10000 层级
.goods-dialog-cascader-popper { .goods-dialog-cascader-popper,
.goods-dialog-pagination-popper {
z-index: 10001 !important; z-index: 10001 !important;
} }
</style> </style>

View File

@@ -1,174 +1,10 @@
<template> <template>
<div class="shop"> <StoreSelector embedded :multiple="false" :query-params="shopQuery" @selected="selectShop" />
<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>
</template> </template>
<script setup> <script setup>
import { nextTick, reactive, ref } from "vue"; import StoreSelector from '@/components/store-selector/index.vue';
import { getShopListData } from "@/api/shops.js"; // Author: Bulbasaur; 2026-11-05. 装修链接复用统一店铺选择组件,保留单选及启用店铺范围。
const emit = defineEmits(['selected']);
const emit = defineEmits(["selected"]); const shopQuery = { storeDisable: 'OPEN' };
function selectShop(rows) { emit('selected', rows.map(row => ({ ...row, ___type: 'shops' }))); }
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" }]);
}
</script> </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, default: false,
}, },
maxlength: Number, maxlength: Number,
beforeSelect: Function,
}); });
const emit = defineEmits(["update:modelValue", "input", "on-change"]); const emit = defineEmits(["update:modelValue", "input", "on-change"]);
const attrs = useAttrs(); const attrs = useAttrs();
@@ -131,6 +132,7 @@ function handleCLickImg(val, index) {
}); });
} }
function handleOssCallback(item) { function handleOssCallback(item) {
if (item?.url && props.beforeSelect && props.beforeSelect(item) === false) return;
picModalFlag.value = false; picModalFlag.value = false;
if (!item?.url) return; if (!item?.url) return;
currentValue.value = item.url; 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 }); defineExpose({ open });
</script> </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 = { const VIEW_ALIASES = {
"customWords/index": "custom-words/index", "customWords/index": "custom-words/index",
"sensitiveWords/index": "sensitive-words/index", "sensitiveWords/index": "sensitive-words/index",
"sys/message/noticeMessageTemplate": "sys/message/messageTemplate",
}; };
function resolveViewPath(url) { function resolveViewPath(url) {

View File

@@ -7,7 +7,13 @@ let util = {};
/** 已弃用、前端未实现或已移除的菜单 frontRoute,注册路由时跳过 */ /** 已弃用、前端未实现或已移除的菜单 frontRoute,注册路由时跳过 */
const DEPRECATED_FRONT_ROUTES = new Set([ const DEPRECATED_FRONT_ROUTES = new Set([
"sys/message/sms",
"sys/message/smsSign",
"goods/goods-manage/spec", "goods/goods-manage/spec",
"member/list/memberRecycle",
"order/order/fictitiousOrderList",
"promotions/flash-discount",
"promotions/nth-item-discount",
]); ]);
function isDeprecatedMenu(menu) { function isDeprecatedMenu(menu) {

View File

@@ -38,6 +38,20 @@ export const otherRouter = {
redirect: "/home", redirect: "/home",
component: Main, component: Main,
children: [ 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", path: "home",
title: '首页', title: '首页',
@@ -112,6 +126,12 @@ export const otherRouter = {
name: "goods-category", name: "goods-category",
component: () => import("@/views/goods/goods-manage/category.vue") 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", path: "goods-group",
title: "商品分组", title: "商品分组",
@@ -149,6 +169,12 @@ export const otherRouter = {
name: "order-detail", name: "order-detail",
component: () => import("@/views/order/order/orderDetail.vue") 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", path: "appointment/work-orders",
title: "预约工单", title: "预约工单",
@@ -198,6 +224,20 @@ export const otherRouter = {
name: "member-benefit", name: "member-benefit",
component: () => import("@/views/member/benefit/index.vue") 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", path: "member-grade-experience",
title: "等级权益设置", title: "等级权益设置",
@@ -346,18 +386,6 @@ export const otherRouter = {
name: "full-discount-detail", name: "full-discount-detail",
component: () => import("@/views/promotions/full-discount/full-discount-detail.vue") 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", path: "promotions/seckill/manager-seckill-add",
title: "编辑秒杀活动", title: "编辑秒杀活动",
@@ -371,12 +399,6 @@ export const otherRouter = {
component: () => import("@/views/promotions/seckill/seckill-goods.vue") 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", path: "live-add",
title: "创建直播", title: "创建直播",
@@ -471,6 +493,13 @@ export const otherRouter = {
meta: { title: "商品销售汇总报表" }, meta: { title: "商品销售汇总报表" },
component: () => import("@/views/statistics/goods-sales-summary.vue") 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", path: "distributionImportRecord",
title: "分销员导入记录", title: "分销员导入记录",

View File

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

View File

@@ -1,3 +1,8 @@
/* 全局禁止多行输入框拖动缩放,覆盖组件生成的内联 resize 样式。 */
.el-textarea__inner {
resize: none !important;
}
/* Element Plus 主题覆盖(对齐原 iView 主色) */ /* Element Plus 主题覆盖(对齐原 iView 主色) */
:root { :root {
// --el-color-primary: #ff5c58; // --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); const el = document.getElementById(elementId);
if (!el) { if (!el) {
return; return;
@@ -13,22 +13,196 @@ export function printElement(elementId, title = "打印") {
doc.open(); doc.open();
doc.write( doc.write(
`<!DOCTYPE html><html><head><title>${title}</title><style> `<!DOCTYPE html><html><head><title>${title}</title><style>
body{font-family:Arial,sans-serif;padding:12px;color:#333} @page {
table{width:100%;border-collapse:collapse} size: portrait;
td,th{border:1px solid #ddd;padding:6px} margin: 8mm 10mm;
.lineH30{line-height:30px} }
.f14{font-size:14px;color:#333} * {
.printgoodtitle{font-size:14px;line-height:1.5;margin-top:15px;color:#333} -webkit-print-color-adjust: exact !important;
.printgoodinfo{padding:10px} print-color-adjust: exact !important;
.printgooditem{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #eee} box-sizing: border-box !important;
.printgoodname{flex:1} }
.printgoodguid{margin-top:4px;color:#666;font-size:12px} html, body {
.printgoodguiditem{margin-right:8px} margin: 0 !important;
.printgoodnumber{white-space:nowrap;margin-left:12px} 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>` </style></head><body>${el.innerHTML}</body></html>`
); );
doc.close(); doc.close();
iframe.contentWindow.focus(); iframe.contentWindow.focus();
iframe.contentWindow.print(); setTimeout(() => {
setTimeout(() => document.body.removeChild(iframe), 1000); iframe.contentWindow.print();
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> </div>
</el-card> </el-card>
<el-dialog <el-drawer
v-model="modalVisible" v-model="modalVisible"
:title="modalTitle" :title="modalTitle"
width="500px" size="500px"
:close-on-click-modal="false" :close-on-click-modal="false"
destroy-on-close destroy-on-close
append-to-body
> >
<el-form ref="formRef" :model="form" label-width="100px" :rules="formValidate"> <el-form ref="formRef" :model="form" label-width="100px" :rules="formValidate">
<el-form-item label="自定义分词" prop="name"> <el-form-item label="自定义分词" prop="name">
@@ -61,7 +62,7 @@
<el-button @click="modalVisible = false">取消</el-button> <el-button @click="modalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">保存</el-button> <el-button type="primary" :loading="submitLoading" @click="handleSubmit">保存</el-button>
</template> </template>
</el-dialog> </el-drawer>
</div> </div>
</template> </template>

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,56 @@
<template> <template>
<div class="search"> <div class="finance-wallet-log-page search">
<el-card> <el-card shadow="never" class="filter-card">
<el-form inline> <el-form inline class="search-form" @keyup.enter="handleSearch">
<el-form-item label="会员"> <el-form-item label="客户ID">
<el-input v-model="searchForm.memberName" clearable style="width: 160px" /> <el-input v-model="searchForm.memberId" placeholder="请输入客户ID" clearable style="width: 180px" />
</el-form-item> </el-form-item>
<el-form-item label="日期"> <el-form-item label="会员名称">
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" /> <el-input v-model="searchForm.memberName" placeholder="请输入会员名称" clearable style="width: 160px" />
</el-form-item> </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 type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button @click="handleExport">导出</el-button> <el-button @click="handleExport">导出</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
<el-card class="mt_10">
<el-table v-loading="loading" border :data="data"> <el-card shadow="never" class="table-card mt_10">
<el-table-column prop="memberName" label="会员" width="140" /> <template #header>
<el-table-column prop="money" label="金额" width="110" /> <div class="card-header-bar">
<el-table-column prop="serviceType" label="业务类型" width="140" /> <div class="header-title">
<el-table-column prop="detail" label="明细" min-width="200" show-overflow-tooltip /> <span>钱包流水明细</span>
<el-table-column prop="createTime" label="时间" width="170" /> </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> </el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<div class="pagination-wrap mt_10">
<el-pagination <el-pagination
v-model:current-page="searchForm.pageNumber" v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize" v-model:page-size="searchForm.pageSize"
@@ -42,15 +70,16 @@ import { useTable } from "@/utils/useTable";
import * as API_Finance from "@/api/finance"; import * as API_Finance from "@/api/finance";
import { getRequest } from "@/libs/axios"; import { getRequest } from "@/libs/axios";
import { downloadBlob } from "@/utils/downloadBlob"; import { downloadBlob } from "@/utils/downloadBlob";
import { ElMessage } from "element-plus";
defineOptions({ name: "finance-wallet-log" }); defineOptions({ name: "finance-wallet-log" });
const dateRange = ref([]); 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), (p) => getRequest("/wallet/log", p),
{ {
pageSize: 20, pageSize: 20,
form: { memberName: "" }, form: { memberId: "", memberName: "" },
beforeRequest: (p) => { beforeRequest: (p) => {
const params = { ...p }; const params = { ...p };
if (dateRange.value?.length === 2) { 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() { function handleExport() {
const params = { memberName: searchForm.memberName }; const params = {
memberId: searchForm.memberId || undefined,
memberName: searchForm.memberName || undefined,
};
if (dateRange.value?.length === 2) { if (dateRange.value?.length === 2) {
params.startDate = dateRange.value[0]; params.startDate = dateRange.value[0];
params.endDate = dateRange.value[1]; params.endDate = dateRange.value[1];
@@ -73,3 +112,78 @@ function handleExport() {
}); });
} }
</script> </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> <template>
<div class="search"> <div class="search">
<el-card> <el-card>
<div class="operation"> <list-action-bar>
<el-button type="primary" @click="add">添加</el-button> <el-button type="primary" @click="add">添加</el-button>
<el-button @click="delAll">批量删除</el-button> <el-button @click="delAll">批量删除</el-button>
</div> </list-action-bar>
<el-table <el-table
ref="table" ref="table"
@@ -66,6 +66,7 @@
</template> </template>
<script setup> <script setup>
import ListActionBar from "@/components/list-action-bar.vue";
import { ref, reactive, onMounted } from "vue"; import { ref, reactive, onMounted } from "vue";
import { import {
addGoodsUnit, addGoodsUnit,

View File

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

View File

@@ -1,47 +1,77 @@
<template> <template>
<div class="search"> <div class="brand-manage-page search">
<el-card> <!-- 顶部筛选表单 -->
<el-card shadow="never" class="filter-card">
<el-form <el-form
ref="searchFormRef" ref="searchFormRef"
:model="searchForm" :model="searchForm"
inline inline
label-width="70px" label-width="76px"
class="search-form" class="search-form"
@keyup.enter="handleSearch" @keyup.enter="handleSearch"
> >
<el-form-item label="品牌名称"> <el-form-item label="品牌名称" prop="name">
<el-input <el-input
v-model="searchForm.name" v-model="searchForm.name"
placeholder="请输入品牌名称" placeholder="请输入品牌名称"
clearable clearable
style="width: 240px" style="width: 220px"
@clear="handleSearch"
/> />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item class="form-actions">
<el-button type="primary" @click="handleSearch">搜索</el-button> <el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button> <el-button @click="resetSearch">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
<el-card class="mt_10"> <!-- 品牌数据表格卡片 -->
<div class="operation padding-row" style="margin-bottom: 10px;"> <el-card shadow="never" class="table-card mt_10">
<el-button type="primary" @click="add">添加</el-button> <list-action-bar>
</div> <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
<el-table-column prop="name" label="品牌名称" width="200" /> ref="table"
<el-table-column label="品牌图标" align="left"> 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 }"> <template #default="{ row }">
<img <div v-if="row && row.logo" class="logo-box">
v-if="row" <el-image
:src="row.logo || ''" :src="row.logo"
alt="加载图片失败" fit="contain"
style="cursor: pointer; width: 80px; height: 60px; margin: 10px 0; object-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> </template>
</el-table-column> </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 }"> <template #default="{ row }">
<el-switch <el-switch
v-if="row" v-if="row"
@@ -53,8 +83,12 @@
/> />
</template> </template>
</el-table-column> </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 #default="{ row }">
<template v-if="row"> <template v-if="row">
<a class="link-text" @click="edit(row)">编辑</a> <a class="link-text" @click="edit(row)">编辑</a>
@@ -67,7 +101,8 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end"> <!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination <el-pagination
v-model:current-page="searchForm.pageNumber" v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize" v-model:page-size="searchForm.pageSize"
@@ -81,24 +116,31 @@
</div> </div>
</el-card> </el-card>
<!-- 添加 / 编辑品牌弹窗 -->
<el-dialog <el-dialog
v-model="modalVisible" v-model="modalVisible"
:title="modalTitle" :title="modalTitle"
width="500px" width="520px"
:close-on-click-modal="false" :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-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>
<el-form-item label="品牌图标" prop="logo"> <el-form-item label="品牌图标" prop="logo">
<div style="display: flex; align-items: center; gap: 8px"> <div class="dialog-logo-picker">
<img <div class="logo-preview-box">
:src="form.logo || defaultPic" <img
alt="" :src="form.logo || defaultPic"
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> </div>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -108,7 +150,8 @@
</template> </template>
</el-dialog> </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 <ossManage
ref="ossManageRef" ref="ossManageRef"
:is-component="true" :is-component="true"
@@ -117,13 +160,15 @@
/> />
</el-dialog> </el-dialog>
<!-- 关联分类弹窗 -->
<el-dialog <el-dialog
v-model="categoryModalVisible" v-model="categoryModalVisible"
:title="categoryModalTitle" :title="categoryModalTitle"
width="700px" width="680px"
:close-on-click-modal="false" :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 <el-tree
ref="categoryTree" ref="categoryTree"
:key="categoryTreeKey" :key="categoryTreeKey"
@@ -139,7 +184,7 @@
<template #footer> <template #footer>
<el-button @click="categoryModalVisible = false">取消</el-button> <el-button @click="categoryModalVisible = false">取消</el-button>
<el-button type="primary" :loading="categorySubmitLoading" @click="submitBrandCategory"> <el-button type="primary" :loading="categorySubmitLoading" @click="submitBrandCategory">
提交 提交关联
</el-button> </el-button>
</template> </template>
</el-dialog> </el-dialog>
@@ -147,6 +192,7 @@
</template> </template>
<script setup> <script setup>
import ListActionBar from "@/components/list-action-bar.vue";
import { ref, reactive, nextTick, getCurrentInstance } from "vue"; import { ref, reactive, nextTick, getCurrentInstance } from "vue";
import { useTable } from "@/utils/useTable"; import { useTable } from "@/utils/useTable";
import { import {
@@ -299,7 +345,7 @@ function handleStatusSwitchChange(row, checked) {
} }
function delBrand(row) { function delBrand(row) {
ElMessageBox.confirm("您确认要删除 " + row.name + " ?", "确认删除", { type: "warning" }).then(() => { ElMessageBox.confirm("您确认要删除品牌 " + row.name + " ?", "确认删除", { type: "warning" }).then(() => {
return delBrandApi(row.id).then((res) => { return delBrandApi(row.id).then((res) => {
if (res.success) { if (res.success) {
ElMessage.success("品牌删除成功!"); ElMessage.success("品牌删除成功!");
@@ -339,7 +385,7 @@ function handleSubmit() {
function add() { function add() {
modalType.value = 0; modalType.value = 0;
modalTitle.value = "添加"; modalTitle.value = "添加品牌";
formRef.value?.resetFields(); formRef.value?.resetFields();
delete form.value.id; delete form.value.id;
modalVisible.value = true; modalVisible.value = true;
@@ -347,7 +393,7 @@ function add() {
function edit(v) { function edit(v) {
modalType.value = 1; modalType.value = 1;
modalTitle.value = "编辑"; modalTitle.value = "编辑品牌";
formRef.value?.resetFields(); formRef.value?.resetFields();
for (const attr in v) { for (const attr in v) {
if (v[attr] === null) { if (v[attr] === null) {
@@ -360,13 +406,108 @@ function edit(v) {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.link-text { .brand-manage-page {
color: #409eff; .filter-card {
cursor: pointer; .search-form {
text-decoration: none; display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0;
:deep(.el-form-item) {
margin-bottom: 0;
margin-right: 16px;
}
.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;
}
} }
.op-split {
margin: 0 8px; .dialog-logo-picker {
color: #dcdee2; 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> </style>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,77 @@
<template> <template>
<div class="search"> <div class="goods-group-page search">
<el-card> <!-- 顶部筛选与操作栏 -->
<div class="operation padding-row"> <el-card shadow="never" class="filter-card">
<el-button type="primary" @click="openAdd">添加分组</el-button> <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> </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="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 prop="description" label="分组描述" min-width="240" show-overflow-tooltip>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }"> <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> <a class="link-text" @click="openEdit(row)">编辑</a>
<span class="op-split">|</span> <span class="op-split">|</span>
<a class="link-text" @click="remove(row)">删除</a> <a class="link-text" @click="remove(row)">删除</a>
</div> </template>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end"> <!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination <el-pagination
v-model:current-page="searchForm.pageNumber" v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize" v-model:page-size="searchForm.pageSize"
@@ -35,13 +85,32 @@
</div> </div>
</el-card> </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 ref="addFormRef" :model="formAdd" :rules="rulesAdd" label-width="90px">
<el-form-item label="分组名称" prop="groupName" style="width: 90%"> <el-form-item label="分组名称" prop="groupName">
<el-input v-model="formAdd.groupName" maxlength="30" placeholder="请输入分组名称" /> <el-input
v-model="formAdd.groupName"
maxlength="30"
show-word-limit
placeholder="请输入分组名称"
/>
</el-form-item> </el-form-item>
<el-form-item label="分组描述" prop="description" style="width: 90%"> <el-form-item label="分组描述" prop="description">
<el-input v-model="formAdd.description" maxlength="200" placeholder="请输入分组描述" /> <el-input
v-model="formAdd.description"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入分组描述信息"
/>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -50,14 +119,35 @@
</template> </template>
</el-dialog> </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-form ref="editFormRef" :model="formEdit" :rules="rulesEdit" label-width="90px">
<el-input v-model="formEdit.id" style="display: none" /> <el-form-item label="分组ID">
<el-form-item label="分组名称" prop="groupName" style="width: 90%"> <span>{{ formEdit.id }}</span>
<el-input v-model="formEdit.groupName" maxlength="30" placeholder="请输入分组名称" />
</el-form-item> </el-form-item>
<el-form-item label="分组描述" prop="description" style="width: 90%"> <el-form-item label="分组名称" prop="groupName">
<el-input v-model="formEdit.description" maxlength="200" placeholder="请输入分组描述" /> <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-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -69,6 +159,7 @@
</template> </template>
<script setup> <script setup>
import ListActionBar from "@/components/list-action-bar.vue";
import { ref, reactive, nextTick, onMounted } from "vue"; import { ref, reactive, nextTick, onMounted } from "vue";
import * as API_Goods from "@/api/goods.js"; import * as API_Goods from "@/api/goods.js";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
@@ -79,6 +170,7 @@ const loading = ref(true);
const searchForm = reactive({ const searchForm = reactive({
pageNumber: 1, pageNumber: 1,
pageSize: 20, pageSize: 20,
groupName: "",
}); });
const data = ref([]); const data = ref([]);
const total = ref(0); const total = ref(0);
@@ -86,18 +178,22 @@ const addFlag = ref(false);
const editFlag = ref(false); const editFlag = ref(false);
const submitAddLoading = ref(false); const submitAddLoading = ref(false);
const submitEditLoading = ref(false); const submitEditLoading = ref(false);
const formAdd = ref({ const formAdd = ref({
groupName: "", groupName: "",
description: "", description: "",
}); });
const formEdit = ref({ const formEdit = ref({
id: "", id: "",
groupName: "", groupName: "",
description: "", description: "",
}); });
const rulesAdd = reactive({ const rulesAdd = reactive({
groupName: [{ required: true, message: "请输入分组名称", trigger: "blur" }], groupName: [{ required: true, message: "请输入分组名称", trigger: "blur" }],
}); });
const rulesEdit = reactive({ const rulesEdit = reactive({
groupName: [{ required: true, message: "请输入分组名称", trigger: "blur" }], groupName: [{ required: true, message: "请输入分组名称", trigger: "blur" }],
}); });
@@ -105,7 +201,14 @@ const rulesEdit = reactive({
const addFormRef = ref(null); const addFormRef = ref(null);
const editFormRef = ref(null); const editFormRef = ref(null);
function init() { function handleSearch() {
searchForm.pageNumber = 1;
getData();
}
function handleReset() {
searchForm.pageNumber = 1;
searchForm.groupName = "";
getData(); getData();
} }
@@ -216,36 +319,59 @@ function submitEdit() {
} }
function remove(row) { function remove(row) {
ElMessageBox.confirm("确定删除该分组?", "提示", { type: "warning" }).then(() => { ElMessageBox.confirm(`确定删除分组 "${row.groupName || "该分组"}" 吗?`, "提示", {
return API_Goods.deleteGoodsGroup(row.id) type: "warning",
.then((res) => { confirmButtonText: "确定删除",
if (res && res.success) { cancelButtonText: "取消",
ElMessage.success("删除成功"); })
getData(); .then(() => {
} else if (res && res.message) { return API_Goods.deleteGoodsGroup(row.id)
ElMessage.error(res.message); .then((res) => {
} if (res && res.success) {
}) ElMessage.success("删除成功");
.catch(() => { getData();
ElMessage.error("删除失败,请检查权限或后端接口"); } else if (res && res.message) {
}); ElMessage.error(res.message);
}).catch(() => {}); }
})
.catch(() => {
ElMessage.error("删除失败,请检查权限或后端接口");
});
})
.catch(() => {});
} }
onMounted(() => { onMounted(() => {
init(); getData();
}); });
</script> </script>
<style scoped> <style scoped lang="scss">
.ops a { .goods-group-page {
color: #2d8cf0; .card-toolbar {
cursor: pointer; display: flex;
text-decoration: none; justify-content: space-between;
} align-items: center;
.ops span { flex-wrap: wrap;
display: inline-block; gap: 12px;
margin: 0 8px;
color: #dcdee2; .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;
}
} }
</style> </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>
<el-card class="mt_10"> <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 <el-alert
type="info" type="info"
show-icon
:closable="false" :closable="false"
title="支持按规格逐条设置虚拟销量,列表总销量 = 真实销量 + 虚拟销量。" title="支持按规格逐条设置虚拟销量,列表总销量 = 真实销量 + 虚拟销量。"
style="margin-bottom: 10px" style="margin-bottom: 10px"
@@ -71,55 +75,65 @@
ref="tableRef" ref="tableRef"
v-loading="loading" v-loading="loading"
:data="data" :data="data"
border
class="mt_10" class="mt_10"
style="width: 100%" style="width: 100%"
@sort-change="changeSort" @sort-change="changeSort"
@selection-change="handleSelectionChange" @selection-change="handleSelectionChange"
> >
<el-table-column type="selection" width="55" align="center" /> <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 label="商品信息" min-width="320">
<el-table-column prop="goodsId" label="SPU编码" width="180" show-overflow-tooltip />
<el-table-column label="商品信息" min-width="360">
<template #default="{ row }"> <template #default="{ row }">
<div v-if="row" class="goods-info"> <div v-if="row" class="goods-info">
<img <div class="goods-thumb-box">
v-if="row.thumbnail" <el-image
:src="row.thumbnail" v-if="row.thumbnail"
class="goods-thumbnail" :src="row.thumbnail"
alt="" fit="cover"
/> preview-teleported
:preview-src-list="[row.thumbnail]"
class="goods-thumbnail"
>
<template #error>
<div class="thumb-slot">无图</div>
</template>
</el-image>
<div v-else class="thumb-slot">无图</div>
</div>
<div class="goods-text"> <div class="goods-text">
<div class="div-zoom">{{ row.goodsName }}</div> <div class="goods-title" :title="row.goodsName">{{ row.goodsName || "-" }}</div>
<div v-if="row.simpleSpecs" class="sub-title"> <div v-if="row.simpleSpecs" class="goods-specs">
规格:{{ row.simpleSpecs.trim() || "-" }} 规格:{{ row.simpleSpecs.trim() || "-" }}
</div> </div>
</div> </div>
</div> </div>
</template> </template>
</el-table-column> </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 }"> <template #default="{ row }">
<span v-if="row">{{ row.buyCount || 0 }}</span> <span v-if="row">{{ row.buyCount || 0 }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="虚拟销量" width="110"> <el-table-column label="虚拟销量" width="110" align="right">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row">{{ row.virtualSalesInput || 0 }}</span> <span v-if="row">{{ row.virtualSalesInput || 0 }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="总销量" width="110"> <el-table-column label="总销量" width="110" align="right">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row">{{ getTotalSales(row) }}</span> <span v-if="row">{{ getTotalSales(row) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="100"> <el-table-column label="状态" width="90" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="row" :type="row.marketEnable === 'UPPER' ? 'success' : 'warning'"> <span v-if="row">
{{ row.marketEnable === "UPPER" ? "上架" : "下架" }} {{ row.marketEnable === "UPPER" ? "上架" : "下架" }}
</el-tag> </span>
</template> </template>
</el-table-column> </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 }"> <template #default="{ row, $index }">
<a <a
v-if="row" v-if="row"
@@ -147,14 +161,19 @@
</div> </div>
</el-card> </el-card>
<el-dialog <el-drawer
v-model="editModalVisible" v-model="editModalVisible"
:title="modalMode === 'batch' ? '批量设置虚拟销量' : '设置虚拟销量'" :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-click-modal="false"
:close-on-press-escape="!modalSubmitting" :close-on-press-escape="!modalSubmitting"
:show-close="!modalSubmitting" :show-close="!modalSubmitting"
destroy-on-close destroy-on-close
@closed="resetVirtualSalesModal"
> >
<div v-if="modalMode === 'single' && currentSku" class="virtual-sales-modal"> <div v-if="modalMode === 'single' && currentSku" class="virtual-sales-modal">
<div class="sku-info-item"> <div class="sku-info-item">
@@ -199,12 +218,14 @@
</el-form> </el-form>
</div> </div>
<template #footer> <template #footer>
<el-button :disabled="modalSubmitting" @click="handleCancelVirtualSales">取消</el-button> <div class="drawer-actions">
<el-button type="primary" :loading="modalSubmitting" @click="handleSubmitVirtualSales"> <el-button :disabled="modalSubmitting" @click="handleCancelVirtualSales">取消</el-button>
提交 <el-button type="primary" :loading="modalSubmitting" @click="handleSubmitVirtualSales">
</el-button> 提交
</el-button>
</div>
</template> </template>
</el-dialog> </el-drawer>
</div> </div>
</template> </template>
@@ -239,6 +260,7 @@ const modalMode = ref("single");
const currentSku = ref(null); const currentSku = ref(null);
const currentIndex = ref(-1); const currentIndex = ref(-1);
const selectedRows = ref([]); const selectedRows = ref([]);
const salesTab = ref("all");
const editForm = reactive({ const editForm = reactive({
virtualSales: 0, virtualSales: 0,
}); });
@@ -254,6 +276,7 @@ const searchForm = ref({
}); });
const data = ref([]); const data = ref([]);
const total = ref(0); const total = ref(0);
let dataRequestId = 0;
function init() { function init() {
getDataList(); getDataList();
@@ -271,6 +294,12 @@ function handleSearch() {
searchForm.value.pageNumber = 1; searchForm.value.pageNumber = 1;
getDataList(); getDataList();
} }
function handleSalesTabChange() {
searchForm.value.pageNumber = 1;
selectedRows.value = [];
tableRef.value?.clearSelection();
getDataList();
}
function handleReset() { function handleReset() {
searchForm.value = { searchForm.value = {
pageNumber: 1, pageNumber: 1,
@@ -298,9 +327,15 @@ function handleSelectionChange(selection) {
selectedRows.value = selection; selectedRows.value = selection;
} }
function getDataList() { function getDataList() {
const requestId = ++dataRequestId;
loading.value = true; loading.value = true;
getGoodsSkuData(searchForm.value) const params = { ...searchForm.value };
if (salesTab.value !== "all") {
params.virtualSalesSet = salesTab.value === "configured";
}
getGoodsSkuData(params)
.then((res) => { .then((res) => {
if (requestId !== dataRequestId) return;
if (res && res.success && res.result) { if (res && res.success && res.result) {
data.value = (res.result.records || []).map((item) => ({ data.value = (res.result.records || []).map((item) => ({
...item, ...item,
@@ -308,6 +343,10 @@ function getDataList() {
saving: false, saving: false,
})); }));
total.value = res.result.total || 0; 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 = []; selectedRows.value = [];
} else { } else {
data.value = []; data.value = [];
@@ -317,13 +356,14 @@ function getDataList() {
} }
}) })
.catch(() => { .catch(() => {
if (requestId !== dataRequestId) return;
data.value = []; data.value = [];
total.value = 0; total.value = 0;
selectedRows.value = []; selectedRows.value = [];
ElMessage.error("加载规格列表失败"); ElMessage.error("加载规格列表失败");
}) })
.finally(() => { .finally(() => {
loading.value = false; if (requestId === dataRequestId) loading.value = false;
}); });
} }
function getVirtualSalesValue(row) { function getVirtualSalesValue(row) {
@@ -354,6 +394,10 @@ function handleCancelVirtualSales() {
if (modalSubmitting.value) return; if (modalSubmitting.value) return;
resetVirtualSalesModal(); resetVirtualSalesModal();
} }
function handleBeforeCloseVirtualSales(done) {
if (modalSubmitting.value) return;
done();
}
function resetVirtualSalesModal() { function resetVirtualSalesModal() {
editModalVisible.value = false; editModalVisible.value = false;
modalMode.value = "single"; modalMode.value = "single";
@@ -388,6 +432,7 @@ function handleSubmitVirtualSales() {
} }
ElMessage.success("虚拟销量设置成功"); ElMessage.success("虚拟销量设置成功");
resetVirtualSalesModal(); resetVirtualSalesModal();
getDataList();
} else { } else {
ElMessage.error((res && res.message) || "规格虚拟销量设置失败"); ElMessage.error((res && res.message) || "规格虚拟销量设置失败");
} }
@@ -424,6 +469,7 @@ function handleBatchSubmitVirtualSales(virtualSales) {
tableRef.value?.clearSelection(); tableRef.value?.clearSelection();
ElMessage.success("虚拟销量设置成功"); ElMessage.success("虚拟销量设置成功");
resetVirtualSalesModal(); resetVirtualSalesModal();
getDataList();
} else { } else {
ElMessage.error((res && res.message) || "虚拟销量设置失败"); ElMessage.error((res && res.message) || "虚拟销量设置失败");
} }
@@ -445,32 +491,85 @@ onMounted(() => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.sub-title { .virtual-sales-drawer {
margin-top: 6px; :deep(.el-drawer__header) {
color: #808695; margin-bottom: 0;
font-size: 12px; 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 { .goods-info {
display: flex; display: flex;
align-items: center; align-items: center;
margin: 5px 0; padding: 4px 0;
padding: 10px 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 { .goods-thumbnail {
width: 50px; width: 44px;
height: 50px; height: 44px;
margin-right: 12px; display: block;
object-fit: cover; }
flex-shrink: 0;
.thumb-slot {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #909399;
background-color: #f5f7fa;
} }
.goods-text { .goods-text {
min-width: 0; 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; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -493,11 +592,28 @@ onMounted(() => {
.sku-info-label { .sku-info-label {
width: 90px; width: 90px;
color: #808695;
flex-shrink: 0; flex-shrink: 0;
} }
.mt_10 { .mt_10 {
margin-top: 10px; 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> </style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,29 @@
<template> <template>
<div class="search"> <div class="member-recharge-page search">
<el-card> <!-- 顶部筛选表单 -->
<el-card shadow="never" class="filter-card">
<el-form <el-form
ref="searchFormRef" ref="searchFormRef"
:model="searchForm" :model="searchForm"
inline inline
label-width="70px" label-width="76px"
class="search-form" class="search-form"
@keyup.enter="handleSearch" @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-form-item label="客户名称" prop="memberName">
<el-input <el-input
v-model="searchForm.memberName" v-model="searchForm.memberName"
placeholder="请输入客户名称" placeholder="请输入客户名称"
clearable clearable
style="width: 240px" style="width: 160px"
/> />
</el-form-item> </el-form-item>
<el-form-item label="充值单号" prop="rechargeSn"> <el-form-item label="充值单号" prop="rechargeSn">
@@ -22,59 +31,106 @@
v-model="searchForm.rechargeSn" v-model="searchForm.rechargeSn"
placeholder="请输入充值单号" placeholder="请输入充值单号"
clearable clearable
style="width: 240px" style="width: 190px"
/> />
</el-form-item> </el-form-item>
<el-form-item label="支付时间"> <el-form-item label="充值时间">
<el-date-picker <el-date-picker
v-model="selectDate" v-model="selectDate"
type="datetimerange" type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss"
clearable clearable
range-separator="至"
start-placeholder="开始时间" start-placeholder="开始时间"
end-placeholder="结束时间" end-placeholder="结束时间"
placeholder="选择起始时间" style="width: 320px"
style="width: 360px"
@change="selectDateRange" @change="selectDateRange"
/> />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item class="form-actions">
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button> <el-button type="primary" class="search-btn" @click="handleSearch">
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </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 <el-table
ref="table"
v-loading="loading" v-loading="loading"
border border
stripe
:data="data" :data="data"
ref="table"
class="mt_10"
style="width: 100%" style="width: 100%"
> >
<el-table-column prop="memberName" label="客户名称" min-width="120" show-overflow-tooltip /> <!-- 客户ID -->
<el-table-column prop="rechargeSn" label="订单号" min-width="180" show-overflow-tooltip /> <el-table-column prop="memberId" label="客户ID" min-width="170" show-overflow-tooltip />
<el-table-column label="充值金额" width="160" sortable>
<!-- 客户名称 -->
<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 }"> <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> </template>
</el-table-column> </el-table-column>
<el-table-column label="充值方式" width="120">
<!-- 充值方式 -->
<el-table-column label="充值方式" width="120" align="center">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row">{{ rechargeWayText(row.rechargeWay) }}</span> {{ row ? rechargeWayText(row.rechargeWay) : "-" }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="支付状态" width="120">
<!-- 支付状态 -->
<el-table-column label="支付状态" width="110" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="row" :type="row.payStatus === 'PAID' ? 'success' : 'danger'"> {{ row ? (row.payStatus === "PAID" ? "已付款" : "未付款") : "-" }}
{{ row.payStatus === "PAID" ? "已付款" : "未付款" }} </template>
</el-tag> </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> </template>
</el-table-column> </el-table-column>
<el-table-column prop="createTime" label="充值时间" width="190" />
<el-table-column prop="payTime" label="支付时间" width="190" />
</el-table> </el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination <el-pagination
v-model:current-page="searchForm.pageNumber" v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize" v-model:page-size="searchForm.pageSize"
@@ -91,49 +147,85 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from "vue"; import { onMounted, ref } from "vue";
import { useTable } from "@/utils/useTable"; import { useTable } from "@/utils/useTable";
import { getUserRecharge } from "@/api/member"; import { getUserRecharge } from "@/api/member";
import { customRouterPush } from "@/utils/filters";
import { ElMessage } from "element-plus";
defineOptions({ name: "recharge" }); defineOptions({ name: "recharge" });
// 模板 el-form 的 ref(原 ref="searchForm",与 data 同名会互相覆盖,改名为 searchFormRef)
const searchFormRef = ref(null); const searchFormRef = ref(null);
const table = ref(null); const table = ref(null);
const selectDate = ref(null); const selectDate = ref(null);
const { loading, data, total, searchForm, getDataList } = useTable(
const { loading, data, total, searchForm, getDataList, handleSearch, resetSearch } = useTable(
getUserRecharge, getUserRecharge,
{ {
pageSize: 20, pageSize: 20,
immediate: false, 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) { function rechargeWayText(way) {
if (way === "ALIPAY") return "支付宝"; if (way === "ALIPAY") return "支付宝";
if (way === "WECHAT") return "微信"; if (way === "WECHAT") return "微信支付";
if (way === "BANK_TRANSFER") 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) { function changePage(v) {
searchForm.pageNumber = v; searchForm.pageNumber = v;
getDataList(); getDataList();
} }
function changePageSize(v) { function changePageSize(v) {
searchForm.pageNumber = 1; searchForm.pageNumber = 1;
searchForm.pageSize = v; searchForm.pageSize = v;
getDataList(); getDataList();
} }
function handleSearch() {
searchForm.pageNumber = 1;
searchForm.pageSize = 20;
getDataList();
}
function selectDateRange(v) { function selectDateRange(v) {
if (v && v.length === 2) { if (v && v.length === 2) {
searchForm.startDate = v[0]; searchForm.startDate = v[0];
@@ -145,6 +237,84 @@ function selectDateRange(v) {
} }
onMounted(() => { onMounted(() => {
init(); getDataList();
}); });
</script> </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> <template>
<div class="search"> <div class="member-wallet-log-page search">
<el-card> <!-- 顶部筛选表单 -->
<el-card shadow="never" class="filter-card">
<el-form <el-form
ref="searchFormRef" ref="searchFormRef"
:model="searchForm" :model="searchForm"
inline inline
label-width="70px" label-width="76px"
class="search-form" class="search-form"
@keyup.enter="handleSearch" @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-form-item label="客户名称" prop="memberName">
<el-input <el-input
v-model="searchForm.memberName" v-model="searchForm.memberName"
placeholder="请输入客户名称" placeholder="请输入客户名称"
clearable clearable
style="width: 240px" style="width: 160px"
/> />
</el-form-item> </el-form-item>
<el-form-item label="支付时间"> <el-form-item label="变动时间">
<el-date-picker <el-date-picker
v-model="selectDate" v-model="selectDate"
type="datetimerange" type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss"
clearable clearable
range-separator="至"
start-placeholder="开始时间" start-placeholder="开始时间"
end-placeholder="结束时间" end-placeholder="结束时间"
placeholder="选择起始时间" style="width: 320px"
style="width: 360px"
@change="selectDateRange" @change="selectDateRange"
/> />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item class="form-actions">
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button> <el-button type="primary" class="search-btn" @click="handleSearch">
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </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 <el-table
ref="table"
v-loading="loading" v-loading="loading"
border border
stripe
:data="data" :data="data"
ref="table"
class="mt_10"
style="width: 100%" style="width: 100%"
> >
<el-table-column prop="memberName" label="客户名称" min-width="100" /> <!-- 客户ID -->
<el-table-column label="变动金额" width="150"> <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 #default="{ row }">
<template v-if="row"> <template v-if="row">
<priceColorScheme <span v-if="row.money > 0" class="money-increase">
v-if="row.money > 0" +¥ {{ formatMoney(row.money) }}
:value="row.money" </span>
color="green" <span v-else-if="row.money < 0" class="money-decrease">
/> -¥ {{ formatMoney(Math.abs(row.money)) }}
<priceColorScheme </span>
v-else-if="row.money < 0" <span v-else class="money-zero">
:value="row.money" ¥ 0.00
:color="$mainColor" </span>
/>
</template> </template>
</template> </template>
</el-table-column> </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 }"> <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> </template>
</el-table-column> </el-table-column>
<el-table-column prop="detail" label="详细" min-width="300" show-overflow-tooltip />
</el-table> </el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination <el-pagination
v-model:current-page="searchForm.pageNumber" v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize" v-model:page-size="searchForm.pageSize"
@@ -86,23 +134,42 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from "vue"; import { onMounted, ref } from "vue";
import { useTable } from "@/utils/useTable"; import { useTable } from "@/utils/useTable";
import { getUserWallet } from "@/api/member"; import { getUserWallet } from "@/api/member";
import { customRouterPush } from "@/utils/filters";
import { ElMessage } from "element-plus";
defineOptions({ name: "walletLog" }); defineOptions({ name: "walletLog" });
// 模板 el-form 的 ref(原 ref="searchForm",与 data 同名会互相覆盖,改名为 searchFormRef)
const searchFormRef = ref(null); const searchFormRef = ref(null);
const table = ref(null); const table = ref(null);
const selectDate = ref(null); const selectDate = ref(null);
const { loading, data, total, searchForm, getDataList } = useTable(
const { loading, data, total, searchForm, getDataList, handleSearch, resetSearch } = useTable(
getUserWallet, getUserWallet,
{ {
pageSize: 20, pageSize: 20,
immediate: false, 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 "余额充值"; if (type === "WALLET_RECHARGE") return "余额充值";
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) { function changePage(v) {
searchForm.pageNumber = v; searchForm.pageNumber = v;
getDataList(); getDataList();
} }
function changePageSize(v) { function changePageSize(v) {
searchForm.pageNumber = 1; searchForm.pageNumber = 1;
searchForm.pageSize = v; searchForm.pageSize = v;
getDataList(); getDataList();
} }
function handleSearch() {
searchForm.pageNumber = 1;
searchForm.pageSize = 20;
getDataList();
}
function selectDateRange(v) { function selectDateRange(v) {
if (v && v.length === 2) { if (v && v.length === 2) {
searchForm.startDate = v[0]; searchForm.startDate = v[0];
@@ -141,6 +231,93 @@ function selectDateRange(v) {
} }
onMounted(() => { onMounted(() => {
init(); getDataList();
}); });
</script> </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>

View File

@@ -1,29 +1,52 @@
<template> <template>
<div class="search"> <div class="member-withdraw-apply-page search">
<el-card> <!-- 顶部筛选表单 -->
<el-card shadow="never" class="filter-card">
<el-form <el-form
ref="searchFormRef" ref="searchFormRef"
:model="searchForm" :model="searchForm"
inline inline
label-width="70px" label-width="76px"
class="search-form" class="search-form"
@keyup.enter="handleSearch" @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-form-item label="客户名称" prop="memberName">
<el-input <el-input
v-model="searchForm.memberName" v-model="searchForm.memberName"
placeholder="请输入客户名称" placeholder="请输入客户名称"
clearable clearable
style="width: 240px" style="width: 160px"
/>
</el-form-item>
<el-form-item label="申请编号" prop="sn">
<el-input
v-model="searchForm.sn"
placeholder="请输入申请编号"
clearable
style="width: 180px"
/> />
</el-form-item> </el-form-item>
<el-form-item label="提现状态" prop="applyStatus"> <el-form-item label="提现状态" prop="applyStatus">
<el-select v-model="searchForm.applyStatus" clearable style="width: 240px"> <el-select
v-model="searchForm.applyStatus"
placeholder="全部状态"
clearable
style="width: 130px"
>
<el-option label="全部" value="" />
<el-option label="申请中" value="APPLY" /> <el-option label="申请中" value="APPLY" />
<el-option label="审核通过" value="VIA_AUDITING" /> <el-option label="审核通过" value="VIA_AUDITING" />
<el-option label="提现成功" value="SUCCESS" />
<el-option label="用户确认" value="WAIT_USER_CONFIRM" /> <el-option label="用户确认" value="WAIT_USER_CONFIRM" />
<el-option label="审核拒绝" value="FAIL_AUDITING" /> <el-option label="审核拒绝" value="FAIL_AUDITING" />
<el-option label="提现成功" value="SUCCESS" />
<el-option label="提现失败" value="ERROR" /> <el-option label="提现失败" value="ERROR" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -33,55 +56,90 @@
type="datetimerange" type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss"
clearable clearable
range-separator="至"
start-placeholder="开始时间" start-placeholder="开始时间"
end-placeholder="结束时间" end-placeholder="结束时间"
placeholder="选择起始时间" style="width: 320px"
style="width: 360px"
@change="selectDateRange" @change="selectDateRange"
/> />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item class="form-actions">
<el-button type="primary" @click="handleSearch">搜索</el-button> <el-button type="primary" class="search-btn" @click="handleSearch">
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </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 <el-table
ref="table"
v-loading="loading" v-loading="loading"
border border
stripe
:data="data" :data="data"
ref="table"
class="mt_10"
style="width: 100%" style="width: 100%"
@sort-change="changeSort" @sort-change="changeSort"
@selection-change="changeSelect"
> >
<el-table-column prop="sn" label="申请编号" min-width="160" show-overflow-tooltip /> <!-- 客户ID -->
<el-table-column prop="memberName" label="客户名称" min-width="120" show-overflow-tooltip /> <el-table-column prop="memberId" label="客户ID" min-width="170" show-overflow-tooltip />
<el-table-column label="申请金额" width="120">
<!-- 客户名称 -->
<el-table-column prop="memberName" label="客户名称" min-width="140" show-overflow-tooltip />
<!-- 申请编号 -->
<el-table-column prop="sn" label="申请编号" min-width="180" show-overflow-tooltip />
<!-- 申请提现金额 -->
<el-table-column label="申请金额" width="130" align="right" sortable="custom" prop="applyMoney">
<template #default="{ row }"> <template #default="{ row }">
<priceColorScheme v-if="row" :value="row.applyMoney" :color="$mainColor" /> <span v-if="row">¥ {{ formatMoney(row.applyMoney) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="提现状态" width="120">
<!-- 提现状态 -->
<el-table-column label="提现状态" width="120" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="row" :type="applyStatusTagType(row.applyStatus)"> {{ row ? applyStatusText(row.applyStatus) : "-" }}
{{ applyStatusText(row.applyStatus) }}
</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="createTime" label="申请时间" width="170" sortable="custom" />
<el-table-column prop="inspectTime" label="审核时间" width="170" /> <!-- 申请时间 -->
<el-table-column label="操作" width="120" align="center" fixed="right"> <el-table-column prop="createTime" label="申请时间" width="170" align="center" sortable="custom" />
<!-- 审核时间 -->
<el-table-column prop="inspectTime" label="审核时间" width="170" align="center" />
<!-- 操作 -->
<el-table-column label="操作" width="160" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div v-if="row" class="ops"> <template v-if="row">
<a class="link-text" v-if="row.applyStatus === 'APPLY'" @click="openAudit(row)">审核</a> <a v-if="row.applyStatus === 'APPLY'" class="link-text" @click="openAudit(row)">审核</a>
<a class="link-text" v-else @click="openQuery(row)">查看</a> <a v-else class="link-text" @click="openQuery(row)">查看</a>
</div> <template v-if="row.memberId">
<span class="op-split">|</span>
<a class="link-text" @click="viewMemberDetail(row)">客户详情</a>
</template>
</template>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination <el-pagination
v-model:current-page="searchForm.pageNumber" v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize" v-model:page-size="searchForm.pageSize"
@@ -95,114 +153,170 @@
</div> </div>
</el-card> </el-card>
<!-- 审核弹窗 -->
<el-dialog <el-dialog
v-model="roleModalVisible" v-model="roleModalVisible"
:title="modalTitle" title="提现申请审核"
width="500px" width="520px"
:close-on-click-modal="false" :close-on-click-modal="false"
destroy-on-close
> >
<el-form label-width="80px"> <div class="dialog-body">
<el-form-item label="申请编号"> <el-descriptions :column="1" border size="small" class="custom-dialog-descriptions">
<span>{{ showList.sn }}</span> <el-descriptions-item label="申请编号">
</el-form-item> <span class="font-mono">{{ showList.sn }}</span>
<el-form-item label="用户名称"> </el-descriptions-item>
<span>{{ showList.memberName }}</span> <el-descriptions-item label="客户ID">
</el-form-item> <span class="font-mono">{{ showList.memberId || "-" }}</span>
<el-form-item label="申请金额"> </el-descriptions-item>
<priceColorScheme :value="showList.applyMoney" :color="$mainColor" /> <el-descriptions-item label="客户名称">
</el-form-item> <span class="font-semibold">{{ showList.memberName }}</span>
<el-form-item label="提现状态"> </el-descriptions-item>
<span>{{ paramTypeFilter(showList.applyStatus) }}</span> <el-descriptions-item label="申请金额">
</el-form-item> <span class="money-text font-semibold">¥ {{ formatMoney(showList.applyMoney) }}</span>
<el-form-item label="申请时间"> </el-descriptions-item>
<span>{{ showList.createTime }}</span> <el-descriptions-item label="申请时间">
</el-form-item> <span>{{ showList.createTime }}</span>
<el-form-item label="审核备注"> </el-descriptions-item>
<el-input v-model="audit" type="textarea" /> </el-descriptions>
</el-form-item>
</el-form> <div class="remark-form-item" style="margin-top: 14px">
<div class="remark-label" style="font-size: 13px; color: #606266; margin-bottom: 6px">
审核备注说明(拒绝时必填):
</div>
<el-input
v-model="audit"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入审核备注说明..."
/>
</div>
</div>
<template #footer> <template #footer>
<template v-if="showList.applyStatus === 'APPLY'"> <el-button @click="roleModalVisible = false">取消</el-button>
<el-button @click="submitRole(false)">拒绝</el-button> <el-button
<el-button type="primary" :loading="submitLoading" @click="submitRole(true)">通过</el-button> type="danger"
</template> :loading="submitLoading"
@click="submitRole(false)"
>
拒绝
</el-button>
<el-button
type="primary"
:loading="submitLoading"
@click="submitRole(true)"
>
通过
</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 查看详情弹窗 -->
<el-dialog <el-dialog
v-model="queryModalVisible" v-model="queryModalVisible"
:title="modalTitle" title="提现申请详情"
width="500px" width="520px"
:close-on-click-modal="false" :close-on-click-modal="false"
destroy-on-close
> >
<el-form label-width="100px"> <div class="dialog-body">
<el-form-item label="申请编号:"> <el-descriptions :column="1" border size="small" class="custom-dialog-descriptions">
<span>{{ showList.sn }}</span> <el-descriptions-item label="申请编号">
</el-form-item> <span class="font-mono">{{ showList.sn }}</span>
<el-form-item label="用户名称:"> </el-descriptions-item>
<span>{{ showList.memberName }}</span> <el-descriptions-item label="客户ID">
</el-form-item> <span class="font-mono">{{ showList.memberId || "-" }}</span>
<el-form-item label="申请金额:"> </el-descriptions-item>
<priceColorScheme :value="showList.applyMoney" :color="$mainColor" /> <el-descriptions-item label="客户名称">
</el-form-item> <span class="font-semibold">{{ showList.memberName }}</span>
<el-form-item label="提现状态:"> </el-descriptions-item>
<span>{{ paramTypeFilter(showList.applyStatus) }}</span> <el-descriptions-item label="申请金额">
</el-form-item> <span class="money-text font-semibold">¥ {{ formatMoney(showList.applyMoney) }}</span>
<el-form-item label="申请时间:"> </el-descriptions-item>
<span>{{ showList.createTime }}</span> <el-descriptions-item label="提现状态">
</el-form-item> <el-tag :type="applyStatusTagType(showList.applyStatus)" size="small">
<el-form-item label="审核时间:"> {{ paramTypeFilter(showList.applyStatus) }}
<span>{{ showList.inspectTime }}</span> </el-tag>
</el-form-item> </el-descriptions-item>
<el-form-item label="审核备注:"> <el-descriptions-item label="申请时间">
<span>{{ showList.inspectRemark || "暂无备注" }}</span> <span>{{ showList.createTime }}</span>
</el-form-item> </el-descriptions-item>
</el-form> <el-descriptions-item label="审核时间">
<span>{{ showList.inspectTime || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="审核备注">
<span>{{ showList.inspectRemark || "暂无备注" }}</span>
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer> <template #footer>
<template v-if="showList.applyStatus === 'APPLY'"> <el-button @click="queryModalVisible = false">关 闭</el-button>
<el-button @click="submitRole(false)">拒绝</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitRole(true)">通过</el-button>
</template>
<el-button v-else @click="queryModalVisible = false">取消</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from "vue"; import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { useTable } from "@/utils/useTable";
import { getUserWithdrawApply, withdrawApply } from "@/api/member"; import { getUserWithdrawApply, withdrawApply } from "@/api/member";
import { customRouterPush } from "@/utils/filters";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
defineOptions({ name: "withdrawApply" }); defineOptions({ name: "withdrawApply" });
// 模板 el-form 的 ref(原 ref="searchForm",与 data 同名会互相覆盖,改名为 searchFormRef) const route = useRoute();
const searchFormRef = ref(null); const searchFormRef = ref(null);
// 模板 el-table 的 ref
const table = ref(null); const table = ref(null);
const selectDate = ref(null);
const modalTitle = ref("");
const loading = ref(true);
const audit = ref(""); const audit = ref("");
const roleModalVisible = ref(false); const roleModalVisible = ref(false);
const queryModalVisible = ref(false); const queryModalVisible = ref(false);
const searchForm = reactive({
pageNumber: 1,
pageSize: 20,
sort: "createTime",
order: "desc",
startDate: "",
endDate: "",
memberName: "",
applyStatus: "",
});
const selectDate = ref(null);
const submitLoading = ref(false); const submitLoading = ref(false);
const selectList = ref([]);
const selectCount = ref(0);
const showList = ref({}); const showList = ref({});
const data = ref([]);
const total = ref(0); const { loading, data, total, searchForm, getDataList, handleSearch, resetSearch } = useTable(
getUserWithdrawApply,
{
pageSize: 20,
immediate: false,
form: {
sort: "createTime",
order: "desc",
memberId: "",
memberName: "",
sn: "",
applyStatus: "",
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 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 paramTypeFilter(val) { function paramTypeFilter(val) {
if (val === "APPLY") return "申请中"; if (val === "APPLY") return "申请中";
@@ -211,41 +325,47 @@ function paramTypeFilter(val) {
if (val === "FAIL_AUDITING") return "审核拒绝"; if (val === "FAIL_AUDITING") return "审核拒绝";
if (val === "SUCCESS") return "提现成功"; if (val === "SUCCESS") return "提现成功";
if (val === "ERROR") return "提现失败"; if (val === "ERROR") return "提现失败";
return "未知状态"; return val || "未知状态";
} }
function applyStatusText(status) { function applyStatusText(status) {
if (status === "APPLY") return "申请中"; if (status === "APPLY") return "申请中";
if (status === "VIA_AUDITING") return "审核通过"; if (status === "VIA_AUDITING") return "审核通过";
if (status === "WAIT_USER_CONFIRM") return "用户确认"; if (status === "WAIT_USER_CONFIRM") return "用户确认";
if (status === "SUCCESS") return "提现成功"; if (status === "SUCCESS") return "提现成功";
if (status === "ERROR") return "提现失败"; if (status === "ERROR") return "提现失败";
return "审核拒绝"; if (status === "FAIL_AUDITING") return "审核拒绝";
return status || "-";
} }
function applyStatusTagType(status) { function applyStatusTagType(status) {
if (status === "APPLY") return "warning"; if (status === "APPLY") return "warning";
if (status === "VIA_AUDITING") return "success"; if (status === "VIA_AUDITING") return "success";
if (status === "WAIT_USER_CONFIRM") return "warning"; if (status === "WAIT_USER_CONFIRM") return "warning";
if (status === "SUCCESS") return "primary"; if (status === "SUCCESS") return "primary";
if (status === "ERROR") return "primary"; if (status === "ERROR") return "danger";
return "danger"; return "danger";
} }
function openAudit(row) { function openAudit(row) {
showList.value = { ...row }; showList.value = { ...row };
audit.value = ""; audit.value = "";
roleModalVisible.value = true; roleModalVisible.value = true;
} }
function openQuery(row) { function openQuery(row) {
showList.value = { ...row }; showList.value = { ...row };
modalTitle.value = "查看";
queryModalVisible.value = true; queryModalVisible.value = true;
} }
function submitRole(res) { function submitRole(res) {
const params = {}; const params = {
params.applyId = showList.value.id; applyId: showList.value.id,
params.result = res; result: res,
params.remark = audit.value; remark: audit.value ? audit.value.trim() : "",
if (res === false && params.remark === "") { };
ElMessage.error("审核备注不能为空"); if (res === false && !params.remark) {
ElMessage.error("审核拒绝时备注说明不能为空");
return; return;
} }
submitLoading.value = true; submitLoading.value = true;
@@ -265,50 +385,36 @@ function submitRole(res) {
submitLoading.value = false; submitLoading.value = false;
}); });
} }
function init() {
function handleReset() {
selectDate.value = null;
searchForm.memberId = "";
searchForm.memberName = "";
searchForm.sn = "";
searchForm.applyStatus = "";
searchForm.startDate = "";
searchForm.endDate = "";
searchForm.pageNumber = 1;
resetSearch();
}
function changeSort(e) {
searchForm.sort = e.prop || e.key || "createTime";
searchForm.order = e.order === "ascending" ? "asc" : e.order === "descending" ? "desc" : "";
getDataList(); getDataList();
} }
function changePage(v) { function changePage(v) {
searchForm.pageNumber = v; searchForm.pageNumber = v;
getDataList(); getDataList();
clearSelectAll();
} }
function changePageSize(v) { function changePageSize(v) {
searchForm.pageNumber = 1;
searchForm.pageSize = v; searchForm.pageSize = v;
getDataList(); getDataList();
} }
function handleSearch() {
searchForm.pageNumber = 1;
searchForm.pageSize = 20;
getDataList();
}
function handleReset() {
searchFormRef.value.resetFields();
searchForm.pageNumber = 1;
searchForm.pageSize = 20;
selectDate.value = null;
searchForm.startDate = "";
searchForm.endDate = "";
searchForm.memberName = "";
getDataList();
}
function changeSort(e) {
searchForm.sort = e.prop || e.key;
searchForm.order = e.order === "ascending" ? "asc" : e.order === "descending" ? "desc" : "";
if (!e.order) {
searchForm.order = "";
}
getDataList();
}
function clearSelectAll() {
if (table.value) {
table.value.clearSelection();
}
}
function changeSelect(e) {
selectList.value = e;
selectCount.value = e.length;
}
function selectDateRange(v) { function selectDateRange(v) {
if (v && v.length === 2) { if (v && v.length === 2) {
searchForm.startDate = v[0]; searchForm.startDate = v[0];
@@ -318,30 +424,97 @@ function selectDateRange(v) {
searchForm.endDate = ""; searchForm.endDate = "";
} }
} }
function getDataList() {
loading.value = true;
getUserWithdrawApply(searchForm).then((res) => {
loading.value = false;
if (res.success) {
data.value = res.result.records;
total.value = res.result.total;
}
});
}
onMounted(() => { onMounted(() => {
init(); if (route.query && route.query.applyStatus) {
searchForm.applyStatus = route.query.applyStatus;
}
getDataList();
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.ops a { .member-withdraw-apply-page {
color: #2d8cf0; padding: 16px 20px;
cursor: pointer; background-color: #f6f8fa;
text-decoration: none; min-height: calc(100vh - 110px);
} }
.ops span {
display: inline-block; .font-mono {
margin: 0 8px; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
color: #dcdee2; }
.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;
}
}
}
.custom-dialog-descriptions {
:deep(.el-descriptions__label) {
width: 100px;
background-color: #fafafa;
color: #606266;
}
.money-text {
font-size: 15px;
color: #f56c6c;
}
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding-top: 6px;
} }
</style> </style>

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,32 @@
<template> <template>
<div class="search"> <div class="member-experience-log-page search">
<el-card> <!-- 顶部筛选表单 -->
<el-card shadow="never" class="filter-card">
<el-form <el-form
ref="searchFormRef" ref="searchFormRef"
:model="searchForm" :model="searchForm"
inline inline
label-width="90px" label-width="84px"
class="search-form" class="search-form"
@keyup.enter="handleSearch" @keyup.enter="handleSearch"
> >
<el-form-item label="客户手机号" prop="mobile"> <el-form-item label="客户手机号" prop="memberMobile">
<el-input <el-input
v-model="searchForm.memberMobile" v-model="searchForm.memberMobile"
clearable clearable
placeholder="请输入客户手机号" placeholder="请输入客户手机号"
style="width: 220px" style="width: 200px"
/> />
</el-form-item> </el-form-item>
<el-form-item label="规则" prop="ruleKey"> <el-form-item label="规则类型" prop="ruleKey">
<el-select v-model="searchForm.ruleKey" clearable filterable style="width: 220px"> <el-select
v-model="searchForm.ruleKey"
clearable
filterable
placeholder="全部规则"
style="width: 180px"
>
<el-option label="全部规则" value="" />
<el-option <el-option
v-for="item in ruleOptions" v-for="item in ruleOptions"
:key="item.value" :key="item.value"
@@ -27,42 +35,103 @@
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item class="form-actions">
<el-button type="primary" @click="handleSearch">搜索</el-button> <el-button type="primary" class="search-btn" @click="handleSearch">
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
<el-card> <!-- 经验值记录表格 -->
<el-table v-loading="loading" border :data="data" class="mt_10" style="width: 100%"> <el-card shadow="never" class="table-card mt_10">
<el-table-column label="客户手机号" width="140" show-overflow-tooltip> <template #header>
<div class="card-header-bar">
<div class="header-title">
<span>客户经验值流水</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getData">刷新</el-button>
</div>
</div>
</template>
<el-table
v-loading="loading"
border
stripe
:data="data"
style="width: 100%"
>
<!-- 客户ID -->
<el-table-column prop="memberId" label="客户ID" min-width="190" show-overflow-tooltip />
<!-- 客户名称 -->
<el-table-column prop="memberName" label="客户名称" min-width="130" show-overflow-tooltip />
<!-- 客户手机号 -->
<el-table-column label="客户手机号" width="130" align="center">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row">{{ row.memberMobile || row.mobile || "-" }}</span> {{ row ? (row.memberMobile || row.mobile || "-") : "-" }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="规则名称" width="150" show-overflow-tooltip>
<!-- 规则名称 -->
<el-table-column label="规则名称" width="130" align="center">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row">{{ row.ruleName || findRuleName(row.ruleKey) || "-" }}</span> {{ row ? (row.ruleName || findRuleName(row.ruleKey) || "-") : "-" }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="变化经验值" width="120">
<!-- 变动前经验值 -->
<el-table-column label="变动前经验值" width="120" align="right">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row">{{ formatExperienceValue(row) }}</span> <span v-if="row">{{ row.beforeExperience != null ? formatNumber(row.beforeExperience) : "-" }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="经验值限额" width="120">
<!-- 变动经验值 -->
<el-table-column label="变动经验值" width="120" align="right">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row">{{ formatMaxValue(row) }}</span> <span v-if="row" class="experience-increase">
+{{ formatNumber(row.variableExperience ?? row.value ?? 0) }}
</span>
</template> </template>
</el-table-column> </el-table-column>
<!-- 当前经验值 -->
<el-table-column label="变动后经验值" width="120" align="right">
<template #default="{ row }">
<span v-if="row" class="experience-after">
{{ formatNumber(row.experience ?? 0) }}
</span>
</template>
</el-table-column>
<!-- 操作说明 / 备注 -->
<el-table-column label="操作说明" min-width="200" show-overflow-tooltip> <el-table-column label="操作说明" min-width="200" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row">{{ row.content || row.remark || row.description || "-" }}</span> <span>{{ row ? row.content || row.remark || row.description || "-" : "-" }}</span>
</template>
</el-table-column>
<!-- 创建时间 -->
<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> </template>
</el-table-column> </el-table-column>
<el-table-column prop="createTime" label="创建时间" width="170" />
</el-table> </el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination <el-pagination
v-model:current-page="searchForm.pageNumber" v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize" v-model:page-size="searchForm.pageSize"
@@ -82,8 +151,10 @@
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { useTable } from "@/utils/useTable"; import { useTable } from "@/utils/useTable";
import * as API_Member from "@/api/member"; import * as API_Member from "@/api/member";
import { customRouterPush } from "@/utils/filters";
import { ElMessage } from "element-plus";
const RULE_OPTIONS = [ const ruleOptions = [
{ value: "CONSUME", label: "消费" }, { value: "CONSUME", label: "消费" },
{ value: "REGISTER", label: "注册" }, { value: "REGISTER", label: "注册" },
{ value: "SIGN_IN", label: "签到" }, { value: "SIGN_IN", label: "签到" },
@@ -101,7 +172,7 @@ defineOptions({ name: "memberGradeExperienceLog" });
const searchFormRef = ref(null); const searchFormRef = ref(null);
const { loading, data, total, searchForm, getDataList: getData } = useTable( const { loading, data, total, searchForm, getDataList: getData, resetSearch } = useTable(
API_Member.getMemberExperienceByPage, API_Member.getMemberExperienceByPage,
{ {
pageSize: 20, pageSize: 20,
@@ -111,45 +182,138 @@ const { loading, data, total, searchForm, getDataList: getData } = useTable(
} }
); );
onMounted(() => { function formatNumber(val) {
init(); const num = Number(val || 0);
}); return num.toLocaleString();
}
function formatExperienceValue(row) {
const v = row.value ?? row.variableExperience ?? row.experience ?? row.variableValue;
return v == null ? "-" : v;
}
function formatMaxValue(row) {
const v = row.maxValue ?? row.maxExperience ?? row.limitValue;
return v == null ? "-" : v;
}
function findRuleName(ruleKey) { function findRuleName(ruleKey) {
const hit = ruleOptions.find((item) => item.value === ruleKey); const hit = ruleOptions.find((item) => item.value === ruleKey);
return hit ? hit.label : ""; return hit ? hit.label : ruleKey || "";
} }
function init() {
getData(); function viewMemberDetail(row) {
if (!row || !row.memberId) return;
customRouterPush({ name: "member-detail", query: { id: row.memberId } });
} }
function buildParams(base = searchForm) { function buildParams(base = searchForm) {
const params = { const params = {
pageNumber: base.pageNumber, pageNumber: base.pageNumber,
pageSize: base.pageSize, pageSize: base.pageSize,
}; };
if (base.memberMobile) params.mobile = base.memberMobile; if (base.memberMobile) params.mobile = base.memberMobile.trim();
if (base.ruleKey) params.ruleKey = base.ruleKey; if (base.ruleKey) params.ruleKey = base.ruleKey;
return params; return params;
} }
function handleSearch() { function handleSearch() {
searchForm.pageNumber = 1; searchForm.pageNumber = 1;
getData(); getData();
} }
function handleReset() {
searchForm.memberMobile = "";
searchForm.ruleKey = "";
searchForm.pageNumber = 1;
resetSearch();
}
function changePage(v) { function changePage(v) {
searchForm.pageNumber = v; searchForm.pageNumber = v;
getData(); getData();
} }
function changePageSize(v) { function changePageSize(v) {
searchForm.pageNumber = 1; searchForm.pageNumber = 1;
searchForm.pageSize = v; searchForm.pageSize = v;
getData(); getData();
} }
onMounted(() => {
getData();
});
</script> </script>
<style lang="scss" scoped>
.member-experience-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;
}
}
}
.experience-increase {
font-size: 13px;
color: #67c23a;
}
.experience-after {
font-size: 13px;
color: #e6a23c;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding-top: 6px;
}
</style>

View File

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

View File

@@ -1,29 +1,102 @@
<template> <template>
<div class="search"> <div class="member-group-page search">
<el-card> <!-- 顶部筛选与操作栏 -->
<div class="operation padding-row"> <el-card shadow="never" class="filter-card">
<el-button type="primary" @click="openAdd">添加分组</el-button> <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 class="toolbar-right">
<el-button type="primary" @click="openAdd">添加分组</el-button>
</div>
</div> </div>
</el-card> </el-card>
<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">
<template #header>
<div class="card-header-bar">
<div class="header-title">
<span>会员分组列表</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getData">刷新</el-button>
</div>
</div>
</template>
<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" />
<!-- 分组名称 -->
<el-table-column prop="groupName" label="分组名称" min-width="160" 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="创建时间" min-width="180" /> <!-- 分组人数 -->
<el-table-column prop="updateTime" label="更新时间" min-width="180" /> <el-table-column prop="memberCount" label="分组人数" width="110" align="center">
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div v-if="row" class="ops" style="display: flex; justify-content: center"> <template v-if="row">
<a
v-if="row.memberCount > 0"
class="link-text"
@click="viewGroupMembers(row)"
>
{{ row.memberCount }}
</a>
<span v-else>{{ row.memberCount ?? 0 }}</span>
</template>
</template>
</el-table-column>
<!-- 分组描述 -->
<el-table-column prop="description" label="分组描述" min-width="240" show-overflow-tooltip>
<template #default="{ row }">
{{ 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="180" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row">
<a class="link-text" @click="viewGroupMembers(row)">查看会员</a>
<span class="op-split">|</span>
<a class="link-text" @click="openEdit(row)">编辑</a> <a class="link-text" @click="openEdit(row)">编辑</a>
<span class="op-split">|</span> <span class="op-split">|</span>
<a class="link-text" @click="remove(row)">删除</a> <a class="link-text error-text" @click="remove(row)">删除</a>
</div> </template>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end"> <!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination <el-pagination
v-model:current-page="searchForm.pageNumber" v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize" v-model:page-size="searchForm.pageSize"
@@ -37,34 +110,78 @@
</div> </div>
</el-card> </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="addForm" :model="formAdd" :rules="rulesAdd" label-width="90px"> <el-form ref="addForm" :model="formAdd" :rules="rulesAdd" label-width="90px">
<el-form-item label="分组名称" prop="groupName" style="width: 90%"> <el-form-item label="分组名称" prop="groupName">
<el-input v-model="formAdd.groupName" maxlength="30" placeholder="请输入分组名称" /> <el-input
v-model="formAdd.groupName"
maxlength="30"
show-word-limit
placeholder="请输入分组名称"
/>
</el-form-item> </el-form-item>
<el-form-item label="分组描述" prop="description" style="width: 90%"> <el-form-item label="分组描述" prop="description">
<el-input v-model="formAdd.description" maxlength="200" placeholder="请输入分组描述" /> <el-input
v-model="formAdd.description"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入分组描述信息"
/>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="addFlag = false">取消</el-button> <el-button @click="addFlag = false">取消</el-button>
<el-button type="primary" :loading="submitAddLoading" @click="submitAdd">确定</el-button> <el-button type="primary" :loading="submitAddLoading" @click="submitAdd">
确定
</el-button>
</template> </template>
</el-dialog> </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="editForm" :model="formEdit" :rules="rulesEdit" label-width="90px"> <el-form ref="editForm" :model="formEdit" :rules="rulesEdit" label-width="90px">
<el-input v-model="formEdit.id" style="display: none" /> <el-form-item label="分组ID">
<el-form-item label="分组名称" prop="groupName" style="width: 90%"> <span>{{ formEdit.id }}</span>
<el-input v-model="formEdit.groupName" maxlength="30" placeholder="请输入分组名称" />
</el-form-item> </el-form-item>
<el-form-item label="分组描述" prop="description" style="width: 90%"> <el-form-item label="分组名称" prop="groupName">
<el-input v-model="formEdit.description" maxlength="200" placeholder="请输入分组描述" /> <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-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="editFlag = false">取消</el-button> <el-button @click="editFlag = false">取消</el-button>
<el-button type="primary" :loading="submitEditLoading" @click="submitEdit">确定</el-button> <el-button type="primary" :loading="submitEditLoading" @click="submitEdit">
确定
</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
@@ -72,11 +189,21 @@
<script setup> <script setup>
import { nextTick, onMounted, reactive, ref } from "vue"; import { nextTick, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import * as API_Member from "@/api/member.js"; import * as API_Member from "@/api/member.js";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
defineOptions({ name: "memberGroup" }); defineOptions({ name: "memberGroup" });
const router = useRouter();
function viewGroupMembers(row) {
router.push({
name: "memberList",
query: { groupId: row.id },
});
}
const addForm = ref(null); const addForm = ref(null);
const editForm = ref(null); const editForm = ref(null);
@@ -84,6 +211,7 @@ const loading = ref(true);
const searchForm = reactive({ const searchForm = reactive({
pageNumber: 1, pageNumber: 1,
pageSize: 20, pageSize: 20,
groupName: "",
}); });
const data = ref([]); const data = ref([]);
const total = ref(0); const total = ref(0);
@@ -91,44 +219,81 @@ const addFlag = ref(false);
const editFlag = ref(false); const editFlag = ref(false);
const submitAddLoading = ref(false); const submitAddLoading = ref(false);
const submitEditLoading = ref(false); const submitEditLoading = ref(false);
const formAdd = ref({ const formAdd = ref({
groupName: "", groupName: "",
description: "", description: "",
}); });
const formEdit = ref({ const formEdit = ref({
id: "", id: "",
groupName: "", groupName: "",
description: "", description: "",
}); });
const rulesAdd = reactive({ const rulesAdd = reactive({
groupName: [{ required: true, message: "请输入分组名称", trigger: "blur" }], groupName: [{ required: true, message: "请输入分组名称", trigger: "blur" }],
}); });
const rulesEdit = reactive({ const rulesEdit = reactive({
groupName: [{ required: true, message: "请输入分组名称", trigger: "blur" }], groupName: [{ required: true, message: "请输入分组名称", trigger: "blur" }],
}); });
function init() { function handleSearch() {
searchForm.pageNumber = 1;
getData(); getData();
} }
function handleReset() {
searchForm.pageNumber = 1;
searchForm.groupName = "";
getData();
}
function changePage(v) { function changePage(v) {
searchForm.pageNumber = v; searchForm.pageNumber = v;
getData(); getData();
} }
function changePageSize(v) { function changePageSize(v) {
searchForm.pageNumber = 1; searchForm.pageNumber = 1;
searchForm.pageSize = v; searchForm.pageSize = v;
getData(); getData();
} }
function getData() { function getData() {
loading.value = true; loading.value = true;
API_Member.getMemberGroupByPage(searchForm).then((res) => { API_Member.getMemberGroupByPage(searchForm)
loading.value = false; .then((res) => {
if (res && res.success && res.result) { loading.value = false;
data.value = res.result.records || []; if (res && res.success && res.result) {
total.value = res.result.total || 0; const records = res.result.records || [];
} data.value = records;
}); total.value = res.result.total || 0;
// 异步获取各分组的会员总数
records.forEach((row) => {
if (row.memberCount == null) {
API_Member.getMemberGroupUsers(row.id, { pageNumber: 1, pageSize: 1 })
.then((userRes) => {
if (userRes && userRes.success && userRes.result) {
row.memberCount = userRes.result.total || 0;
} else {
row.memberCount = 0;
}
})
.catch(() => {
row.memberCount = 0;
});
}
});
}
})
.catch(() => {
loading.value = false;
});
} }
function openAdd() { function openAdd() {
addFlag.value = true; addFlag.value = true;
nextTick(() => { nextTick(() => {
@@ -136,20 +301,26 @@ function openAdd() {
formAdd.value = { groupName: "", description: "" }; formAdd.value = { groupName: "", description: "" };
}); });
} }
function submitAdd() { function submitAdd() {
addForm.value.validate((valid) => { addForm.value.validate((valid) => {
if (!valid) return; if (!valid) return;
submitAddLoading.value = true; submitAddLoading.value = true;
API_Member.addMemberGroup(formAdd.value).then((res) => { API_Member.addMemberGroup(formAdd.value)
submitAddLoading.value = false; .then((res) => {
if (res && res.success) { submitAddLoading.value = false;
ElMessage.success("添加成功"); if (res && res.success) {
addFlag.value = false; ElMessage.success("添加成功");
getData(); addFlag.value = false;
} getData();
}); }
})
.catch(() => {
submitAddLoading.value = false;
});
}); });
} }
function openEdit(row) { function openEdit(row) {
editFlag.value = true; editFlag.value = true;
submitEditLoading.value = false; submitEditLoading.value = false;
@@ -169,48 +340,120 @@ function openEdit(row) {
} }
}); });
} }
function submitEdit() { function submitEdit() {
editForm.value.validate((valid) => { editForm.value.validate((valid) => {
if (!valid) return; if (!valid) return;
submitEditLoading.value = true; submitEditLoading.value = true;
const { id, groupName, description } = formEdit.value; const { id, groupName, description } = formEdit.value;
API_Member.updateMemberGroup(id, { groupName, description }).then((res) => { API_Member.updateMemberGroup(id, { groupName, description })
submitEditLoading.value = false; .then((res) => {
if (res && res.success) { submitEditLoading.value = false;
ElMessage.success("修改成功"); if (res && res.success) {
editFlag.value = false; ElMessage.success("修改成功");
getData(); editFlag.value = false;
} getData();
}); }
})
.catch(() => {
submitEditLoading.value = false;
});
}); });
} }
function remove(row) { function remove(row) {
ElMessageBox.confirm("确定删除该分组?", "提示", { type: "warning" }).then(() => { ElMessageBox.confirm(`确定删除分组 "${row.groupName || "该分组"}" 吗?`, "删除确认", {
API_Member.deleteMemberGroup(row.id).then((res) => { type: "warning",
if (res && res.success) { confirmButtonText: "确定删除",
ElMessage.success("删除成功"); cancelButtonText: "取消",
getData(); })
} else if (res && res.message) { .then(() => {
ElMessage.error(res.message); API_Member.deleteMemberGroup(row.id).then((res) => {
} if (res && res.success) {
}); ElMessage.success("删除成功");
}).catch(() => {}); getData();
} else if (res && res.message) {
ElMessage.error(res.message);
}
});
})
.catch(() => {});
} }
onMounted(() => { onMounted(() => {
init(); getData();
}); });
</script> </script>
<style scoped> <style scoped lang="scss">
.ops a { .member-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;
}
}
.toolbar-right {
display: flex;
align-items: center;
}
}
.card-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
.header-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
}
}
.link-text {
color: #2d8cf0; color: #2d8cf0;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
} font-size: 14px;
.ops span { line-height: 1;
display: inline-block; display: inline-block;
&:hover {
color: #57a3f3;
}
&.error-text {
color: #ed4014;
&:hover {
color: #f16643;
}
}
}
.op-split {
margin: 0 8px; margin: 0 8px;
color: #dcdee2; color: #dcdee2;
font-size: 14px;
} }
</style> </style>

View File

@@ -48,6 +48,11 @@
</el-card> </el-card>
<el-card> <el-card>
<el-tabs v-if="!selectedMember" v-model="memberStatus" @tab-change="handleStatusChange">
<el-tab-pane label="全部" name="ALL" />
<el-tab-pane label="启用" name="OPEN" />
<el-tab-pane label="禁用" name="CLOSE" />
</el-tabs>
<div v-if="!selectedMember" class="operation padding-row"> <div v-if="!selectedMember" class="operation padding-row">
<el-button type="primary" @click="addMember">添加会员</el-button> <el-button type="primary" @click="addMember">添加会员</el-button>
<el-button <el-button
@@ -88,6 +93,11 @@
</el-table-column> </el-table-column>
<el-table-column prop="username" label="客户名称" min-width="150" show-overflow-tooltip /> <el-table-column prop="username" label="客户名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="nickName" label="会员昵称" min-width="120" show-overflow-tooltip /> <el-table-column prop="nickName" label="会员昵称" min-width="120" show-overflow-tooltip />
<el-table-column v-if="!selectedMember" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.disabled ? 'success' : 'info'">{{ row.disabled ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="联系方式" min-width="130"> <el-table-column label="联系方式" min-width="130">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row">{{ row.mobile || "" }}</span> <span v-if="row">{{ row.mobile || "" }}</span>
@@ -142,7 +152,7 @@
<el-dropdown-item command="edit">编辑会员</el-dropdown-item> <el-dropdown-item command="edit">编辑会员</el-dropdown-item>
<el-dropdown-item command="increaseWallet">增加余额</el-dropdown-item> <el-dropdown-item command="increaseWallet">增加余额</el-dropdown-item>
<el-dropdown-item command="updatePoint">修改积分</el-dropdown-item> <el-dropdown-item command="updatePoint">修改积分</el-dropdown-item>
<el-dropdown-item command="disabled">禁用会员</el-dropdown-item> <el-dropdown-item command="disabled">{{ row.disabled ? '禁用会员' : '启用会员' }}</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
@@ -308,6 +318,7 @@
<script setup> <script setup>
import { ref, watch, nextTick, onMounted } from "vue"; import { ref, watch, nextTick, onMounted } from "vue";
import { useRoute } from "vue-router";
import { ArrowDown } from "@element-plus/icons-vue"; import { ArrowDown } from "@element-plus/icons-vue";
import multipleMap from "@/components/map/multiple-map"; import multipleMap from "@/components/map/multiple-map";
import * as API_Member from "@/api/member.js"; import * as API_Member from "@/api/member.js";
@@ -320,6 +331,8 @@ import defaultPic from "@/assets/default.png";
defineOptions({ name: "member" }); defineOptions({ name: "member" });
const route = useRoute();
const props = defineProps({ const props = defineProps({
selectedMember: { selectedMember: {
type: Boolean, type: Boolean,
@@ -356,6 +369,7 @@ const addMemberForm = ref({
username: "", username: "",
password: "", password: "",
}); });
const memberStatus = ref("ALL");
const searchForm = ref({ const searchForm = ref({
pageNumber: 1, pageNumber: 1,
pageSize: 20, pageSize: 20,
@@ -363,7 +377,7 @@ const searchForm = ref({
id: "", id: "",
username: "", username: "",
mobile: "", mobile: "",
disabled: "OPEN", disabled: props.selectedMember ? "OPEN" : "",
groupId: "", groupId: "",
}); });
const picModelFlag = ref(false); const picModelFlag = ref(false);
@@ -511,7 +525,15 @@ function submitSetMemberGroup() {
const groupId = memberGroupForm.value.groupId; const groupId = memberGroupForm.value.groupId;
memberGroupLoading.value = true; memberGroupLoading.value = true;
Promise.all( Promise.all(
memberIds.map((memberId) => API_Member.setMemberUserGroups(memberId, [groupId])) memberIds.map((memberId) =>
API_Member.setMemberUserGroups(memberId, [groupId]).then((res) => {
if (!res || !res.success) {
// 后端 updateUserGroups 若因原无分组返回失败,自动降级尝试 addMemberGroupUsers
return API_Member.addMemberGroupUsers(groupId, [memberId]);
}
return res;
})
)
) )
.then((results) => { .then((results) => {
memberGroupLoading.value = false; memberGroupLoading.value = false;
@@ -575,6 +597,11 @@ function handleSearch() {
searchForm.value.pageSize = 20; searchForm.value.pageSize = 20;
getData(); getData();
} }
function handleStatusChange(status) {
searchForm.value.disabled = status === "ALL" ? "" : status;
searchForm.value.pageNumber = 1;
getData();
}
function editPerm(val) { function editPerm(val) {
descTitle.value = `查看用户 ${val.username}`; descTitle.value = `查看用户 ${val.username}`;
descFlag.value = true; descFlag.value = true;
@@ -598,20 +625,27 @@ function getMemberInfo(id) {
} }
}); });
} }
let memberListRequestId = 0;
function getData() { function getData() {
const requestId = ++memberListRequestId;
loading.value = true; loading.value = true;
if (!props.selectedMember) { if (!props.selectedMember) {
selectedRows.value = []; selectedRows.value = [];
clearSelection(); clearSelection();
} }
API_Member.getMemberListData(searchForm.value).then((res) => { API_Member.getMemberListData({ ...searchForm.value })
if (res.result.records) { .then((res) => {
loading.value = false; if (requestId !== memberListRequestId) return;
init(res.result.records); if (res.success && res.result) {
total.value = res.result.total; init(res.result.records || []);
syncTableSelection(); total.value = res.result.total || 0;
} syncTableSelection();
}); }
})
.catch(() => {})
.finally(() => {
if (requestId === memberListRequestId) loading.value = false;
});
} }
function openPicSelector() { function openPicSelector() {
picModelFlag.value = true; picModelFlag.value = true;
@@ -655,14 +689,15 @@ function detail(row) {
customRouterPush({ name: "member-detail", query: { id: row.id } }); customRouterPush({ name: "member-detail", query: { id: row.id } });
} }
function disabled(v) { function disabled(v) {
const action = v.disabled ? "禁用" : "启用";
let params = { let params = {
memberIds: [v.id], memberIds: [v.id],
disabled: false, disabled: !v.disabled,
}; };
ElMessageBox.confirm("确认禁用此会员?", "提示", { type: "warning" }).then(() => { ElMessageBox.confirm(`确认${action}此会员?`, "提示", { type: "warning" }).then(() => {
API_Member.updateMemberStatus(params).then((res) => { API_Member.updateMemberStatus(params).then((res) => {
if (res.success) { if (res.success) {
ElMessage.success("禁用成功"); ElMessage.success(`${action}成功`);
getData(); getData();
} }
}); });
@@ -758,11 +793,25 @@ function handleSubmitModal() {
} }
onMounted(() => { onMounted(() => {
if (route.query && route.query.groupId) {
searchForm.value.groupId = String(route.query.groupId);
}
getData(); getData();
loadMemberGroupList(); loadMemberGroupList();
loadMemberGradeList(); loadMemberGradeList();
}); });
watch(
() => route.query.groupId,
(val) => {
if (val !== undefined) {
searchForm.value.groupId = val ? String(val) : "";
searchForm.value.pageNumber = 1;
getData();
}
}
);
// 外部通过 ref 调用(promotions/coupon-activity/coupon-publish.vue 的 memberLayoutRef.getSelection()) // 外部通过 ref 调用(promotions/coupon-activity/coupon-publish.vue 的 memberLayoutRef.getSelection())
defineExpose({ getSelection }); defineExpose({ getSelection });
</script> </script>

View File

@@ -1,323 +0,0 @@
<template>
<div class="search">
<el-card>
<el-form
ref="searchFormRef"
:model="searchForm"
inline
label-width="70px"
class="search-form"
@keyup.enter="handleSearch"
>
<el-form-item label="客户ID" prop="id">
<el-input
v-model="searchForm.id"
placeholder="请输入客户ID"
clearable
style="width: 240px"
/>
</el-form-item>
<el-form-item label="客户名称" prop="username">
<el-input
v-model="searchForm.username"
placeholder="请输入客户名称"
clearable
style="width: 240px"
/>
</el-form-item>
<el-form-item label="联系方式" prop="mobile">
<el-input
v-model="searchForm.mobile"
placeholder="请输入会员联系方式"
clearable
style="width: 240px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<el-table
ref="table"
v-loading="loading"
border
:data="data"
class="mt_10"
style="width: 100%"
>
<el-table-column prop="id" label="客户ID" min-width="120" show-overflow-tooltip />
<el-table-column prop="username" label="客户名称" min-width="150" align="left" show-overflow-tooltip />
<el-table-column prop="nickName" label="昵称" min-width="120" align="left" show-overflow-tooltip />
<el-table-column label="联系方式" min-width="130">
<template #default="{ row }">
<span v-if="row">{{ row.mobile || "" }}</span>
</template>
</el-table-column>
<el-table-column prop="createTime" label="注册时间" min-width="180" />
<el-table-column label="积分数量" min-width="120" align="left">
<template #default="{ row }">
<span v-if="row">{{ row.point == void 0 ? "0" : row.point }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<div v-if="row" class="ops" style="display: flex; justify-content: center">
<template v-if="selectedMember">
<a class="link-text" @click="callback(row)">选择</a>
<span class="op-split">|</span>
</template>
<a class="link-text" @click="detail(row)">查看</a>
<template v-if="!selectedMember">
<span class="op-split">|</span>
<a class="link-text" @click="enable(row)">启用</a>
<span class="op-split">|</span>
<a class="link-text" @click="editPerm(row)">编辑</a>
</template>
</div>
</template>
</el-table-column>
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
:page-sizes="[20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
size="small"
@current-change="changePage"
@size-change="changePageSize"
/>
</div>
</el-card>
<el-dialog v-model="descFlag" :title="descTitle" width="500px" destroy-on-close>
<el-form ref="formValidateRef" :model="formValidate" :rules="ruleValidate" label-width="80px">
<el-form-item label="头像">
<img alt="会员头像" :src="formValidate.face" class="face" />
<el-button
type="primary"
link
class="upload"
@click="openPicSelector"
>修改</el-button>
<input type="file" style="display: none" id="file" />
</el-form-item>
<el-form-item label="客户名称" prop="name">
<el-input v-model="formValidate.username" style="width: 200px" disabled />
</el-form-item>
<el-form-item label="用户昵称" prop="name">
<el-input v-model="formValidate.nickName" style="width: 200px" />
</el-form-item>
<el-form-item label="性别" prop="sex">
<el-radio-group v-model="formValidate.sex">
<el-radio-button :value="1">男</el-radio-button>
<el-radio-button :value="0">女</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="修改密码" prop="password">
<el-input v-model="formValidate.newPassword" type="password" show-password style="width: 220px" />
</el-form-item>
<el-form-item label="生日" prop="birthday">
<el-date-picker
v-model="formValidate.birthday"
type="date"
value-format="YYYY-MM-DD"
style="width: 220px"
/>
</el-form-item>
<el-form-item label="所在地" prop="mail">
{{ formValidate.region || "暂无地址" }}
<el-button style="margin-left: 10px" @click="$refs.map.open()">选择</el-button>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="descFlag = false">取消</el-button>
<el-button type="primary" @click="handleSubmitModal">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="picModelFlag" width="1200px">
<ossManage
ref="ossManageRef"
@callback="callbackSelected"
:isComponent="true"
:initialize="picModelFlag"
/>
</el-dialog>
<multipleMap ref="map" @callback="selectedRegion" />
</div>
</template>
<script setup>
import { ref, nextTick, onMounted } from "vue";
import * as API_Member from "@/api/member.js";
import ossManage from "@/views/sys/oss-manage/ossManage";
import multipleMap from "@/components/map/multiple-map";
import { ElMessage, ElMessageBox } from "element-plus";
import { customRouterPush } from "@/utils/filters";
import { md5 } from "@/utils/md5.js";
defineOptions({ name: "memberRecycle" });
const emit = defineEmits(["callback"]);
// 模板 el-form 的 ref(原 ref="searchForm",与 data 同名会互相覆盖,改名为 searchFormRef)
const searchFormRef = ref(null);
const table = ref(null);
// 模板编辑弹窗 el-form 的 ref(原 ref="formValidate",与 data 同名会互相覆盖,改名为 formValidateRef)
const formValidateRef = ref(null);
// 模板 ossManage 组件的 ref(原 ref="ossManage",与组件 import 同名会冲突,改名为 ossManageRef)
const ossManageRef = ref(null);
const selectedMember = ref(false);
const descTitle = ref("");
const descFlag = ref(false);
const openSearch = ref(true);
const loading = ref(true);
const searchForm = ref({
pageNumber: 1,
pageSize: 20,
order: "desc",
id: "",
username: "",
mobile: "",
disabled: "CLOSE",
});
const picModelFlag = ref(false);
const formValidate = ref({});
const ruleValidate = ref({});
const data = ref([]);
const total = ref(0);
function callback(val) {
emit("callback", val);
}
function init() {
getData();
}
function changePage() {
getData();
}
function changePageSize() {
searchForm.value.pageNumber = 1;
getData();
}
function handleSearch() {
searchForm.value.pageNumber = 1;
searchForm.value.pageSize = 20;
getData();
}
function editPerm(val) {
descTitle.value = `查看用户 ${val.username}`;
descFlag.value = true;
getMemberInfo(val.id);
}
function getMemberInfo(id) {
API_Member.getMemberInfoData(id).then((res) => {
if (res.result) {
formValidate.value = res.result;
}
});
}
function getData() {
loading.value = true;
API_Member.getMemberListData(searchForm.value).then((res) => {
loading.value = false;
if (res.success) {
data.value = res.result.records;
total.value = res.result.total;
}
});
}
function openPicSelector() {
picModelFlag.value = true;
nextTick(() => {
if (ossManageRef.value) {
ossManageRef.value.selectImage = true;
}
});
}
function callbackSelected(val) {
picModelFlag.value = false;
formValidate.value.face = val.url;
}
function selectedRegion(val) {
if (val.type === "select") {
const paths = val.data.map((item) => item.name).join(",");
const ids = val.data.map((item) => item.id).join(",");
formValidate.value.region = paths;
formValidate.value.regionId = ids;
} else {
formValidate.value.region = val.data.addr;
formValidate.value.regionId = val.data.addrId;
}
}
function detail(row) {
customRouterPush({ name: "member-detail", query: { id: row.id } });
}
function enable(v) {
let params = {
memberIds: [v.id],
disabled: true,
};
ElMessageBox.confirm("确定启用此会员?", "提示", { type: "warning" }).then(() => {
API_Member.updateMemberStatus(params).then((res) => {
if (res.success) {
ElMessage.success("启用成功");
getData();
}
});
}).catch(() => {});
}
function handleSubmitModal() {
const { nickName, sex, username, face, newPassword, id, regionId, region } = formValidate.value;
let time = new Date(formValidate.value.birthday);
let birthday = time.getFullYear() + "-" + (time.getMonth() + 1) + "-" + time.getDate();
let submit = {
regionId: regionId,
region: region,
nickName,
sex,
birthday,
face: face || "",
id,
};
if (newPassword) {
// 原 this.md5 为 main.js 注册的全局属性,改为具名导入(同 login.vue / userManage.vue)
submit.password = md5(newPassword);
}
API_Member.updateMember(submit).then((res) => {
if (res.result) {
ElMessage.success("修改成功!");
descFlag.value = false;
init();
}
});
}
onMounted(() => {
init();
});
</script>
<style lang="scss" scoped>
.face {
width: 60px;
height: 60px;
border-radius: 50%;
}
.ops a {
color: #2d8cf0;
cursor: pointer;
text-decoration: none;
}
.ops span {
display: inline-block;
margin: 0 8px;
color: #dcdee2;
}
</style>

View File

@@ -1,283 +0,0 @@
<template>
<div>
<el-dialog v-model="wechatModal" title="微信设置" width="530px">
<el-form ref="wechatFormRef" :model="wechatFormData" label-width="100px">
<el-form-item v-if="tab === 'WECHAT'" label="模板名称">
<el-input v-model="wechatFormData.name" maxlength="9" disabled />
</el-form-item>
<el-form-item v-if="tab === 'WECHAT'" label="头部信息" prop="first">
<el-input v-model="wechatFormData.first" maxlength="50" />
</el-form-item>
<el-form-item v-if="tab === 'WECHAT'" label="备注" prop="remark">
<el-input v-model="wechatFormData.remark" type="textarea" :rows="5" maxlength="150" />
</el-form-item>
<el-form-item label="是否开启" prop="enable">
<el-switch v-model="wechatFormData.enable" active-text="开启" inactive-text="关闭" />
</el-form-item>
</el-form>
<template #footer>
<el-button v-if="tab === 'WECHAT'" type="primary" @click="wechatFormDataEdit">保存</el-button>
<el-button v-else type="primary" @click="wechatMPFormDataEdit">保存</el-button>
</template>
</el-dialog>
<el-card>
<el-tabs v-model="tab" @tab-click="tabPaneChange">
<el-tab-pane label="微信消息" name="WECHAT">
<div class="search">
<div class="operation mt_10">
<el-button type="primary" @click="weChatSync">初始化微信消息</el-button>
</div>
<el-table v-loading="loading" border :data="weChatData" ref="weChatTable" style="width: 100%">
<el-table-column prop="code" label="模板编号" width="500" sortable />
<el-table-column label="是否开启" width="150" sortable>
<template #default="{ row }">
<span v-if="row">{{ row.enable ? "开启" : "关闭" }}</span>
</template>
</el-table-column>
<el-table-column prop="name" label="模板名称" width="200" sortable />
<el-table-column prop="createTime" label="创建时间" sortable />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row">
<a class="link-text" @click="wechatSettingAlert(row)">编辑</a>
<span class="op-split">|</span>
<a class="link-text" @click="delWeChat(row)">删除</a>
</template>
</template>
</el-table-column>
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="weChatSearchForm.pageNumber"
v-model:page-size="weChatSearchForm.pageSize"
:page-sizes="[20, 50, 100]"
:total="weChatTotal"
layout="total, sizes, prev, pager, next"
size="small"
@current-change="changePage"
@size-change="changePageSize"
/>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="微信小程序订阅消息" name="WECHATMP">
<div class="search">
<div class="operation mt_10">
<el-button type="primary" @click="weChatSync('mp')">初始化微信小程序订阅消息</el-button>
</div>
<el-table v-loading="loading" border :data="weChatMPData" ref="weChatMPTable" style="width: 100%">
<el-table-column prop="code" label="模板编号" width="500" sortable />
<el-table-column label="是否开启" width="150" sortable>
<template #default="{ row }">
<span v-if="row">{{ row.enable ? "开启" : "关闭" }}</span>
</template>
</el-table-column>
<el-table-column prop="name" label="模板名称" width="200" sortable />
<el-table-column prop="createTime" label="创建时间" sortable />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row">
<a class="link-text" @click="wechatSettingAlert(row)">编辑</a>
<span class="op-split">|</span>
<a class="link-text" @click="delWeChat(row)">删除</a>
</template>
</template>
</el-table-column>
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="weChatMPSearchForm.pageNumber"
v-model:page-size="weChatMPSearchForm.pageSize"
:page-sizes="[20, 50, 100]"
:total="weChatMPTotal"
layout="total, sizes, prev, pager, next"
size="small"
@current-change="changePage"
@size-change="changePageSize"
/>
</div>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import {
wechatMessageSync,
getWechatMessagePage as fetchWechatMessagePage,
editWechatMessageTemplate,
delWechatMessageTemplate,
wechatMPMessageSync,
getWechatMPMessagePage as fetchWechatMPMessagePage,
editWechatMPMessageTemplate,
delWechatMPMessageTemplate,
} from "@/api/setting";
import { ElMessage, ElMessageBox } from "element-plus";
defineOptions({ title: "wechat-message-manage" });
const wechatFormRef = ref(null);
const wechatModal = ref(false);
const wechatFormData = ref({});
const wechatMPFormData = ref({});
const tab = ref("WECHAT");
const searchForm = reactive({ type: "WECHAT" });
const loading = ref(true);
const id = ref("");
const weChatSearchForm = reactive({ pageNumber: 1, pageSize: 20 });
const weChatMPSearchForm = reactive({ pageNumber: 1, pageSize: 20 });
const weChatData = ref([]);
const weChatMPData = ref([]);
const weChatTotal = ref(0);
const weChatMPTotal = ref(0);
onMounted(() => {
init();
});
function init() {
getDataList();
}
function changePage(v) {
searchForm.type = tab.value;
if (tab.value === "WECHAT") {
weChatSearchForm.pageNumber = v;
getWechatMessagePage();
} else {
weChatMPSearchForm.pageNumber = v;
getWechatMPMessagePage();
}
}
function changePageSize(v) {
searchForm.type = tab.value;
if (tab.value === "WECHAT") {
weChatSearchForm.pageSize = v;
getWechatMessagePage();
} else {
weChatMPSearchForm.pageSize = v;
getWechatMPMessagePage();
}
}
function wechatSettingAlert(v) {
wechatFormData.value = v;
id.value = v.id;
wechatModal.value = true;
}
function weChatSync(mp) {
ElMessageBox.confirm("确认要初始化微信小程序消息订阅?", "提示", { type: "warning" }).then(() => {
if (mp === "mp") {
wechatMPMessageSync().then((res) => {
if (res.success) {
ElMessage.success("微信小程序消息订阅初始化");
getWechatMPMessagePage();
}
});
} else {
wechatMessageSync().then((res) => {
if (res.success) {
ElMessage.success("微信消息模板初始化成功");
getWechatMessagePage();
}
});
}
}).catch(() => {});
}
function wechatFormDataEdit() {
wechatFormRef.value.validate((valid) => {
if (valid) {
if (!wechatFormData.value.updateTime) {
wechatFormData.value.updateTime = "";
}
editWechatMessageTemplate(id.value, wechatFormData.value).then((res) => {
if (res.message === "success") {
ElMessage.success("微信模板修改成功");
wechatModal.value = false;
getWechatMessagePage();
}
});
}
});
}
function wechatMPFormDataEdit() {
wechatFormRef.value.validate((valid) => {
if (valid) {
editWechatMPMessageTemplate(id.value, wechatMPFormData.value).then((res) => {
if (res.message === "success") {
ElMessage.success("微信消息订阅模板修改成功");
wechatModal.value = false;
getWechatMPMessagePage();
}
});
}
});
}
function delWeChat(v) {
ElMessageBox.confirm("确定删除此模板?", "提示", { type: "warning" }).then(() => {
if (tab.value === "WECHAT") {
delWechatMessageTemplate(v.id).then((res) => {
if (res.success) {
ElMessage.success("微信模板删除成功");
getWechatMessagePage();
}
});
} else {
delWechatMPMessageTemplate(v.id).then((res) => {
if (res.success) {
ElMessage.success("微信消息订阅删除成功");
getWechatMPMessagePage();
}
});
}
}).catch(() => {});
}
function getDataList() {
getWechatMessagePage();
}
function getWechatMessagePage() {
loading.value = true;
fetchWechatMessagePage(weChatSearchForm).then((res) => {
loading.value = false;
if (res.success) {
weChatData.value = res.result.records;
weChatTotal.value = res.result.total;
}
});
}
function getWechatMPMessagePage() {
loading.value = true;
fetchWechatMPMessagePage(weChatMPSearchForm).then((res) => {
loading.value = false;
if (res.success) {
weChatMPData.value = res.result.records;
weChatMPTotal.value = res.result.total;
}
});
}
function tabPaneChange(tabPane) {
const v = tabPane.paneName || tab.value;
searchForm.type = v;
if (v === "WECHAT") {
getWechatMessagePage();
} else if (v === "WECHATMP") {
getWechatMPMessagePage();
}
}
</script>
<style scoped>
.link-text {
color: #2d8cf0;
cursor: pointer;
text-decoration: none;
}
.op-split {
display: inline-block;
margin: 0 8px;
color: #dcdee2;
}
</style>

View File

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

View File

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

View File

@@ -1,37 +1,54 @@
<template> <template>
<div class="search"> <div class="member-point-page search">
<el-card class="points-statistics-card"> <!-- 顶部数据统计概览看板 -->
<el-row type="flex" justify="space-around" align="middle" class="points-statistics"> <el-card shadow="never" class="points-statistics-card">
<el-row :gutter="24" class="points-statistics">
<el-col :xs="24" :sm="12" class="points-statistics-item"> <el-col :xs="24" :sm="12" class="points-statistics-item">
<div class="points-statistics-title">已发放积分数</div> <div class="stat-inner-box">
<div class="points-statistics-subtitle">历史累计发放积分数</div> <div class="points-statistics-title">已发放积分数</div>
<div class="points-statistics-value">{{ formatNumber(pointsStatistics.totalPoint) }}</div> <div class="points-statistics-subtitle">平台历史累计发放积分总量</div>
<div class="points-statistics-value">{{ formatNumber(pointsStatistics.totalPoint) }}</div>
</div>
</el-col> </el-col>
<el-col :xs="24" :sm="12" class="points-statistics-item"> <el-col :xs="24" :sm="12" class="points-statistics-item">
<div class="points-statistics-title">未使用积分数</div> <div class="stat-inner-box">
<div class="points-statistics-subtitle">会员账户未使用积分数</div> <div class="points-statistics-title">未使用积分数</div>
<div class="points-statistics-value">{{ formatNumber(pointsStatistics.unUsedPoint) }}</div> <div class="points-statistics-subtitle">会员账户当前剩余未使用积分总量</div>
<div class="points-statistics-value text-warning">{{ formatNumber(pointsStatistics.unUsedPoint) }}</div>
</div>
</el-col> </el-col>
</el-row> </el-row>
</el-card> </el-card>
<!-- 选项卡与数据主区域 -->
<div class="point-tabs-wrap"> <div class="point-tabs-wrap">
<el-tabs v-model="activeTab" class="point-tabs"> <el-tabs v-model="activeTab" class="point-tabs">
<el-tab-pane label="积分列表" name="pointList"> <!-- 选项卡 1:会员积分列表 -->
<el-card class="point-content-card"> <el-tab-pane label="会员积分列表" name="pointList">
<!-- 筛选条件 -->
<el-card shadow="never" class="point-filter-card">
<el-form <el-form
ref="memberSearchFormRef" ref="memberSearchFormRef"
:model="memberSearchForm" :model="memberSearchForm"
inline inline
label-width="70px" label-width="76px"
class="search-form" class="search-form"
@keyup.enter="handleMemberSearch" @keyup.enter="handleMemberSearch"
> >
<el-form-item label="客户ID" prop="id">
<el-input
v-model="memberSearchForm.id"
placeholder="请输入客户ID"
clearable
style="width: 190px"
/>
</el-form-item>
<el-form-item label="客户名称" prop="nickName"> <el-form-item label="客户名称" prop="nickName">
<el-input <el-input
v-model="memberSearchForm.nickName" v-model="memberSearchForm.nickName"
placeholder="请输入客户名称" placeholder="请输入客户名称"
clearable clearable
style="width: 180px" style="width: 160px"
/> />
</el-form-item> </el-form-item>
<el-form-item label="客户账号" prop="username"> <el-form-item label="客户账号" prop="username">
@@ -39,82 +56,128 @@
v-model="memberSearchForm.username" v-model="memberSearchForm.username"
placeholder="请输入客户账号" placeholder="请输入客户账号"
clearable clearable
style="width: 180px" style="width: 160px"
/> />
</el-form-item> </el-form-item>
<el-form-item label="账号状态" prop="disabled"> <el-form-item label="账号状态" prop="disabled">
<el-select <el-select
v-model="memberSearchForm.disabled" v-model="memberSearchForm.disabled"
clearable clearable
placeholder="请选择账号状态" placeholder="全部状态"
style="width: 160px" style="width: 130px"
> >
<el-option label="全部" value="" />
<el-option label="启用" value="OPEN" /> <el-option label="启用" value="OPEN" />
<el-option label="禁用" value="CLOSE" /> <el-option label="禁用" value="CLOSE" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="积分值"> <el-form-item label="积分区间">
<el-input-number <el-input-number
v-model="memberSearchForm.minPoint" v-model="memberSearchForm.minPoint"
:min="0" :min="0"
:precision="0" :precision="0"
placeholder="最小积分值" placeholder="最小"
style="width: 140px" controls-position="right"
style="width: 120px"
/> />
<span class="point-range-separator">-</span> <span class="point-range-separator">-</span>
<el-input-number <el-input-number
v-model="memberSearchForm.maxPoint" v-model="memberSearchForm.maxPoint"
:min="0" :min="0"
:precision="0" :precision="0"
placeholder="最大积分值" placeholder="最大"
style="width: 140px" controls-position="right"
style="width: 120px"
/> />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item class="form-actions">
<el-button class="search-btn" type="primary" @click="handleMemberSearch">搜索</el-button> <el-button class="search-btn" type="primary" @click="handleMemberSearch">
搜索
</el-button>
<el-button @click="handleMemberReset">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
<el-card class="point-content-card member-list-card">
<!-- 会员积分表格 -->
<el-card shadow="never" class="point-content-card member-list-card mt_10">
<template #header> <template #header>
<div class="card-title">用户列表</div> <div class="card-header-bar">
<div class="header-title">
<span>会员积分信息</span>
<span class="total-badge">共 {{ memberTotal }} 位会员</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getMemberList">刷新</el-button>
</div>
</div>
</template> </template>
<el-table <el-table
v-loading="memberLoading" v-loading="memberLoading"
border border
stripe
:data="memberData" :data="memberData"
class="member-table" class="custom-point-table"
style="width: 100%" style="width: 100%"
> >
<!-- 客户ID -->
<el-table-column label="客户ID" min-width="170">
<template #default="{ row }">
<span v-if="row" class="font-mono text-dark">{{ row.id || "-" }}</span>
</template>
</el-table-column>
<!-- 客户名称 -->
<el-table-column label="客户名称" min-width="160" show-overflow-tooltip> <el-table-column label="客户名称" min-width="160" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row">{{ row.nickName || "-" }}</span> <span v-if="row" class="font-semibold text-dark">{{ row.nickName || "-" }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="客户账号" min-width="260" show-overflow-tooltip>
<!-- 客户账号 -->
<el-table-column label="客户账号" min-width="180" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row">{{ row.username || "-" }}</span> <span v-if="row" class="font-mono">{{ row.username || "-" }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="账号状态" min-width="120" align="center">
<!-- 账号状态 -->
<el-table-column label="账号状态" width="110" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="row" :type="row.disabled === true ? 'success' : 'info'"> <el-tag
v-if="row"
:type="row.disabled === true ? 'success' : 'info'"
size="small"
>
{{ row.disabled === true ? "启用" : "禁用" }} {{ row.disabled === true ? "启用" : "禁用" }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="积分余额" min-width="120" align="center">
<!-- 积分余额 -->
<el-table-column label="积分余额" width="130" align="right">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row">{{ row.point == null ? 0 : row.point }}</span> <span v-if="row" class="point-num-text">
{{ formatNumber(row.point == null ? 0 : row.point) }}
</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="120" align="center" fixed="right">
<!-- 操作 -->
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<a v-if="row" class="link-text" @click="detail(row)">详情</a> <template v-if="row">
<a class="link-text" @click="detail(row)">客户详情</a>
<span class="op-split">|</span>
<a class="link-text" @click="viewMemberHistory(row)">变动明细</a>
</template>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 分页 -->
<div class="pagination-wrap mt_10">
<el-pagination <el-pagination
v-model:current-page="memberSearchForm.pageNumber" v-model:current-page="memberSearchForm.pageNumber"
v-model:page-size="memberSearchForm.pageSize" v-model:page-size="memberSearchForm.pageSize"
@@ -128,63 +191,140 @@
</div> </div>
</el-card> </el-card>
</el-tab-pane> </el-tab-pane>
<!-- 选项卡 2:积分增减记录 -->
<el-tab-pane label="积分增减记录" name="pointChangeRecord"> <el-tab-pane label="积分增减记录" name="pointChangeRecord">
<el-card class="point-content-card"> <!-- 筛选条件 -->
<el-card shadow="never" class="point-filter-card">
<el-form <el-form
ref="searchFormRef" ref="searchFormRef"
:model="searchForm" :model="searchForm"
inline inline
label-width="70px" label-width="76px"
class="search-form" class="search-form"
@keyup.enter="handleSearch" @keyup.enter="handleSearch"
> >
<el-form-item label="会员名称" prop="username"> <el-form-item label="客户ID" prop="memberId">
<el-input
v-model="searchForm.memberId"
placeholder="请输入客户ID"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="会员名称" prop="memberName">
<el-input <el-input
v-model="searchForm.memberName" v-model="searchForm.memberName"
placeholder="请输入会员名称" placeholder="请输入会员名称"
clearable clearable
style="width: 240px" style="width: 180px"
/> />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item class="form-actions">
<el-button class="search-btn" type="primary" @click="handleSearch">搜索</el-button> <el-button class="search-btn" type="primary" @click="handleSearch">
搜索
</el-button>
<el-button @click="handleHistoryReset">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
<el-card class="point-content-card">
<!-- 积分记录表格 -->
<el-card shadow="never" class="point-content-card mt_10">
<template #header>
<div class="card-header-bar">
<div class="header-title">
<span>积分流水记录</span>
<span class="total-badge">共 {{ total }} 条明细</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getData">刷新</el-button>
</div>
</div>
</template>
<el-table <el-table
v-loading="loading" v-loading="loading"
border border
stripe
:data="data" :data="data"
ref="table" ref="table"
class="mt_10 point-table" class="custom-point-table"
style="width: 100%" style="width: 100%"
> >
<el-table-column prop="memberName" label="会员名称" min-width="120" show-overflow-tooltip /> <!-- 客户ID -->
<el-table-column prop="content" label="操作内容" min-width="200" show-overflow-tooltip /> <el-table-column label="客户ID" min-width="170">
<el-table-column prop="beforePoint" label="之前积分" width="200" /> <template #default="{ row }">
<el-table-column label="变动积分" width="200"> <span v-if="row" class="font-mono text-dark">{{ row.memberId || "-" }}</span>
</template>
</el-table-column>
<!-- 会员名称 -->
<el-table-column prop="memberName" label="会员名称" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row" class="font-semibold text-dark">{{ row.memberName || "-" }}</span>
</template>
</el-table-column>
<!-- 操作内容/原因 -->
<el-table-column prop="content" label="操作内容" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row ? row.content || "-" : "-" }}</span>
</template>
</el-table-column>
<!-- 变动前积分 -->
<el-table-column label="变动前积分" width="130" align="right">
<template #default="{ row }">
<span v-if="row">{{ formatNumber(row.beforePoint) }}</span>
</template>
</el-table-column>
<!-- 变动积分 -->
<el-table-column label="变动积分" width="130" align="right">
<template #default="{ row }"> <template #default="{ row }">
<template v-if="row"> <template v-if="row">
<priceColorScheme <el-tag
v-if="row.pointType === 'INCREASE'" v-if="row.pointType === 'INCREASE'"
:value="row.variablePoint" type="success"
color="green" size="small"
unit="+" effect="light"
/> class="point-change-tag"
<priceColorScheme >
+{{ formatNumber(row.variablePoint) }}
</el-tag>
<el-tag
v-else v-else
:value="row.variablePoint" type="danger"
:color="$mainColor" size="small"
unit="-" effect="light"
/> class="point-change-tag"
>
-{{ formatNumber(row.variablePoint) }}
</el-tag>
</template> </template>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="point" label="当前积分" width="200" />
<el-table-column prop="createTime" label="操作时间" width="200" /> <!-- 变动后积分 -->
<el-table-column label="变动后积分" width="130" align="right">
<template #default="{ row }">
<span v-if="row" class="point-num-text">
{{ formatNumber(row.point) }}
</span>
</template>
</el-table-column>
<!-- 操作时间 -->
<el-table-column prop="createTime" label="操作时间" width="170" align="center">
<template #default="{ row }">
<span v-if="row" class="time-text">{{ row.createTime }}</span>
</template>
</el-table-column>
</el-table> </el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 分页 -->
<div class="pagination-wrap mt_10">
<el-pagination <el-pagination
v-model:current-page="searchForm.pageNumber" v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize" v-model:page-size="searchForm.pageSize"
@@ -207,12 +347,12 @@
import { onMounted, reactive, ref } from "vue"; import { onMounted, reactive, ref } from "vue";
import * as API_Member from "@/api/member.js"; import * as API_Member from "@/api/member.js";
import { customRouterPush } from "@/utils/filters"; import { customRouterPush } from "@/utils/filters";
import { ElMessage } from "element-plus";
defineOptions({ name: "point" }); defineOptions({ name: "point" });
const emit = defineEmits(["callback"]); const emit = defineEmits(["callback"]);
// 模板 ref,script 内未使用
const memberSearchFormRef = ref(null); const memberSearchFormRef = ref(null);
const searchFormRef = ref(null); const searchFormRef = ref(null);
const table = ref(null); const table = ref(null);
@@ -224,35 +364,60 @@ const pointsStatistics = ref({
totalPoint: 0, totalPoint: 0,
unUsedPoint: 0, unUsedPoint: 0,
}); });
const searchForm = reactive({ const searchForm = reactive({
pageNumber: 1, pageNumber: 1,
pageSize: 20, pageSize: 20,
memberId: "",
memberName: "",
}); });
const memberSearchForm = reactive({ const memberSearchForm = reactive({
pageNumber: 1, pageNumber: 1,
pageSize: 20, pageSize: 20,
id: "",
nickName: "", nickName: "",
username: "", username: "",
disabled: "", disabled: "",
minPoint: null, minPoint: null,
maxPoint: null, maxPoint: null,
}); });
const data = ref([]); const data = ref([]);
const memberData = ref([]); const memberData = ref([]);
const memberTotal = ref(0); const memberTotal = ref(0);
const total = ref(0); const total = ref(0);
function callback(val) { function copyText(text, label = "客户ID") {
emit("callback", val); if (!text) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(String(text))
.then(() => ElMessage.success(`已复制${label}到剪贴板`))
.catch(() => ElMessage.info(String(text)));
} else {
ElMessage.info(String(text));
}
} }
function detail(row) { function detail(row) {
customRouterPush({ name: "member-detail", query: { id: row.id } }); customRouterPush({ name: "member-detail", query: { id: row.id } });
} }
function viewMemberHistory(row) {
activeTab.value = "pointChangeRecord";
searchForm.memberId = row.id;
searchForm.memberName = row.nickName || row.username || "";
searchForm.pageNumber = 1;
getData();
}
function init() { function init() {
getStatistics(); getStatistics();
getMemberList(); getMemberList();
getData(); getData();
} }
function getStatistics() { function getStatistics() {
API_Member.queryMemberPointsStatistics().then((res) => { API_Member.queryMemberPointsStatistics().then((res) => {
if (res && res.success && res.result) { if (res && res.success && res.result) {
@@ -263,45 +428,71 @@ function getStatistics() {
} }
}); });
} }
function formatNumber(value) { function formatNumber(value) {
const numericValue = Number(value || 0); const numericValue = Number(value || 0);
if (!Number.isFinite(numericValue)) return "0"; if (!Number.isFinite(numericValue)) return "0";
return numericValue.toLocaleString(); return numericValue.toLocaleString();
} }
function changePage(v) { function changePage(v) {
searchForm.pageNumber = v; searchForm.pageNumber = v;
getData(); getData();
} }
function changePageSize(v) { function changePageSize(v) {
searchForm.pageNumber = 1; searchForm.pageNumber = 1;
searchForm.pageSize = v; searchForm.pageSize = v;
getData(); getData();
} }
function handleSearch() { function handleSearch() {
searchForm.pageNumber = 1; searchForm.pageNumber = 1;
searchForm.pageSize = 20;
getData(); getData();
} }
function handleHistoryReset() {
searchForm.memberId = "";
searchForm.memberName = "";
searchForm.pageNumber = 1;
getData();
}
function handleMemberSearch() { function handleMemberSearch() {
memberSearchForm.pageNumber = 1; memberSearchForm.pageNumber = 1;
getMemberList(); getMemberList();
} }
function handleMemberReset() {
memberSearchForm.id = "";
memberSearchForm.nickName = "";
memberSearchForm.username = "";
memberSearchForm.disabled = "";
memberSearchForm.minPoint = null;
memberSearchForm.maxPoint = null;
memberSearchForm.pageNumber = 1;
getMemberList();
}
function changeMemberPage(v) { function changeMemberPage(v) {
memberSearchForm.pageNumber = v; memberSearchForm.pageNumber = v;
getMemberList(); getMemberList();
} }
function changeMemberPageSize(v) { function changeMemberPageSize(v) {
memberSearchForm.pageNumber = 1; memberSearchForm.pageNumber = 1;
memberSearchForm.pageSize = v; memberSearchForm.pageSize = v;
getMemberList(); getMemberList();
} }
function getMemberList() { function getMemberList() {
memberLoading.value = true; memberLoading.value = true;
const params = { const params = {
pageNumber: memberSearchForm.pageNumber, pageNumber: memberSearchForm.pageNumber,
pageSize: memberSearchForm.pageSize, pageSize: memberSearchForm.pageSize,
nickName: memberSearchForm.nickName || undefined, id: memberSearchForm.id ? memberSearchForm.id.trim() : undefined,
username: memberSearchForm.username || undefined, nickName: memberSearchForm.nickName ? memberSearchForm.nickName.trim() : undefined,
username: memberSearchForm.username ? memberSearchForm.username.trim() : undefined,
disabled: memberSearchForm.disabled || undefined, disabled: memberSearchForm.disabled || undefined,
minPoint: memberSearchForm.minPoint, minPoint: memberSearchForm.minPoint,
maxPoint: memberSearchForm.maxPoint, maxPoint: memberSearchForm.maxPoint,
@@ -318,31 +509,111 @@ function getMemberList() {
memberLoading.value = false; memberLoading.value = false;
}); });
} }
function getData() { function getData() {
loading.value = true; loading.value = true;
API_Member.getHistoryPointData(searchForm).then((res) => { const params = {
loading.value = false; pageNumber: searchForm.pageNumber,
if (res.success) { pageSize: searchForm.pageSize,
data.value = res.result.records; memberId: searchForm.memberId ? searchForm.memberId.trim() : undefined,
total.value = res.result.total; memberName: searchForm.memberName ? searchForm.memberName.trim() : undefined,
} };
}); API_Member.getHistoryPointData(params)
.then((res) => {
loading.value = false;
if (res && res.success && res.result) {
data.value = res.result.records || [];
total.value = res.result.total || 0;
}
})
.catch(() => {
loading.value = false;
});
} }
onMounted(() => { onMounted(() => {
init(); init();
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.points-statistics-card { .member-point-page {
margin-bottom: 10px; 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-warning {
color: #e6a23c;
}
/* 顶部统计卡片 */
.points-statistics-card {
margin-bottom: 14px;
border-radius: 8px;
border: 1px solid #ebeef5;
:deep(.el-card__body) {
padding: 16px 24px;
}
.points-statistics {
width: 100%;
.points-statistics-item {
padding: 6px 0;
.stat-inner-box {
text-align: center;
padding: 12px 16px;
background: #fbfbfc;
border-radius: 6px;
border: 1px solid #f2f3f5;
.points-statistics-title {
font-size: 14px;
font-weight: 600;
color: #303133;
line-height: 20px;
}
.points-statistics-subtitle {
font-size: 12px;
color: #909399;
line-height: 18px;
margin-top: 4px;
}
.points-statistics-value {
font-size: 22px;
font-weight: 700;
color: #f56c6c;
line-height: 30px;
margin-top: 8px;
}
}
}
}
}
/* 选项卡容器 */
.point-tabs-wrap { .point-tabs-wrap {
padding: 12px 16px 16px; padding: 14px 18px 18px;
background: #fff; background: #fff;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 10px rgba(23, 35, 61, 0.04); border: 1px solid #ebeef5;
} }
.point-tabs { .point-tabs {
@@ -352,107 +623,108 @@ onMounted(() => {
:deep(.el-tabs__item) { :deep(.el-tabs__item) {
padding: 8px 18px; padding: 8px 18px;
color: #515a6e; font-size: 14px;
color: #606266;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
:deep(.el-tabs__item.is-active) { :deep(.el-tabs__item.is-active) {
color: #409eff; color: #409eff;
font-weight: 500; font-weight: 600;
}
:deep(.el-tabs__active-bar) {
height: 2px;
border-radius: 2px;
} }
} }
.points-statistics { /* 筛选表单 */
width: 100%; .point-filter-card {
} border-radius: 6px;
border: 1px solid #ebeef5;
background-color: #fafbfe;
.points-statistics-item { :deep(.el-card__body) {
padding: 10px 0; padding: 16px 16px 2px;
text-align: center;
}
.points-statistics-title {
font-size: 14px;
color: #17233d;
line-height: 20px;
}
.points-statistics-subtitle {
font-size: 12px;
color: #808695;
line-height: 18px;
margin-top: 4px;
}
.points-statistics-value {
font-size: 18px;
font-weight: 600;
color: #fa6419;
line-height: 26px;
margin-top: 8px;
}
.point-content-card {
margin-bottom: 12px;
}
.member-list-card {
:deep(.el-card__header) {
border-bottom: 1px solid #f5f5f5;
}
}
.card-title {
font-size: 14px;
font-weight: 500;
color: #17233d;
}
.member-table {
width: 100%;
:deep(.el-table__header th.el-table__cell) {
background: #fafafa;
padding: 14px 0;
font-size: 14px;
color: #17233d;
} }
:deep(.el-table__header .cell) { .search-form {
padding: 0 16px; display: flex;
line-height: 22px; flex-wrap: wrap;
} align-items: center;
:deep(.el-table__body td.el-table__cell) { .el-form-item {
padding: 12px 0; margin-bottom: 14px;
} margin-right: 16px;
}
:deep(.el-table__body .cell) { .form-actions {
padding: 0 16px; margin-left: auto;
line-height: 22px; }
} }
} }
.point-range-separator { .point-range-separator {
display: inline-block; display: inline-block;
margin: 0 8px; margin: 0 6px;
color: #808695; color: #909399;
} }
.link-text { /* 表格区域卡片 */
color: #409eff; .point-content-card {
cursor: pointer; border-radius: 6px;
text-decoration: none; border: 1px solid #ebeef5;
.card-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
.header-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: #303133;
.total-badge {
font-size: 12px;
font-weight: normal;
color: #909399;
}
}
}
} }
.face { /* 表格单元格 */
width: 60px; .custom-point-table {
height: 60px; .id-cell {
border-radius: 50%; display: flex;
align-items: center;
gap: 8px;
.copy-btn {
padding: 0;
font-size: 12px;
}
}
.point-num-text {
font-weight: 600;
color: #f56c6c;
}
.point-change-tag {
font-weight: 600;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.time-text {
font-size: 12px;
color: #909399;
}
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding-top: 6px;
} }
</style> </style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,235 +0,0 @@
<template>
<div class="search">
<el-card>
<el-form
ref="searchFormRef"
:model="searchForm"
inline
label-width="70px"
class="search-form"
@keyup.enter="handleSearch"
>
<el-form-item label="订单号" prop="orderSn">
<el-input
v-model="searchForm.orderSn"
placeholder="请输入订单号"
clearable
style="width: 240px"
/>
</el-form-item>
<el-form-item label="会员名称" prop="buyerName">
<el-input
v-model="searchForm.buyerName"
placeholder="请输入会员名称"
clearable
style="width: 240px"
/>
</el-form-item>
<el-form-item label="下单时间">
<el-date-picker
v-model="selectDate"
type="datetimerange"
value-format="YYYY-MM-DD"
clearable
start-placeholder="开始时间"
end-placeholder="结束时间"
placeholder="选择起始时间"
style="width: 240px"
@change="selectDateRange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<div class="order-tab">
<el-tabs v-model="currentStatus" @tab-click="onStatusTabClick">
<el-tab-pane
v-for="(item, index) in orderStatus"
:key="index"
:label="item.title"
:name="item.value"
/>
</el-tabs>
</div>
<el-table
ref="tableRef"
v-loading="loading"
border
:data="data"
class="mt_10"
style="width: 100%"
@sort-change="changeSort"
>
<el-table-column prop="sn" label="订单号" min-width="230" show-overflow-tooltip />
<el-table-column prop="createTime" label="下单时间" width="200" />
<el-table-column label="订单来源" width="95">
<template #default="{ row }">
<span v-if="row">
<span v-if="row.clientType == 'H5'">移动端</span>
<span v-else-if="row.clientType == 'PC'">PC端</span>
<span v-else-if="row.clientType == 'WECHAT_MP'">小程序端</span>
<span v-else-if="row.clientType == 'APP'">移动应用端</span>
<span v-else>{{ row.clientType }}</span>
</span>
</template>
</el-table-column>
<el-table-column prop="memberName" label="买家名称" width="130" />
<el-table-column label="订单金额" prop="flowPrice" min-width="120" sortable="custom">
<template #default="{ row }">
<priceColorScheme v-if="row" :value="row.flowPrice" :color="$mainColor" />
</template>
</el-table-column>
<el-table-column label="订单状态" width="95">
<template #default="{ row }">
<template v-if="row">
<el-tag v-if="row.orderStatus == 'UNPAID'" type="danger">未付款</el-tag>
<el-tag v-else-if="row.orderStatus == 'PAID'" type="primary">已付款</el-tag>
<el-tag v-else-if="row.orderStatus == 'COMPLETED'" type="success">已完成</el-tag>
<el-tag v-else-if="row.orderStatus == 'TAKE'" type="warning">待核验</el-tag>
<el-tag v-else-if="row.orderStatus == 'CANCELLED'" type="info">已关闭</el-tag>
</template>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<template v-if="row">
<a
class="link-text"
:class="{ disabled: row.orderStatus != 'UNPAID' }"
@click="row.orderStatus == 'UNPAID' && confirmPrice(row)"
>收款</a>
<span class="op-split">|</span>
<a class="link-text" @click="detail(row)">查看</a>
</template>
</template>
</el-table-column>
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
:page-sizes="[20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
size="small"
@current-change="changePage"
@size-change="changePageSize"
/>
</div>
</el-card>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import * as API_Order from "@/api/order";
import { ElMessage, ElMessageBox } from "element-plus";
import { customRouterPush } from "@/utils/filters";
defineOptions({ name: "fictitiousOrderList" });
const searchFormRef = ref(null);
const tableRef = ref(null);
const loading = ref(true);
const searchForm = reactive({
pageNumber: 1,
pageSize: 20,
sort: "",
order: "",
startDate: "",
endDate: "",
orderType: "VIRTUAL",
orderSn: "",
buyerName: "",
orderStatus: "",
});
const selectDate = ref(null);
const data = ref([]);
const total = ref(0);
const orderStatus = ref([
{ title: "全部", value: "" },
{ title: "未付款", value: "UNPAID" },
{ title: "已付款", value: "PAID" },
{ title: "待核验", value: "TAKE" },
{ title: "已完成", value: "COMPLETED" },
{ title: "已关闭", value: "CANCELLED" },
]);
const currentStatus = ref("");
onMounted(() => {
init();
});
function init() {
getDataList();
}
function changePage() {
getDataList();
}
function changePageSize() {
searchForm.pageNumber = 1;
getDataList();
}
function handleSearch() {
searchForm.pageNumber = 1;
searchForm.pageSize = 20;
getDataList();
}
function changeSort(e) {
searchForm.sort = e.prop;
searchForm.order = e.order === "ascending" ? "asc" : e.order === "descending" ? "desc" : "";
getDataList();
}
function selectDateRange(v) {
if (v) {
searchForm.startDate = v[0];
searchForm.endDate = v[1];
}
}
function getDataList() {
loading.value = true;
API_Order.getOrderList(searchForm).then((res) => {
loading.value = false;
if (res.success) {
data.value = res.result.records;
total.value = res.result.total;
}
});
}
function confirmPrice(v) {
ElMessageBox.confirm("您确定要收款吗?", "确认收款", { type: "warning" }).then(() => {
API_Order.orderPay(v.sn).then((res) => {
if (res.success) {
ElMessage.success("收款成功");
getDataList();
}
});
}).catch(() => {});
}
function detail(v) {
let sn = v.sn;
customRouterPush({
name: "order-detail",
query: { sn: sn, orderType: v.orderType },
});
}
function onStatusTabClick(tab) {
const item = tab.paneName;
currentStatus.value = item;
searchForm.orderStatus = item;
getDataList();
}
</script>
<style lang="scss" scoped>
.order-tab {
margin-top: 20px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,7 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="支付方式" prop="paymentMethod"> <el-form-item label="支付方式" prop="paymentMethod">
<el-select v-model="searchForm.paymentMethod" placeholder="请选择支付方式" clearable> <el-select v-model="searchForm.paymentMethod" placeholder="请选择支付方式" :disabled="!!fixedPaymentMethod" :clearable="!fixedPaymentMethod">
<el-option v-for="item in paymentOptions" :key="item.value" :label="item.label" :value="item.value" /> <el-option v-for="item in paymentOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -70,7 +70,7 @@
/> />
</el-tabs> </el-tabs>
</div> </div>
<div> <div v-if="allowExport">
<el-button type="warning" class="export" :loading="exporting" @click="exportOrder">导出订单</el-button> <el-button type="warning" class="export" :loading="exporting" @click="exportOrder">导出订单</el-button>
</div> </div>
@@ -150,14 +150,20 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import * as API_Order from "@/api/order"; import * as API_Order from "@/api/order";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { unitPrice, customRouterPush } from "@/utils/filters"; import { unitPrice, customRouterPush } from "@/utils/filters";
const QUERY_KEY = "manager-order-list-query";
const PAGE_SIZES = [20, 50, 100]; const PAGE_SIZES = [20, 50, 100];
// 独立渠道页面复用列表,来源限制不从地址栏或可重置表单中读取。
const props = defineProps({
fixedClientType: { type: String, default: "" },
fixedPaymentMethod: { type: String, default: "" },
allowExport: { type: Boolean, default: true },
});
const PROMOTION_OPTIONS = [ const PROMOTION_OPTIONS = [
{ label: "普通订单", value: "NORMAL" }, { label: "普通订单", value: "NORMAL" },
{ label: "拼团订单", value: "PINTUAN" }, { label: "拼团订单", value: "PINTUAN" },
@@ -196,42 +202,16 @@ function emptySearchForm(pageSize = 20) {
consigneeMobile: "", consigneeMobile: "",
storeName: "", storeName: "",
orderStatus: "", orderStatus: "",
paymentMethod: "", paymentMethod: props.fixedPaymentMethod,
orderPromotionType: "", orderPromotionType: "",
}; };
} }
function readSavedQuery() {
const raw = sessionStorage.getItem(QUERY_KEY);
if (!raw) return null;
try {
const saved = JSON.parse(raw);
const form = emptySearchForm();
const src = saved && saved.searchForm ? saved.searchForm : {};
Object.keys(form).forEach((key) => {
if (src[key] !== undefined && src[key] !== null) {
form[key] = src[key];
}
});
form.pageNumber = Number(form.pageNumber) || 1;
form.pageSize = PAGE_SIZES.includes(Number(form.pageSize)) ? Number(form.pageSize) : 20;
const currentStatus = saved.currentStatus || form.orderStatus || "ALL";
if (!currentStatus || currentStatus === "ALL") {
form.orderStatus = "";
}
return {
searchForm: form,
selectDate: Array.isArray(saved.selectDate) ? saved.selectDate : null,
currentStatus: currentStatus || "ALL",
};
} catch (e) {
sessionStorage.removeItem(QUERY_KEY);
return null;
}
}
defineOptions({ name: "orderList" }); defineOptions({ name: "orderList" });
const route = useRoute();
const searchFormRef = ref(null); const searchFormRef = ref(null);
const tableRef = ref(null); const tableRef = ref(null);
@@ -248,6 +228,25 @@ const total = ref(0);
const orderNumData = ref({}); const orderNumData = ref({});
const currentStatus = ref("ALL"); const currentStatus = ref("ALL");
function applyRouteQuery() {
const q = route.query;
if (!q) return;
if (q.orderSn) searchForm.value.orderSn = String(q.orderSn);
if (q.buyerName) searchForm.value.buyerName = String(q.buyerName);
if (q.goodsName) searchForm.value.goodsName = String(q.goodsName);
if (q.storeName) searchForm.value.storeName = String(q.storeName);
if (q.orderType) searchForm.value.orderType = String(q.orderType);
if (q.orderStatus) {
searchForm.value.orderStatus = String(q.orderStatus);
currentStatus.value = String(q.orderStatus);
}
if (q.startDate && q.endDate) {
searchForm.value.startDate = String(q.startDate);
searchForm.value.endDate = String(q.endDate);
selectDate.value = [String(q.startDate), String(q.endDate)];
}
}
const orderStatusWithCount = computed(() => { const orderStatusWithCount = computed(() => {
const count = (value) => (value ? `(${value})` : ""); const count = (value) => (value ? `(${value})` : "");
return [ return [
@@ -340,23 +339,6 @@ function orderStatusTagType(v) {
}; };
return map[v] || "info"; return map[v] || "info";
} }
function rememberQuery() {
sessionStorage.setItem(
QUERY_KEY,
JSON.stringify({
searchForm: searchForm.value,
selectDate: selectDate.value,
currentStatus: currentStatus.value,
})
);
}
function restoreQuery() {
const saved = readSavedQuery();
if (!saved) return;
searchForm.value = saved.searchForm;
selectDate.value = saved.selectDate;
currentStatus.value = saved.currentStatus;
}
function init() { function init() {
getDataList(); getDataList();
getOrderNumData(); getOrderNumData();
@@ -380,7 +362,6 @@ function handleReset() {
searchForm.value = emptySearchForm(pageSize); searchForm.value = emptySearchForm(pageSize);
selectDate.value = null; selectDate.value = null;
currentStatus.value = "ALL"; currentStatus.value = "ALL";
sessionStorage.removeItem(QUERY_KEY);
init(); init();
} }
function selectDateRange(v) { function selectDateRange(v) {
@@ -393,7 +374,7 @@ function selectDateRange(v) {
} }
} }
function countParams() { function countParams() {
const params = { ...searchForm.value }; const params = listParams();
delete params.orderStatus; delete params.orderStatus;
delete params.pageNumber; delete params.pageNumber;
delete params.pageSize; delete params.pageSize;
@@ -401,10 +382,15 @@ function countParams() {
delete params.order; delete params.order;
return params; return params;
} }
function listParams() {
const params = { ...searchForm.value };
if (props.fixedClientType) params.clientType = props.fixedClientType;
if (props.fixedPaymentMethod) params.paymentMethod = props.fixedPaymentMethod;
return params;
}
function getDataList() { function getDataList() {
loading.value = true; loading.value = true;
rememberQuery(); API_Order.getOrderList(listParams())
API_Order.getOrderList(searchForm.value)
.then((res) => { .then((res) => {
if (res.success) { if (res.success) {
data.value = res.result.records; data.value = res.result.records;
@@ -422,6 +408,7 @@ function detail(v) {
}); });
} }
async function exportOrder() { async function exportOrder() {
if (!props.allowExport) return;
if (!searchForm.value.startDate || !searchForm.value.endDate) { if (!searchForm.value.startDate || !searchForm.value.endDate) {
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
@@ -434,7 +421,7 @@ async function exportOrder() {
} }
} }
exporting.value = true; exporting.value = true;
API_Order.exportOrder(searchForm.value) API_Order.exportOrder(listParams())
.then((res) => { .then((res) => {
const blob = new Blob([res], { const blob = new Blob([res], {
type: "application/vnd.ms-excel;charset=utf-8", type: "application/vnd.ms-excel;charset=utf-8",
@@ -478,10 +465,24 @@ function getOrderNumData() {
}); });
} }
// created watch(
restoreQuery(); () => route.query,
(newQuery) => {
if (newQuery && Object.keys(newQuery).length > 0) {
applyRouteQuery();
searchForm.value.pageNumber = 1;
init();
}
}
);
onMounted(() => { onMounted(() => {
try {
sessionStorage.removeItem("manager-order-list-query");
} catch (e) {
// ignore
}
applyRouteQuery();
init(); init();
}); });
</script> </script>

View File

@@ -0,0 +1,25 @@
<template>
<div>
<el-alert
title="小程序订单"
type="info"
:closable="false"
show-icon
class="wechat-order-notice"
description="仅展示微信小程序下单且使用微信支付的订单。自动上报开关开启且微信侧已开通发货管理时,按现有履约流程上报。"
/>
<OrderList fixed-client-type="WECHAT_MP" fixed-payment-method="WECHAT" :allow-export="false" />
</div>
</template>
<script setup>
import OrderList from "../order/orderList.vue";
defineOptions({ name: "wechatOrderList" });
</script>
<style scoped>
.wechat-order-notice {
margin-bottom: 16px;
}
</style>

View File

@@ -29,6 +29,10 @@
<div class="hotzone-preview-box" v-if="options.imageUrl"> <div class="hotzone-preview-box" v-if="options.imageUrl">
<img :src="options.imageUrl" alt="hotzone" class="img-preview" /> <img :src="options.imageUrl" alt="hotzone" class="img-preview" />
<div class="preview-actions-bar"> <div class="preview-actions-bar">
<el-button size="small" type="primary" @click="handleOpenHotzone">
<el-icon class="mr-4"><Aim /></el-icon>
绘制热区
</el-button>
<el-button size="small" type="primary" plain @click="handleSelectImage"> <el-button size="small" type="primary" plain @click="handleSelectImage">
<el-icon class="mr-4"><Picture /></el-icon> <el-icon class="mr-4"><Picture /></el-icon>
更换底图 更换底图
@@ -46,6 +50,9 @@
<div class="section-title flex-between"> <div class="section-title flex-between">
<span>热区列表 ({{ options.zones?.length || 0 }})</span> <span>热区列表 ({{ options.zones?.length || 0 }})</span>
<div class="header-action-group"> <div class="header-action-group">
<el-button type="primary" plain size="small" @click="handleOpenHotzone">
<el-icon class="mr-4"><Aim /></el-icon> 绘制热区
</el-button>
<el-button link type="primary" size="small" @click="handleAddZone"> <el-button link type="primary" size="small" @click="handleAddZone">
<el-icon><Plus /></el-icon> 添加热区 <el-icon><Plus /></el-icon> 添加热区
</el-button> </el-button>
@@ -204,16 +211,25 @@
</div> </div>
<div class="empty-zones-box" v-else> <div class="empty-zones-box" v-else>
<span>暂无热区,请点击右上角“添加热区”</span> <span>暂无热区,可点击“绘制热区”在底图上拖拽画热区</span>
<div class="mt-8">
<el-button type="primary" size="small" @click="handleOpenHotzone">
<el-icon class="mr-4"><Aim /></el-icon> 立即绘制热区
</el-button>
</div>
</div> </div>
</div> </div>
<!-- 绘制热区弹窗(复用移动端热区绘制器) -->
<hotzone ref="hotzoneDialogRef" width="960px" @changeZone="handleChangeZone" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { inject } from 'vue'; import { ref, inject } from 'vue';
import { Plus, Delete, Link, Picture, Close, Top, Bottom } from '@element-plus/icons-vue'; import { Plus, Delete, Link, Picture, Close, Top, Bottom, Aim } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus'; import { ElMessageBox, ElMessage } from 'element-plus';
import hotzone from '@/components/hotzone';
const props = defineProps({ const props = defineProps({
component: { component: {
@@ -298,6 +314,68 @@ function handleAddZone() {
height: 35 height: 35
}); });
} }
const hotzoneDialogRef = ref(null);
// 绘制热区弹窗打开
function handleOpenHotzone() {
const imgUrl = props.options.imageUrl || '';
if (!imgUrl) {
ElMessage.warning('请先输入或选择热区底图');
return;
}
const zoneInfo = (props.options.zones || []).map((z, idx) => {
const leftPer = z.leftPer !== undefined ? z.leftPer : (Number(z.left) || 0) / 100;
const topPer = z.topPer !== undefined ? z.topPer : (Number(z.top) || 0) / 100;
const widthPer = z.widthPer !== undefined ? z.widthPer : (Number(z.width) || 20) / 100;
const heightPer = z.heightPer !== undefined ? z.heightPer : (Number(z.height) || 20) / 100;
const link = z.url || z.link || '';
return {
...z,
leftPer,
topPer,
widthPer,
heightPer,
link,
title: z.title || `热区 ${idx + 1}`,
type: z.type || 'other'
};
});
if (hotzoneDialogRef.value) {
hotzoneDialogRef.value.open({
img: imgUrl,
zoneInfo
});
}
}
// 热区绘制器变更回调
function handleChangeZone(zoneList) {
if (!Array.isArray(zoneList)) return;
props.options.zones = zoneList.map((z, idx) => {
const leftVal = z.leftPer !== undefined ? z.leftPer * 100 : (z.left !== undefined ? z.left : 0);
const topVal = z.topPer !== undefined ? z.topPer * 100 : (z.top !== undefined ? z.top : 0);
const widthVal = z.widthPer !== undefined ? z.widthPer * 100 : (z.width !== undefined ? z.width : 20);
const heightVal = z.heightPer !== undefined ? z.heightPer * 100 : (z.height !== undefined ? z.height : 20);
const linkVal = z.link || z.url || '';
const titleVal = z.title || `热区 ${idx + 1}`;
return {
...z,
title: titleVal,
url: linkVal,
link: linkVal,
left: Math.round(leftVal * 10) / 10,
top: Math.round(topVal * 10) / 10,
width: Math.round(widthVal * 10) / 10,
height: Math.round(heightVal * 10) / 10,
type: z.type || 'other'
};
});
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -299,7 +299,7 @@ export const COMPONENT_DEFINITIONS = [
{ {
type: 'channelGrid', type: 'channelGrid',
defaultRowHeight: 120, defaultRowHeight: '',
name: '频道宫格', name: '频道宫格',
group: 'navigation', group: 'navigation',
icon: 'Grid', icon: 'Grid',
@@ -431,7 +431,7 @@ export const COMPONENT_DEFINITIONS = [
{ {
type: 'brandWall', type: 'brandWall',
defaultRowHeight: 220, defaultRowHeight: '',
name: '品牌图', name: '品牌图',
group: 'media', group: 'media',
icon: 'Medal', icon: 'Medal',
@@ -443,7 +443,7 @@ export const COMPONENT_DEFINITIONS = [
{ title: '数码潮玩 · 爆款立减500', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_1.png', url: '/goodsList?category=1' }, { title: '数码潮玩 · 爆款立减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: '美妆奢宠 · 每满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: '运动潮品 · 爆款低至5折', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_3.png', url: '/goodsList?category=3' },
{ title: '品质生活 · 限时第2件半价', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_4.png', url: '/goodsList?category=4' } { title: '品质生活 · 精选好物', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_4.png', url: '/goodsList?category=4' }
] ]
}, },
defaultComponentStyle: { defaultComponentStyle: {
@@ -513,7 +513,7 @@ export const COMPONENT_DEFINITIONS = [
defaultOptions: { defaultOptions: {
height: 320, height: 450,
elements: [] elements: []
@@ -532,7 +532,7 @@ export const COMPONENT_DEFINITIONS = [
{ {
type: 'promoGroup', type: 'promoGroup',
defaultRowHeight: 180, defaultRowHeight: 220,
name: '活动卡组', name: '活动卡组',
@@ -567,13 +567,14 @@ export const COMPONENT_DEFINITIONS = [
{ {
type: 'video', type: 'video',
defaultRowHeight: 360, defaultRowHeight: 450,
name: '视频', name: '视频',
group: 'media', group: 'media',
icon: 'VideoCamera', icon: 'VideoCamera',
description: '品牌宣传与商品视频播放', description: '品牌宣传与商品视频播放',
defaultOptions: { defaultOptions: {
videoUrl: '', videoUrl: '',
height: 450,
coverUrl: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/video/video_cover.png', coverUrl: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/video/video_cover.png',
url: '', url: '',
autoPlay: false, autoPlay: false,
@@ -597,7 +598,7 @@ export const COMPONENT_DEFINITIONS = [
{ {
type: 'goodsList', type: 'goodsList',
defaultRowHeight: 980, defaultRowHeight: '',
name: '商品列表', name: '商品列表',
@@ -727,7 +728,7 @@ export const COMPONENT_DEFINITIONS = [
{ {
type: 'gridFloor', type: 'gridFloor',
defaultRowHeight: 560, defaultRowHeight: '',
name: '宫格楼层', name: '宫格楼层',
@@ -843,7 +844,7 @@ export const COMPONENT_DEFINITIONS = [
{ {
type: 'goodsGrid', type: 'goodsGrid',
defaultRowHeight: 420, defaultRowHeight: '',
name: '商品宫格', name: '商品宫格',
group: 'commerce', group: 'commerce',
icon: 'Menu', icon: 'Menu',
@@ -922,7 +923,7 @@ export const COMPONENT_DEFINITIONS = [
{ {
type: 'bannerFloor', type: 'bannerFloor',
defaultRowHeight: 560, defaultRowHeight: '',
name: '横幅楼层', name: '横幅楼层',
@@ -964,7 +965,7 @@ export const COMPONENT_DEFINITIONS = [
{ {
type: 'goodsBar', type: 'goodsBar',
defaultRowHeight: 280, defaultRowHeight: '',
name: '商品条', name: '商品条',
@@ -1001,7 +1002,7 @@ export const COMPONENT_DEFINITIONS = [
}, },
{type: 'channelFloor', {type: 'channelFloor',
defaultRowHeight: 620, defaultRowHeight: '',
name: '频道楼层', name: '频道楼层',
@@ -1045,7 +1046,7 @@ export const COMPONENT_DEFINITIONS = [
{ {
type: 'recommendFeed', type: 'recommendFeed',
defaultRowHeight: 1270, defaultRowHeight: '',
name: '推荐商品', name: '推荐商品',
@@ -1120,7 +1121,7 @@ export const COMPONENT_DEFINITIONS = [
}, },
{ {
type: 'couponList', type: 'couponList',
defaultRowHeight: 160, defaultRowHeight: '',
name: '优惠券', name: '优惠券',

View File

@@ -225,7 +225,7 @@
:class="{ 'is-row-selected': isCurrentRowSelected(row.id) }" :class="{ 'is-row-selected': isCurrentRowSelected(row.id) }"
:style="getRowWrapperStyle(row.rowStyle)" :style="getRowWrapperStyle(row.rowStyle, row.components)"
@click.stop="handleSelectRow(row, rIndex)" @click.stop="handleSelectRow(row, rIndex)"
@@ -289,14 +289,14 @@
group="pc-row-components" group="pc-row-components"
item-key="id" item-key="id"
class="row-components-flex" class="row-components-flex"
:style="getRowFlexStyle(row.rowStyle)" :style="getRowFlexStyle(row.rowStyle, row.components)"
@change="markDirty" @change="markDirty"
> >
<template #item="{ element: comp, index: cIndex }"> <template #item="{ element: comp, index: cIndex }">
<div <div
class="canvas-comp-wrap" class="canvas-comp-wrap"
:class="{ 'is-selected': selectedCompId === comp.id }" :class="{ 'is-selected': selectedCompId === comp.id }"
:style="getCompWrapperStyle(comp.componentStyle, row.rowStyle, row.components ? row.components.length : 1)" :style="getCompWrapperStyle(comp.componentStyle, row.rowStyle, row.components ? row.components.length : 1, comp.type)"
@click.stop="handleSelectComp(row, rIndex, comp)" @click.stop="handleSelectComp(row, rIndex, comp)"
> >
<!-- 选中的组件左上角名称标识徽标(万米魔方设计) --> <!-- 选中的组件左上角名称标识徽标(万米魔方设计) -->
@@ -800,11 +800,19 @@ function clearSelection() {
function normalizeRows(list) { function normalizeRows(list) {
if (!Array.isArray(list)) return []; if (!Array.isArray(list)) return [];
const autoCompTypes = ['goodsGrid', 'gridFloor', 'bannerFloor', 'goodsBar', 'channelFloor', 'goodsList', 'recommendFeed', 'articleContent', 'brandWall', 'channelGrid', 'couponList'];
const legacyHeights = [40, '40', 280, '280', 420, '420', 560, '560', 620, '620', 980, '980', 1270, '1270'];
const normalized = []; const normalized = [];
list.forEach(r => { list.forEach(r => {
if (!r) return; if (!r) return;
if (r.rowStyle && (r.rowStyle.height === 40 || r.rowStyle.height === "40")) { if (r.rowStyle) {
r.rowStyle.height = ""; if (r.rowStyle.height === 40 || r.rowStyle.height === "40") {
r.rowStyle.height = "";
}
const hasAutoComp = Array.isArray(r.components) && r.components.some(c => autoCompTypes.includes(c?.type));
if (hasAutoComp && legacyHeights.includes(r.rowStyle.height)) {
r.rowStyle.height = "";
}
} }
normalized.push(r); normalized.push(r);
}); });
@@ -1252,7 +1260,8 @@ function getCompIcon(type) {
// 计算行样式 // 计算行样式
function getRowWrapperStyle(style) { function getRowWrapperStyle(style, components = []) {
const hasAutoComp = Array.isArray(components) && components.some(c => ['goodsGrid', 'gridFloor', 'bannerFloor', 'goodsBar', 'channelFloor', 'goodsList', 'recommendFeed', 'articleContent', 'brandWall', 'channelGrid', 'couponList'].includes(c?.type));
if (!style) return { width: '100%' }; if (!style) return { width: '100%' };
const s = {}; const s = {};
@@ -1280,8 +1289,12 @@ function getRowWrapperStyle(style) {
const h = String(style.height).trim(); const h = String(style.height).trim();
if (h && Number(h) > 0) { if (h && Number(h) > 0) {
const hVal = /^\d+$/.test(h) ? `${h}px` : h; const hVal = /^\d+$/.test(h) ? `${h}px` : h;
s.height = hVal; if (hasAutoComp) {
s.minHeight = hVal; s.minHeight = hVal;
} else {
s.height = hVal;
s.minHeight = hVal;
}
} }
} }
@@ -1318,7 +1331,8 @@ function getRowWrapperStyle(style) {
return s; return s;
} }
function getRowFlexStyle(style) { function getRowFlexStyle(style, components = []) {
const hasAutoComp = Array.isArray(components) && components.some(c => ['goodsGrid', 'gridFloor', 'bannerFloor', 'goodsBar', 'channelFloor', 'goodsList', 'recommendFeed', 'articleContent', 'brandWall', 'channelGrid', 'couponList'].includes(c?.type));
if (!style) return { display: 'flex', flexDirection: 'row', flexWrap: 'nowrap', width: '100%', height: '100%' }; if (!style) return { display: 'flex', flexDirection: 'row', flexWrap: 'nowrap', width: '100%', height: '100%' };
const s = { const s = {
display: 'flex', display: 'flex',
@@ -1333,8 +1347,12 @@ function getRowFlexStyle(style) {
const h = String(style.height).trim(); const h = String(style.height).trim();
if (h && Number(h) > 0) { if (h && Number(h) > 0) {
const hVal = /^\d+$/.test(h) ? `${h}px` : h; const hVal = /^\d+$/.test(h) ? `${h}px` : h;
s.height = hVal; if (hasAutoComp) {
s.minHeight = hVal; s.minHeight = hVal;
} else {
s.height = hVal;
s.minHeight = hVal;
}
} }
} }
if (style.gap !== undefined && style.gap !== '') { if (style.gap !== undefined && style.gap !== '') {
@@ -1348,7 +1366,8 @@ function getRowFlexStyle(style) {
// 计算组件外层样式 // 计算组件外层样式
function getCompWrapperStyle(style, rowStyle = {}, totalComps = 1) { function getCompWrapperStyle(style, rowStyle = {}, totalComps = 1, compType = '') {
const isAutoHeightComp = compType && ['goodsGrid', 'gridFloor', 'bannerFloor', 'goodsBar', 'channelFloor', 'goodsList', 'recommendFeed', 'articleContent', 'brandWall', 'channelGrid', 'couponList'].includes(compType);
if (!style) return {}; if (!style) return {};
@@ -1358,8 +1377,12 @@ function getCompWrapperStyle(style, rowStyle = {}, totalComps = 1) {
const rh = String(rowStyle.height).trim(); const rh = String(rowStyle.height).trim();
if (rh && Number(rh) > 0) { if (rh && Number(rh) > 0) {
const rhVal = /^\d+$/.test(rh) ? `${rh}px` : rh; const rhVal = /^\d+$/.test(rh) ? `${rh}px` : rh;
s.height = rhVal; if (isAutoHeightComp) {
s.minHeight = rhVal; s.minHeight = rhVal;
} else {
s.height = rhVal;
s.minHeight = rhVal;
}
} }
} }
const gap = Number(rowStyle?.gap) || 0; const gap = Number(rowStyle?.gap) || 0;
@@ -1612,7 +1635,11 @@ function goBack() {
} else { } else {
router.push('/pcFloor'); if (window.history.state?.back) {
router.go(-1);
} else {
window.close();
}
} }
@@ -1643,6 +1670,8 @@ function goBack() {
} }
let autoSaveTimer = null;
onMounted(() => { onMounted(() => {
const id = route.query.id; const id = route.query.id;
@@ -1671,7 +1700,10 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
if (autoSaveTimer) clearInterval(autoSaveTimer); if (autoSaveTimer) {
clearInterval(autoSaveTimer);
autoSaveTimer = null;
}
}); });

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