12 Commits

Author SHA1 Message Date
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
191 changed files with 39006 additions and 17347 deletions

216
LICENSE
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -283,11 +283,6 @@ export const getCouponReceiveList = (params) => {
return getRequest("/promotion/coupon/received", params);
};
export const getFlashDiscountList = (params) => getRequest("/promotion/flashDiscount", params);
export const getFlashDiscountDetail = (id) => getRequest(`/promotion/flashDiscount/${id}`);
export const updateFlashDiscountStatus = (id, params) => putRequest(`/promotion/flashDiscount/status/${id}`, params);
export const deleteFlashDiscount = (id) => deleteRequest(`/promotion/flashDiscount/${id}`);
export const getNthItemDiscountList = (params) => getRequest("/promotion/nthItemDiscount", params);
// 现金礼品卡活动分页
export const getGiftCardCashActivityPage = (params) => {

View File

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

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

View File

@@ -1,100 +1,140 @@
<template>
<div class="wrapper">
<div class="wap-content">
<div class="query-wrapper">
<div class="query-item">
<div>搜索范围</div>
<el-input
v-model="goodsParams.goodsName"
placeholder="商品名称"
clearable
style="width: 150px"
@clear="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 class="goods-dialog-wrapper">
<!-- 顶部搜索筛选栏 -->
<div class="goods-query-bar">
<div class="query-item">
<span class="query-label">商品名称:</span>
<el-input
v-model="goodsParams.goodsName"
placeholder="请输入商品名称"
clearable
style="width: 220px"
@clear="onSearchGoods"
@keyup.enter="onSearchGoods"
/>
</div>
<div class="query-item">
<span class="query-label">商品编号:</span>
<el-input
v-model="goodsParams.sn"
placeholder="请输入商品编号/货号"
clearable
style="width: 190px"
@clear="onSearchGoods"
@keyup.enter="onSearchGoods"
/>
</div>
<div class="query-item">
<span class="query-label">商品分类:</span>
<el-cascader
v-model="category"
:options="skuList"
placeholder="请选择商品分类"
popper-class="goods-dialog-cascader-popper"
style="width: 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="销售价" 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 }">
<el-tag size="small" type="info">{{ goodsTypeLabel(row.goodsType) }}</el-tag>
</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>
</template>
<script setup>
import { nextTick, onMounted, reactive, ref, watch } from "vue";
import * as API_Goods from "@/api/goods";
@@ -131,6 +171,7 @@ const tableRef = ref(null);
watch(category, (val) => {
goodsParams.categoryPath = val && val.length ? val[2] : "";
});
watch(
() => props.selectedWay,
(val) => {
@@ -139,6 +180,7 @@ watch(
},
{ deep: true, immediate: true }
);
watch(
() => goodsParams.categoryPath,
() => {
@@ -158,10 +200,27 @@ function onSearchGoods() {
goodsParams.pageNumber = 1;
getQueryGoodsList();
}
function changePageSize(v) {
function onResetGoods() {
goodsParams.goodsName = "";
goodsParams.sn = "";
category.value = [];
goodsParams.categoryPath = "";
goodsParams.pageNumber = 1;
getQueryGoodsList();
}
function changePage(v) {
goodsParams.pageNumber = v;
getQueryGoodsList();
}
function onPageSizeChange(val) {
goodsParams.pageSize = val;
goodsParams.pageNumber = 1;
getQueryGoodsList();
}
function getQueryGoodsList() {
loading.value = true;
API_Goods.getGoodsSkuData(goodsParams)
@@ -172,6 +231,7 @@ function getQueryGoodsList() {
loading.value = false;
});
}
function initGoods(res) {
const records = res?.result?.records || [];
records.forEach((item) => {
@@ -181,6 +241,7 @@ function initGoods(res) {
goodsData.value = records;
nextTick(syncTableSelection);
}
function syncTableSelection() {
const table = tableRef.value;
if (!table) return;
@@ -189,16 +250,19 @@ function syncTableSelection() {
return;
}
goodsData.value.forEach((item) => {
const selected = selectedList.value.some((e) => (e.id || e.skuId) && String(e.id || e.skuId) === String(item.id || item.skuId));
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) {
selectedList.value = list;
emit("selected", selectedList.value);
}
function onSelectionChange(rows) {
// 选择链接时为单选:保留最后勾选的一项,多选框交互不变
if (type.value !== "multiple") {
if (!rows.length) {
emitSelected([]);
@@ -214,17 +278,18 @@ function onSelectionChange(rows) {
}
emitSelected(rows);
}
function init() {
API_Goods.getGoodsSkuData(goodsParams).then((res) => {
initGoods(res);
});
getQueryGoodsList();
API_Goods.getCategoryTree({ deleteFlag: false }).then((res) => {
if (res.success) {
if (res.success && res.result) {
deepGroup(res.result);
}
});
}
function deepGroup(val) {
skuList.value = [];
val.forEach((item) => {
let childWay = [];
if (item.children) {
@@ -252,34 +317,102 @@ function deepGroup(val) {
});
}
// 父组件 lili-dialog/index.vue 通过 $refs.goodsDialog.type = "single" 切换单选;
// expose 的 ref 外部读写等价于原 data 字段
defineExpose({ type });
</script>
<style scoped lang="scss">
@import "./style.scss";
.wap-content {
.goods-dialog-wrapper {
width: 100%;
flex: 1;
padding: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
/* 顶部搜索栏 */
.goods-query-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 0 0 12px;
flex-shrink: 0;
.query-item {
display: flex;
align-items: center;
.query-label {
font-size: 13px;
color: #606266;
white-space: nowrap;
margin-right: 4px;
}
}
.query-actions {
margin-left: auto;
display: flex;
gap: 8px;
}
}
/* 表格容器 */
.goods-table-wrap {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
}
.goods-thumb {
display: block;
width: 48px;
height: 48px;
margin: 0 auto;
border-radius: 4px;
object-fit: cover;
border: 1px solid #ebeef5;
}
.thumb-slot {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
font-size: 12px;
color: #c0c4cc;
}
.goods-title-line {
font-size: 13px;
color: #303133;
line-height: 1.4;
}
.goods-sn-line {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.goods-price {
color: $theme_color;
color: #f56c6c;
}
.pageration {
/* 底部翻页栏 */
.goods-pagination-bar {
display: flex;
justify-content: flex-end;
width: 100%;
margin-top: 16px;
padding-right: 8px;
box-sizing: border-box;
align-items: center;
padding-top: 12px;
flex-shrink: 0;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,32 @@
<template>
<div class="search">
<el-card>
<div class="member-experience-log-page search">
<!-- 顶部筛选表单 -->
<el-card shadow="never" class="filter-card">
<el-form
ref="searchFormRef"
:model="searchForm"
inline
label-width="90px"
label-width="84px"
class="search-form"
@keyup.enter="handleSearch"
>
<el-form-item label="客户手机号" prop="mobile">
<el-form-item label="客户手机号" prop="memberMobile">
<el-input
v-model="searchForm.memberMobile"
clearable
placeholder="请输入客户手机号"
style="width: 220px"
style="width: 200px"
/>
</el-form-item>
<el-form-item label="规则" prop="ruleKey">
<el-select v-model="searchForm.ruleKey" clearable filterable style="width: 220px">
<el-form-item label="规则类型" prop="ruleKey">
<el-select
v-model="searchForm.ruleKey"
clearable
filterable
placeholder="全部规则"
style="width: 180px"
>
<el-option label="全部规则" value="" />
<el-option
v-for="item in ruleOptions"
:key="item.value"
@@ -27,42 +35,103 @@
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-form-item class="form-actions">
<el-button type="primary" class="search-btn" @click="handleSearch">
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<el-table v-loading="loading" border :data="data" class="mt_10" style="width: 100%">
<el-table-column label="客户手机号" width="140" show-overflow-tooltip>
<!-- 经验值记录表格 -->
<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
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 }">
<span v-if="row">{{ row.memberMobile || row.mobile || "-" }}</span>
{{ row ? (row.memberMobile || row.mobile || "-") : "-" }}
</template>
</el-table-column>
<el-table-column label="规则名称" width="150" show-overflow-tooltip>
<!-- 规则名称 -->
<el-table-column label="规则名称" width="130" align="center">
<template #default="{ row }">
<span v-if="row">{{ row.ruleName || findRuleName(row.ruleKey) || "-" }}</span>
{{ row ? (row.ruleName || findRuleName(row.ruleKey) || "-") : "-" }}
</template>
</el-table-column>
<el-table-column label="变化经验值" width="120">
<!-- 变动前经验值 -->
<el-table-column label="变动前经验值" width="120" align="right">
<template #default="{ row }">
<span v-if="row">{{ formatExperienceValue(row) }}</span>
<span v-if="row">{{ row.beforeExperience != null ? formatNumber(row.beforeExperience) : "-" }}</span>
</template>
</el-table-column>
<el-table-column label="经验值限额" width="120">
<!-- 变动经验值 -->
<el-table-column label="变动经验值" width="120" align="right">
<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>
</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>
<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>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="170" />
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
@@ -82,8 +151,10 @@
import { onMounted, ref } from "vue";
import { useTable } from "@/utils/useTable";
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: "REGISTER", label: "注册" },
{ value: "SIGN_IN", label: "签到" },
@@ -101,7 +172,7 @@ defineOptions({ name: "memberGradeExperienceLog" });
const searchFormRef = ref(null);
const { loading, data, total, searchForm, getDataList: getData } = useTable(
const { loading, data, total, searchForm, getDataList: getData, resetSearch } = useTable(
API_Member.getMemberExperienceByPage,
{
pageSize: 20,
@@ -111,45 +182,138 @@ const { loading, data, total, searchForm, getDataList: getData } = useTable(
}
);
onMounted(() => {
init();
});
function formatNumber(val) {
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) {
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) {
const params = {
pageNumber: base.pageNumber,
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;
return params;
}
function handleSearch() {
searchForm.pageNumber = 1;
getData();
}
function handleReset() {
searchForm.memberMobile = "";
searchForm.ruleKey = "";
searchForm.pageNumber = 1;
resetSearch();
}
function changePage(v) {
searchForm.pageNumber = v;
getData();
}
function changePageSize(v) {
searchForm.pageNumber = 1;
searchForm.pageSize = v;
getData();
}
onMounted(() => {
getData();
});
</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

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

View File

@@ -48,6 +48,11 @@
</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">
<el-button type="primary" @click="addMember">添加会员</el-button>
<el-button
@@ -88,6 +93,11 @@
</el-table-column>
<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 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">
<template #default="{ row }">
<span v-if="row">{{ row.mobile || "" }}</span>
@@ -142,7 +152,7 @@
<el-dropdown-item command="edit">编辑会员</el-dropdown-item>
<el-dropdown-item command="increaseWallet">增加余额</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>
</template>
</el-dropdown>
@@ -308,6 +318,7 @@
<script setup>
import { ref, watch, nextTick, onMounted } from "vue";
import { useRoute } from "vue-router";
import { ArrowDown } from "@element-plus/icons-vue";
import multipleMap from "@/components/map/multiple-map";
import * as API_Member from "@/api/member.js";
@@ -320,6 +331,8 @@ import defaultPic from "@/assets/default.png";
defineOptions({ name: "member" });
const route = useRoute();
const props = defineProps({
selectedMember: {
type: Boolean,
@@ -356,6 +369,7 @@ const addMemberForm = ref({
username: "",
password: "",
});
const memberStatus = ref("ALL");
const searchForm = ref({
pageNumber: 1,
pageSize: 20,
@@ -363,7 +377,7 @@ const searchForm = ref({
id: "",
username: "",
mobile: "",
disabled: "OPEN",
disabled: props.selectedMember ? "OPEN" : "",
groupId: "",
});
const picModelFlag = ref(false);
@@ -511,7 +525,15 @@ function submitSetMemberGroup() {
const groupId = memberGroupForm.value.groupId;
memberGroupLoading.value = true;
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) => {
memberGroupLoading.value = false;
@@ -575,6 +597,11 @@ function handleSearch() {
searchForm.value.pageSize = 20;
getData();
}
function handleStatusChange(status) {
searchForm.value.disabled = status === "ALL" ? "" : status;
searchForm.value.pageNumber = 1;
getData();
}
function editPerm(val) {
descTitle.value = `查看用户 ${val.username}`;
descFlag.value = true;
@@ -598,20 +625,27 @@ function getMemberInfo(id) {
}
});
}
let memberListRequestId = 0;
function getData() {
const requestId = ++memberListRequestId;
loading.value = true;
if (!props.selectedMember) {
selectedRows.value = [];
clearSelection();
}
API_Member.getMemberListData(searchForm.value).then((res) => {
if (res.result.records) {
loading.value = false;
init(res.result.records);
total.value = res.result.total;
syncTableSelection();
}
});
API_Member.getMemberListData({ ...searchForm.value })
.then((res) => {
if (requestId !== memberListRequestId) return;
if (res.success && res.result) {
init(res.result.records || []);
total.value = res.result.total || 0;
syncTableSelection();
}
})
.catch(() => {})
.finally(() => {
if (requestId === memberListRequestId) loading.value = false;
});
}
function openPicSelector() {
picModelFlag.value = true;
@@ -655,14 +689,15 @@ function detail(row) {
customRouterPush({ name: "member-detail", query: { id: row.id } });
}
function disabled(v) {
const action = v.disabled ? "禁用" : "启用";
let params = {
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) => {
if (res.success) {
ElMessage.success("禁用成功");
ElMessage.success(`${action}成功`);
getData();
}
});
@@ -758,11 +793,25 @@ function handleSubmitModal() {
}
onMounted(() => {
if (route.query && route.query.groupId) {
searchForm.value.groupId = String(route.query.groupId);
}
getData();
loadMemberGroupList();
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())
defineExpose({ getSelection });
</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

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

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

@@ -150,12 +150,11 @@
</template>
<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 { ElMessage, ElMessageBox } from "element-plus";
import { unitPrice, customRouterPush } from "@/utils/filters";
const QUERY_KEY = "manager-order-list-query";
const PAGE_SIZES = [20, 50, 100];
const PROMOTION_OPTIONS = [
@@ -201,37 +200,11 @@ function emptySearchForm(pageSize = 20) {
};
}
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" });
const route = useRoute();
const searchFormRef = ref(null);
const tableRef = ref(null);
@@ -248,6 +221,25 @@ const total = ref(0);
const orderNumData = ref({});
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 count = (value) => (value ? `(${value})` : "");
return [
@@ -340,23 +332,6 @@ function orderStatusTagType(v) {
};
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() {
getDataList();
getOrderNumData();
@@ -380,7 +355,6 @@ function handleReset() {
searchForm.value = emptySearchForm(pageSize);
selectDate.value = null;
currentStatus.value = "ALL";
sessionStorage.removeItem(QUERY_KEY);
init();
}
function selectDateRange(v) {
@@ -403,7 +377,6 @@ function countParams() {
}
function getDataList() {
loading.value = true;
rememberQuery();
API_Order.getOrderList(searchForm.value)
.then((res) => {
if (res.success) {
@@ -478,10 +451,24 @@ function getOrderNumData() {
});
}
// created
restoreQuery();
watch(
() => route.query,
(newQuery) => {
if (newQuery && Object.keys(newQuery).length > 0) {
applyRouteQuery();
searchForm.value.pageNumber = 1;
init();
}
}
);
onMounted(() => {
try {
sessionStorage.removeItem("manager-order-list-query");
} catch (e) {
// ignore
}
applyRouteQuery();
init();
});
</script>

View File

@@ -29,6 +29,10 @@
<div class="hotzone-preview-box" v-if="options.imageUrl">
<img :src="options.imageUrl" alt="hotzone" class="img-preview" />
<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-icon class="mr-4"><Picture /></el-icon>
更换底图
@@ -46,6 +50,9 @@
<div class="section-title flex-between">
<span>热区列表 ({{ options.zones?.length || 0 }})</span>
<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-icon><Plus /></el-icon> 添加热区
</el-button>
@@ -204,16 +211,25 @@
</div>
<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>
<!-- 绘制热区弹窗(复用移动端热区绘制器) -->
<hotzone ref="hotzoneDialogRef" width="960px" @changeZone="handleChangeZone" />
</div>
</template>
<script setup>
import { inject } from 'vue';
import { Plus, Delete, Link, Picture, Close, Top, Bottom } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
import { ref, inject } from 'vue';
import { Plus, Delete, Link, Picture, Close, Top, Bottom, Aim } from '@element-plus/icons-vue';
import { ElMessageBox, ElMessage } from 'element-plus';
import hotzone from '@/components/hotzone';
const props = defineProps({
component: {
@@ -298,6 +314,68 @@ function handleAddZone() {
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>
<style lang="scss" scoped>

View File

@@ -299,7 +299,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'channelGrid',
defaultRowHeight: 120,
defaultRowHeight: '',
name: '频道宫格',
group: 'navigation',
icon: 'Grid',
@@ -431,7 +431,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'brandWall',
defaultRowHeight: 220,
defaultRowHeight: '',
name: '品牌图',
group: 'media',
icon: 'Medal',
@@ -513,7 +513,7 @@ export const COMPONENT_DEFINITIONS = [
defaultOptions: {
height: 320,
height: 450,
elements: []
@@ -532,7 +532,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'promoGroup',
defaultRowHeight: 180,
defaultRowHeight: 220,
name: '活动卡组',
@@ -567,13 +567,14 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'video',
defaultRowHeight: 360,
defaultRowHeight: 450,
name: '视频',
group: 'media',
icon: 'VideoCamera',
description: '品牌宣传与商品视频播放',
defaultOptions: {
videoUrl: '',
height: 450,
coverUrl: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/video/video_cover.png',
url: '',
autoPlay: false,
@@ -597,7 +598,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'goodsList',
defaultRowHeight: 980,
defaultRowHeight: '',
name: '商品列表',
@@ -727,7 +728,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'gridFloor',
defaultRowHeight: 560,
defaultRowHeight: '',
name: '宫格楼层',
@@ -843,7 +844,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'goodsGrid',
defaultRowHeight: 420,
defaultRowHeight: '',
name: '商品宫格',
group: 'commerce',
icon: 'Menu',
@@ -922,7 +923,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'bannerFloor',
defaultRowHeight: 560,
defaultRowHeight: '',
name: '横幅楼层',
@@ -964,7 +965,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'goodsBar',
defaultRowHeight: 280,
defaultRowHeight: '',
name: '商品条',
@@ -1001,7 +1002,7 @@ export const COMPONENT_DEFINITIONS = [
},
{type: 'channelFloor',
defaultRowHeight: 620,
defaultRowHeight: '',
name: '频道楼层',
@@ -1045,7 +1046,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'recommendFeed',
defaultRowHeight: 1270,
defaultRowHeight: '',
name: '推荐商品',
@@ -1120,7 +1121,7 @@ export const COMPONENT_DEFINITIONS = [
},
{
type: 'couponList',
defaultRowHeight: 160,
defaultRowHeight: '',
name: '优惠券',

View File

@@ -225,7 +225,7 @@
:class="{ 'is-row-selected': isCurrentRowSelected(row.id) }"
:style="getRowWrapperStyle(row.rowStyle)"
:style="getRowWrapperStyle(row.rowStyle, row.components)"
@click.stop="handleSelectRow(row, rIndex)"
@@ -289,14 +289,14 @@
group="pc-row-components"
item-key="id"
class="row-components-flex"
:style="getRowFlexStyle(row.rowStyle)"
:style="getRowFlexStyle(row.rowStyle, row.components)"
@change="markDirty"
>
<template #item="{ element: comp, index: cIndex }">
<div
class="canvas-comp-wrap"
: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)"
>
<!-- 选中的组件左上角名称标识徽标(万米魔方设计) -->
@@ -800,11 +800,19 @@ function clearSelection() {
function normalizeRows(list) {
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 = [];
list.forEach(r => {
if (!r) return;
if (r.rowStyle && (r.rowStyle.height === 40 || r.rowStyle.height === "40")) {
r.rowStyle.height = "";
if (r.rowStyle) {
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);
});
@@ -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%' };
const s = {};
@@ -1280,8 +1289,12 @@ function getRowWrapperStyle(style) {
const h = String(style.height).trim();
if (h && Number(h) > 0) {
const hVal = /^\d+$/.test(h) ? `${h}px` : h;
s.height = hVal;
s.minHeight = hVal;
if (hasAutoComp) {
s.minHeight = hVal;
} else {
s.height = hVal;
s.minHeight = hVal;
}
}
}
@@ -1318,7 +1331,8 @@ function getRowWrapperStyle(style) {
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%' };
const s = {
display: 'flex',
@@ -1333,8 +1347,12 @@ function getRowFlexStyle(style) {
const h = String(style.height).trim();
if (h && Number(h) > 0) {
const hVal = /^\d+$/.test(h) ? `${h}px` : h;
s.height = hVal;
s.minHeight = hVal;
if (hasAutoComp) {
s.minHeight = hVal;
} else {
s.height = hVal;
s.minHeight = hVal;
}
}
}
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 {};
@@ -1358,8 +1377,12 @@ function getCompWrapperStyle(style, rowStyle = {}, totalComps = 1) {
const rh = String(rowStyle.height).trim();
if (rh && Number(rh) > 0) {
const rhVal = /^\d+$/.test(rh) ? `${rh}px` : rh;
s.height = rhVal;
s.minHeight = rhVal;
if (isAutoHeightComp) {
s.minHeight = rhVal;
} else {
s.height = rhVal;
s.minHeight = rhVal;
}
}
}
const gap = Number(rowStyle?.gap) || 0;
@@ -1612,7 +1635,11 @@ function goBack() {
} 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(() => {
const id = route.query.id;
@@ -1671,7 +1700,10 @@ onMounted(() => {
onUnmounted(() => {
if (autoSaveTimer) clearInterval(autoSaveTimer);
if (autoSaveTimer) {
clearInterval(autoSaveTimer);
autoSaveTimer = null;
}
});

View File

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

View File

@@ -23,7 +23,7 @@ const props = defineProps({
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
height: `${props.options?.height || 260}px`,
height: `${props.options?.height || 450}px`,
backgroundColor: s.backgroundColor || '#f8fafc',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`

View File

@@ -7,10 +7,10 @@
:key="idx"
class="zone-mask"
:style="{
top: `${zone.top || 0}%`,
left: `${zone.left || 0}%`,
width: `${zone.width || 10}%`,
height: `${zone.height || 10}%`
top: `${zone.top !== undefined ? zone.top : ((zone.topPer || 0) * 100)}%`,
left: `${zone.left !== undefined ? zone.left : ((zone.leftPer || 0) * 100)}%`,
width: `${zone.width !== undefined ? zone.width : ((zone.widthPer || 0.1) * 100)}%`,
height: `${zone.height !== undefined ? zone.height : ((zone.heightPer || 0.1) * 100)}%`
}"
>
<span class="zone-label">{{ zone.title || `热区 ${idx + 1}` }}</span>

View File

@@ -32,12 +32,13 @@
</el-table>
</el-card>
<el-dialog
<el-drawer
v-model="modalVisible"
:title="modalTitle"
width="500px"
size="500px"
:close-on-click-modal="false"
destroy-on-close
append-to-body
>
<el-form ref="formRef" :model="formAdd" label-width="100px" :rules="formValidate">
<el-form-item v-if="showParent" label="上级分类" prop="parentId">
@@ -55,7 +56,7 @@
<el-button @click="modalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="Submit">提交</el-button>
</template>
</el-dialog>
</el-drawer>
</div>
</template>

View File

@@ -0,0 +1,170 @@
<template>
<div>
<el-card>
<template #header>
<span>{{ isEdit ? "编辑文章" : "添加文章" }}</span>
</template>
<el-form ref="formRef" :model="form" label-width="100px" :rules="formValidate">
<el-form-item label="文章标题" prop="title">
<el-input v-model="form.title" clearable style="width: 40%" />
</el-form-item>
<el-form-item label="文章分类" prop="categoryId">
<el-tree-select
v-model="form.categoryId"
:data="treeData"
:props="{ label: 'title', value: 'value', children: 'children' }"
placeholder="请选择"
clearable
check-strictly
style="width: 180px"
/>
</el-form-item>
<el-form-item label="文章排序" prop="sort">
<el-input v-model="form.sort" type="number" clearable style="width: 10%" />
</el-form-item>
<el-form-item class="form-item-view-el" label="文章内容" prop="content">
<tinymec v-if="!loading" ref="editor" open-xss v-model="form.content" />
</el-form-item>
<el-form-item label="是否展示" prop="openStatus">
<el-switch
v-model="form.openStatus"
inline-prompt
active-text="展示"
inactive-text="隐藏"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">提交</el-button>
<el-button @click="goBack">返回</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
getArticleCategory,
saveArticle,
updateArticle,
seeArticle,
} from "@/api/pages";
import tinymec from "@/components/editor/index.vue";
import { regular } from "@/utils";
import { Message } from "@/utils/message";
const validateArticleContent = (rule, value, callback) => {
const html = String(value || "").trim();
if (!html) {
callback(new Error("请填写文章内容"));
return;
}
const text = html
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/gi, " ")
.trim();
const hasImage = /<img[\s>]/i.test(html);
if (!text && !hasImage) {
callback(new Error("请填写文章内容"));
return;
}
callback();
};
defineOptions({ name: "articleEdit" });
const route = useRoute();
const router = useRouter();
const formRef = ref(null);
const editor = ref(null);
const loading = ref(false);
const submitLoading = ref(false);
const treeData = ref([]);
const form = ref({
openStatus: false,
title: "",
categoryId: "",
sort: 1,
content: "",
id: "",
});
const formValidate = ref({
title: [{ required: true, message: "请输入文章标题", trigger: "blur" }],
categoryId: [{ required: true, message: "请选择文章分类", trigger: "change" }],
sort: [regular.REQUIRED, regular.INTEGER],
content: [{ required: true, validator: validateArticleContent, trigger: "change" }],
});
const isEdit = ref(!!route.query.id);
function getTree(tree = []) {
const arr = [];
if (!!tree && tree.length !== 0) {
tree.forEach((item) => {
const obj = {};
obj.title = item.articleCategoryName;
obj.value = item.id;
obj.children = getTree(item.children);
arr.push(obj);
});
}
return arr;
}
function getCategoryTree() {
getArticleCategory(0).then((res) => {
if (res.success) {
treeData.value = getTree(res.result);
}
});
}
function getDetail(id) {
loading.value = true;
seeArticle(id)
.then((res) => {
if (res.result) {
form.value.categoryId = res.result.categoryId;
form.value.id = id;
form.value.content = res.result.content;
form.value.title = res.result.title;
form.value.sort = res.result.sort;
form.value.openStatus = res.result.openStatus;
}
})
.finally(() => {
loading.value = false;
});
}
function handleSubmit() {
if (editor.value?.getContent) {
form.value.content = editor.value.getContent();
}
formRef.value.validate((valid) => {
if (!valid) return;
submitLoading.value = true;
const request = isEdit.value ? updateArticle(form.value) : saveArticle(form.value);
request
.then((res) => {
if (res.success) {
Message.success("操作成功");
goBack();
}
})
.finally(() => {
submitLoading.value = false;
});
});
}
function goBack() {
router.back();
}
onMounted(() => {
getCategoryTree();
if (isEdit.value) {
getDetail(route.query.id);
}
});
</script>

View File

@@ -2,6 +2,9 @@
<div class="article-page">
<div class="article-page__aside">
<el-card class="article-category">
<template #header>
<span>文章分类</span>
</template>
<el-tree
:data="treeData"
:props="{ label: 'title', children: 'children' }"
@@ -13,38 +16,43 @@
</el-card>
</div>
<div class="article-page__main">
<el-card class="article-detail">
<el-form
ref="searchFormRef"
:model="searchForm"
inline
label-width="70px"
style="width: 100%"
class="search-form"
@keyup.enter="handleSearch"
>
<el-form-item label="文章标题" prop="title">
<el-input
v-model="searchForm.title"
placeholder="请输入文章标题"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
</el-form-item>
</el-form>
<el-card class="article-search">
<el-form
ref="searchFormRef"
:model="searchForm"
inline
label-width="70px"
class="search-form"
@keyup.enter="handleSearch"
>
<el-form-item label="文章标题" prop="title">
<el-input
v-model="searchForm.title"
placeholder="请输入文章标题"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
</el-form-item>
</el-form>
</el-card>
<div class="operation padding-row">
<el-card class="article-detail mt_10">
<div class="toolbar">
<el-button v-if="!selected" type="primary" @click="add">添加</el-button>
</div>
<div class="article-table-wrap">
<el-table ref="table" v-loading="loading" border :data="data" style="width: 100%">
<el-table-column prop="articleCategoryName" label="分类名称" width="150" />
<el-table-column prop="title" label="文章标题" min-width="200" show-overflow-tooltip />
<el-table-column label="是否显示" width="100">
<el-table-column label="文章标题" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
{{ decodeEntities(row.title) }}
</template>
</el-table-column>
<el-table-column label="是否显示" width="100" align="center">
<template #default="{ row }">
<el-switch
v-if="row"
@@ -56,10 +64,10 @@
/>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="100" />
<el-table-column label="操作" width="230" align="center">
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column label="操作" width="160" align="right">
<template #default="{ row, $index }">
<template v-if="row">
<div v-if="row" class="ops-row">
<a
v-if="selected"
class="link-text"
@@ -71,7 +79,7 @@
<a class="link-text" @click="edit(row)">编辑</a>
<span class="op-split">|</span>
<a class="link-text" @click="remove(row)">删除</a>
</template>
</div>
</template>
</el-table-column>
</el-table>
@@ -91,92 +99,20 @@
</div>
</el-card>
</div>
<el-dialog
v-if="!selected"
v-model="modalVisible"
:title="modalTitle"
width="1100px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form ref="formRef" :model="form" label-width="100px" :rules="formValidate">
<el-form-item label="文章标题" prop="title">
<el-input v-model="form.title" clearable style="width: 40%" />
</el-form-item>
<el-form-item label="文章分类" prop="categoryId">
<el-tree-select
v-model="form.categoryId"
:data="treeDataDefault"
:props="{ label: 'title', value: 'value', children: 'children' }"
placeholder="请选择"
clearable
check-strictly
style="width: 180px"
@change="handleCategoryChange"
/>
</el-form-item>
<el-form-item label="文章排序" prop="sort">
<el-input v-model="form.sort" type="number" clearable style="width: 10%" />
</el-form-item>
<el-form-item class="form-item-view-el" label="文章内容" prop="content">
<tinymec
v-if="modalVisible"
ref="editor"
open-xss
v-model="form.content"
/>
</el-form-item>
<el-form-item label="是否展示" prop="openStatus">
<el-switch
v-model="form.openStatus"
inline-prompt
active-text="展示"
inactive-text="隐藏"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="modalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, watch, nextTick, onMounted } from "vue";
import { ref, watch, onMounted } from "vue";
import { useRouter } from "vue-router";
import {
getArticleCategory,
saveArticle,
getArticle,
delArticle,
updateArticle,
seeArticle,
updateArticleStatus,
} from "@/api/pages";
import tinymec from "@/components/editor/index.vue";
import { regular } from "@/utils";
import { Message, Modal } from "@/utils/message";
const validateArticleContent = (rule, value, callback) => {
const html = String(value || "").trim();
if (!html) {
callback(new Error("请填写文章内容"));
return;
}
const text = html
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/gi, " ")
.trim();
const hasImage = /<img[\s>]/i.test(html);
if (!text && !hasImage) {
callback(new Error("请填写文章内容"));
return;
}
callback();
};
defineProps({
selected: {
type: Boolean,
@@ -185,19 +121,14 @@ defineProps({
});
const emit = defineEmits(["callbacked"]);
const router = useRouter();
// 模板 el-form 的 ref(原 ref="searchForm",与 data 同名会互相覆盖,改名为 searchFormRef)
const searchFormRef = ref(null);
const table = ref(null);
// 模板 el-form 的 ref(原 ref="form",与 data 同名会互相覆盖,改名为 formRef)
const formRef = ref(null);
const editor = ref(null);
const selectedIndex = ref(99999);
const loading = ref(true);
const modalType = ref(0);
const modalVisible = ref(false);
const modalTitle = ref("");
const treeDataDefault = ref([]);
const searchForm = ref({
pageNumber: 1,
pageSize: 20,
@@ -206,24 +137,7 @@ const searchForm = ref({
categoryId: "",
});
const searchTreeValue = ref("");
const form = ref({
openStatus: false,
title: "",
categoryId: "",
sort: 1,
content: "",
id: "",
});
const formValidate = ref({
title: [{ required: true, message: "请输入文章标题", trigger: "blur" }],
categoryId: [{ required: true, message: "请选择文章分类", trigger: "change" }],
sort: [regular.REQUIRED, regular.INTEGER],
content: [{ required: true, validator: validateArticleContent, trigger: "change" }],
});
const list = ref([]);
const treeValue = ref("");
const treeData = ref([]);
const submitLoading = ref(false);
const data = ref([]);
const total = ref(0);
@@ -242,15 +156,6 @@ watch(
{ deep: true }
);
function getDefaultForm() {
return {
openStatus: false,
title: "",
categoryId: "",
sort: 1,
content: "",
};
}
function init() {
getDataList();
getAllList(0);
@@ -268,37 +173,13 @@ function handleCateChange(data) {
}
function changeSwitch(v) {
const params = { status: v.openStatus };
updateArticleStatus(v.id, params).then((res) => {
submitLoading.value = false;
if (res.success) {
}
});
updateArticleStatus(v.id, params).then(() => {});
}
function handleCategoryChange(value) {
form.value.categoryId = value;
const findTitle = (nodes, id) => {
for (const node of nodes || []) {
if (node.value === id) return node.title;
const childTitle = findTitle(node.children, id);
if (childTitle) return childTitle;
}
return "";
};
treeValue.value = findTitle(treeDataDefault.value, value);
}
function handleCheckChange(data) {
let value = "";
let title = "";
list.value = [];
data.forEach((item) => {
value += `${item.value},`;
title += `${item.title},`;
});
value = value.substring(0, value.length - 1);
title = title.substring(0, title.length - 1);
list.value.push({ value, title });
form.value.categoryId = value;
treeValue.value = title;
function decodeEntities(str) {
if (!str) return "";
const txt = document.createElement("textarea");
txt.innerHTML = str;
return txt.value;
}
function changePage(v) {
searchForm.value.pageNumber = v;
@@ -320,7 +201,6 @@ function getAllList(parent_id) {
loading.value = false;
if (res.success) {
treeData.value = getTree(res.result);
treeDataDefault.value = getTree(res.result);
treeData.value.unshift({
title: "全部",
level: 0,
@@ -360,8 +240,7 @@ function buildArticleSearchParams() {
}
return params;
}
function getDataList(val) {
if (val) form.value = {};
function getDataList() {
loading.value = true;
getArticle(buildArticleSearchParams())
.then((res) => {
@@ -374,63 +253,11 @@ function getDataList(val) {
loading.value = false;
});
}
function handleSubmit() {
if (editor.value?.getContent) {
form.value.content = editor.value.getContent();
}
formRef.value.validate((valid) => {
if (valid) {
submitLoading.value = true;
if (modalType.value === 0) {
delete form.value.id;
saveArticle(form.value).then((res) => {
submitLoading.value = false;
if (res.success) {
Message.success("操作成功");
getDataList();
modalVisible.value = false;
}
});
} else {
updateArticle(form.value).then((res) => {
submitLoading.value = false;
if (res.success) {
Message.success("操作成功");
getDataList();
modalVisible.value = false;
}
});
}
}
});
}
function add() {
modalType.value = 0;
modalTitle.value = "添加文章";
treeValue.value = "";
form.value = getDefaultForm();
modalVisible.value = true;
nextTick(() => {
formRef.value?.clearValidate?.();
});
router.push({ name: "article-edit" });
}
function edit(data) {
modalType.value = 1;
modalTitle.value = "编辑文章";
treeValue.value = "";
form.value = getDefaultForm();
seeArticle(data.id).then((res) => {
if (res.result) {
modalVisible.value = true;
form.value.categoryId = res.result.categoryId;
treeValue.value = data.articleCategoryName;
form.value.id = data.id;
form.value.content = res.result.content;
form.value.title = res.result.title;
form.value.sort = res.result.sort;
form.value.openStatus = res.result.openStatus;
}
});
function edit(row) {
router.push({ name: "article-edit", query: { id: row.id } });
}
function remove(v) {
Modal.confirm({
@@ -458,7 +285,7 @@ onMounted(() => {
.article-page {
display: flex;
gap: 10px;
align-items: flex-start;
align-items: stretch;
width: 100%;
min-width: 0;
}
@@ -468,6 +295,8 @@ onMounted(() => {
width: 220px;
min-width: 180px;
max-width: 260px;
display: flex;
flex-direction: column;
}
.article-page__main {
@@ -476,10 +305,25 @@ onMounted(() => {
}
.article-category,
.article-search,
.article-detail {
width: 100%;
}
.article-category {
flex: 1;
height: 100%;
}
.toolbar {
display: flex;
margin-bottom: 10px;
}
.ops-row {
white-space: nowrap;
}
.article-table-wrap {
width: 100%;
overflow-x: auto;

View File

@@ -25,10 +25,10 @@
<el-empty v-if="!data.length" description="暂无热词,点击上方按钮设置" :image-size="80" />
</div>
<el-dialog
<el-drawer
v-model="modalVisible"
:title="modalTitle"
width="500px"
size="500px"
:close-on-click-modal="false"
append-to-body
destroy-on-close
@@ -45,7 +45,7 @@
<el-button @click="modalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</el-drawer>
</div>
</template>

View File

@@ -45,7 +45,7 @@
</div>
</el-card>
<el-dialog v-model="detailVisible" title="详细信息" width="500px" destroy-on-close>
<el-drawer v-model="detailVisible" title="详细信息" size="500px" destroy-on-close append-to-body>
<el-form ref="formValidate" :model="form" label-width="80px">
<el-form-item label="用户名" prop="userName">
<span>{{ form.userName }}</span>
@@ -84,7 +84,7 @@
<template #footer>
<el-button @click="detailVisible = false">取消</el-button>
</template>
</el-dialog>
</el-drawer>
</div>
</template>

View File

@@ -1,76 +1,162 @@
<template>
<div>
<div class="content-goods-publish">
<el-form ref="form" label-width="70px">
<div class="base-info-item">
<h4>优惠券活动详情</h4>
<div class="form-item-view">
<el-form-item label="活动名称">
<span>{{ couponActivity.promotionName }}</span>
</el-form-item>
<el-form-item label="活动类型">
<span v-if="couponActivity.couponActivityType === 'REGISTERED'">新人发券</span>
<span v-else-if="couponActivity.couponActivityType === 'AUTO_COUPON'">自动发券</span>
<span v-else>精确发券</span>
</el-form-item>
<el-form-item
v-if="couponActivity.couponFrequencyEnum != '' && couponActivity.couponFrequencyEnum != null && couponActivity.couponFrequencyEnum != undefined"
label="领取频率"
>
<span v-if="couponActivity.couponFrequencyEnum === 'DAY'">每日</span>
<span v-else-if="couponActivity.couponFrequencyEnum === 'WEEK'">每周一次</span>
<span v-else>每月一次</span>
</el-form-item>
<el-form-item
v-if="couponActivity.couponActivityType === 'SPECIFY' || couponActivity.couponActivityType === 'AUTO_COUPON'"
label="活动范围"
>
<span v-if="couponActivity.activityScope === 'ALL'">全部会员</span>
<span v-else>指定会员</span>
</el-form-item>
<el-form-item label="活动时间">
<span>{{ couponActivity.startTime }}~{{ couponActivity.endTime }}</span>
</el-form-item>
<el-form-item label="活动状态">
<span v-if="couponActivity.promotionStatus === 'NEW'">未开始</span>
<span v-if="couponActivity.promotionStatus === 'START'">已开始</span>
<span v-if="couponActivity.promotionStatus === 'END'">已结束</span>
<span v-if="couponActivity.promotionStatus === 'CLOSE'">已关闭</span>
</el-form-item>
<div class="coupon-activity-info-page">
<!-- 顶部标题与返回栏 -->
<el-card shadow="never" class="header-card">
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>优惠券活动详情</span>
<el-tag :type="statusTagType(couponActivity.promotionStatus)" size="small" effect="plain" class="status-tag">
{{ statusText(couponActivity.promotionStatus) }}
</el-tag>
<span v-if="id" class="activity-id font-mono">ID: {{ id }}</span>
</div>
<h4>优惠券列表</h4>
<el-table :data="couponData" style="width: 100%">
<el-table-column prop="couponName" label="优惠券名称" />
<el-table-column label="优惠券金额">
<template #default="{ row }">
<template v-if="row">
<span v-if="row.couponType === 'DISCOUNT'">{{ row.couponDiscount }}折</span>
<priceColorScheme v-else-if="row.couponType === 'PRICE'" :value="row.price" :color="$mainColor" />
<span v-else>未知</span>
</template>
</template>
</el-table-column>
<el-table-column label="优惠券类型">
<template #default="{ row }">
<span v-if="row">{{ couponTypeText(row.couponType) }}</span>
</template>
</el-table-column>
<el-table-column prop="num" label="赠送数量" />
</el-table>
<template v-if="couponActivity.activityScopeInfo && memberData.length > 0">
<h4 class="mt_10">会员列表列表</h4>
<el-table :data="memberData" style="width: 100%">
<el-table-column prop="id" label="会员id" />
<el-table-column prop="nickName" label="昵称" />
</el-table>
</template>
</div>
</el-form>
</div>
<div class="header-right">
<el-button @click="back">返回活动列表</el-button>
</div>
</div>
</el-card>
<div class="footer">
<el-button type="primary" @click="back">返回活动列表</el-button>
</div>
<!-- 活动基本信息卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">基本信息</span>
</div>
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="活动名称">
<span class="font-semibold text-dark">{{ couponActivity.promotionName || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="活动类型">
<el-tag size="small" effect="light" type="primary">
{{ activityTypeText(couponActivity.couponActivityType) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item
v-if="couponActivity.couponFrequencyEnum"
label="领取频率"
>
<span>{{ frequencyText(couponActivity.couponFrequencyEnum) }}</span>
</el-descriptions-item>
<el-descriptions-item
v-if="couponActivity.couponActivityType === 'SPECIFY' || couponActivity.couponActivityType === 'AUTO_COUPON'"
label="活动范围"
>
<el-tag size="small" effect="light" :type="couponActivity.activityScope === 'ALL' ? 'success' : 'warning'">
{{ couponActivity.activityScope === "ALL" ? "全部会员" : "指定会员" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="活动时间" :span="2">
<span class="font-mono text-dark">
{{ couponActivity.startTime || "-" }} 至 {{ couponActivity.endTime || "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="活动状态" :span="2">
<el-tag :type="statusTagType(couponActivity.promotionStatus)" size="small" effect="dark">
{{ statusText(couponActivity.promotionStatus) }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 关联优惠券列表卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">赠送优惠券列表</span>
<span class="section-badge">共 {{ couponData.length }} 张优惠券</span>
</div>
<el-table
border
stripe
:data="couponData"
style="width: 100%"
empty-text="暂无关联优惠券"
class="custom-table"
>
<el-table-column prop="couponName" label="优惠券名称" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="font-semibold text-dark">{{ row.couponName || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="优惠券类型" width="130" align="center">
<template #default="{ row }">
<el-tag v-if="row" size="small" effect="light" :type="row.couponType === 'DISCOUNT' ? 'warning' : 'danger'">
{{ couponTypeText(row.couponType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="面额 / 折扣" width="140" align="right">
<template #default="{ row }">
<template v-if="row">
<span v-if="row.couponType === 'DISCOUNT'" class="price-val font-mono font-semibold">
{{ row.couponDiscount }} 折
</span>
<span v-else-if="row.couponType === 'PRICE'" class="price-val font-mono font-semibold">
¥ {{ Number(row.price || 0).toFixed(2) }}
</span>
<span v-else class="text-placeholder">-</span>
</template>
</template>
</el-table-column>
<el-table-column prop="num" label="赠送数量" width="120" align="right">
<template #default="{ row }">
<span class="font-mono text-dark">{{ row.num ?? "-" }}</span>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 指定会员列表(若存在) -->
<el-card
v-if="memberData && memberData.length > 0"
shadow="never"
class="info-card mt_10"
>
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">指定参与会员列表</span>
<span class="section-badge">共 {{ memberData.length }} 位会员</span>
</div>
<el-table
border
stripe
:data="memberData"
style="width: 100%"
class="custom-table"
>
<el-table-column prop="id" label="会员ID" min-width="220">
<template #default="{ row }">
<span class="font-mono text-dark">{{ row.id || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="nickName" label="会员昵称" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="font-semibold text-dark">{{ row.nickName || row.username || "-" }}</span>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 底部操作栏 -->
<el-card shadow="never" class="footer-card mt_10">
<div class="footer-actions">
<el-button type="primary" @click="back">返回活动列表</el-button>
</div>
</el-card>
</div>
</template>
@@ -96,65 +182,175 @@ onMounted(() => {
});
function couponTypeText(v) {
if (v === "DISCOUNT") return "打折";
if (v === "PRICE") return "减免现金";
return "未知";
if (v === "DISCOUNT") return "打折券";
if (v === "PRICE") return "减免现金券";
return "未知类型";
}
function activityTypeText(t) {
if (t === "REGISTERED") return "新人发券";
if (t === "AUTO_COUPON") return "自动发券";
if (t === "SPECIFY") return "精确发券";
return t || "-";
}
function frequencyText(f) {
if (f === "DAY") return "每日一次";
if (f === "WEEK") return "每周一次";
if (f === "MONTH") return "每月一次";
return f || "-";
}
function statusText(s) {
if (s === "NEW") return "未开始";
if (s === "START") return "已开始";
if (s === "END") return "已结束";
if (s === "CLOSE") return "已关闭";
return s || "-";
}
function statusTagType(s) {
if (s === "START") return "success";
if (s === "NEW") return "info";
if (s === "END") return "danger";
if (s === "CLOSE") return "warning";
return "info";
}
function getCouponActivity() {
if (!id) return;
apiGetCouponActivity(id).then((res) => {
couponActivity.value = res.result;
couponData.value = couponActivity.value.couponActivityItems;
memberData.value = JSON.parse(couponActivity.value.activityScopeInfo);
if (res && res.success && res.result) {
couponActivity.value = res.result;
couponData.value = Array.isArray(res.result.couponActivityItems)
? res.result.couponActivityItems
: [];
if (res.result.activityScopeInfo) {
try {
const parsed = JSON.parse(res.result.activityScopeInfo);
memberData.value = Array.isArray(parsed) ? parsed : [];
} catch {
memberData.value = [];
}
} else {
memberData.value = [];
}
}
});
}
function back() {
appStore.removeTag("coupon-activity-info");
appStore.removeTag("coupon-activity");
router.go(-1);
}
</script>
<style lang="scss" scoped>
.content-goods-publish {
padding: 15px;
margin: 0 auto;
text-align: center;
border: 1px solid #ddd;
background: none repeat 0 0 #fff;
height: 100%;
margin-bottom: 20px;
}
.coupon-activity-info-page {
padding-bottom: 20px;
div.base-info-item {
h4 {
margin-bottom: 10px;
padding: 0 10px;
border: 1px solid #ddd;
background-color: #f8f8f8;
font-weight: bold;
color: #333;
font-size: 14px;
line-height: 40px;
text-align: left;
.header-card {
.page-header-row {
display: flex;
justify-content: space-between;
align-items: center;
.header-left {
.header-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 600;
color: #303133;
.status-tag {
font-weight: normal;
}
.activity-id {
font-size: 13px;
font-weight: normal;
color: #909399;
background-color: #f4f4f5;
padding: 2px 8px;
border-radius: 4px;
}
}
}
}
}
.form-item-view {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
padding-left: 40px;
.info-card {
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.section-indicator {
width: 4px;
height: 16px;
border-radius: 2px;
background-color: #409eff;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.section-badge {
font-size: 12px;
color: #909399;
margin-left: 6px;
}
}
.custom-descriptions {
:deep(.el-descriptions__label) {
width: 140px;
background-color: #fafafa;
color: #606266;
font-weight: 500;
}
:deep(.el-descriptions__content) {
color: #303133;
}
}
.custom-table {
.price-val {
color: #f56c6c;
}
}
}
.footer-card {
.footer-actions {
display: flex;
justify-content: center;
align-items: center;
}
}
.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;
}
}
.footer {
width: 88.7%;
padding: 10px;
background-color: #ffc;
position: fixed;
bottom: 0px;
left: 10%;
text-align: center;
z-index: 9999;
}
</style>

View File

@@ -1,34 +1,72 @@
<template>
<div>
<el-card>
<el-form ref="formRef" :model="form" label-width="120px" :rules="formRule">
<div class="base-info-item">
<h4>基本信息</h4>
<div class="form-item-view">
<div class="coupon-publish-page">
<!-- 顶部页面标题与操作栏 -->
<el-card shadow="never" class="header-card">
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>{{ isOnlyView ? "平台优惠券详情" : (id ? "编辑平台优惠券" : "添加平台优惠券") }}</span>
<el-tag v-if="isOnlyView" type="info" size="small" effect="plain" class="mode-tag">
只读模式
</el-tag>
<el-tag v-else-if="id" type="warning" size="small" effect="plain" class="mode-tag">
编辑模式
</el-tag>
<span v-if="id" class="coupon-id-tip font-mono">ID: {{ id }}</span>
</div>
<p class="page-description">配置优惠内容、发放规则及适用商品范围。</p>
</div>
<div class="header-right">
<el-button :icon="ArrowLeft" @click="closeCurrentPage">返回优惠券列表</el-button>
</div>
</div>
</el-card>
<!-- 表单卡片 -->
<el-card shadow="never" class="form-card mt_10">
<el-form
ref="formRef"
:model="form"
label-width="130px"
:rules="formRule"
class="custom-coupon-form"
>
<!-- 模块一:基本信息 -->
<div class="form-section">
<div class="section-header">
<span class="section-number">01</span>
<span class="section-title">基本信息</span>
<span class="section-subtitle">配置活动名称、券面额及发放规则</span>
</div>
<div class="section-body">
<el-form-item label="活动名称" prop="promotionName">
<el-input
:disabled="disabled"
v-model="form.promotionName"
placeholder="活动名称"
placeholder="请输入活动名称"
clearable
style="width: 260px"
style="width: 360px"
/>
</el-form-item>
<el-form-item label="优惠券名称" prop="couponName">
<el-input
:disabled="disabled"
v-model="form.couponName"
placeholder="优惠券名称"
placeholder="请输入优惠券名称"
clearable
style="width: 260px"
style="width: 360px"
/>
</el-form-item>
<el-form-item label="优惠券类型" prop="couponType">
<el-select :disabled="disabled" v-model="form.couponType" style="width: 260px">
<el-select :disabled="disabled" v-model="form.couponType" style="width: 240px">
<el-option label="打折" value="DISCOUNT" />
<el-option label="减免现金" value="PRICE" />
</el-select>
</el-form-item>
<el-form-item
label="折扣"
prop="couponDiscount"
@@ -42,24 +80,21 @@
:step="0.1"
:precision="1"
v-model="form.couponDiscount"
style="width: 260px"
style="width: 240px"
/>
<span class="describe">请输入0-10的数字,可有一位小数</span>
<span class="form-describe">请输入 0.1 ~ 9.9 之间的数值(1位小数)</span>
</el-form-item>
<el-form-item label="面额" prop="price" v-if="form.couponType == 'PRICE'">
<el-input
:disabled="disabled"
v-model="form.price"
placeholder="面额"
placeholder="请输入减免面额"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item label="活动类型" prop="getType">
<el-select :disabled="disabled" v-model="form.getType" style="width: 260px">
<el-option label="免费领取" value="FREE" />
<el-option label="活动赠送" value="ACTIVITY" />
</el-select>
style="width: 240px"
>
<template #prefix>¥</template>
</el-input>
</el-form-item>
<el-form-item label="店铺承担比例" prop="storeCommission">
@@ -67,21 +102,23 @@
:disabled="disabled"
v-model="form.storeCommission"
placeholder="店铺承担比例"
style="width: 260px"
style="width: 240px"
>
<template #append>%</template>
</el-input>
<span class="describe">店铺承担比例,输入0-100之间数值</span>
<span class="form-describe">店铺承担的资金比例,范围 0-100</span>
</el-form-item>
<el-form-item label="发放数量" prop="publishNum" v-if="form.getType === 'FREE'">
<el-input
:disabled="disabled"
v-model="form.publishNum"
placeholder="发放数量"
style="width: 260px"
placeholder="请输入发放数量"
style="width: 240px"
/>
<div class="tips">如果发放数量为0时,则代表不限制发放数量</div>
<span class="form-describe">若设置为 0,则代表不限制发放数量</span>
</el-form-item>
<el-form-item
label="领取数量限制"
prop="couponLimitNum"
@@ -90,148 +127,243 @@
<el-input
:disabled="disabled"
v-model="form.couponLimitNum"
placeholder="领取限制"
placeholder="每人限领张数"
clearable
style="width: 260px"
style="width: 240px"
/>
<div class="tips">如果领取数量为0时,则代表不限制领取数量</div>
<span class="form-describe">若设置为 0,则代表不限制每人领取数量</span>
</el-form-item>
<el-form-item label="范围描述" prop="description">
<el-form-item label="范围描述" prop="description" class="form-item-wide">
<el-input
:disabled="disabled"
v-model="form.description"
type="textarea"
:rows="4"
:rows="3"
maxlength="50"
show-word-limit
clearable
style="width: 260px"
placeholder="请输入优惠券适用范围或简要说明"
style="width: 480px"
/>
</el-form-item>
</div>
<h4>使用限制</h4>
<div class="form-item-view">
</div>
<!-- 模块二:使用限制 -->
<div class="form-section mt_10">
<div class="section-header">
<span class="section-number">02</span>
<span class="section-title">使用限制</span>
<span class="section-subtitle">配置消费门槛、有效时间以及指定适用商品范围</span>
</div>
<div class="section-body usage-fields">
<el-form-item label="消费门槛" prop="consumeThreshold">
<el-input
:disabled="disabled"
v-model="form.consumeThreshold"
placeholder="消费门槛"
placeholder="请输入消费门槛金额"
clearable
style="width: 260px"
/>
style="width: 240px"
>
<template #prefix>¥</template>
</el-input>
<span class="form-describe">订单满此金额方可使用该优惠券</span>
</el-form-item>
<el-form-item label="有效期" prop="rangeTime">
<div v-if="form.getType == 'ACTIVITY'">
<el-radio-group v-model="rangeTimeType">
<el-radio :disabled="disabled" :value="1" v-if="form.getType !== 'ACTIVITY'">起止时间</el-radio>
<el-radio :disabled="disabled" :value="0">固定时间</el-radio>
</el-radio-group>
</div>
<div v-if="rangeTimeType == 1">
<el-date-picker
:disabled="disabled"
type="datetimerange"
v-model="form.rangeTime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
:disabled-date="options.disabledDate"
style="width: 260px"
/>
</div>
<div class="effectiveDays" v-if="rangeTimeType == 0">
领取当天开始
<el-input-number
:disabled="disabled"
v-model="form.effectiveDays"
:min="1"
style="width: 100px"
:max="365"
/>
天内有效(1-365间的整数)
<div class="validity-field">
<div v-if="form.getType == 'ACTIVITY'" class="validity-type">
<el-radio-group v-model="rangeTimeType">
<el-radio :disabled="disabled" :value="1" v-if="form.getType !== 'ACTIVITY'">起止时间</el-radio>
<el-radio :disabled="disabled" :value="0">固定有效天数</el-radio>
</el-radio-group>
</div>
<div v-if="rangeTimeType == 1">
<el-date-picker
:disabled="disabled"
type="datetimerange"
v-model="form.rangeTime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
range-separator="至"
:disabled-date="options.disabledDate"
style="width: 380px"
/>
</div>
<div class="effectiveDays-row" v-if="rangeTimeType == 0">
<span class="days-label">领取当天开始</span>
<el-input-number
:disabled="disabled"
v-model="form.effectiveDays"
:min="1"
:max="365"
style="width: 130px"
/>
<span class="days-unit">天内有效</span>
</div>
<div v-if="rangeTimeType == 0" class="validity-help">有效天数为 1–365 天的整数</div>
</div>
</el-form-item>
<el-form-item label="使用范围" prop="scopeType">
<el-radio-group v-model="form.scopeType">
<el-radio-button :disabled="disabled" value="ALL">全品类</el-radio-button>
<el-radio-button :disabled="disabled" value="ALL">全品类通用</el-radio-button>
<el-radio-button :disabled="disabled" value="PORTION_GOODS">指定商品</el-radio-button>
<el-radio-button :disabled="disabled" value="PORTION_GOODS_CATEGORY">部分商品分类</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item style="width: 100%" v-if="form.scopeType == 'PORTION_GOODS'">
<div style="display: flex; margin-bottom: 10px">
<el-button :disabled="disabled" type="primary" @click="openSkuList">选择商品</el-button>
<el-button
:disabled="disabled"
type="danger"
plain
style="margin-left: 10px"
@click="delSelectGoods"
>批量删除</el-button>
</div>
<el-table
border
:data="form.promotionGoodsList"
style="width: 100%"
@selection-change="changeSelect"
>
<el-table-column type="selection" width="60" align="center" />
<el-table-column prop="goodsName" label="商品名称" min-width="120" show-overflow-tooltip />
<el-table-column label="商品价格" width="110">
<template #default="{ row }">
<span v-if="row" :style="{ color: $mainColor }">
{{ $filters.unitPrice(row.price, "¥") }}</span>
</template>
</el-table-column>
<el-table-column label="库存" width="90">
<template #default="{ row }">
<span v-if="row">{{ row.quantity }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ $index }">
<!-- 指定适用商品表格 -->
<el-form-item
v-if="form.scopeType == 'PORTION_GOODS'"
:label-width="0"
class="portion-goods-form-item"
>
<div class="portion-goods-wrapper">
<div class="portion-goods-toolbar">
<div class="portion-goods-heading">
<h3>指定商品</h3>
<span>已选 {{ form.promotionGoodsList.length }} 件商品</span>
</div>
<div v-if="!isOnlyView && form.promotionGoodsList.length" class="goods-toolbar-actions">
<el-button
:disabled="disabled"
v-if="selectedGoods.length"
type="danger"
size="small"
plain
@click="delGoods($index)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
@click="delSelectGoods"
>
批量删除 ({{ selectedGoods.length }})
</el-button>
<el-button type="primary" :icon="Plus" @click="openSkuList">选择商品</el-button>
</div>
</div>
<el-table
v-if="form.promotionGoodsList.length"
:data="form.promotionGoodsList"
style="width: 100%"
empty-text="暂无指定商品数据"
class="portion-goods-table"
row-key="skuId"
scrollbar-always-on
@selection-change="changeSelect"
>
<el-table-column
v-if="!isOnlyView"
type="selection"
width="50"
align="center"
/>
<el-table-column label="图片" width="76" align="center">
<template #default="{ row }">
<el-image
v-if="row && row.thumbnail"
:src="row.thumbnail"
fit="cover"
class="table-goods-thumb"
preview-teleported
:preview-src-list="[row.thumbnail]"
>
<template #error>
<div class="thumb-slot">无图</div>
</template>
</el-image>
<div v-else class="table-goods-thumb thumb-slot">无图</div>
</template>
</el-table-column>
<el-table-column
prop="goodsName"
label="商品名称"
min-width="280"
>
<template #default="{ row }">
<div class="selected-goods-name" :title="row.goodsName">{{ row.goodsName || '-' }}</div>
<div class="selected-goods-sku">SKU:{{ row.skuId }}</div>
</template>
</el-table-column>
<el-table-column label="商品单价" width="130" align="right">
<template #default="{ row }">
<span v-if="row" class="price-text font-mono font-semibold">
{{ $filters.unitPrice(row.price, "¥") }}
</span>
</template>
</el-table-column>
<el-table-column label="库存" width="100" align="right">
<template #default="{ row }">
<span v-if="row" class="font-mono">{{ row.quantity ?? "-" }}</span>
</template>
</el-table-column>
<el-table-column
v-if="!isOnlyView"
label="操作"
width="90"
align="center"
fixed="right"
>
<template #default="{ $index }">
<el-button
type="danger"
size="small"
link
@click="delGoods($index)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="portion-goods-empty">
<div class="empty-icon"><el-icon :size="28"><ShoppingBag /></el-icon></div>
<h3>选择优惠券适用的商品</h3>
<p>{{ isOnlyView ? '暂无指定商品' : '仅选中的商品可使用该优惠券,可一次添加多个商品' }}</p>
<el-button v-if="!isOnlyView" type="primary" :icon="Plus" @click="openSkuList">选择商品</el-button>
</div>
</div>
</el-form-item>
<el-form-item v-if="form.scopeType == 'PORTION_GOODS_CATEGORY'">
<!-- 部分商品分类 -->
<el-form-item v-if="form.scopeType == 'PORTION_GOODS_CATEGORY'" label="指定分类">
<el-cascader
:disabled="disabled"
:options="goodsCategoryList"
style="width: 260px"
placeholder="请选择适用的商品分类"
style="width: 360px"
v-model="form.scopeIdGoods"
clearable
/>
</el-form-item>
<div>
<el-button :disabled="disabled" link @click="closeCurrentPage">返回</el-button>
<el-button
:disabled="disabled"
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>提交</el-button>
</div>
</div>
</div>
</el-form>
</el-card>
<!-- 底部固定操作栏 -->
<el-card shadow="never" class="footer-card mt_10">
<div class="footer-actions">
<el-button @click="closeCurrentPage">返回列表</el-button>
<el-button
v-if="!isOnlyView"
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
保存提交
</el-button>
</div>
</el-card>
<sku-select ref="skuSelectRef" @selectedGoodsData="selectedGoodsData"></sku-select>
</div>
</template>
<script setup>
import { onMounted, reactive, ref, watch } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { ArrowLeft, Plus, ShoppingBag } from "@element-plus/icons-vue";
import { useRoute, useRouter } from "vue-router";
import {
savePlatformCoupon,
@@ -277,7 +409,9 @@ const checkWeight = (rule, value, callback) => {
}
};
const disabled = ref(route.query.onlyView);
const isOnlyView = computed(() => route.query.onlyView === "true" || route.query.onlyView === true);
const disabled = computed(() => isOnlyView.value);
const rangeTimeType = ref(1);
const modalType = ref(0);
const form = ref({
@@ -288,7 +422,7 @@ const form = ref({
couponType: "PRICE",
couponName: "",
promotionName: "",
getType: "FREE",
getType: route.query.getType === "ACTIVITY" ? "ACTIVITY" : "FREE",
promotionGoodsList: [],
scopeIdGoods: [],
rangeDayType: "",
@@ -298,6 +432,7 @@ const id = ref(route.query.id);
const submitLoading = ref(false);
const selectedGoods = ref([]);
const goodsCategoryList = ref([]);
const formRule = reactive({
promotionName: [{ required: true, message: "活动名称不能为空" }],
couponName: [{ required: true, message: "优惠券名称不能为空" }],
@@ -328,6 +463,7 @@ const formRule = reactive({
],
description: [{ required: true, message: "请输入范围描述" }],
});
const options = {
disabledDate(date) {
return date && date.valueOf() < Date.now() - 86400000;
@@ -339,6 +475,7 @@ watch(
(val) => {
if (val == "FREE") {
rangeTimeType.value = 1;
formRule.rangeTime = [{ required: true, message: "请选择优惠券有效期" }];
} else {
rangeTimeType.value = 0;
}
@@ -346,8 +483,9 @@ watch(
delete formRule.rangeTime;
}
},
{ deep: true }
{ immediate: true }
);
watch(
() => route.query,
(e) => {
@@ -355,7 +493,9 @@ watch(
if (id.value) {
getCoupon();
} else {
formRef.value.resetFields();
formRef.value?.resetFields();
modalType.value = 0;
form.value.getType = e.getType === "ACTIVITY" ? "ACTIVITY" : "FREE";
}
}
);
@@ -374,7 +514,7 @@ function getCoupon() {
if (!data.promotionGoodsList) data.promotionGoodsList = [];
rangeTimeType.value = data.rangeDayType === "DYNAMICTIME" ? 0 : 1;
if (data.scopeType == "PORTION_GOODS_CATEGORY") {
let prevCascader = data.scopeId.split(",");
let prevCascader = data.scopeId ? data.scopeId.split(",") : [];
function next(params, prev) {
for (let i = 0; i < params.length; i++) {
const item = params[i];
@@ -407,6 +547,7 @@ function getCoupon() {
form.value = data;
});
}
function handleSubmit() {
formRef.value.validate((valid) => {
if (valid) {
@@ -465,7 +606,7 @@ function handleSubmit() {
savePlatformCoupon(params).then((res) => {
submitLoading.value = false;
if (res.success) {
Message.success("优惠券发送成功");
Message.success("优惠券创建成功");
closeCurrentPage();
}
});
@@ -484,11 +625,14 @@ function handleSubmit() {
}
});
}
function closeCurrentPage() {
appStore.removeTag("edit-platform-coupon");
appStore.removeTag("add-platform-coupon");
localStorage.pageOpenedList = JSON.stringify(appStore.pageOpenedList);
router.go(-1);
}
function openSkuList() {
skuSelectRef.value.open("goods");
let data = JSON.parse(JSON.stringify(form.value.promotionGoodsList));
@@ -497,9 +641,11 @@ function openSkuList() {
});
skuSelectRef.value.goodsData = data;
}
function changeSelect(e) {
selectedGoods.value = e;
}
function delSelectGoods() {
if (selectedGoods.value.length <= 0) {
Message.warning("您还未选择要删除的数据");
@@ -520,9 +666,11 @@ function delSelectGoods() {
},
});
}
function delGoods(index) {
form.value.promotionGoodsList.splice(index, 1);
}
function selectedGoodsData(item) {
let list = [];
item.forEach((e) => {
@@ -535,7 +683,7 @@ function selectedGoodsData(item) {
storeName: e.storeName,
skuId: e.id,
categoryPath: e.categoryPath,
thumbnail: e.small,
thumbnail: e.thumbnail || e.small,
goodsType: e.goodsType,
goodsId: e.goodsId,
originPrice: e.price,
@@ -543,6 +691,7 @@ function selectedGoodsData(item) {
});
form.value.promotionGoodsList = list;
}
async function getCagetoryList() {
let data = await getCategoryTree();
goodsCategoryList.value = data.result;
@@ -572,6 +721,7 @@ async function getCagetoryList() {
return { value: item.id, label: item.name, children: item.children };
});
}
function filterCategoryId(list, idArr) {
list.forEach((e) => {
if (e instanceof Array) {
@@ -584,36 +734,230 @@ function filterCategoryId(list, idArr) {
}
</script>
<style lang="scss" scpoed>
h4 {
margin-bottom: 10px;
padding: 0 10px;
border: 1px solid #ddd;
background-color: #f8f8f8;
font-weight: bold;
color: #333;
font-size: 14px;
line-height: 40px;
text-align: left;
}
<style lang="scss" scoped>
.coupon-publish-page {
max-width: 1640px;
padding: 12px 20px 24px;
margin: 0 auto;
.describe {
font-size: 12px;
margin-left: 10px;
color: #999;
}
.header-card {
border: 0;
background: transparent;
:deep(.el-card__body) { padding: 8px 0 4px; }
.page-header-row {
display: flex;
justify-content: space-between;
align-items: center;
.effectiveDays {
font-size: 12px;
color: #999;
.header-left {
.header-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 22px;
font-weight: 500;
color: #303133;
> * {
margin: 0 4px;
.mode-tag {
font-weight: normal;
}
.coupon-id-tip {
font-size: 13px;
font-weight: normal;
color: #909399;
background-color: #f4f4f5;
padding: 2px 8px;
border-radius: 4px;
}
}
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
}
}
.form-card {
background: transparent;
border: 0;
:deep(.el-card__body) { padding: 0; }
.custom-coupon-form {
padding: 0;
}
.form-section {
border: 1px solid #ebeef5;
border-radius: 10px;
padding: 24px;
background-color: var(--el-bg-color);
margin-top: 16px;
.section-header {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 14px;
margin-bottom: 16px;
border-bottom: 1px solid #f0f2f5;
.section-title {
font-size: 15px;
font-weight: 500;
color: #303133;
}
.section-subtitle {
font-size: 12px;
color: #909399;
margin-left: 6px;
}
}
.section-body {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 24px;
width: 100%;
min-width: 0;
}
.section-body.usage-fields { display: block; }
}
}
.form-describe {
font-size: 12px;
margin-top: 6px;
width: 100%;
color: #909399;
}
.validity-field {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
width: 100%;
min-width: 0;
}
.validity-help {
font-size: 12px;
line-height: 20px;
color: var(--el-text-color-secondary);
}
.effectiveDays-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
:deep(.el-input-number) {
flex: 0 0 130px;
width: 130px !important;
max-width: 130px;
}
.days-label,
.days-unit {
font-size: 13px;
color: #606266;
white-space: nowrap;
}
}
.portion-goods-wrapper {
width: 100%;
.portion-goods-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.portion-goods-table {
.table-goods-thumb {
display: block;
width: 44px;
height: 44px;
margin: 0 auto;
border-radius: 4px;
border: 1px solid #ebeef5;
}
.thumb-slot {
display: flex;
justify-content: center;
align-items: center;
font-size: 11px;
color: #c0c4cc;
background-color: #fafafa;
}
.price-text {
color: #f56c6c;
}
}
}
.footer-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
margin-top: 16px;
.footer-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 14px;
}
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.page-description { margin: 8px 0 0; color: var(--el-text-color-secondary); font-size: 13px; }
.section-number { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 28px; height: 28px; border-radius: 7px; background: var(--el-color-primary-light-9); color: var(--el-color-primary); font-size: 12px; font-weight: 500; }
.section-header { flex-wrap: wrap; }
.section-body > .el-form-item { min-width: 0; margin-bottom: 22px; align-self: start; }
.form-item-wide { grid-column: 1 / -1; }
:deep(.el-form-item__content) { min-width: 0; }
.section-body :deep(.el-input), .section-body :deep(.el-select), .section-body :deep(.el-input-number), .section-body :deep(.el-cascader) { width: 100% !important; max-width: 360px; }
.section-body :deep(.el-textarea) { width: 100% !important; max-width: 720px; }
.usage-fields :deep(.el-date-editor.el-date-editor--datetimerange) { width: 520px !important; max-width: 100%; }
.portion-goods-form-item :deep(.el-form-item__content) { margin-left: 0 !important; }
.portion-goods-heading, .goods-toolbar-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
.portion-goods-heading h3 { margin: 0; font-size: 15px; font-weight: 500; }
.portion-goods-heading span { color: var(--el-text-color-secondary); font-size: 13px; }
.goods-toolbar-actions :deep(.el-button + .el-button), .footer-actions :deep(.el-button + .el-button) { margin-left: 0; }
.selected-goods-name { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.6; }
.selected-goods-sku { margin-top: 4px; color: var(--el-text-color-secondary); font: 12px/1.5 Consolas, monospace; overflow-wrap: anywhere; }
.portion-goods-table :deep(.el-table__cell) { padding: 12px 0; }
.portion-goods-table :deep(th.el-table__cell) { background: var(--el-fill-color-light); font-weight: 400; }
.portion-goods-empty { display: flex; align-items: center; justify-content: center; flex-direction: column; min-height: 230px; padding: 24px 20px; box-sizing: border-box; border: 1px dashed var(--el-border-color); border-radius: 8px; background: var(--el-fill-color-lighter); text-align: center; }
.portion-goods-empty h3 { margin: 0; font-size: 16px; font-weight: 500; }
.portion-goods-empty p { margin: 8px 0 20px; font-size: 13px; color: var(--el-text-color-secondary); }
.empty-icon { display: grid; place-items: center; width: 60px; height: 60px; margin-bottom: 16px; border-radius: 16px; color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
@media (max-width: 1100px) {
.form-card .form-section .section-body { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 768px) {
padding: 8px 8px 20px;
.page-header-row { flex-wrap: wrap; gap: 16px; }
.form-card .form-section { padding: 16px; }
.section-subtitle { width: 100%; }
.effectiveDays-row { flex-wrap: wrap; }
}
}
.tips {
font-size: 12px;
color: #999;
margin-left: 10px;
}
</style>

View File

@@ -72,18 +72,18 @@
</el-card>
<el-card>
<div class="operation padding-row" v-if="getType !== 'ACTIVITY'">
<list-action-bar v-if="getType !== 'ACTIVITY'">
<el-button type="primary" @click="add">添加优惠券</el-button>
<el-button class="activity-coupon-button" @click="addActivityCoupon">添加活动优惠券</el-button>
<el-button @click="delAll">批量关闭</el-button>
<el-button type="warning" @click="receivePage()">优惠券领取记录</el-button>
</div>
</list-action-bar>
<el-table
ref="table"
v-loading="loading"
border
:data="data"
class="mt_10"
style="width: 100%"
row-key="id"
@selection-change="changeSelect"
@@ -201,6 +201,7 @@
</template>
<script setup>
import ListActionBar from "@/components/list-action-bar.vue";
import { ref, computed, watch, nextTick, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
@@ -423,6 +424,10 @@ function init() {
function add() {
router.push({ name: "add-platform-coupon" });
}
function addActivityCoupon() {
router.push({ name: "add-platform-coupon", query: { getType: "ACTIVITY" } });
}
function changePage(v) {
searchForm.value.pageNumber = v;
getDataList();
@@ -561,6 +566,19 @@ defineExpose({ check, getSelection, clearSelectAll });
</script>
<style scoped>
.activity-coupon-button {
--el-button-text-color: #b35c12;
--el-button-bg-color: #fff7ed;
--el-button-border-color: #fed7aa;
--el-button-hover-text-color: #9a4b0b;
--el-button-hover-bg-color: #ffedd5;
--el-button-hover-border-color: #fdba74;
--el-button-active-text-color: #9a4b0b;
--el-button-active-bg-color: #fed7aa;
--el-button-active-border-color: #fb923c;
font-weight: 500;
}
.link-text {
color: #409eff;
cursor: pointer;
@@ -580,9 +598,6 @@ defineExpose({ check, getSelection, clearSelectAll });
.mt_10 {
margin-top: 10px;
}
.padding-row {
margin-bottom: 10px;
}
</style>
<style lang="scss">

View File

@@ -1,30 +0,0 @@
<template>
<div>
<el-card>
<el-form inline @keyup.enter="load">
<el-form-item label="活动名称"><el-input v-model="query.promotionName" clearable /></el-form-item>
<el-form-item><el-button type="primary" @click="load">搜索</el-button></el-form-item>
</el-form>
</el-card>
<el-card class="mt_10">
<el-table v-loading="loading" :data="rows" border>
<el-table-column prop="promotionName" label="活动名称" />
<el-table-column prop="storeName" label="店铺" />
<el-table-column prop="startTime" label="开始" width="170" />
<el-table-column prop="endTime" label="结束" width="170" />
<el-table-column prop="promotionStatus" label="状态" width="100" />
</el-table>
<el-pagination class="mt_10" v-model:current-page="query.pageNumber" :total="total" @current-change="load" />
</el-card>
</div>
</template>
<script setup>
import { useTable } from "@/utils/useTable";
import { getFlashDiscountList } from "@/api/promotion.js";
const { loading, data: rows, total, searchForm: query, getDataList: load } = useTable(
getFlashDiscountList,
{ pageSize: 20, form: { promotionName: "" } }
);
</script>

View File

@@ -1,237 +1,223 @@
<template>
<div>
<el-card>
<el-form ref="formRef" :model="form" label-width="120px">
<div class="base-info-item">
<h4>基本信息</h4>
<div class="form-item-view">
<el-form-item label="活动名称" prop="promotionName">
<el-input
v-model="form.promotionName"
disabled
placeholder="活动名称"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item label="活动时间" prop="rangeTime">
<el-date-picker
type="datetimerange"
v-model="form.rangeTime"
disabled
format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
placeholder="请选择"
:disabled-date="options.disabledDate"
style="width: 320px"
/>
</el-form-item>
<el-form-item label="活动描述" prop="description">
<el-input
v-model="form.description"
disabled
type="textarea"
:rows="4"
clearable
style="width: 260px"
/>
</el-form-item>
</div>
<h4>优惠设置</h4>
<div class="form-item-view">
<el-form-item label="优惠门槛" prop="fullMoney">
<el-input
v-model="form.fullMoney"
disabled
placeholder="优惠门槛"
clearable
style="width: 260px"
/>
<span class="describe">消费达到当前金额可以参与优惠</span>
</el-form-item>
<el-form-item label="赠送优惠券">
<el-radio-group v-model="form.discountType">
<el-radio-button value="fullMinusFlag" disabled>减现金</el-radio-button>
<el-radio-button value="fullRateFlag" disabled>打折</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="form.discountType == 'fullMinusFlag'"
label="优惠金额"
prop="fullMinus"
>
<el-input
disabled
v-model="form.fullMinus"
placeholder="优惠金额"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item
v-if="form.discountType == 'fullRateFlag'"
label="优惠折扣"
prop="fullRate"
>
<el-input-number
placeholder="优惠折扣"
:max="9.9"
:min="0.1"
:step="0.1"
:precision="1"
v-model="form.fullRate"
style="width: 260px"
/>
<span class="describe">优惠折扣为0-10之间数字,可有一位小数</span>
</el-form-item>
<el-form-item label="额外赠送">
<el-checkbox v-model="form.freeFreightFlag" disabled>免邮费</el-checkbox>
<el-checkbox v-model="form.couponFlag" disabled>送优惠券</el-checkbox>
<el-checkbox v-model="form.giftFlag" disabled>送赠品</el-checkbox>
<el-checkbox v-model="form.pointFlag" disabled>送积分</el-checkbox>
</el-form-item>
<el-form-item v-if="form.couponFlag" label="赠送优惠券" prop="couponId">
<el-select
v-model="form.couponId"
:disabled="form.promotionStatus != 'NEW'"
filterable
remote
:remote-method="getCouponList"
placeholder="输入优惠券名称搜索"
:loading="couponLoading"
style="width: 280px"
>
<el-option
v-for="item in couponList"
:value="item.id"
:key="item.id"
:label="item.couponName"
/>
</el-select>
</el-form-item>
<el-form-item v-if="form.giftFlag" label="赠品" prop="giftId">
<el-select
v-model="form.giftId"
filterable
remote
:remote-method="getGiftList"
placeholder="输入赠品名称搜索"
disabled
:loading="giftLoading"
style="width: 260px"
>
<el-option
v-for="item in giftList"
:value="item.id"
:key="item.id"
:label="item.goodsName"
/>
</el-select>
</el-form-item>
<el-form-item v-if="form.pointFlag" label="赠积分" prop="point">
<el-input
v-model="form.point"
type="number"
disabled
style="width: 260px"
/>
</el-form-item>
<el-form-item label="使用范围" prop="scopeType">
<el-radio-group v-model="form.scopeType">
<el-radio-button value="ALL" disabled>全品类</el-radio-button>
<el-radio-button value="PORTION_GOODS" disabled>指定商品</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
style="width: 100%"
v-if="form.scopeType == 'PORTION_GOODS'"
>
<el-table border :data="form.promotionGoodsList" style="width: 100%">
<el-table-column type="selection" width="60" align="center" />
<el-table-column label="商品名称" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
<template v-if="row">
<a class="link-text mr_10" @click="linkTo(row.goodsId, row.skuId)">{{
row.goodsName
}}</a>
<el-popover trigger="hover" title="扫码在手机中查看" placement="top" width="180">
<template #reference>
<img
src="../../../assets/qrcode.svg"
style="vertical-align: middle"
class="hover-pointer"
width="20"
height="20"
alt=""
/>
</template>
<vue-qr
:text="wapLinkTo(row.goodsId, row.skuId)"
:margin="0"
color-dark="#000"
color-light="#fff"
:size="150"
/>
</el-popover>
</template>
</template>
</el-table-column>
<el-table-column label="商品价格" width="110">
<template #default="{ row }">
<span v-if="row" :style="{ color: $mainColor }">
{{ $filters.unitPrice(row.price, "¥") }}</span>
</template>
</el-table-column>
<el-table-column label="库存" width="90">
<template #default="{ row }">
<span v-if="row">{{ row.quantity }}</span>
</template>
</el-table-column>
</el-table>
</el-form-item>
<div>
<el-button @click="$router.push({ name: 'promotions/full-discount' })">返回</el-button>
</div>
<div class="full-discount-detail-page">
<!-- 顶部标题与返回栏 -->
<el-card shadow="never" class="header-card">
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>满优惠活动详情</span>
<el-tag type="info" size="small" effect="plain" class="mode-tag">
只读模式
</el-tag>
<span v-if="id" class="activity-id font-mono">ID: {{ id }}</span>
</div>
</div>
</el-form>
<div class="header-right">
<el-button @click="back">返回</el-button>
</div>
</div>
</el-card>
<!-- 活动基本信息卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">基本信息</span>
</div>
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="活动名称">
<span class="font-semibold text-dark">{{ form.promotionName || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="活动范围">
<el-tag size="small" effect="light" :type="form.scopeType === 'ALL' ? 'success' : 'primary'">
{{ form.scopeType === "ALL" ? "全品类通用" : "指定商品适用" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="活动时间" :span="2">
<span class="font-mono text-dark">
{{ form.startTime || "-" }} 至 {{ form.endTime || "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="活动描述" :span="2">
<span class="text-secondary">{{ form.description || "暂无活动描述" }}</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 优惠规则与赠品权益卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">优惠规则与权益</span>
</div>
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="优惠门槛">
<span class="money-highlight font-mono font-semibold">
满 ¥ {{ Number(form.fullMoney || 0).toFixed(2) }}
</span>
<span class="text-secondary ml_10" style="font-size: 12px">(达到当前金额可享优惠)</span>
</el-descriptions-item>
<el-descriptions-item label="优惠形式">
<template v-if="form.discountType === 'fullMinusFlag'">
<el-tag size="small" effect="light" type="danger">减现金</el-tag>
<span class="money-highlight font-mono font-semibold ml_10">
减 ¥ {{ Number(form.fullMinus || 0).toFixed(2) }}
</span>
</template>
<template v-else-if="form.discountType === 'fullRateFlag'">
<el-tag size="small" effect="light" type="warning">打折</el-tag>
<span class="money-highlight font-mono font-semibold ml_10">
打 {{ form.fullRate }} 折
</span>
</template>
<template v-else>
<span class="text-secondary">无直接立减/打折</span>
</template>
</el-descriptions-item>
<el-descriptions-item label="附加赠送权益" :span="2">
<div class="extra-rights-row">
<el-tag :type="form.freeFreightFlag ? 'success' : 'info'" effect="plain" size="small">
{{ form.freeFreightFlag ? "✓ 包邮(免运费)" : "不包邮" }}
</el-tag>
<el-tag :type="form.couponFlag ? 'success' : 'info'" effect="plain" size="small">
{{ form.couponFlag ? "✓ 赠送优惠券" : "不送券" }}
</el-tag>
<el-tag :type="form.giftFlag ? 'success' : 'info'" effect="plain" size="small">
{{ form.giftFlag ? "✓ 赠送赠品" : "不送赠品" }}
</el-tag>
<el-tag :type="form.pointFlag ? 'success' : 'info'" effect="plain" size="small">
{{ form.pointFlag ? "✓ 赠送积分" : "不送积分" }}
</el-tag>
</div>
</el-descriptions-item>
<el-descriptions-item v-if="form.couponFlag" label="赠送优惠券" :span="2">
<span class="font-semibold text-dark">{{ couponNameText }}</span>
</el-descriptions-item>
<el-descriptions-item v-if="form.giftFlag" label="赠品信息" :span="2">
<span class="font-semibold text-dark">{{ giftNameText }}</span>
</el-descriptions-item>
<el-descriptions-item v-if="form.pointFlag" label="赠送积分数" :span="2">
<span class="font-mono font-semibold text-dark">{{ form.point ?? 0 }} 积分</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 指定参与商品列表(当指定商品时展示) -->
<el-card
v-if="form.scopeType === 'PORTION_GOODS'"
shadow="never"
class="info-card mt_10"
>
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">指定适用商品列表</span>
<span class="section-badge">共 {{ (form.promotionGoodsList || []).length }} 件商品</span>
</div>
<el-table
border
stripe
:data="form.promotionGoodsList || []"
style="width: 100%"
empty-text="暂无指定商品数据"
class="custom-table"
>
<el-table-column label="商品图片" width="76" align="center">
<template #default="{ row }">
<el-image
v-if="row && (row.thumbnail || row.small)"
:src="row.thumbnail || row.small"
fit="cover"
class="table-goods-thumb"
preview-teleported
:preview-src-list="[row.thumbnail || row.small]"
>
<template #error>
<div class="thumb-slot">无图</div>
</template>
</el-image>
<div v-else class="table-goods-thumb thumb-slot">无图</div>
</template>
</el-table-column>
<el-table-column label="商品名称" min-width="240" show-overflow-tooltip>
<template #default="{ row }">
<template v-if="row">
<span class="goods-title font-semibold text-dark">{{ row.goodsName || "-" }}</span>
</template>
</template>
</el-table-column>
<el-table-column label="商品单价" width="130" align="right">
<template #default="{ row }">
<span v-if="row" class="price-val font-mono font-semibold">
{{ $filters.unitPrice(row.price, "¥") }}
</span>
</template>
</el-table-column>
<el-table-column label="库存" width="100" align="right">
<template #default="{ row }">
<span v-if="row" class="font-mono text-dark">{{ row.quantity ?? "-" }}</span>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 底部操作栏 -->
<el-card shadow="never" class="footer-card mt_10">
<div class="footer-actions">
<el-button type="primary" @click="back">返回活动列表</el-button>
</div>
</el-card>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { getPlatformCouponList, getFullDiscountById } from "@/api/promotion";
import { getGoodsSkuData } from "@/api/goods";
import vueQr from "vue-qr";
import { useAppStore } from "@/stores/app";
defineOptions({ name: "add-full-discount" });
defineOptions({ name: "full-discount-detail" });
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const formRef = ref(null);
const id = ref(route.query.id);
const form = ref({
discountType: "fullMinusFlag",
scopeType: "ALL",
promotionGoodsList: [],
});
const id = ref(route.query.id);
const couponList = ref([]);
const giftList = ref([]);
const giftLoading = ref(false);
const couponLoading = ref(false);
const options = {
disabledDate(date) {
return date && date.valueOf() < Date.now() - 86400000;
},
};
onMounted(async () => {
const couponNameText = computed(() => {
if (!form.value.couponId) return "未指定优惠券";
const found = couponList.value.find((c) => String(c.id) === String(form.value.couponId));
return found ? found.couponName : `优惠券 ID: ${form.value.couponId}`;
});
const giftNameText = computed(() => {
if (!form.value.giftId) return "未指定赠品";
const found = giftList.value.find((g) => String(g.id) === String(form.value.giftId));
return found ? found.goodsName : `赠品 ID: ${form.value.giftId}`;
});
onMounted(() => {
if (id.value) {
getDetail();
}
@@ -241,79 +227,194 @@ onMounted(async () => {
function getDetail() {
getFullDiscountById(id.value).then((res) => {
let data = res.result;
if (!data.scopeType === "ALL") {
data.promotionGoodsList = [];
if (res && res.success && res.result) {
let data = res.result;
if (data.scopeType !== "PORTION_GOODS") {
data.promotionGoodsList = [];
}
if (data.fullRateFlag) {
data.discountType = "fullRateFlag";
} else {
data.discountType = "fullMinusFlag";
}
form.value = data;
}
if (data.fullMinusFlag) {
data.discountType = "fullMinusFlag";
delete data.fullMinusFlag;
} else {
data.discountType = "fullMinusFlag";
delete data.fullRateFlag;
}
data.rangeTime = [];
data.rangeTime.push(new Date(data.startTime), new Date(data.endTime));
form.value = data;
});
}
function getCouponList(query) {
let params = {
pageSize: 20,
function getCouponList() {
const params = {
pageSize: 50,
pageNumber: 1,
getType: "ACTIVITY",
storeId: "",
couponName: query,
promotionStatus: "START",
};
couponLoading.value = true;
getPlatformCouponList(params).then((res) => {
couponLoading.value = false;
if (res.success) {
couponList.value = res.result.records;
if (res && res.success && res.result) {
couponList.value = res.result.records || [];
}
});
}
function getGiftList(query) {
let params = {
pageSize: 20,
function getGiftList() {
const params = {
pageSize: 50,
pageNumber: 1,
id: query === form.value.giftId ? form.value.giftId : null,
goodsName: query === form.value.giftId ? null : query,
marketEnable: "UPPER",
authFlag: "PASS"
authFlag: "PASS",
};
giftLoading.value = true;
getGoodsSkuData(params).then((res) => {
giftLoading.value = false;
if (res.success) {
giftList.value = res.result.records;
if (res && res.success && res.result) {
giftList.value = res.result.records || [];
}
});
}
function back() {
appStore.removeTag("full-discount-detail");
router.go(-1);
}
</script>
<style lang="scss" scoped>
h4 {
margin-bottom: 10px;
padding: 0 10px;
border: 1px solid #ddd;
background-color: #f8f8f8;
font-weight: bold;
color: #333;
font-size: 14px;
line-height: 40px;
text-align: left;
}
.describe {
font-size: 12px;
margin-left: 10px;
color: #999;
}
.link-text {
color: #409eff;
cursor: pointer;
text-decoration: none;
.full-discount-detail-page {
padding-bottom: 20px;
.header-card {
.page-header-row {
display: flex;
justify-content: space-between;
align-items: center;
.header-left {
.header-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 600;
color: #303133;
.mode-tag {
font-weight: normal;
}
.activity-id {
font-size: 13px;
font-weight: normal;
color: #909399;
background-color: #f4f4f5;
padding: 2px 8px;
border-radius: 4px;
}
}
}
}
}
.info-card {
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.section-indicator {
width: 4px;
height: 16px;
border-radius: 2px;
background-color: #409eff;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.section-badge {
font-size: 12px;
color: #909399;
margin-left: 6px;
}
}
.custom-descriptions {
:deep(.el-descriptions__label) {
width: 140px;
background-color: #fafafa;
color: #606266;
font-weight: 500;
}
:deep(.el-descriptions__content) {
color: #303133;
}
}
.money-highlight {
color: #f56c6c;
font-size: 14px;
}
.extra-rights-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.custom-table {
.table-goods-thumb {
display: block;
width: 44px;
height: 44px;
margin: 0 auto;
border-radius: 4px;
border: 1px solid #ebeef5;
}
.thumb-slot {
display: flex;
justify-content: center;
align-items: center;
font-size: 11px;
color: #c0c4cc;
background-color: #fafafa;
}
.price-val {
color: #f56c6c;
}
}
}
.footer-card {
.footer-actions {
display: flex;
justify-content: center;
align-items: center;
}
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-secondary {
color: #909399;
}
.ml_10 {
margin-left: 10px;
}
}
</style>

View File

@@ -1,103 +1,188 @@
<template>
<div>
<el-card v-loading="detailLoading">
<el-form ref="formRef" :model="form" label-width="130px" :rules="onlyView ? {} : formRule">
<div class="base-info-item">
<h4>{{ pageTitle }}</h4>
<div class="form-item-view gift-card-activity-form">
<el-form-item label="礼品卡名称" prop="giftCardName">
<el-input
v-model="form.giftCardName"
type="text"
placeholder="请输入礼品卡名称"
clearable
maxlength="64"
show-word-limit
style="width: 360px"
:disabled="onlyView"
/>
</el-form-item>
<el-form-item label="礼品卡类型" prop="cardType" class="gift-card-type-form-item">
<el-radio-group v-model="form.cardType" class="gift-card-type-group">
<el-radio value="CASH" :disabled="onlyView">现金卡</el-radio>
<el-radio value="PICKUP" disabled>提货卡</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="面值" prop="faceValue">
<el-input
v-model="form.faceValue"
type="text"
placeholder="面值"
clearable
style="width: 200px"
:disabled="onlyView"
/>
</el-form-item>
<el-form-item label="总库存" prop="stockTotal">
<el-input-number
v-model="form.stockTotal"
:min="1"
:precision="0"
placeholder="最多可制发凭证张数"
style="width: 200px"
:disabled="onlyView"
/>
</el-form-item>
<el-form-item prop="validityType" :label-width="0" class="gift-card-validity-form-item">
<div class="gift-card-validity-outer">
<div class="gift-card-validity-label-col">
<span v-if="!onlyView" class="gift-card-validity-required">*</span>
<span>有效期</span>
</div>
<div class="gift-card-validity-radios">
<el-radio-group v-model="form.validityType" class="gift-card-validity-group">
<el-radio value="LONG_TERM" class="gift-card-validity-row" :disabled="onlyView"
>长期有效</el-radio
>
<el-radio value="AFTER_ACTIVATE_MONTHS" class="gift-card-validity-row" :disabled="onlyView">
<span>自激活后</span>
<el-input-number
v-model="form.validMonthsAfterActivate"
:min="1"
:max="99"
:precision="0"
placeholder="请输入1-99的整数"
:disabled="onlyView || form.validityType !== 'AFTER_ACTIVATE_MONTHS'"
class="gift-card-validity-input-gap"
/>
<span>月内有效</span>
</el-radio>
<el-radio value="FIXED_UNTIL" class="gift-card-validity-row" :disabled="onlyView">
<span>指定有效期至</span>
<el-date-picker
v-model="form.validEndTime"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择结束时间"
clearable
:disabled="onlyView || form.validityType !== 'FIXED_UNTIL'"
class="gift-card-validity-date-gap"
style="width: 220px"
/>
</el-radio>
</el-radio-group>
</div>
</div>
</el-form-item>
</div>
<div class="foot-btn">
<el-button style="margin-right: 8px" @click="closeCurrentPage">返回</el-button>
<el-button
v-if="!onlyView"
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>提交</el-button
<div class="gift-card-activity-page">
<!-- 顶部标题与返回栏 -->
<el-card shadow="never" class="header-card">
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>{{ pageTitle }}</span>
<el-tag
v-if="onlyView || isEditMode"
size="small"
effect="plain"
:type="onlyView ? 'info' : (isEditMode ? 'warning' : 'primary')"
class="mode-tag"
>
{{ onlyView ? "查看模式" : "编辑模式" }}
</el-tag>
<span v-if="route.query.id" class="activity-id font-mono">
ID: {{ route.query.id }}
</span>
</div>
</div>
</el-form>
<div class="header-right">
<el-button @click="closeCurrentPage">返回礼品卡列表</el-button>
</div>
</div>
</el-card>
<!-- 礼品卡基础信息卡片 -->
<el-card shadow="never" v-loading="detailLoading" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">礼品卡配置信息</span>
</div>
<!-- 查看模式:以结构化描述列表呈现 -->
<template v-if="onlyView">
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="礼品卡名称">
<span class="font-semibold text-dark">{{ form.giftCardName || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="礼品卡类型">
<el-tag size="small" effect="plain" type="primary">
{{ form.cardType === "CASH" ? "现金卡" : "提货卡" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="礼品卡面值">
<span class="price-highlight font-mono font-semibold">
{{ form.faceValue ? $filters.unitPrice(form.faceValue, "¥") : "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="总库存">
<span class="font-mono text-dark font-semibold">
{{ form.stockTotal != null ? `${form.stockTotal} 张` : "-" }}
</span>
<span class="hint-inline">(最多可制发凭证张数)</span>
</el-descriptions-item>
<el-descriptions-item label="有效期限" :span="2">
<span class="font-mono text-dark">{{ validityDisplayText }}</span>
</el-descriptions-item>
<el-descriptions-item v-if="activityDetail.createTime" label="创建时间">
<span class="font-mono text-secondary">{{ activityDetail.createTime }}</span>
</el-descriptions-item>
<el-descriptions-item v-if="route.query.id" label="活动ID">
<span class="font-mono text-secondary">{{ route.query.id }}</span>
</el-descriptions-item>
</el-descriptions>
</template>
<!-- 编辑 / 新增模式:表单输入 -->
<template v-else>
<el-form
ref="formRef"
:model="form"
label-width="120px"
:rules="formRule"
class="edit-form"
>
<el-form-item label="礼品卡名称" prop="giftCardName">
<el-input
v-model="form.giftCardName"
type="text"
placeholder="请输入礼品卡名称"
clearable
maxlength="64"
show-word-limit
style="width: 380px"
/>
</el-form-item>
<el-form-item label="礼品卡类型" prop="cardType">
<el-radio-group v-model="form.cardType">
<el-radio value="CASH">现金卡</el-radio>
<el-radio value="PICKUP" disabled>提货卡(暂未开放)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="面值" prop="faceValue">
<el-input
v-model="form.faceValue"
type="number"
placeholder="请填写面值金额"
clearable
style="width: 260px"
>
<template #append>元</template>
</el-input>
</el-form-item>
<el-form-item label="总库存" prop="stockTotal">
<el-input-number
v-model="form.stockTotal"
:min="1"
:precision="0"
placeholder="最多可制发凭证张数"
style="width: 220px"
/>
<span class="input-tip-text">张(最多可制发凭证张数)</span>
</el-form-item>
<el-form-item label="有效期限" required class="validity-form-item">
<el-radio-group v-model="form.validityType" class="validity-radio-group">
<div class="validity-row">
<el-radio value="LONG_TERM">长期有效</el-radio>
</div>
<div class="validity-row">
<el-radio value="AFTER_ACTIVATE_MONTHS">
<span class="radio-inline-text">自激活后</span>
<el-input-number
v-model="form.validMonthsAfterActivate"
:min="1"
:max="99"
:precision="0"
placeholder="1-99"
:disabled="form.validityType !== 'AFTER_ACTIVATE_MONTHS'"
class="validity-input-num"
/>
<span class="radio-inline-text">个月内有效</span>
</el-radio>
</div>
<div class="validity-row">
<el-radio value="FIXED_UNTIL">
<span class="radio-inline-text">指定有效期至</span>
<el-date-picker
v-model="form.validEndTime"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择有效截止时间"
clearable
:disabled="form.validityType !== 'FIXED_UNTIL'"
class="validity-date-picker"
style="width: 220px"
/>
</el-radio>
</div>
</el-radio-group>
</el-form-item>
</el-form>
</template>
</el-card>
<!-- 底部操作栏 -->
<el-card shadow="never" class="footer-card mt_10">
<div class="footer-actions">
<el-button @click="closeCurrentPage">
{{ onlyView ? "返回" : "取消" }}
</el-button>
<el-button
v-if="!onlyView"
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
{{ isEditMode ? "保存修改" : "提交创建" }}
</el-button>
</div>
</el-card>
</div>
</template>
@@ -121,6 +206,56 @@ const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const detailLoading = ref(false);
const submitLoading = ref(false);
const activityDetail = ref({});
const form = reactive({
giftCardName: "",
cardType: "CASH",
faceValue: "",
stockTotal: null,
validityType: "LONG_TERM",
validEndTime: null,
validMonthsAfterActivate: null,
});
const formRef = ref(null);
const onlyView = computed(() => {
const q = route.query.onlyView;
return q === "true" || q === true;
});
const isEditMode = computed(() => {
return !!route.query.id && !onlyView.value;
});
const pageTitle = computed(() => {
if (onlyView.value) {
return "查看礼品卡";
}
if (isEditMode.value) {
return "编辑礼品卡";
}
return "添加礼品卡";
});
const validityDisplayText = computed(() => {
const type = String(form.validityType || "").toUpperCase();
if (type === "LONG_TERM") {
return "长期有效";
}
if (type === "AFTER_ACTIVATE_MONTHS") {
const m = form.validMonthsAfterActivate;
return m != null && m !== "" ? `自激活后 ${m} 个月内有效` : "自激活后指定月数有效";
}
if (type === "FIXED_UNTIL") {
return form.validEndTime ? `指定有效期至 ${form.validEndTime}` : "指定时间有效";
}
return "-";
});
const checkFaceValue = (rule, value, callback) => {
if (!value && value !== 0) {
return callback(new Error("面额不能为空"));
@@ -135,6 +270,7 @@ const checkFaceValue = (rule, value, callback) => {
callback();
}
};
const validateStock = (rule, value, callback) => {
if (value === null || value === undefined || value === "") {
callback(new Error("请填写总库存"));
@@ -147,17 +283,6 @@ const validateStock = (rule, value, callback) => {
callback();
};
const detailLoading = ref(false);
const submitLoading = ref(false);
const form = reactive({
giftCardName: "",
cardType: "CASH",
faceValue: "",
stockTotal: null,
validityType: "LONG_TERM",
validEndTime: null,
validMonthsAfterActivate: null,
});
const formRule = {
giftCardName: [
{ required: true, message: "活动名称不能为空", trigger: "blur" },
@@ -167,51 +292,6 @@ const formRule = {
stockTotal: [{ required: true, validator: validateStock, trigger: "blur" }],
};
const formRef = ref(null);
const onlyView = computed(() => {
const q = route.query.onlyView;
return q === "true" || q === true;
});
const isEditMode = computed(() => {
return !!route.query.id && !onlyView.value;
});
const pageTitle = computed(() => {
if (onlyView.value) {
return "查看礼品卡";
}
if (isEditMode.value) {
return "编辑礼品卡";
}
return "添加礼品卡";
});
onMounted(() => {
if (route.query.id) {
loadDetail(route.query.id);
}
});
watch(
() => form.validityType,
(v) => {
if (v !== "FIXED_UNTIL") {
form.validEndTime = null;
}
if (v !== "AFTER_ACTIVATE_MONTHS") {
form.validMonthsAfterActivate = null;
}
}
);
watch(
() => route.query.id,
(id) => {
if (id) {
loadDetail(id);
}
}
);
const parseDetailDate = (val) => {
if (!val) {
return null;
@@ -222,14 +302,15 @@ const parseDetailDate = (val) => {
const d = new Date(val);
return Number.isNaN(d.getTime()) ? null : d;
};
const loadDetail = (id) => {
detailLoading.value = true;
getGiftCardCashActivity(id)
.then((res) => {
detailLoading.value = false;
if (!res.success || !res.result) {
return;
}
activityDetail.value = res.result;
const d = res.result;
form.giftCardName = d.giftCardName || "";
form.cardType = d.cardType || "CASH";
@@ -247,18 +328,26 @@ const loadDetail = (id) => {
? d.validMonthsAfterActivate
: null;
})
.catch(() => {
.finally(() => {
detailLoading.value = false;
});
};
const closeCurrentPage = () => {
const name = route.name;
if (name) {
if (name && appStore?.removeTag) {
appStore.removeTag(name);
}
localStorage.pageOpenedList = JSON.stringify(appStore.pageOpenedList);
router.go(-1);
if (localStorage?.pageOpenedList && appStore?.pageOpenedList) {
localStorage.pageOpenedList = JSON.stringify(appStore.pageOpenedList);
}
if (window.history.length > 1) {
router.go(-1);
} else {
router.push({ name: "manager-gift-card-cash" });
}
};
const validateByValidityType = () => {
const t = form.validityType;
if (t === "FIXED_UNTIL") {
@@ -276,6 +365,7 @@ const validateByValidityType = () => {
}
return true;
};
const buildPayload = () => {
const payload = {
giftCardName: (form.giftCardName || "").trim(),
@@ -295,6 +385,7 @@ const buildPayload = () => {
}
return payload;
};
const handleSubmit = () => {
if (onlyView.value) {
return;
@@ -312,97 +403,221 @@ const handleSubmit = () => {
: saveGiftCardCashActivity(buildPayload());
req
.then((res) => {
submitLoading.value = false;
if (res.success) {
Message.success(isEditMode.value ? "礼品卡活动已更新" : "礼品卡活动创建成功");
closeCurrentPage();
}
})
.catch(() => {
.finally(() => {
submitLoading.value = false;
});
});
};
watch(
() => form.validityType,
(v) => {
if (v !== "FIXED_UNTIL") {
form.validEndTime = null;
}
if (v !== "AFTER_ACTIVATE_MONTHS") {
form.validMonthsAfterActivate = null;
}
}
);
watch(
() => route.query.id,
(id) => {
if (id) {
loadDetail(id);
}
}
);
onMounted(() => {
if (route.query.id) {
loadDetail(route.query.id);
}
});
</script>
<style scoped>
.gift-card-activity-form :deep(.el-form-item) {
margin-bottom: 26px;
}
.gift-card-type-form-item :deep(.el-form-item__label) {
line-height: 32px;
padding-top: 1px;
}
.gift-card-type-form-item :deep(.el-form-item__content) {
display: flex;
align-items: center;
min-height: 32px;
}
.gift-card-type-group {
gap: 20px;
}
.gift-card-validity-form-item :deep(.el-form-item__content) {
margin-left: 0 !important;
}
.gift-card-validity-outer {
display: flex;
align-items: flex-start;
width: 100%;
}
.gift-card-validity-label-col {
flex: 0 0 130px;
width: 130px;
padding-right: 12px;
<style lang="scss" scoped>
.gift-card-activity-page {
padding: 16px 20px;
background-color: #f0f2f5;
min-height: 100vh;
box-sizing: border-box;
text-align: right;
line-height: 32px;
min-height: 32px;
display: flex;
align-items: center;
justify-content: flex-end;
padding-top: 1px;
}
.gift-card-validity-required {
color: #ed4014;
margin-right: 4px;
font-family: SimSun, serif;
}
.gift-card-validity-radios {
flex: 1;
min-width: 0;
padding-top: 1px;
}
.gift-card-validity-group {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
padding-top: 0;
padding-bottom: 0;
}
.gift-card-validity-group :deep(.el-radio) {
display: inline-flex;
align-items: center;
margin-right: 0;
margin-bottom: 10px;
line-height: 32px;
min-height: 32px;
height: auto;
}
.gift-card-validity-group :deep(.el-radio:last-child) {
margin-bottom: 0;
}
.gift-card-validity-group :deep(.el-radio__label) {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
line-height: 32px;
}
.gift-card-validity-input-gap {
width: 168px;
margin: 0 10px;
}
.gift-card-validity-date-gap {
margin-left: 10px;
.mt_10 {
margin-top: 12px;
}
.font-mono {
font-family: Monaco, Menlo, Consolas, "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-secondary {
color: #606266;
}
.header-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 16px 20px;
}
.page-header-row {
display: flex;
align-items: center;
justify-content: space-between;
.header-left {
.header-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 600;
color: #1f2d3d;
.mode-tag {
font-weight: normal;
}
.activity-id {
font-size: 13px;
color: #909399;
font-weight: normal;
}
}
}
}
}
.info-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 20px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.section-indicator {
width: 4px;
height: 16px;
background-color: #409eff;
border-radius: 2px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.custom-descriptions {
:deep(.el-descriptions__label) {
width: 130px;
background-color: #f8f9fb;
color: #606266;
font-weight: 500;
}
:deep(.el-descriptions__content) {
color: #303133;
}
.price-highlight {
color: #f56c6c;
font-size: 16px;
}
.hint-inline {
font-size: 12px;
color: #909399;
margin-left: 6px;
}
}
.edit-form {
padding: 8px 12px;
.input-tip-text {
font-size: 13px;
color: #909399;
margin-left: 10px;
}
.validity-radio-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
width: 100%;
.validity-row {
display: flex;
align-items: center;
:deep(.el-radio) {
margin-right: 0;
display: inline-flex;
align-items: center;
}
:deep(.el-radio__label) {
display: inline-flex;
align-items: center;
}
.radio-inline-text {
font-size: 14px;
}
.validity-input-num {
width: 130px;
margin: 0 10px;
}
.validity-date-picker {
margin-left: 10px;
}
}
}
}
}
.footer-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 16px 20px;
}
.footer-actions {
display: flex;
justify-content: center;
gap: 16px;
}
}
}
</style>

View File

@@ -1,109 +1,260 @@
<template>
<div>
<el-card>
<el-form ref="formRef" :model="form" label-width="120px" :rules="formRule">
<div class="base-info-item">
<div class="form-item-view">
<h4>商品信息</h4>
<div class="kanjia-add-page">
<!-- 顶部标题与返回栏 -->
<el-card shadow="never" class="header-card">
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>添加砍价活动</span>
</div>
<p class="page-description">设置活动时间,选择参与商品并配置砍价规则。</p>
</div>
<div class="header-right">
<el-button :icon="ArrowLeft" @click="closeCurrentPage">返回砍价列表</el-button>
</div>
</div>
</el-card>
<el-form-item label="选择商品" prop="promotionGoodsList">
<el-button type="primary" @click="openSkuList">选择商品</el-button>
</el-form-item>
<el-form
ref="formRef"
:model="form"
label-width="110px"
:rules="formRule"
class="main-form"
>
<!-- 活动时间设置卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-number">01</span>
<span class="section-title">活动时间设置</span>
</div>
<el-form-item style="width: 100%">
<el-table
border
class="kanjia-goods-table"
:data="form.promotionGoodsList"
style="width: 100%"
@selection-change="changeSelect"
>
<el-table-column type="selection" width="60" align="center" />
<el-table-column prop="goodsName" label="商品名称" min-width="100" show-overflow-tooltip />
<el-table-column label="商品价格" width="150">
<template #default="{ row }">
<span v-if="row" :style="{ color: $mainColor }">
{{ $filters.unitPrice(row.price, "¥") }}</span>
</template>
</el-table-column>
<el-table-column label="库存" width="120">
<template #default="{ row }">
<span v-if="row">{{ row.quantity }}</span>
</template>
</el-table-column>
<el-table-column label="结算价格" width="160">
<template #default="{ $index }">
<el-input-number
:min="0"
v-model="form.promotionGoodsList[$index].settlementPrice"
controls-position="right"
class="kanjia-goods-input"
/>
</template>
</el-table-column>
<el-table-column label="最低砍" width="160">
<template #default="{ $index }">
<el-input-number
:min="0"
v-model="form.promotionGoodsList[$index].lowestPrice"
controls-position="right"
class="kanjia-goods-input"
/>
</template>
</el-table-column>
<el-table-column label="最高砍" width="160">
<template #default="{ $index }">
<el-input-number
:min="0"
v-model="form.promotionGoodsList[$index].highestPrice"
controls-position="right"
class="kanjia-goods-input"
/>
</template>
</el-table-column>
<el-table-column label="活动库存" width="160">
<template #default="{ $index }">
<el-input-number
:min="0"
v-model="form.promotionGoodsList[$index].stock"
controls-position="right"
class="kanjia-goods-input"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template #default="{ $index }">
<el-button type="danger" size="small" plain @click="delGoods($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-form-item>
<el-form-item label="活动时间" prop="rangeTime" class="kanjia-activity-time-form-item">
<el-date-picker
type="datetimerange"
v-model="form.rangeTime"
format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
placeholder="请选择"
:disabled-date="options.disabledDate"
/>
</el-form-item>
<div>
<el-button link @click="closeCurrentPage">返回</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">提交</el-button>
</div>
<el-form-item
label="活动时间"
prop="rangeTime"
class="kanjia-activity-time-form-item"
>
<el-date-picker
v-model="form.rangeTime"
type="datetimerange"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
placeholder="请选择砍价活动有效时间范围"
:disabled-date="options.disabledDate"
class="activity-date-picker"
range-separator="至"
/>
<span class="time-tip-text">所有参与商品将使用同一活动时间</span>
</el-form-item>
</el-card>
<!-- 砍价商品列表卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="card-header-bar">
<div class="header-title-group">
<span class="section-number">02</span>
<span class="section-title">砍价活动商品</span>
<span class="total-badge">已选 {{ form.promotionGoodsList.length }} 件商品</span>
</div>
<div class="header-actions">
<el-button
v-if="selectedGoods.length"
type="danger"
plain
size="small"
@click="delBatch"
>
批量删除 ({{ selectedGoods.length }})
</el-button>
<el-button
v-if="form.promotionGoodsList.length"
link
type="danger"
size="small"
@click="clearAllGoods"
>
清空商品
</el-button>
<el-button v-if="form.promotionGoodsList.length" type="primary" :icon="Plus" @click="openSkuList">
选择商品
</el-button>
</div>
</div>
</el-form>
</el-card>
<el-form-item prop="promotionGoodsList" :label-width="0" class="table-form-item">
<el-table
v-if="form.promotionGoodsList.length"
scrollbar-always-on
class="kanjia-goods-table"
:data="form.promotionGoodsList"
style="width: 100%"
empty-text="暂未添加商品,请点击右上角【选择商品】按钮添加"
@selection-change="changeSelect"
>
<el-table-column type="selection" width="50" align="center" />
<!-- 商品图片 -->
<el-table-column label="商品信息" min-width="290">
<template #default="{ row }">
<div class="product-info">
<div v-if="row && row.thumbnail" class="goods-thumb-box">
<el-image
:src="row.thumbnail"
fit="cover"
preview-teleported
:preview-src-list="[row.thumbnail]"
class="goods-thumb-img"
>
<template #error>
<div class="thumb-slot">无图</div>
</template>
</el-image>
</div>
<div v-else class="goods-thumb-box thumb-slot">无图</div>
<div v-if="row" class="product-details">
<div class="product-name text-dark" :title="row.goodsName">{{ row.goodsName || "-" }}</div>
<div v-if="row.skuId" class="sku-sub-text font-mono">
SKU: {{ row.skuId }}
</div>
</div>
</div>
</template>
</el-table-column>
<!-- 所属商家 -->
<el-table-column
prop="storeName"
label="所属商家"
min-width="120"
show-overflow-tooltip
>
<template #default="{ row }">
<span class="text-secondary">{{ (row && row.storeName) || "-" }}</span>
</template>
</el-table-column>
<!-- 商品原价 -->
<el-table-column label="商品原价" width="110" align="right">
<template #default="{ row }">
<span v-if="row" class="font-mono text-dark">
{{ $filters.unitPrice(row.price || row.originalPrice, "¥") }}
</span>
</template>
</el-table-column>
<!-- 当前库存 -->
<el-table-column label="可用库存" width="90" align="right">
<template #default="{ row }">
<span v-if="row" class="font-mono text-dark">{{ row.quantity ?? "-" }}</span>
</template>
</el-table-column>
<!-- 结算价格 -->
<el-table-column label="结算价格(元)" width="140" align="center">
<template #default="{ $index }">
<el-input-number
v-model="form.promotionGoodsList[$index].settlementPrice"
:min="0"
:precision="2"
:controls="false"
class="kanjia-goods-input"
placeholder="结算价"
/>
</template>
</el-table-column>
<!-- 最低砍 -->
<el-table-column label="最低可砍(元)" width="140" align="center">
<template #default="{ $index }">
<el-input-number
v-model="form.promotionGoodsList[$index].lowestPrice"
:min="0"
:precision="2"
:controls="false"
class="kanjia-goods-input"
placeholder="最低可砍"
/>
</template>
</el-table-column>
<!-- 最高砍 -->
<el-table-column label="最高可砍(元)" width="140" align="center">
<template #default="{ $index }">
<el-input-number
v-model="form.promotionGoodsList[$index].highestPrice"
:min="0"
:precision="2"
:controls="false"
class="kanjia-goods-input"
placeholder="最高可砍"
/>
</template>
</el-table-column>
<!-- 活动库存 -->
<el-table-column label="活动库存(件)" width="130" align="center">
<template #default="{ $index }">
<el-input-number
v-model="form.promotionGoodsList[$index].stock"
:min="1"
:precision="0"
:controls="false"
class="kanjia-goods-input"
placeholder="活动库存"
/>
</template>
</el-table-column>
<!-- 操作 -->
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ $index }">
<el-button
type="danger"
link
@click="delGoods($index)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="goods-empty">
<div class="empty-icon"><el-icon :size="28"><ShoppingBag /></el-icon></div>
<h3>选择参与砍价的商品</h3>
<p>添加商品后,可逐项设置结算价格、可砍金额和活动库存</p>
<el-button type="primary" :icon="Plus" @click="openSkuList">选择活动商品</el-button>
</div>
</el-form-item>
</el-card>
<!-- 底部操作栏 -->
<el-card shadow="never" class="footer-card mt_10">
<div class="footer-actions">
<span class="footer-summary">已选 <strong>{{ form.promotionGoodsList.length }}</strong> 件商品</span>
<div class="footer-buttons">
<el-button @click="closeCurrentPage">取消</el-button>
<el-button
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
提交创建
</el-button>
</div>
</div>
</el-card>
</el-form>
<sku-select ref="skuSelectRef" @selectedGoodsData="selectedGoodsData"></sku-select>
</div>
</template>
<script setup>
import { nextTick, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { nextTick, reactive, ref } from "vue";
import { ArrowLeft, Plus, ShoppingBag } from "@element-plus/icons-vue";
import { useRouter } from "vue-router";
import { saveKanJiaActivityGoods } from "@/api/promotion";
import { regular } from "@/utils";
import skuSelect from "@/components/lili-dialog";
@@ -114,19 +265,24 @@ import { useAppStore } from "@/stores/app";
defineOptions({ name: "add-kanjia-activity-goods" });
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const modalType = ref(0);
function getDefaultActivityTime() {
const start = new Date();
const end = new Date(start);
end.setDate(end.getDate() + 7);
return [unixToDate(start.getTime() / 1000), unixToDate(end.getTime() / 1000)];
}
const form = reactive({
promotionGoodsList: [],
rangeTime: [],
rangeTime: getDefaultActivityTime(),
});
const id = ref(route.query.id);
const submitLoading = ref(false);
const selectedGoods = ref([]);
const promotionGoodsList = ref([]);
const formRule = {
promotionGoodsList: [
{
@@ -146,6 +302,7 @@ const formRule = {
},
],
};
const options = {
disabledDate(date) {
return date && date.valueOf() < Date.now() - 86400000;
@@ -155,14 +312,6 @@ const options = {
const formRef = ref(null);
const skuSelectRef = ref(null);
onMounted(async () => {
if (id.value) {
// 历史遗留:原代码调用了未定义的 getCoupon 方法,此处原样保留
getCoupon();
modalType.value = 1;
}
});
function handleSubmit() {
formRef.value.validate((valid) => {
if (!valid) {
@@ -182,18 +331,19 @@ function handleSubmit() {
delete params.rangeTime;
let checkResult = true;
form.promotionGoodsList.forEach((res) => {
for (let i = 0; i < form.promotionGoodsList.length; i++) {
const res = form.promotionGoodsList[i];
const eCoupon = isECoupon(res.goodsType);
if (res.stock <= 0 || res.stock > res.quantity) {
checkResult = false;
ElMessage.error("活动库存不能为0且不能超过商品库存");
return;
ElMessage.error(`商品【${res.goodsName}】活动库存不能为0且不能超过可用库存`);
break;
}
if (!regular.money.test(res.settlementPrice)) {
checkResult = false;
ElMessage.error("结算价格金额格式不正确");
return;
ElMessage.error(`商品【${res.goodsName}】结算价格金额格式不正确`);
break;
}
if (
(eCoupon ? res.settlementPrice < 0 : res.settlementPrice <= 0) ||
@@ -202,63 +352,78 @@ function handleSubmit() {
checkResult = false;
ElMessage.error(
eCoupon
? "结算价格不能小于0且不能超过商品价格"
: "结算价格金额不能为0且不能超过商品价格"
? `商品【${res.goodsName}】结算价格不能小于0且不能超过商品原价`
: `商品【${res.goodsName}】结算价格不能为0且不能超过商品原价`
);
return;
break;
}
if (!regular.money.test(res.highestPrice)) {
checkResult = false;
ElMessage.error("最高可砍金额格式错误");
return;
ElMessage.error(`商品【${res.goodsName}】最高可砍金额格式错误`);
break;
}
if (res.highestPrice <= 0 || res.highestPrice > res.price) {
checkResult = false;
ElMessage.error("最高可砍金额不能为0且不能超过商品价格");
return;
ElMessage.error(`商品【${res.goodsName}】最高可砍金额不能为0且不能超过商品原价`);
break;
}
if (!regular.money.test(res.lowestPrice)) {
checkResult = false;
ElMessage.error("最低可砍金额格式错误");
return;
ElMessage.error(`商品【${res.goodsName}】最低可砍金额格式错误`);
break;
}
if (res.lowestPrice < 0 || (!eCoupon && res.lowestPrice <= 0) || res.lowestPrice > res.price) {
if (
res.lowestPrice < 0 ||
(!eCoupon && res.lowestPrice <= 0) ||
res.lowestPrice > res.price
) {
checkResult = false;
ElMessage.error(eCoupon ? "最低可砍金额不能小于0" : "最低可砍金额不能为0");
return;
ElMessage.error(
eCoupon
? `商品【${res.goodsName}】最低可砍金额不能小于0`
: `商品【${res.goodsName}】最低可砍金额不能为0`
);
break;
}
if (parseInt(res.lowestPrice) > parseInt(res.highestPrice)) {
if (parseFloat(res.lowestPrice) > parseFloat(res.highestPrice)) {
checkResult = false;
ElMessage.error("最低砍价金额不能大于最高砍价金额");
return;
ElMessage.error(`商品【${res.goodsName}】最低砍价金额不能大于最高砍价金额`);
break;
}
});
}
if (!checkResult) {
return;
}
submitLoading.value = true;
saveKanJiaActivityGoods(params).then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success(
modalType.value === 1 ? "砍价活动修改成功" : "砍价活动添加成功"
);
closeCurrentPage();
}
});
saveKanJiaActivityGoods(params)
.then((res) => {
if (res && res.success) {
ElMessage.success("砍价活动添加成功");
closeCurrentPage();
}
})
.finally(() => {
submitLoading.value = false;
});
});
}
function closeCurrentPage() {
appStore.removeTag("add-kan-jia-goods");
localStorage.pageOpenedList = JSON.stringify(appStore.pageOpenedList);
if (appStore?.removeTag) {
appStore.removeTag("add-kanjia-activity-goods");
appStore.removeTag("add-kan-jia-goods");
}
if (localStorage?.pageOpenedList && appStore?.pageOpenedList) {
localStorage.pageOpenedList = JSON.stringify(appStore.pageOpenedList);
}
router.go(-1);
}
function openSkuList() {
skuSelectRef.value.open("goods");
let data = JSON.parse(JSON.stringify(form.promotionGoodsList));
const data = JSON.parse(JSON.stringify(form.promotionGoodsList));
data.forEach((e) => {
e.id = e.skuId;
});
@@ -266,7 +431,7 @@ function openSkuList() {
}
function changeSelect(e) {
selectedGoods.value = e;
selectedGoods.value = e || [];
}
function delGoods(index) {
@@ -274,17 +439,33 @@ function delGoods(index) {
touchPromotionGoodsValidate();
}
function delBatch() {
if (!selectedGoods.value.length) return;
const skuIds = new Set(selectedGoods.value.map((g) => g.skuId || g.id));
form.promotionGoodsList = form.promotionGoodsList.filter(
(g) => !skuIds.has(g.skuId || g.id)
);
selectedGoods.value = [];
touchPromotionGoodsValidate();
}
function clearAllGoods() {
form.promotionGoodsList = [];
selectedGoods.value = [];
touchPromotionGoodsValidate();
}
function touchPromotionGoodsValidate() {
nextTick(() => {
const form = formRef.value;
if (form && typeof form.validateField === "function") {
form.validateField("promotionGoodsList", () => {});
const f = formRef.value;
if (f && typeof f.validateField === "function") {
f.validateField("promotionGoodsList", () => {});
}
});
}
function selectedGoodsData(item) {
let list = [];
const list = [];
item.forEach((e) => {
list.push({
settlementPrice: e.settlementPrice || 0,
@@ -310,55 +491,236 @@ function selectedGoodsData(item) {
</script>
<style lang="scss" scoped>
.kanjia-goods-table {
:deep(.el-table__body .el-table__cell .cell) {
overflow: visible;
text-overflow: clip;
white-space: normal;
.kanjia-add-page {
padding: 12px 20px 24px;
max-width: 1640px;
margin: 0 auto;
box-sizing: border-box;
.mt_10 {
margin-top: 16px;
}
}
.kanjia-goods-input {
width: 100%;
}
.kanjia-activity-time-form-item {
:deep(.el-date-editor) {
width: 360px !important;
max-width: 360px;
flex-grow: 0;
.font-mono {
font-family: Monaco, Menlo, Consolas, "Courier New", monospace;
}
}
h4 {
margin-bottom: 10px;
padding: 0 10px;
border: 1px solid #ddd;
background-color: #f8f8f8;
font-weight: bold;
color: #333;
font-size: 14px;
line-height: 40px;
text-align: left;
}
.describe {
font-size: 12px;
margin-left: 10px;
color: #999;
}
.effectiveDays {
font-size: 12px;
color: #999;
> * {
margin: 0 4px;
.font-semibold {
font-weight: 600;
}
}
.tips {
font-size: 12px;
color: #999;
.text-dark {
color: #303133;
}
.text-secondary {
color: #606266;
}
.header-card {
border: none;
border-radius: 4px;
background: transparent;
:deep(.el-card__body) {
padding: 8px 0 4px;
}
.page-header-row {
display: flex;
align-items: center;
justify-content: space-between;
.header-left {
.header-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 22px;
font-weight: 500;
color: #1f2d3d;
.mode-tag {
font-weight: normal;
}
}
}
}
}
.main-form {
.table-form-item {
margin-bottom: 0;
:deep(.el-form-item__content) {
margin-left: 0 !important;
}
}
}
.info-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
:deep(.el-card__body) {
padding: 20px 24px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.section-indicator {
width: 4px;
height: 16px;
background-color: #409eff;
border-radius: 2px;
}
.section-title {
font-size: 15px;
font-weight: 500;
color: #303133;
}
}
.time-tip-text {
font-size: 13px;
color: #909399;
margin-top: 6px;
width: 100%;
}
.card-header-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.header-title-group {
display: flex;
align-items: center;
gap: 8px;
.section-indicator {
width: 4px;
height: 16px;
background-color: #409eff;
border-radius: 2px;
}
.section-title {
font-size: 15px;
font-weight: 500;
color: #303133;
}
.total-badge {
font-size: 13px;
color: #909399;
margin-left: 6px;
}
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
}
.kanjia-goods-table {
:deep(th.el-table__cell) {
background-color: var(--el-fill-color-light);
color: var(--el-text-color-secondary);
font-weight: 400;
padding: 12px 0;
}
.goods-thumb-box {
width: 48px;
height: 48px;
flex: 0 0 48px;
border-radius: 6px;
overflow: hidden;
background-color: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #ebeef5;
margin: 0;
.goods-thumb-img {
width: 100%;
height: 100%;
display: block;
}
}
.thumb-slot {
font-size: 11px;
color: #c0c4cc;
display: flex;
align-items: center;
justify-content: center;
}
.sku-sub-text {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.kanjia-goods-input {
width: 100%;
}
}
}
.footer-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
box-shadow: 0 4px 18px rgb(31 45 61 / 5%);
:deep(.el-card__body) {
padding: 16px 20px;
}
.footer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
}
.page-description { margin: 8px 0 0; color: var(--el-text-color-secondary); font-size: 13px; }
.section-number { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 28px; height: 28px; border-radius: 7px; background: var(--el-color-primary-light-9); color: var(--el-color-primary); font-size: 12px; font-weight: 500; }
.kanjia-activity-time-form-item { margin: 16px 0 0 36px; }
.kanjia-activity-time-form-item :deep(.el-form-item__content) { min-width: 0; }
:deep(.activity-date-picker.el-date-editor) { flex: 0 1 520px; width: 520px; max-width: 100%; }
.goods-empty { width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 230px; border: 1px dashed var(--el-border-color); border-radius: 8px; background: var(--el-fill-color-lighter); padding: 24px 20px; text-align: center; }
.empty-icon { display: grid; place-items: center; width: 60px; height: 60px; border-radius: 16px; color: var(--el-color-primary); background: var(--el-color-primary-light-9); margin-bottom: 16px; }
.goods-empty h3 { margin: 0; font-size: 16px; font-weight: 500; color: var(--el-text-color-primary); }
.goods-empty p { margin: 8px 0 20px; font-size: 13px; color: var(--el-text-color-secondary); line-height: 1.6; }
.product-info { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
.product-details { min-width: 0; }
.product-name { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.6; }
.sku-sub-text { overflow-wrap: anywhere; }
.footer-summary { font-size: 13px; color: var(--el-text-color-secondary); }
.footer-summary strong { color: var(--el-color-primary); font-weight: 500; padding: 0 4px; }
.footer-buttons { display: flex; gap: 12px; }
.footer-buttons :deep(.el-button + .el-button) { margin-left: 0; }
@media (max-width: 768px) {
padding: 8px 8px 20px;
.page-header-row, .card-header-bar { flex-wrap: wrap; gap: 16px; }
.info-card :deep(.el-card__body) { padding: 16px; }
.kanjia-activity-time-form-item { margin-left: 0; }
.kanjia-activity-time-form-item :deep(.el-form-item__label) { width: 80px !important; }
.header-title-group { flex-wrap: wrap; }
.footer-card :deep(.el-card__body) { padding: 14px 16px; }
}
}
</style>

View File

@@ -1,94 +1,238 @@
<template>
<div>
<el-card>
<el-form ref="formRef" :model="form" label-width="120px" :rules="formRule">
<div class="base-info-item">
<div class="form-item-view">
<h4>商品信息</h4>
<el-form-item label="商品名称">
<div>{{ form.goodsName }}</div>
</el-form-item>
<el-form-item label="SKU编码">
<div>{{ form.skuId }}</div>
</el-form-item>
<el-form-item label="店铺名称">
<div>{{ form.goodsSku.storeName }}</div>
</el-form-item>
<el-form-item label="商品价格">
<div>
<priceColorScheme :value="form.goodsSku.price" :color="$mainColor" />
</div>
</el-form-item>
<el-form-item label="商品库存">
<div>{{ form.goodsSku.quantity }}</div>
</el-form-item>
<el-form-item label="结算价格" prop="settlementPrice">
<el-input
:disabled="onlyView"
type="number"
v-model="form.settlementPrice"
placeholder="请填写结算价格"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item label="最低可砍" prop="lowestPrice">
<el-input
:disabled="onlyView"
type="number"
v-model="form.lowestPrice"
placeholder="请填写最低可砍金额"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item label="最高可砍" prop="highestPrice">
<el-input
:disabled="onlyView"
type="number"
v-model="form.highestPrice"
placeholder="请填写最高可砍金额"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item label="活动库存" prop="stock">
<el-input
:disabled="onlyView"
type="number"
v-model="form.stock"
placeholder="请填写活动库存"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item label="活动时间" prop="rangeTime" class="kanjia-activity-time-form-item">
<el-date-picker
:disabled="onlyView"
v-model="form.rangeTime"
type="datetimerange"
format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
placeholder="请选择"
:disabled-date="options.disabledDate"
/>
</el-form-item>
<div>
<el-button link @click="closeCurrentPage">返回</el-button>
<el-button v-if="!onlyView" type="primary" :loading="submitLoading" @click="handleSubmit">
提交
</el-button>
</div>
<div class="kanjia-edit-page">
<!-- 顶部标题与返回栏 -->
<el-card shadow="never" class="header-card">
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>{{ onlyView ? "砍价活动商品详情" : "编辑砍价活动商品" }}</span>
<el-tag
size="small"
effect="plain"
:type="onlyView ? 'info' : 'warning'"
class="mode-tag"
>
{{ onlyView ? "查看模式" : "编辑模式" }}
</el-tag>
<el-tag
v-if="form.promotionStatus"
size="small"
effect="dark"
:type="promotionStatusTagType(form.promotionStatus)"
class="status-tag"
>
{{ promotionStatusText(form.promotionStatus) }}
</el-tag>
<span v-if="id" class="activity-id font-mono">ID: {{ id }}</span>
</div>
</div>
</el-form>
<div class="header-right">
<el-button @click="closeCurrentPage">返回砍价列表</el-button>
</div>
</div>
</el-card>
<!-- 商品基本信息卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">商品基本信息</span>
</div>
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="商品图片" width="120px">
<div class="goods-thumb-box">
<el-image
v-if="goodsThumbnail"
:src="goodsThumbnail"
fit="cover"
preview-teleported
:preview-src-list="[goodsThumbnail]"
class="goods-thumb-img"
>
<template #error>
<div class="thumb-slot">无图</div>
</template>
</el-image>
<div v-else class="thumb-slot">无图</div>
</div>
</el-descriptions-item>
<el-descriptions-item label="商品名称">
<div class="font-semibold text-dark">{{ form.goodsName || "-" }}</div>
<div v-if="form.goodsId" class="sub-id-text font-mono">
商品ID: {{ form.goodsId }}
</div>
</el-descriptions-item>
<el-descriptions-item label="SKU编码">
<span class="font-mono text-dark">{{ form.skuId || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="所属店铺">
<span class="text-secondary">{{ goodsStoreName }}</span>
</el-descriptions-item>
<el-descriptions-item label="商品原价">
<span class="original-price-val font-mono">
{{ $filters.unitPrice(goodsPrice, "¥") }}
</span>
</el-descriptions-item>
<el-descriptions-item label="商品库存">
<span class="font-mono text-dark">{{ goodsStock }} 件</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 砍价活动配置区域 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">砍价规则与参数</span>
</div>
<!-- 查看模式:以结构化描述列表呈现 -->
<template v-if="onlyView">
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="结算价格">
<span class="price-highlight font-mono font-semibold">
{{ $filters.unitPrice(form.settlementPrice, "¥") }}
</span>
</el-descriptions-item>
<el-descriptions-item label="活动库存">
<span class="font-mono text-dark font-semibold">
{{ form.stock != null ? `${form.stock} 件` : "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="最低可砍金额">
<span class="font-mono text-dark">
{{ $filters.unitPrice(form.lowestPrice, "¥") }}
</span>
</el-descriptions-item>
<el-descriptions-item label="最高可砍金额">
<span class="font-mono text-dark">
{{ $filters.unitPrice(form.highestPrice, "¥") }}
</span>
</el-descriptions-item>
<el-descriptions-item
v-if="form.purchasePrice != null"
label="最低购买金额"
>
<span class="font-mono text-dark">
{{ $filters.unitPrice(form.purchasePrice, "¥") }}
</span>
</el-descriptions-item>
<el-descriptions-item label="活动时间" :span="form.purchasePrice != null ? 1 : 2">
<span class="font-mono text-dark">
{{ activityTimeRange }}
</span>
</el-descriptions-item>
</el-descriptions>
</template>
<!-- 编辑模式:表单输入 -->
<template v-else>
<el-form
ref="formRef"
:model="form"
label-width="120px"
:rules="formRule"
class="edit-form"
>
<el-form-item label="结算价格" prop="settlementPrice">
<el-input
v-model="form.settlementPrice"
type="number"
placeholder="请填写结算价格"
clearable
style="width: 280px"
>
<template #append>元</template>
</el-input>
</el-form-item>
<el-form-item label="最低可砍" prop="lowestPrice">
<el-input
v-model="form.lowestPrice"
type="number"
placeholder="请填写最低可砍金额"
clearable
style="width: 280px"
>
<template #append>元</template>
</el-input>
</el-form-item>
<el-form-item label="最高可砍" prop="highestPrice">
<el-input
v-model="form.highestPrice"
type="number"
placeholder="请填写最高可砍金额"
clearable
style="width: 280px"
>
<template #append>元</template>
</el-input>
</el-form-item>
<el-form-item label="活动库存" prop="stock">
<el-input
v-model="form.stock"
type="number"
placeholder="请填写活动库存"
clearable
style="width: 280px"
>
<template #append>件</template>
</el-input>
</el-form-item>
<el-form-item
label="活动时间"
prop="rangeTime"
class="kanjia-activity-time-form-item"
>
<el-date-picker
v-model="form.rangeTime"
type="datetimerange"
format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
placeholder="请选择活动时间"
:disabled-date="options.disabledDate"
/>
</el-form-item>
</el-form>
</template>
</el-card>
<!-- 底部操作栏 -->
<el-card shadow="never" class="footer-card mt_10">
<div class="footer-actions">
<el-button @click="closeCurrentPage">
{{ onlyView ? "返回" : "取消" }}
</el-button>
<el-button
v-if="!onlyView"
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
保存修改
</el-button>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { ref, onMounted, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { getKanJiaActivityGoodsById, editKanJiaActivityGoods } from "@/api/promotion";
import { regular } from "@/utils";
@@ -103,6 +247,66 @@ const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const id = computed(() => route.query.id || "");
const onlyView = computed(() => {
return route.query.onlyView === "true" || route.query.onlyView === true;
});
const submitLoading = ref(false);
const form = ref({
purchasePrice: 0,
goodsSku: {},
rangeTime: [],
});
const goodsThumbnail = computed(() => {
return form.value.thumbnail || form.value.goodsSku?.thumbnail || "";
});
const goodsStoreName = computed(() => {
return form.value.goodsSku?.storeName || form.value.storeName || "-";
});
const goodsPrice = computed(() => {
if (form.value.goodsSku?.price != null) return form.value.goodsSku.price;
if (form.value.originalPrice != null) return form.value.originalPrice;
return 0;
});
const goodsStock = computed(() => {
if (form.value.goodsSku?.quantity != null) return form.value.goodsSku.quantity;
return "-";
});
const activityTimeRange = computed(() => {
const start = form.value.startTime || (form.value.rangeTime && form.value.rangeTime[0]);
const end = form.value.endTime || (form.value.rangeTime && form.value.rangeTime[1]);
if (start && end) {
return `${start} 至 ${end}`;
}
return start || end || "-";
});
function promotionStatusText(status) {
const map = {
NEW: "未开始",
START: "已开始",
END: "已结束",
CLOSE: "已关闭",
};
return map[status] || status;
}
function promotionStatusTagType(status) {
const map = {
NEW: "info",
START: "success",
END: "danger",
CLOSE: "danger",
};
return map[status] || "info";
}
const checkSettlementPrice = (rule, value, callback) => {
if (!value && value !== 0) {
return callback(new Error("结算金额不能为空"));
@@ -115,6 +319,7 @@ const checkSettlementPrice = (rule, value, callback) => {
callback();
}
};
const checkLowestPrice = (rule, value, callback) => {
if (!value && value !== 0) {
return callback(new Error("最低可砍金额不能为空"));
@@ -127,6 +332,7 @@ const checkLowestPrice = (rule, value, callback) => {
callback();
}
};
const checkHighestPrice = (rule, value, callback) => {
if (!value && value !== 0) {
return callback(new Error("最高可砍金额不能为空"));
@@ -140,15 +346,6 @@ const checkHighestPrice = (rule, value, callback) => {
}
};
const modalType = ref(0);
const form = ref({
purchasePrice: 0,
goodsSku: {},
rangeTime: [],
});
const id = route.query.id;
const onlyView = route.query.onlyView;
const submitLoading = ref(false);
const formRule = {
settlementPrice: [
{ required: true, message: "请输入结算金额" },
@@ -172,6 +369,7 @@ const formRule = {
],
stock: [{ required: true, message: "请输入活动库存" }],
};
const options = {
disabledDate(date) {
return date && date.valueOf() < Date.now() - 86400000;
@@ -180,17 +378,21 @@ const options = {
const formRef = ref(null);
onMounted(async () => {
if (id) {
onMounted(() => {
if (id.value) {
getKanJiaActivityGoods();
modalType.value = 1;
}
});
function getKanJiaActivityGoods() {
getKanJiaActivityGoodsById(id).then((res) => {
form.value = res.result;
form.value.rangeTime = [res.result.startTime, res.result.endTime];
getKanJiaActivityGoodsById(id.value).then((res) => {
if (res && res.success && res.result) {
form.value = {
...res.result,
goodsSku: res.result.goodsSku || {},
rangeTime: [res.result.startTime, res.result.endTime],
};
}
});
}
@@ -213,7 +415,7 @@ function handleSubmit() {
const eCoupon = isECoupon(params.goodsType || params.goodsSku?.goodsType);
if (params.stock <= 0 || params.stock > params.goodsSku.quantity) {
if (params.stock <= 0 || (params.goodsSku?.quantity != null && params.stock > params.goodsSku.quantity)) {
ElMessage.error("活动库存不能为0且不能超过商品库存");
return;
}
@@ -223,7 +425,7 @@ function handleSubmit() {
}
if (
(eCoupon ? params.settlementPrice < 0 : params.settlementPrice <= 0) ||
params.settlementPrice > params.price
params.settlementPrice > (params.price || goodsPrice.value)
) {
ElMessage.error(
eCoupon
@@ -236,7 +438,7 @@ function handleSubmit() {
ElMessage.error("最高可砍金额格式错误");
return;
}
if (params.highestPrice <= 0 || params.highestPrice > params.price) {
if (params.highestPrice <= 0 || params.highestPrice > (params.price || goodsPrice.value)) {
ElMessage.error("最高可砍金额不能为0且不能超过商品价格");
return;
}
@@ -247,52 +449,212 @@ function handleSubmit() {
if (
params.lowestPrice < 0 ||
(!eCoupon && params.lowestPrice <= 0) ||
params.lowestPrice > params.price
params.lowestPrice > (params.price || goodsPrice.value)
) {
ElMessage.error(eCoupon ? "最低可砍金额不能小于0" : "最低可砍金额不能为0");
return;
}
if (params.lowestPrice > params.highestPrice) {
if (parseFloat(params.lowestPrice) > parseFloat(params.highestPrice)) {
ElMessage.error("最低砍价金额不能大于最高砍价金额");
return;
}
submitLoading.value = true;
editKanJiaActivityGoods(params).then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success("砍价活动修改成功");
closeCurrentPage();
}
});
editKanJiaActivityGoods(params)
.then((res) => {
if (res && res.success) {
ElMessage.success("砍价活动修改成功");
closeCurrentPage();
}
})
.finally(() => {
submitLoading.value = false;
});
});
}
function closeCurrentPage() {
appStore.removeTag("add-kan-jia-goods");
localStorage.pageOpenedList = JSON.stringify(appStore.pageOpenedList);
if (appStore?.removeTag) {
appStore.removeTag("add-kan-jia-goods");
appStore.removeTag("edit-kanJia-activity-goods");
}
if (localStorage?.pageOpenedList && appStore?.pageOpenedList) {
localStorage.pageOpenedList = JSON.stringify(appStore.pageOpenedList);
}
router.go(-1);
}
</script>
<style lang="scss" scoped>
h4 {
margin-bottom: 10px;
padding: 0 10px;
border: 1px solid #ddd;
background-color: #f8f8f8;
font-weight: bold;
color: #333;
font-size: 14px;
line-height: 40px;
text-align: left;
}
.kanjia-edit-page {
padding: 16px 20px;
background-color: #f0f2f5;
min-height: 100vh;
box-sizing: border-box;
.kanjia-activity-time-form-item {
:deep(.el-date-editor) {
width: 360px !important;
max-width: 360px;
flex-grow: 0;
.mt_10 {
margin-top: 12px;
}
.font-mono {
font-family: Monaco, Menlo, Consolas, "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-secondary {
color: #606266;
}
.header-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 16px 20px;
}
.page-header-row {
display: flex;
align-items: center;
justify-content: space-between;
.header-left {
.header-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 600;
color: #1f2d3d;
.mode-tag,
.status-tag {
font-weight: normal;
}
.activity-id {
font-size: 13px;
color: #909399;
font-weight: normal;
}
}
}
}
}
.info-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 20px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.section-indicator {
width: 4px;
height: 16px;
background-color: #409eff;
border-radius: 2px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.custom-descriptions {
:deep(.el-descriptions__label) {
width: 130px;
background-color: #f8f9fb;
color: #606266;
font-weight: 500;
}
:deep(.el-descriptions__content) {
color: #303133;
}
.goods-thumb-box {
width: 56px;
height: 56px;
border-radius: 4px;
overflow: hidden;
background-color: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #ebeef5;
.goods-thumb-img {
width: 100%;
height: 100%;
display: block;
}
.thumb-slot {
font-size: 12px;
color: #c0c4cc;
}
}
.sub-id-text {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.original-price-val {
color: #303133;
font-size: 14px;
}
.price-highlight {
color: #f56c6c;
font-size: 16px;
}
}
.edit-form {
padding: 8px 12px;
.kanjia-activity-time-form-item {
:deep(.el-date-editor) {
width: 380px !important;
max-width: 380px;
flex-grow: 0;
}
}
}
}
.footer-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 16px 20px;
}
.footer-actions {
display: flex;
justify-content: center;
gap: 16px;
}
}
}
</style>

View File

@@ -1,31 +0,0 @@
<template>
<div>
<el-card>
<el-form inline @keyup.enter="load">
<el-form-item label="活动名称"><el-input v-model="query.promotionName" clearable /></el-form-item>
<el-form-item><el-button type="primary" @click="load">搜索</el-button></el-form-item>
</el-form>
</el-card>
<el-card class="mt_10">
<el-table v-loading="loading" :data="rows" border>
<el-table-column prop="promotionName" label="活动名称" />
<el-table-column prop="storeName" label="店铺" />
<el-table-column prop="nthNum" label="第N件" width="80" />
<el-table-column prop="discountType" label="优惠方式" width="100" />
<el-table-column prop="startTime" label="开始" width="170" />
<el-table-column prop="endTime" label="结束" width="170" />
<el-table-column prop="promotionStatus" label="状态" width="100" />
</el-table>
<el-pagination class="mt_10" v-model:current-page="query.pageNumber" :total="total" @current-change="load" />
</el-card>
</div>
</template>
<script setup>
import { useTable } from "@/utils/useTable";
import { getNthItemDiscountList } from "@/api/promotion.js";
const { loading, data: rows, total, searchForm: query, getDataList: load } = useTable(
getNthItemDiscountList,
{ pageSize: 20, form: { promotionName: "" } }
);
</script>

View File

@@ -1,98 +1,290 @@
<template>
<div class="pintuan-goods">
<el-card>
<h4>活动详情</h4>
<el-table border :data="data" style="width: 100%; margin: 10px 0">
<el-table-column prop="promotionName" label="活动名称" min-width="120" />
<el-table-column prop="startTime" label="活动开始时间" min-width="120" />
<el-table-column prop="endTime" label="活动结束时间" min-width="120" />
<el-table-column prop="requiredNum" label="成团人数" min-width="90" />
<el-table-column prop="limitNum" label="限购数量" min-width="90" />
<el-table-column label="状态" min-width="100">
<template #default="{ row }">
<el-tag v-if="row" :type="promotionStatusTagType(row.promotionStatus)">
{{ promotionStatusText(row.promotionStatus) }}
<div class="pintuan-goods-page">
<!-- 顶部标题与返回栏 -->
<el-card shadow="never" class="header-card">
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>拼团活动商品管理</span>
<el-tag
v-if="pintuanInfo.promotionStatus"
:type="promotionStatusTagType(pintuanInfo.promotionStatus)"
size="small"
effect="plain"
class="status-tag"
>
{{ promotionStatusText(pintuanInfo.promotionStatus) }}
</el-tag>
</template>
</el-table-column>
</el-table>
<span v-if="pintuanId" class="activity-id font-mono">ID: {{ pintuanId }}</span>
</div>
</div>
<div class="header-right">
<el-button @click="back">返回拼团列表</el-button>
</div>
</div>
</el-card>
<!-- 活动信息卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">活动信息</span>
</div>
<el-descriptions :column="3" border class="custom-descriptions">
<el-descriptions-item label="活动名称">
<span class="font-semibold text-dark">{{ pintuanInfo.promotionName || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="活动状态">
<el-tag
v-if="pintuanInfo.promotionStatus"
:type="promotionStatusTagType(pintuanInfo.promotionStatus)"
size="small"
effect="dark"
>
{{ promotionStatusText(pintuanInfo.promotionStatus) }}
</el-tag>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="所属店铺">
<span class="text-secondary">{{ pintuanInfo.storeName || "全平台 / 自营" }}</span>
</el-descriptions-item>
<el-descriptions-item label="活动开始时间">
<span class="font-mono text-dark">{{ pintuanInfo.startTime || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="活动结束时间">
<span class="font-mono text-dark">{{ pintuanInfo.endTime || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="成团人数">
<span class="font-mono text-dark">
{{ pintuanInfo.requiredNum ? `${pintuanInfo.requiredNum} 人` : "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="限购数量">
<span class="font-mono text-dark">
{{ pintuanInfo.limitNum > 0 ? `${pintuanInfo.limitNum} 件` : "不限购" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="虚拟成团">
<el-tag
v-if="pintuanInfo.fictitious != null"
:type="pintuanInfo.fictitious ? 'success' : 'info'"
size="small"
effect="plain"
>
{{ pintuanInfo.fictitious ? "已开启" : "未开启" }}
</el-tag>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="活动ID">
<span class="font-mono text-secondary">{{ pintuanId || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item
v-if="pintuanInfo.pintuanRule"
label="规则说明"
:span="3"
>
<span class="text-secondary rule-desc">{{ pintuanInfo.pintuanRule }}</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 拼团商品列表卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="card-header-bar">
<div class="header-title-group">
<span class="section-indicator"></span>
<span class="section-title">已报名拼团商品</span>
<span class="total-badge">共 {{ total }} 件商品</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getDataList">刷新</el-button>
</div>
</div>
<h4>商品信息</h4>
<el-table
ref="table"
v-loading="loading"
border
class="operation"
stripe
:data="goodsData"
style="width: 100%"
empty-text="暂无报名的拼团商品"
class="custom-table"
>
<el-table-column label="商品名称" min-width="120">
<!-- 商品图片 -->
<el-table-column label="商品图片" width="76" align="center">
<template #default="{ row }">
<div v-if="row && (row.thumbnail || row.small || row.goodsImage)" class="goods-thumb-box">
<el-image
:src="row.thumbnail || row.small || row.goodsImage"
fit="cover"
preview-teleported
:preview-src-list="[row.thumbnail || row.small || row.goodsImage]"
class="goods-thumb-img"
>
<template #error>
<div class="thumb-slot">无图</div>
</template>
</el-image>
</div>
<div v-else class="goods-thumb-box thumb-slot">无图</div>
</template>
</el-table-column>
<!-- 商品名称 -->
<el-table-column prop="goodsName" label="商品名称" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<div v-if="row">
<a
class="goods-link"
@click="handleLinkTo(row.goodsId, row.skuId)"
>
{{ row.goodsName || "-" }}
</a>
<div v-if="row.skuId" class="sku-sub-text font-mono">
SKU: {{ row.skuId }}
</div>
</div>
</template>
</el-table-column>
<!-- 所属商家 -->
<el-table-column prop="storeName" label="所属商家" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span class="text-secondary">{{ (row && row.storeName) || "-" }}</span>
</template>
</el-table-column>
<!-- 商品原价 -->
<el-table-column label="商品原价" width="120" align="right">
<template #default="{ row }">
<span v-if="row" class="original-price font-mono">
{{ $filters.unitPrice(row.originalPrice, "¥") }}
</span>
</template>
</el-table-column>
<!-- 拼团价格 -->
<el-table-column label="拼团价格" width="130" align="right">
<template #default="{ row }">
<span v-if="row" class="pintuan-price font-mono font-semibold">
{{ $filters.unitPrice(row.price, "¥") }}
</span>
</template>
</el-table-column>
<!-- 活动库存 -->
<el-table-column label="活动库存" width="100" align="right">
<template #default="{ row }">
<span v-if="row" class="font-mono text-dark">{{ row.quantity ?? "-" }}</span>
</template>
</el-table-column>
<!-- 限购数量 -->
<el-table-column label="限购数量" width="100" align="right">
<template #default="{ row }">
<span v-if="row" class="font-mono text-dark">
{{ row.limitNum > 0 ? row.limitNum : "不限" }}
</span>
</template>
</el-table-column>
<!-- 操作 -->
<el-table-column label="查看" width="140" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row">
<a class="link-text mr_10" @click="linkTo(row.goodsId, row.skuId)">{{ row.goodsName }}</a>
<el-popover trigger="hover" title="扫码在手机中查看" placement="top" width="180">
<el-button
link
type="primary"
@click="handleLinkTo(row.goodsId, row.skuId)"
>
PC查看
</el-button>
<el-popover
trigger="hover"
placement="top"
width="180"
>
<template #reference>
<img
src="../../../assets/qrcode.svg"
style="vertical-align: middle"
class="hover-pointer"
width="20"
height="20"
alt="qrcode"
/>
<el-button link type="primary" class="ml_8">
手机扫码
</el-button>
</template>
<vue-qr
:text="wapLinkTo(row.goodsId, row.skuId)"
:margin="0"
color-dark="#000"
color-light="#fff"
:size="150"
/>
<div class="qr-popover-content">
<div class="qr-popover-title">扫码在手机中查看</div>
<vue-qr
:text="handleWapLinkTo(row.goodsId, row.skuId)"
:margin="4"
color-dark="#000"
color-light="#fff"
:size="140"
/>
</div>
</el-popover>
</template>
</template>
</el-table-column>
<el-table-column prop="quantity" label="库存" min-width="80" />
<el-table-column label="拼团价格" min-width="100">
<template #default="{ row }">
<span v-if="row" :style="{ color: $mainColor }">
{{ $filters.unitPrice(row.price, "¥") }}</span>
</template>
</el-table-column>
</el-table>
<div class="page operation mt_10" style="display: flex; justify-content: flex-end">
<!-- 分页区域 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
:page-sizes="[20, 50, 100]"
:page-sizes="[10, 20, 50, 100]"
:total="total"
background
layout="total, sizes, prev, pager, next, jumper"
size="small"
size="default"
@current-change="changePage"
@size-change="changePageSize"
/>
</div>
</el-card>
<!-- 底部返回卡片 -->
<el-card shadow="never" class="footer-card mt_10">
<div class="footer-actions">
<el-button @click="back">返回拼团列表</el-button>
</div>
</el-card>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import { onMounted, reactive, ref, computed, getCurrentInstance } from "vue";
import { useRoute, useRouter } from "vue-router";
import { getPintuanGoodsList, getPintuanDetail } from "@/api/promotion.js";
import vueQr from "vue-qr";
const route = useRoute();
const router = useRouter();
const { proxy } = getCurrentInstance() || {};
const loading = ref(false);
const pintuanInfo = ref({});
const goodsData = ref([]);
const total = ref(0);
const searchForm = reactive({
pageNumber: 1,
pageSize: 20,
});
const data = ref([]);
const total = ref(0);
const goodsData = ref([]);
const pintuanId = computed(() => {
return route.query.id || pintuanInfo.value.id || "";
});
function promotionStatusText(status) {
const map = {
@@ -103,6 +295,7 @@ function promotionStatusText(status) {
};
return map[status] || "未知";
}
function promotionStatusTagType(status) {
const map = {
NEW: "info",
@@ -110,34 +303,77 @@ function promotionStatusTagType(status) {
END: "danger",
CLOSE: "danger",
};
return map[status] || "danger";
return map[status] || "info";
}
function init() {
getDataList();
getPintuanMsg();
function handleLinkTo(goodsId, skuId) {
if (proxy && typeof proxy.linkTo === "function") {
proxy.linkTo(goodsId, skuId);
} else {
const pcUrl = window.BASE?.PC_URL || "";
const url = skuId
? `${pcUrl}/goodsDetail?skuId=${skuId}&goodsId=${goodsId}`
: `${pcUrl}/goodsDetail?goodsId=${goodsId}`;
window.open(url, "_blank");
}
}
function handleWapLinkTo(goodsId, skuId) {
if (proxy && typeof proxy.wapLinkTo === "function") {
return proxy.wapLinkTo(goodsId, skuId);
}
const wapUrl = window.BASE?.WAP_URL || "";
if (skuId) {
return `${wapUrl}/pages/product/goods?id=${skuId}&goodsId=${goodsId}`;
}
return `${wapUrl}/pages/product/goods?goodsId=${goodsId}`;
}
function back() {
if (window.history.length > 1) {
router.go(-1);
} else {
router.push({ name: "manager-pintuan" });
}
}
function changePage() {
getDataList();
}
function changePageSize() {
searchForm.pageNumber = 1;
getDataList();
}
function getDataList() {
if (!pintuanId.value) return;
loading.value = true;
searchForm.pintuanId = route.query.id;
getPintuanGoodsList(searchForm).then((res) => {
loading.value = false;
if (res.success) {
goodsData.value = res.result.records;
total.value = res.result.total;
searchForm.pintuanId = pintuanId.value;
getPintuanGoodsList(searchForm)
.then((res) => {
if (res && res.success && res.result) {
goodsData.value = res.result.records || [];
total.value = res.result.total || 0;
}
})
.finally(() => {
loading.value = false;
});
}
function getPintuanMsg() {
if (!pintuanId.value) return;
getPintuanDetail(pintuanId.value).then((res) => {
if (res && res.success && res.result) {
pintuanInfo.value = res.result;
}
});
}
function getPintuanMsg() {
getPintuanDetail(route.query.id).then((res) => {
if (res.success) data.value.push(res.result);
});
function init() {
getPintuanMsg();
getDataList();
}
onMounted(() => {
@@ -146,31 +382,246 @@ onMounted(() => {
</script>
<style lang="scss" scoped>
h4 {
margin: 20px 0;
padding: 0 10px;
font-weight: bold;
color: #333;
font-size: 14px;
text-align: left;
border-left: 3px solid red;
.pintuan-goods-page {
padding: 16px 20px;
background-color: #f0f2f5;
min-height: 100vh;
box-sizing: border-box;
.mt_10 {
margin-top: 12px;
}
.ml_8 {
margin-left: 8px;
}
.font-mono {
font-family: Monaco, Menlo, Consolas, "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-secondary {
color: #606266;
}
.header-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 16px 20px;
}
.page-header-row {
display: flex;
align-items: center;
justify-content: space-between;
.header-left {
.header-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 600;
color: #1f2d3d;
.status-tag {
font-weight: normal;
}
.activity-id {
font-size: 13px;
color: #909399;
font-weight: normal;
}
}
}
}
}
.info-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 20px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.section-indicator {
width: 4px;
height: 16px;
background-color: #409eff;
border-radius: 2px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.custom-descriptions {
:deep(.el-descriptions__label) {
width: 120px;
background-color: #f8f9fb;
color: #606266;
font-weight: 500;
}
:deep(.el-descriptions__content) {
color: #303133;
}
.rule-desc {
white-space: pre-wrap;
line-height: 1.6;
}
}
.card-header-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.header-title-group {
display: flex;
align-items: center;
gap: 8px;
.section-indicator {
width: 4px;
height: 16px;
background-color: #409eff;
border-radius: 2px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.total-badge {
font-size: 13px;
color: #909399;
margin-left: 6px;
}
}
}
.custom-table {
:deep(th.el-table__cell) {
background-color: #fafafa;
color: #303133;
font-weight: 600;
}
.goods-thumb-box {
width: 44px;
height: 44px;
border-radius: 4px;
overflow: hidden;
background-color: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #ebeef5;
margin: 0 auto;
.goods-thumb-img {
width: 100%;
height: 100%;
display: block;
}
}
.thumb-slot {
font-size: 11px;
color: #c0c4cc;
display: flex;
align-items: center;
justify-content: center;
}
.goods-link {
color: #303133;
font-weight: 500;
cursor: pointer;
transition: color 0.2s;
text-decoration: none;
&:hover {
color: #409eff;
}
}
.sku-sub-text {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.original-price {
color: #909399;
text-decoration: line-through;
font-size: 13px;
}
.pintuan-price {
color: #f56c6c;
font-size: 14px;
}
}
.pagination-container {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}
.footer-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 16px 20px;
}
.footer-actions {
display: flex;
justify-content: center;
gap: 12px;
}
}
}
.link-text {
color: #409eff;
cursor: pointer;
text-decoration: none;
}
.qr-popover-content {
text-align: center;
padding: 6px 0;
.mr_10 {
margin-right: 10px;
}
.hover-pointer {
cursor: pointer;
}
.mt_10 {
margin-top: 10px;
.qr-popover-title {
font-size: 13px;
font-weight: 500;
color: #303133;
margin-bottom: 8px;
}
}
</style>

View File

@@ -1,126 +1,163 @@
<template>
<div class="wrapper">
<el-card>
<el-form ref="formRef" :model="form" :rules="formRule" label-width="120px">
<div class="base-info-item">
<div class="form-item-view">
<el-form-item style="width: 100%">
<div style="display: flex; margin-bottom: 10px">
<el-button type="primary" @click="openSkuList">选择商品</el-button>
<el-button
type="danger"
plain
style="margin-left: 10px"
@click="delSelectGoods"
>批量删除</el-button>
</div>
<el-table
border
v-if="showTable"
:data="promotionGoodsList"
style="width: 100%"
@selection-change="changeSelect"
>
<el-table-column type="selection" width="60" align="center" />
<el-table-column prop="goodsName" label="商品名称" min-width="120" show-overflow-tooltip />
<el-table-column label="类型" width="100">
<template #default="{ row }">
<span v-if="row">{{ goodsTypeLabel(row.goodsType) }}</span>
</template>
</el-table-column>
<el-table-column label="SKU编码" min-width="120">
<template #default="{ row }">
<span v-if="row">{{ row.skuId }}</span>
</template>
</el-table-column>
<el-table-column prop="storeName" label="店铺名称" min-width="100" show-overflow-tooltip />
<el-table-column label="商品价格" width="110">
<template #default="{ row }">
<span v-if="row" :style="{ color: $mainColor }">
{{ $filters.unitPrice(row.price, "¥") }}</span>
</template>
</el-table-column>
<el-table-column label="库存" width="90">
<template #default="{ row }">
<span v-if="row">{{ row.quantity }}</span>
</template>
</el-table-column>
<el-table-column label="结算价格" min-width="130">
<template #default="{ $index }">
<el-input-number
:min="0"
v-model="promotionGoodsList[$index].settlementPrice"
style="width: 110px"
/>
</template>
</el-table-column>
<el-table-column label="分类" min-width="160">
<template #default="{ $index }">
<el-select
v-model="promotionGoodsList[$index].pointsGoodsCategoryId"
@change="(val) => changeCategory(val, $index)"
style="width: 140px"
>
<el-option
v-for="item in categoryList"
:value="item.id"
:key="item.id"
:label="item.name"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="活动库存" min-width="130">
<template #default="{ $index }">
<el-input-number
:min="0"
v-model="promotionGoodsList[$index].activeStock"
style="width: 110px"
/>
</template>
</el-table-column>
<el-table-column label="兑换积分" min-width="130">
<template #default="{ $index }">
<el-input-number
:min="0"
v-model="promotionGoodsList[$index].points"
style="width: 110px"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ $index }">
<el-button type="danger" size="small" plain @click="delGoods($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-form-item>
<div class="points-goods-add-page">
<div class="page-header">
<div>
<h1>添加积分商品</h1>
<p>设置兑换时间,选择商品并配置兑换积分、价格与库存。</p>
</div>
<el-button :icon="ArrowLeft" @click="closeCurrentPage">返回积分商品列表</el-button>
</div>
<el-form-item label="活动时间" prop="rangeTime" class="points-goods-time-form-item">
<el-date-picker
type="datetimerange"
v-model="form.rangeTime"
format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
placeholder="请选择"
:disabled-date="options.disabledDate"
/>
</el-form-item>
<div>
<el-button link @click="closeCurrentPage">返回</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">提交</el-button>
</div>
<el-form ref="formRef" :model="form" :rules="formRule" label-width="90px">
<el-card shadow="never" class="section-card">
<div class="section-heading">
<span class="section-number">01</span>
<h2>活动时间设置</h2>
</div>
<el-form-item label="活动时间" prop="rangeTime" class="points-goods-time-form-item">
<el-date-picker
type="datetimerange"
v-model="form.rangeTime"
format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
range-separator="至"
:disabled-date="options.disabledDate"
/>
<span class="field-hint">所有参与商品将使用同一兑换时间</span>
</el-form-item>
</el-card>
<el-card shadow="never" class="section-card">
<div class="goods-section-heading">
<div class="section-heading">
<span class="section-number">02</span>
<h2>积分商品</h2>
<span class="selected-count">已选 {{ promotionGoodsList.length }} 件商品</span>
</div>
<div v-if="promotionGoodsList.length" class="goods-actions">
<el-button v-if="selectedGoods.length" type="danger" plain @click="delSelectGoods">批量删除({{ selectedGoods.length }})</el-button>
<el-button type="primary" :icon="Plus" @click="openSkuList">选择商品</el-button>
</div>
</div>
</el-form>
</el-card>
<el-table
v-if="promotionGoodsList.length"
:data="promotionGoodsList"
class="points-goods-table"
style="width: 100%"
row-key="skuId"
scrollbar-always-on
@selection-change="changeSelect"
>
<el-table-column type="selection" width="48" align="center" />
<el-table-column label="商品信息" min-width="280">
<template #default="{ row }">
<div class="product-info">
<el-image :src="row.thumbnail" fit="cover" class="product-image" preview-teleported :preview-src-list="row.thumbnail ? [row.thumbnail] : []">
<template #error><span class="image-placeholder">无图</span></template>
</el-image>
<div class="product-details">
<div class="product-name" :title="row.goodsName">{{ row.goodsName || '-' }}</div>
<div class="product-code">SKU:{{ row.skuId }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="类型" width="100">
<template #default="{ row }">
<span v-if="row">{{ goodsTypeLabel(row.goodsType) }}</span>
</template>
</el-table-column>
<el-table-column prop="storeName" label="所属店铺" min-width="120" show-overflow-tooltip />
<el-table-column label="商品原价" width="110" align="right">
<template #default="{ row }">
<span v-if="row" :style="{ color: $mainColor }">
{{ $filters.unitPrice(row.price, "¥") }}</span>
</template>
</el-table-column>
<el-table-column label="可用库存" width="90" align="right">
<template #default="{ row }">
<span v-if="row">{{ row.quantity }}</span>
</template>
</el-table-column>
<el-table-column label="结算价格(元)" width="140" align="center">
<template #default="{ $index }">
<el-input-number
:min="0"
:controls="false"
v-model="promotionGoodsList[$index].settlementPrice"
class="goods-number-input"
/>
</template>
</el-table-column>
<el-table-column label="积分分类" width="160">
<template #default="{ $index }">
<el-select
v-model="promotionGoodsList[$index].pointsGoodsCategoryId"
@change="(val) => changeCategory(val, $index)"
style="width: 100%"
placeholder="请选择分类"
>
<el-option
v-for="item in categoryList"
:value="item.id"
:key="item.id"
:label="item.name"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="活动库存(件)" width="130" align="center">
<template #default="{ $index }">
<el-input-number
:min="0"
:controls="false"
v-model="promotionGoodsList[$index].activeStock"
class="goods-number-input"
/>
</template>
</el-table-column>
<el-table-column label="兑换积分" width="130" align="center">
<template #default="{ $index }">
<el-input-number
:min="0"
:controls="false"
v-model="promotionGoodsList[$index].points"
class="goods-number-input"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ $index }">
<el-button type="danger" link @click="delGoods($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="goods-empty">
<div class="empty-icon"><el-icon :size="28"><ShoppingBag /></el-icon></div>
<h3>选择参与积分兑换的商品</h3>
<p>添加商品后,可逐项设置积分分类、兑换积分和活动库存</p>
<el-button type="primary" :icon="Plus" @click="openSkuList">选择活动商品</el-button>
</div>
</el-card>
<el-card shadow="never" class="footer-card">
<div class="footer-content">
<span class="selected-count">已选 <strong>{{ promotionGoodsList.length }}</strong> 件商品</span>
<div class="footer-buttons">
<el-button @click="closeCurrentPage">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">提交创建</el-button>
</div>
</div>
</el-card>
</el-form>
<sku-select ref="skuSelectRef" @selectedGoodsData="selectedGoodsData"></sku-select>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from "vue";
import { ArrowLeft, Plus, ShoppingBag } from "@element-plus/icons-vue";
import { useRouter } from "vue-router";
import { addPointsGoods, getPointsGoodsCategoryList } from "@/api/promotion";
import skuSelect from "@/components/lili-dialog";
@@ -141,7 +178,6 @@ const form = reactive({
rangeTime: null,
promotionGoodsList: [],
});
const showTable = ref(true);
const promotionGoodsList = ref([]);
const categoryList = ref([]);
const submitLoading = ref(false);
@@ -185,6 +221,10 @@ function handleSubmit() {
return;
}
for (const row of params) {
if (!row.pointsGoodsCategoryId) {
Message.error(`请为【${row.goodsName}】选择积分分类`);
return;
}
const stock = Number(row.quantity) || 0;
if (isECoupon(row.goodsType) && Number(row.activeStock) > stock) {
Message.error(`【${row.goodsName}】活动库存不能超过卡池库存(${stock})`);
@@ -265,7 +305,7 @@ function selectedGoodsData(item) {
item.forEach((e) => {
const obj = {
settlementPrice: e.settlementPrice || e.price,
pointsGoodsCategoryId: e.pointsGoodsCategoryId || 0,
pointsGoodsCategoryId: e.pointsGoodsCategoryId || "",
pointsGoodsCategoryName: e.pointsGoodsCategoryName || "",
activeStock: e.activeStock || 1,
points: e.points || 1,
@@ -286,34 +326,59 @@ function selectedGoodsData(item) {
</script>
<style lang="scss" scoped>
h4 {
margin-bottom: 10px;
padding: 0 10px;
border: 1px solid #ddd;
background-color: #f8f8f8;
font-weight: bold;
color: #333;
font-size: 14px;
line-height: 40px;
text-align: left;
.points-goods-add-page {
max-width: 1640px;
margin: 0 auto;
padding: 12px 20px 24px;
color: var(--el-text-color-primary);
}
.describe {
font-size: 12px;
margin-left: 10px;
color: #999;
.page-header, .goods-section-heading, .footer-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.wrapper {
min-height: 800px;
.page-header { padding: 8px 0 4px; margin-bottom: 16px; }
.page-header h1 { margin: 0; font-size: 22px; font-weight: 500; }
.page-header p { margin: 8px 0 0; font-size: 13px; color: var(--el-text-color-secondary); }
.section-card, .footer-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
margin-bottom: 16px;
}
.points-goods-time-form-item {
:deep(.el-date-editor.el-date-editor--datetimerange) {
width: 420px !important;
min-width: 420px;
max-width: 420px;
flex-shrink: 0;
}
.section-card :deep(.el-card__body) { padding: 20px 24px; }
.section-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.section-heading h2 { margin: 0; font-size: 15px; font-weight: 500; }
.section-number { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 28px; height: 28px; border-radius: 7px; background: var(--el-color-primary-light-9); color: var(--el-color-primary); font-size: 12px; font-weight: 500; }
.points-goods-time-form-item { margin: 16px 0 0 36px; }
.points-goods-time-form-item :deep(.el-form-item__content) { min-width: 0; }
.points-goods-time-form-item :deep(.el-date-editor) { flex: 0 1 520px; width: 520px; max-width: 100%; }
.field-hint { width: 100%; margin-top: 6px; color: var(--el-text-color-secondary); font-size: 13px; }
.goods-section-heading { margin-bottom: 16px; flex-wrap: wrap; }
.selected-count { color: var(--el-text-color-secondary); font-size: 13px; }
.selected-count strong { padding: 0 4px; color: var(--el-color-primary); font-weight: 500; }
.goods-actions, .footer-buttons { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
.goods-actions :deep(.el-button + .el-button), .footer-buttons :deep(.el-button + .el-button) { margin-left: 0; }
.goods-empty { width: 100%; min-height: 230px; padding: 24px 20px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; background: var(--el-fill-color-lighter); border: 1px dashed var(--el-border-color); border-radius: 8px; box-sizing: border-box; }
.empty-icon { display: grid; place-items: center; width: 60px; height: 60px; border-radius: 16px; color: var(--el-color-primary); background: var(--el-color-primary-light-9); margin-bottom: 16px; }
.goods-empty h3 { margin: 0; font-size: 16px; font-weight: 500; }
.goods-empty p { margin: 8px 0 20px; font-size: 13px; line-height: 1.6; color: var(--el-text-color-secondary); }
.product-info { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
.product-image { width: 48px; height: 48px; flex: 0 0 48px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; }
.image-placeholder { display: grid; place-items: center; width: 100%; height: 100%; font-size: 12px; color: var(--el-text-color-placeholder); background: var(--el-fill-color-light); }
.product-details { min-width: 0; }
.product-name { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.6; }
.product-code { margin-top: 4px; font-size: 12px; color: var(--el-text-color-secondary); font-family: Consolas, monospace; overflow-wrap: anywhere; }
.goods-number-input { width: 100%; }
.points-goods-table :deep(th.el-table__cell) { background: var(--el-fill-color-light); font-weight: 400; padding: 12px 0; }
.footer-card { box-shadow: 0 4px 18px rgb(31 45 61 / 5%); }
.footer-card :deep(.el-card__body) { padding: 16px 24px; }
@media (max-width: 768px) {
.points-goods-add-page { padding: 8px 8px 20px; }
.page-header { flex-wrap: wrap; }
.section-card :deep(.el-card__body) { padding: 16px; }
.points-goods-time-form-item { margin-left: 0; }
.points-goods-time-form-item :deep(.el-form-item__label) { width: 80px !important; }
.footer-card :deep(.el-card__body) { padding: 14px 16px; }
}
</style>

View File

@@ -1,89 +1,239 @@
<template>
<div>
<el-card>
<el-form ref="formRef" :model="form" label-width="120px" :rules="formRule">
<div class="base-info-item">
<h4>积分商品信息</h4>
<div class="form-item-view">
<el-form-item label="商品名称">
<div>{{ form.goodsSku.goodsName }}</div>
</el-form-item>
<el-form-item label="SKU编码">
<div>{{ form.goodsSku.id }}</div>
</el-form-item>
<el-form-item label="店铺名称">
<div>{{ form.goodsSku.storeName }}</div>
</el-form-item>
<el-form-item label="商品价格">
<div>
<priceColorScheme :value="form.goodsSku.price" :color="$mainColor" />
</div>
</el-form-item>
<el-form-item label="库存">
<div>{{ form.goodsSku.quantity }}</div>
</el-form-item>
<el-form-item label="结算价格" prop="settlementPrice">
<el-input
type="number"
v-model="form.settlementPrice"
placeholder="请填写结算价格"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item label="分类" prop="pointsGoodsCategoryId">
<el-select
v-model="form.pointsGoodsCategoryId"
placeholder="请选择分类"
style="width: 260px"
@change="changeCategory"
>
<el-option v-for="item in categoryList" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="活动库存" prop="activeStock">
<el-input
type="number"
v-model="form.activeStock"
placeholder="请填写活动库存"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item label="兑换积分" prop="points">
<el-input
type="number"
v-model="form.points"
placeholder="请填写兑换积分"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item label="活动开始时间">
<el-date-picker
v-model="form.rangeTime"
type="datetimerange"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
:disabled-date="disabledDate"
style="width: 360px"
/>
</el-form-item>
</div>
<div class="footer">
<el-button @click="closeCurrentPage" style="margin-right: 5px">返回</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">保存</el-button>
<div class="points-goods-edit-page">
<!-- 顶部标题与返回栏 -->
<el-card shadow="never" class="header-card">
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>{{ onlyView ? "积分商品详情" : "编辑积分商品" }}</span>
<el-tag
size="small"
effect="plain"
:type="onlyView ? 'info' : 'warning'"
class="mode-tag"
>
{{ onlyView ? "查看模式" : "编辑模式" }}
</el-tag>
<el-tag
v-if="form.promotionStatus"
size="small"
effect="dark"
:type="promotionStatusTagType(form.promotionStatus)"
class="status-tag"
>
{{ promotionStatusText(form.promotionStatus) }}
</el-tag>
<span v-if="id" class="activity-id font-mono">ID: {{ id }}</span>
</div>
</div>
</el-form>
<div class="header-right">
<el-button @click="closeCurrentPage">返回积分商品列表</el-button>
</div>
</div>
</el-card>
<!-- 商品基本信息卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">商品基本信息</span>
</div>
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="商品图片" width="120px">
<div class="goods-thumb-box">
<el-image
v-if="goodsThumbnail"
:src="goodsThumbnail"
fit="cover"
preview-teleported
:preview-src-list="[goodsThumbnail]"
class="goods-thumb-img"
>
<template #error>
<div class="thumb-slot">无图</div>
</template>
</el-image>
<div v-else class="thumb-slot">无图</div>
</div>
</el-descriptions-item>
<el-descriptions-item label="商品名称">
<div class="font-semibold text-dark">
{{ form.goodsName || form.goodsSku?.goodsName || "-" }}
</div>
<div v-if="form.goodsId" class="sub-id-text font-mono">
商品ID: {{ form.goodsId }}
</div>
</el-descriptions-item>
<el-descriptions-item label="SKU编码">
<span class="font-mono text-dark">
{{ form.skuId || form.goodsSku?.id || "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="所属店铺">
<span class="text-secondary">
{{ form.goodsSku?.storeName || form.storeName || "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="商品原价">
<span class="original-price-val font-mono">
{{ $filters.unitPrice(goodsPrice, "¥") }}
</span>
</el-descriptions-item>
<el-descriptions-item label="商品库存">
<span class="font-mono text-dark">
{{ form.goodsSku?.quantity != null ? `${form.goodsSku.quantity} 件` : "-" }}
</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 积分兑换配置区域 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">积分兑换规则与参数</span>
</div>
<!-- 查看模式:以结构化描述列表呈现 -->
<template v-if="onlyView">
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="兑换所需积分">
<span class="points-highlight font-mono font-semibold">
{{ form.points != null ? `${form.points} 积分` : "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="结算价格">
<span class="font-mono font-semibold text-dark">
{{ form.settlementPrice != null ? $filters.unitPrice(form.settlementPrice, "¥") : "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="积分商品分类">
<el-tag size="small" effect="plain" type="primary">
{{ currentCategoryName || "未设置分类" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="活动库存">
<span class="font-mono text-dark font-semibold">
{{ form.activeStock != null ? `${form.activeStock} 件` : "-" }}
</span>
</el-descriptions-item>
<el-descriptions-item label="活动时间" :span="2">
<span class="font-mono text-dark">
{{ activityTimeRange }}
</span>
</el-descriptions-item>
</el-descriptions>
</template>
<!-- 编辑模式:表单输入 -->
<template v-else>
<el-form
ref="formRef"
:model="form"
label-width="120px"
:rules="formRule"
class="edit-form"
>
<el-form-item label="兑换积分" prop="points">
<el-input
v-model="form.points"
type="number"
placeholder="请填写所需兑换积分"
clearable
style="width: 280px"
>
<template #append>积分</template>
</el-input>
</el-form-item>
<el-form-item label="结算价格" prop="settlementPrice">
<el-input
v-model="form.settlementPrice"
type="number"
placeholder="请填写结算价格"
clearable
style="width: 280px"
>
<template #append>元</template>
</el-input>
</el-form-item>
<el-form-item label="积分分类" prop="pointsGoodsCategoryId">
<el-select
v-model="form.pointsGoodsCategoryId"
placeholder="请选择积分商品分类"
style="width: 280px"
@change="changeCategory"
>
<el-option
v-for="item in categoryList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="活动库存" prop="activeStock">
<el-input
v-model="form.activeStock"
type="number"
placeholder="请填写活动库存数量"
clearable
style="width: 280px"
>
<template #append>件</template>
</el-input>
</el-form-item>
<el-form-item label="活动时间" required class="points-activity-time-item">
<el-date-picker
v-model="form.rangeTime"
type="datetimerange"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
placeholder="请选择活动时间"
:disabled-date="disabledDate"
style="width: 380px"
/>
</el-form-item>
</el-form>
</template>
</el-card>
<!-- 底部操作栏 -->
<el-card shadow="never" class="footer-card mt_10">
<div class="footer-actions">
<el-button @click="closeCurrentPage">
{{ onlyView ? "返回" : "取消" }}
</el-button>
<el-button
v-if="!onlyView"
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
保存修改
</el-button>
</div>
</el-card>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { onMounted, ref, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
updatePointsGoods,
@@ -99,6 +249,11 @@ const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const id = computed(() => route.query.id || "");
const onlyView = computed(() => {
return route.query.onlyView === "true" || route.query.onlyView === true;
});
const pointsGoodsCategoryId = ref("");
const pointsGoodsCategoryName = ref("");
const form = ref({
@@ -111,49 +266,106 @@ const form = ref({
promotionStatus: "NEW",
rangeTime: [],
});
const categoryList = ref([]);
const id = route.query.id;
const submitLoading = ref(false);
const formRule = {
settlementPrice: [{ required: true, message: "请填写结算价格" }],
pointsGoodsCategoryId: [{ required: true, message: "请选择积分商品分类" }],
points: [{ required: true, message: "请填写兑换积分" }],
activeStock: [{ required: true, message: "请填写库存" }],
settlementPrice: [{ required: true, message: "请填写结算价格", trigger: "blur" }],
pointsGoodsCategoryId: [
{ required: true, message: "请选择积分商品分类", trigger: "change" },
],
points: [{ required: true, message: "请填写兑换积分", trigger: "blur" }],
activeStock: [{ required: true, message: "请填写库存", trigger: "blur" }],
};
const formRef = ref(null);
onMounted(async () => {
await getCategory();
if (id) {
getData();
}
const goodsThumbnail = computed(() => {
return form.value.thumbnail || form.value.goodsSku?.thumbnail || "";
});
const goodsPrice = computed(() => {
if (form.value.goodsSku?.price != null) return form.value.goodsSku.price;
if (form.value.originalPrice != null) return form.value.originalPrice;
return 0;
});
const currentCategoryName = computed(() => {
if (pointsGoodsCategoryName.value) return pointsGoodsCategoryName.value;
if (form.value.pointsGoodsCategoryName) return form.value.pointsGoodsCategoryName;
const match = categoryList.value.find(
(c) => c.id === (pointsGoodsCategoryId.value || form.value.pointsGoodsCategoryId)
);
return match ? match.name : "";
});
const activityTimeRange = computed(() => {
const start = form.value.startTime || (form.value.rangeTime && form.value.rangeTime[0]);
const end = form.value.endTime || (form.value.rangeTime && form.value.rangeTime[1]);
if (start && end) {
return `${start} 至 ${end}`;
}
return start || end || "-";
});
function promotionStatusText(status) {
const map = {
NEW: "未开始",
START: "已开始",
END: "已结束",
CLOSE: "已关闭",
};
return map[status] || status;
}
function promotionStatusTagType(status) {
const map = {
NEW: "info",
START: "success",
END: "danger",
CLOSE: "danger",
};
return map[status] || "info";
}
function disabledDate(date) {
return date && date.getTime() < Date.now() - 86400000;
}
function changeCategory(id) {
const item = categoryList.value.find((c) => c.id === id);
pointsGoodsCategoryId.value = id;
function changeCategory(catId) {
const item = categoryList.value.find((c) => c.id === catId);
pointsGoodsCategoryId.value = catId;
pointsGoodsCategoryName.value = item ? item.name : "";
}
function closeCurrentPage() {
appStore.removeTag("edit-points-goods");
localStorage.pageOpenedList = JSON.stringify(appStore.pageOpenedList);
router.go(-1);
if (appStore?.removeTag) {
appStore.removeTag("edit-points-goods");
}
if (localStorage?.pageOpenedList && appStore?.pageOpenedList) {
localStorage.pageOpenedList = JSON.stringify(appStore.pageOpenedList);
}
if (window.history.length > 1) {
router.go(-1);
} else {
router.push({ name: "points-goods" });
}
}
function getData() {
getPointsGoodsById(id).then((res) => {
if (res.success) {
if (!id.value) return;
getPointsGoodsById(id.value).then((res) => {
if (res && res.success && res.result) {
const data = res.result;
form.value = data;
data.rangeTime = [];
form.value = {
...data,
goodsSku: data.goodsSku || {},
};
if (data.startTime && data.endTime) {
data.rangeTime = [data.startTime, data.endTime];
form.value.rangeTime = [data.startTime, data.endTime];
} else {
form.value.rangeTime = [];
}
if (data.pointsGoodsCategoryId) {
changeCategory(data.pointsGoodsCategoryId);
@@ -163,8 +375,14 @@ function getData() {
}
async function getCategory() {
const res = await getPointsGoodsCategoryList();
categoryList.value = res.result.records;
try {
const res = await getPointsGoodsCategoryList();
if (res && res.success && res.result) {
categoryList.value = res.result.records || [];
}
} catch (e) {
console.error("加载积分商品分类失败", e);
}
}
function handleSubmit() {
@@ -179,69 +397,203 @@ function handleSubmit() {
submitLoading.value = true;
const params = {
...form.value,
pointsGoodsCategoryId: pointsGoodsCategoryId.value || form.value.pointsGoodsCategoryId,
pointsGoodsCategoryId:
pointsGoodsCategoryId.value || form.value.pointsGoodsCategoryId,
pointsGoodsCategoryName:
pointsGoodsCategoryName.value || form.value.pointsGoodsCategoryName,
};
updatePointsGoods(params).then((res) => {
submitLoading.value = false;
if (res.success) {
Message.success("积分商品修改成功");
closeCurrentPage();
}
});
updatePointsGoods(params)
.then((res) => {
if (res && res.success) {
Message.success("积分商品修改成功");
closeCurrentPage();
}
})
.finally(() => {
submitLoading.value = false;
});
}
});
}
onMounted(async () => {
await getCategory();
if (id.value) {
getData();
}
});
</script>
<style lang="scss" scoped>
.el-form {
padding-bottom: 80px;
.points-goods-edit-page {
padding: 16px 20px;
background-color: #f0f2f5;
min-height: 100vh;
box-sizing: border-box;
.el-form-item {
width: 100%;
color: gray;
text-align: left;
}
}
div.base-info-item > div {
margin-left: 5%;
}
div.base-info-item {
margin-bottom: 10px;
h4 {
margin-bottom: 10px;
padding: 0 10px;
border: 1px solid #ddd;
background-color: #f8f8f8;
font-weight: bold;
color: #333;
font-size: 14px;
line-height: 40px;
text-align: left;
.mt_10 {
margin-top: 12px;
}
.form-item-view {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
padding-left: 80px;
.font-mono {
font-family: Monaco, Menlo, Consolas, "Courier New", monospace;
}
}
.footer {
width: 100%;
padding: 10px;
background-color: #ffc;
position: fixed;
bottom: 0;
right: 0;
text-align: center;
z-index: 9999;
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-secondary {
color: #606266;
}
.header-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 16px 20px;
}
.page-header-row {
display: flex;
align-items: center;
justify-content: space-between;
.header-left {
.header-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 600;
color: #1f2d3d;
.mode-tag,
.status-tag {
font-weight: normal;
}
.activity-id {
font-size: 13px;
color: #909399;
font-weight: normal;
}
}
}
}
}
.info-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 20px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.section-indicator {
width: 4px;
height: 16px;
background-color: #409eff;
border-radius: 2px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.custom-descriptions {
:deep(.el-descriptions__label) {
width: 130px;
background-color: #f8f9fb;
color: #606266;
font-weight: 500;
}
:deep(.el-descriptions__content) {
color: #303133;
}
.goods-thumb-box {
width: 56px;
height: 56px;
border-radius: 4px;
overflow: hidden;
background-color: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #ebeef5;
.goods-thumb-img {
width: 100%;
height: 100%;
display: block;
}
.thumb-slot {
font-size: 12px;
color: #c0c4cc;
}
}
.sub-id-text {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.original-price-val {
color: #303133;
font-size: 14px;
}
.points-highlight {
color: #f56c6c;
font-size: 16px;
}
}
.edit-form {
padding: 8px 12px;
.points-activity-time-item {
:deep(.el-date-editor) {
width: 380px !important;
max-width: 380px;
flex-grow: 0;
}
}
}
}
.footer-card {
border: none;
border-radius: 4px;
:deep(.el-card__body) {
padding: 16px 20px;
}
.footer-actions {
display: flex;
justify-content: center;
gap: 16px;
}
}
}
</style>

View File

@@ -1,70 +1,192 @@
<template>
<div class="seckill-goods">
<el-card>
<el-table border :data="data" style="width: 100%">
<el-table-column prop="promotionName" label="活动名称" min-width="120" />
<el-table-column prop="startTime" label="活动开始时间" min-width="160" />
<el-table-column label="报名截止时间" min-width="160">
<template #default="{ row }">
<span v-if="row">{{ unixDate(row.applyEndTime) }}</span>
</template>
</el-table-column>
<el-table-column label="时间场次" min-width="160">
<template #default="{ row }">
<template v-if="row">
<el-tag v-for="item in unixHours(row.hours)" :key="item" class="hour-tag">{{ item }}</el-tag>
</template>
</template>
</el-table-column>
<el-table-column label="活动状态" min-width="100">
<template #default="{ row }">
<el-tag v-if="row" :type="seckillStatusTagType(row.promotionStatus)">
{{ seckillStatusText(row.promotionStatus) }}
<div class="seckill-goods-page">
<!-- 顶部标题与返回栏 -->
<el-card shadow="never" class="header-card">
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>秒杀活动商品管理</span>
<el-tag
v-if="seckillInfo.promotionStatus"
:type="seckillStatusTagType(seckillInfo.promotionStatus)"
size="small"
effect="plain"
class="status-tag"
>
{{ seckillStatusText(seckillInfo.promotionStatus) }}
</el-tag>
</template>
</el-table-column>
</el-table>
<span v-if="seckillId" class="activity-id font-mono">ID: {{ seckillId }}</span>
</div>
</div>
<div class="header-right">
<el-button @click="back">返回秒杀列表</el-button>
</div>
</div>
</el-card>
<!-- 活动信息卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">活动信息</span>
</div>
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="活动名称">
<span class="font-semibold text-dark">{{ seckillInfo.promotionName || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="活动状态">
<el-tag
v-if="seckillInfo.promotionStatus"
:type="seckillStatusTagType(seckillInfo.promotionStatus)"
size="small"
effect="dark"
>
{{ seckillStatusText(seckillInfo.promotionStatus) }}
</el-tag>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="活动开始时间">
<span class="font-mono text-dark">{{ seckillInfo.startTime || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="报名截止时间">
<span class="font-mono text-dark">{{ unixDate(seckillInfo.applyEndTime) }}</span>
</el-descriptions-item>
<el-descriptions-item label="时间场次" :span="2">
<div class="hours-tags-row">
<template v-if="seckillInfo.hours">
<el-tag
v-for="item in unixHours(seckillInfo.hours)"
:key="item"
size="small"
effect="light"
class="hour-tag"
>
{{ item }}
</el-tag>
</template>
<span v-else class="text-placeholder">未设置场次</span>
</div>
</el-descriptions-item>
<el-descriptions-item v-if="seckillInfo.seckillRule" label="规则说明" :span="2">
<span class="text-secondary">{{ seckillInfo.seckillRule }}</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 秒杀商品列表卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="card-header-bar">
<div class="header-title-group">
<span class="section-indicator"></span>
<span class="section-title">已报名秒杀商品</span>
<span class="total-badge">共 {{ total }} 件商品</span>
</div>
<div class="header-actions">
<el-button size="small" @click="getDataList">刷新</el-button>
</div>
</div>
<el-table
ref="table"
v-loading="loading"
border
class="operation"
stripe
:data="goodsList"
style="width: 100%"
empty-text="暂无报名的秒杀商品"
class="custom-table"
>
<el-table-column prop="goodsName" label="商品名称" min-width="120" show-overflow-tooltip />
<el-table-column label="商品价格" width="110">
<!-- 商品图片 -->
<el-table-column label="商品图片" width="76" align="center">
<template #default="{ row }">
<span v-if="row" :style="{ color: $mainColor }">
{{ $filters.unitPrice(row.originalPrice, "¥") }}</span>
<div v-if="row && (row.thumbnail || row.small || row.goodsImage)" class="goods-thumb-box">
<el-image
:src="row.thumbnail || row.small || row.goodsImage"
fit="cover"
preview-teleported
:preview-src-list="[row.thumbnail || row.small || row.goodsImage]"
class="goods-thumb-img"
>
<template #error>
<div class="thumb-slot">无图</div>
</template>
</el-image>
</div>
<div v-else class="goods-thumb-box thumb-slot">无图</div>
</template>
</el-table-column>
<el-table-column label="库存" width="90">
<!-- 商品名称 -->
<el-table-column prop="goodsName" label="商品名称" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row">{{ row.quantity }}</span>
<span class="font-semibold text-dark">{{ row.goodsName || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="活动价格" width="100">
<!-- 商家名称 -->
<el-table-column prop="storeName" label="所属商家" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row" :style="{ color: $mainColor }">
{{ $filters.unitPrice(row.price, "¥") }}</span>
<span class="text-secondary">{{ row.storeName || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="storeName" label="商家名称" min-width="100" show-overflow-tooltip />
<el-table-column label="活动场次" width="100">
<!-- 活动场次 -->
<el-table-column label="活动场次" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row">{{ row.timeLine + ":00" }}</el-tag>
<el-tag v-if="row && row.timeLine != null" size="small" effect="plain" type="warning">
{{ row.timeLine }}:00 场
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" align="center">
<!-- 原价 -->
<el-table-column label="商品原价" width="120" align="right">
<template #default="{ row }">
<span v-if="row" class="original-price font-mono">
{{ $filters.unitPrice(row.originalPrice, "¥") }}
</span>
</template>
</el-table-column>
<!-- 秒杀价格 -->
<el-table-column label="秒杀价格" width="130" align="right">
<template #default="{ row }">
<span v-if="row" class="seckill-price font-mono font-semibold">
{{ $filters.unitPrice(row.price, "¥") }}
</span>
</template>
</el-table-column>
<!-- 库存 -->
<el-table-column label="活动库存" width="100" align="right">
<template #default="{ row }">
<span v-if="row" class="font-mono text-dark">{{ row.quantity ?? "-" }}</span>
</template>
</el-table-column>
<!-- 操作 -->
<el-table-column label="操作" width="110" align="center" fixed="right">
<template #default="{ row, $index }">
<a v-if="row" class="link-text" @click="delGoods($index, row)">删除</a>
<el-button
v-if="row"
link
type="danger"
@click="delGoods($index, row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<!-- 底部分页 -->
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
@@ -77,108 +199,142 @@
/>
</div>
</el-card>
<!-- 底部操作栏 -->
<el-card shadow="never" class="footer-card mt_10">
<div class="footer-actions">
<el-button type="primary" @click="back">返回秒杀列表</el-button>
</div>
</el-card>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import {
seckillGoodsList,
seckillDetail,
delSeckillGoods,
} from "@/api/promotion.js";
import { Message, Modal } from "@/utils/message";
import { unixToDate } from "@/utils/filters";
import { ElMessage, ElMessageBox } from "element-plus";
import { useAppStore } from "@/stores/app";
defineOptions({ name: "seckill-goods" });
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const promotionStatus = ref("");
const seckillId = route.query.id;
const seckillInfo = ref({});
const loading = ref(false);
const searchForm = reactive({
pageNumber: 1,
pageSize: 20,
});
const total = ref(0);
const data = ref([]);
const goodsList = ref([]);
function seckillStatusText(status) {
const map = {
NEW: "新建",
START: "开始",
END: "结束",
CLOSE: "废弃",
NEW: "未开始",
START: "进行中",
END: "已结束",
CLOSE: "已关闭",
};
return map[status] || status || "-";
}
function seckillStatusTagType(status) {
const map = {
NEW: "danger",
NEW: "info",
START: "success",
END: "danger",
CLOSE: "danger",
CLOSE: "warning",
};
return map[status] || "danger";
return map[status] || "info";
}
function init() {
getSeckillMsg();
}
function changePage() {
getDataList();
}
function changePageSize() {
searchForm.pageNumber = 1;
getDataList();
}
function getDataList() {
if (!seckillId) return;
loading.value = true;
searchForm.seckillId = route.query.id;
seckillGoodsList(searchForm).then((res) => {
loading.value = false;
if (res.success && res.result) {
goodsList.value = res.result.records;
total.value = res.result.total;
}
});
searchForm.seckillId = seckillId;
seckillGoodsList(searchForm)
.then((res) => {
loading.value = false;
if (res && res.success && res.result) {
goodsList.value = res.result.records || [];
total.value = res.result.total || 0;
}
})
.catch(() => {
loading.value = false;
});
}
function getSeckillMsg() {
seckillDetail(route.query.id).then((res) => {
if (res.success && res.result) {
data.value = [];
data.value.push(res.result);
promotionStatus.value = res.result.promotionStatus;
if (!seckillId) return;
seckillDetail(seckillId).then((res) => {
if (res && res.success && res.result) {
seckillInfo.value = res.result;
getDataList();
}
});
}
function delGoods(index, row) {
Modal.confirm({
title: "确认删除",
content: "您确认要删除该商品吗?删除后不可恢复",
onOk: () => {
ElMessageBox.confirm(`确认要删除商品 "${row.goodsName || "该商品"}" 吗?删除后不可恢复。`, "确认删除", {
type: "warning",
confirmButtonText: "确定删除",
cancelButtonText: "取消",
})
.then(() => {
const params = {
seckillId: row.seckillId,
id: row.id,
};
delSeckillGoods(params).then((res) => {
if (res.success) {
if (res && res.success) {
goodsList.value.splice(index, 1);
Message.success("删除成功!");
total.value = Math.max(0, total.value - 1);
ElMessage.success("删除成功!");
} else if (res && res.message) {
ElMessage.error(res.message);
}
});
},
});
})
.catch(() => {});
}
function unixDate(time) {
if (!time) return "-";
return unixToDate(new Date(time) / 1000);
}
function unixHours(item) {
const hourArr = item.split(",");
for (let i = 0; i < hourArr.length; i++) {
hourArr[i] += ":00";
}
return hourArr;
if (!item) return [];
const hourArr = String(item).split(",");
return hourArr.map((h) => `${h.trim()}:00`);
}
function back() {
appStore.removeTag("seckill-goods");
router.go(-1);
}
onMounted(() => {
@@ -187,22 +343,193 @@ onMounted(() => {
</script>
<style lang="scss" scoped>
.operation {
margin: 10px 0;
}
.seckill-goods-page {
padding-bottom: 20px;
.hour-tag {
margin-right: 4px;
margin-bottom: 4px;
}
.header-card {
.page-header-row {
display: flex;
justify-content: space-between;
align-items: center;
.link-text {
color: #409eff;
cursor: pointer;
text-decoration: none;
}
.header-left {
.header-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 600;
color: #303133;
.mt_10 {
margin-top: 10px;
.status-tag {
font-weight: normal;
}
.activity-id {
font-size: 13px;
font-weight: normal;
color: #909399;
background-color: #f4f4f5;
padding: 2px 8px;
border-radius: 4px;
}
}
}
}
}
.info-card {
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.section-indicator {
width: 4px;
height: 16px;
border-radius: 2px;
background-color: #409eff;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.card-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
.header-title-group {
display: flex;
align-items: center;
gap: 8px;
.section-indicator {
width: 4px;
height: 16px;
border-radius: 2px;
background-color: #409eff;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.total-badge {
font-size: 12px;
font-weight: normal;
color: #909399;
background-color: #f4f4f5;
padding: 2px 8px;
border-radius: 10px;
margin-left: 4px;
}
}
.header-actions {
display: flex;
align-items: center;
}
}
.custom-descriptions {
:deep(.el-descriptions__label) {
width: 140px;
background-color: #fafafa;
color: #606266;
font-weight: 500;
}
:deep(.el-descriptions__content) {
color: #303133;
}
}
.hours-tags-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
.hour-tag {
margin: 0;
}
}
.custom-table {
.goods-thumb-box {
width: 44px;
height: 44px;
margin: 0 auto;
border: 1px solid #ebeef5;
border-radius: 4px;
background-color: #fafafa;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.goods-thumb-img {
width: 40px;
height: 40px;
}
}
.thumb-slot {
font-size: 11px;
color: #c0c4cc;
}
.original-price {
color: #909399;
text-decoration: line-through;
}
.seckill-price {
color: #f56c6c;
}
}
}
.footer-card {
.footer-actions {
display: flex;
justify-content: center;
align-items: center;
}
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-semibold {
font-weight: 600;
}
.text-dark {
color: #303133;
}
.text-secondary {
color: #909399;
}
.text-placeholder {
color: #c0c4cc;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
}
}
</style>

View File

@@ -102,10 +102,11 @@
</el-tabs>
</el-card>
<el-dialog
<el-drawer
v-model="modalVisible"
:title="modalTitle"
width="500px"
size="500px"
append-to-body
:close-on-click-modal="false"
destroy-on-close
>
@@ -118,7 +119,7 @@
<el-button @click="modalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</el-drawer>
</div>
</template>

View File

@@ -9,7 +9,7 @@
<el-card class="card">
<div>
<h4>交易概况</h4>
<div class="flex">
<div class="flex" style="align-items: flex-start;">
<div class="transactionList">
<div
class="transaction-item"
@@ -98,45 +98,159 @@
</div>
</div>
<div class="shap">
<div id="overViewChart">
<!-- 上 -->
<div class="block">
<div class="box">
<span>访客数</span>
<span>{{ overViewList.uvNum || 0 }}</span>
</div>
</div>
<!-- 中 -->
<div class="block">
<div class="box">
<span>下单笔数</span>
<span>{{ overViewList.orderNum || 0 }}</span>
</div>
</div>
<!-- 下 -->
<div class="block">
<div class="box">
<span>付款笔数</span>
<span>{{ overViewList.paymentOrderNum || 0 }}</span>
</div>
<div class="funnel-card">
<div class="funnel-header">
<h4>交易转化漏斗</h4>
<span class="funnel-sub">全链路转化分析</span>
</div>
<div class="funnel-body">
<svg
class="funnel-svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 460 236"
width="460"
height="236"
>
<defs>
<linearGradient id="gradTier1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FF5C58" />
<stop offset="100%" stop-color="#E83D39" />
</linearGradient>
<linearGradient id="gradTier2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FF8259" />
<stop offset="100%" stop-color="#F25B30" />
</linearGradient>
<linearGradient id="gradTier3" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFA454" />
<stop offset="100%" stop-color="#F28528" />
</linearGradient>
<!-- 线 -->
<div class="rightBorder"></div>
<div class="leftTopBorder"></div>
<div class="leftBottomBorder"></div>
<!--数据 -->
<div class="leftTopTips">
<div>下单转化率</div>
<div>{{ overViewList.orderConversionRate || "0%" }}</div>
<filter id="funnelShadow" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="2" stdDeviation="2.5" flood-opacity="0.12" />
</filter>
<filter id="badgeShadow" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.06" />
</filter>
</defs>
<!-- 连线:左侧 访客 -> 下单 -->
<path
class="bracket-line"
d="M 138 42 L 98 42 Q 90 42 90 50 L 90 102 Q 90 110 98 110 L 161 110"
/>
<!-- 连线:左侧 下单 -> 付款 -->
<path
class="bracket-line"
d="M 161 110 L 98 110 Q 90 110 90 118 L 90 170 Q 90 178 98 178 L 182 178"
/>
<!-- 连线:右侧 全店转化率 -->
<path
class="bracket-line"
d="M 322 42 L 362 42 Q 370 42 370 50 L 370 170 Q 370 178 362 178 L 278 178"
/>
<!-- 漏斗层级 1:访客数 -->
<g class="funnel-tier">
<polygon
points="128,14 332,14 312,70 148,70"
fill="url(#gradTier1)"
stroke="url(#gradTier1)"
stroke-width="6"
stroke-linejoin="round"
filter="url(#funnelShadow)"
/>
<text x="230" y="37" text-anchor="middle" class="funnel-label">访客数 UV</text>
<text x="230" y="59" text-anchor="middle" class="funnel-value">
{{ overViewList.uvNum || 0 }}
</text>
</g>
<!-- 漏斗层级 2:下单笔数 -->
<g class="funnel-tier">
<polygon
points="152,82 308,82 290,138 170,138"
fill="url(#gradTier2)"
stroke="url(#gradTier2)"
stroke-width="6"
stroke-linejoin="round"
filter="url(#funnelShadow)"
/>
<text x="230" y="105" text-anchor="middle" class="funnel-label">下单笔数</text>
<text x="230" y="127" text-anchor="middle" class="funnel-value">
{{ overViewList.orderNum || 0 }}
</text>
</g>
<!-- 漏斗层级 3:付款笔数 -->
<g class="funnel-tier">
<polygon
points="174,150 286,150 270,206 190,206"
fill="url(#gradTier3)"
stroke="url(#gradTier3)"
stroke-width="6"
stroke-linejoin="round"
filter="url(#funnelShadow)"
/>
<text x="230" y="173" text-anchor="middle" class="funnel-label">付款笔数</text>
<text x="230" y="195" text-anchor="middle" class="funnel-value">
{{ overViewList.paymentOrderNum || 0 }}
</text>
</g>
<!-- 左侧卡片 1:下单转化率 -->
<g transform="translate(6, 57)" filter="url(#badgeShadow)">
<rect width="80" height="38" class="badge-bg" />
<text x="40" y="16" text-anchor="middle" class="badge-title">下单转化率</text>
<text x="40" y="31" text-anchor="middle" class="badge-value">
{{ overViewList.orderConversionRate || "0%" }}
</text>
</g>
<!-- 左侧卡片 2:付款转化率 -->
<g transform="translate(6, 125)" filter="url(#badgeShadow)">
<rect width="80" height="38" class="badge-bg" />
<text x="40" y="16" text-anchor="middle" class="badge-title">付款转化率</text>
<text x="40" y="31" text-anchor="middle" class="badge-value">
{{ overViewList.paymentsConversionRate || "0%" }}
</text>
</g>
<!-- 右侧卡片:全店转化率 -->
<g transform="translate(374, 91)" filter="url(#badgeShadow)">
<rect width="80" height="38" class="badge-bg-highlight" />
<text x="40" y="16" text-anchor="middle" class="badge-title">全店转化率</text>
<text x="40" y="31" text-anchor="middle" class="badge-value-highlight">
{{ overViewList.overallConversionRate || "0%" }}
</text>
</g>
</svg>
</div>
<div class="leftBottomTips">
<div>付款转化率</div>
<div>{{ overViewList.paymentsConversionRate || "0%" }}</div>
</div>
<div class="rightTips">
<div>整体转换率</div>
<div>{{ overViewList.overallConversionRate || "0%" }}</div>
<div class="funnel-summary">
<div class="summary-col">
<div class="dot dot-tier1"></div>
<div class="summary-text">
<span class="label">访客UV</span>
<span class="val">{{ overViewList.uvNum || 0 }}</span>
</div>
</div>
<div class="summary-arrow">➔</div>
<div class="summary-col">
<div class="dot dot-tier2"></div>
<div class="summary-text">
<span class="label">下单笔数</span>
<span class="val">{{ overViewList.orderNum || 0 }}</span>
</div>
</div>
<div class="summary-arrow">➔</div>
<div class="summary-col">
<div class="dot dot-tier3"></div>
<div class="summary-text">
<span class="label">付款笔数</span>
<span class="val">{{ overViewList.paymentOrderNum || 0 }}</span>
</div>
</div>
</div>
</div>
</div>
@@ -553,128 +667,168 @@ onMounted(() => {
text-align: right;
margin: 20px 0;
}
#overViewChart {
.shap {
width: 480px;
flex-shrink: 0;
margin-left: 10px;
}
.funnel-card {
background: #fbfcfe;
border: 1px solid #ebedf0;
border-radius: 8px;
padding: 16px 14px 14px;
display: flex;
flex-direction: column;
}
.funnel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
padding: 0 6px;
h4 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #303133;
}
.funnel-sub {
font-size: 12px;
color: #909399;
}
}
.funnel-body {
width: 100%;
display: flex;
justify-content: center;
flex-direction: column;
}
.funnel-svg {
display: block;
max-width: 100%;
height: auto;
.funnel-label {
font-size: 13px;
fill: rgba(255, 255, 255, 0.94);
font-weight: 500;
}
.funnel-value {
font-size: 17px;
font-weight: 700;
fill: #ffffff;
letter-spacing: 0.5px;
}
.bracket-line {
fill: none;
stroke: #cbd5e1;
stroke-width: 1.5;
stroke-dasharray: 4, 3;
}
.badge-bg {
fill: #ffffff;
stroke: #e2e8f0;
stroke-width: 1;
rx: 6px;
}
.badge-bg-highlight {
fill: #fff7f6;
stroke: #ffccc7;
stroke-width: 1;
rx: 6px;
}
.badge-title {
font-size: 12px;
fill: #64748b;
font-weight: 400;
}
.badge-value {
font-size: 13px;
font-weight: 700;
fill: #1e293b;
}
.badge-value-highlight {
font-size: 13px;
font-weight: 700;
fill: #e83d39;
}
}
.funnel-tier {
cursor: pointer;
transition: transform 0.2s ease, filter 0.2s ease;
&:hover {
filter: brightness(1.04);
}
}
.funnel-summary {
display: flex;
align-items: center;
position: relative;
margin-left: 20px;
> .leftTopTips {
position: absolute;
top: 68px;
left: -2px;
width: 85px;
text-align: center;
background: rgb(255, 255, 255);
z-index: 1;
padding: 5px 0px;
}
> .leftBottomTips {
position: absolute;
bottom: 100px;
left: -2px;
width: 85px;
text-align: center;
background: rgb(255, 255, 255);
z-index: 1;
padding: 5px 0px;
}
> .rightTips {
position: absolute;
bottom: 240px;
right: 0px;
width: 85px;
text-align: center;
background: rgb(255, 255, 255);
z-index: 1;
padding: 5px 0px;
}
> .rightBorder {
width: 110px;
position: absolute;
top: 30px;
right: 40px;
border: 2px solid #d9d9d9;
border-left: 0;
height: 280px;
}
> .leftTopBorder {
border: 2px solid #d9d9d9;
height: 118px;
width: 56px;
position: absolute;
left: 40px;
top: 30px;
border-right: 0;
}
> .leftBottomBorder {
width: 108px;
border: 2px solid #d9d9d9;
height: 150px;
position: absolute;
bottom: 45px;
left: 40px;
border-right: 0;
}
> .block {
height: 0px;
border-left: 30px solid transparent;
border-right: 30px solid transparent;
position: relative;
background: rgb(255, 255, 255);
z-index: 1;
position: relative;
}
> .block:nth-of-type(1) {
width: 240px;
border-top: 90px solid #ff4646;
> .box {
left: -30px;
top: -90px;
width: 240px;
height: 90px;
}
}
> .block:nth-of-type(2) {
width: 172px;
border-top: 100px solid #ff8585;
margin-top: 10px;
> .box {
left: -29px;
top: -100px;
width: 172px;
height: 100px;
}
}
> .block:nth-of-type(3) {
width: 100px;
margin-top: 10px;
border-top: 150px solid #ffb396;
border-left: 50px solid transparent;
border-right: 50px solid transparent;
justify-content: space-around;
margin-top: 12px;
padding: 10px 14px;
background: #ffffff;
border-radius: 6px;
border: 1px solid #f0f2f5;
> .box {
left: -50px;
top: -150px;
width: 100px;
height: 120px;
z-index: 2;
.summary-col {
display: flex;
align-items: center;
gap: 8px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&.dot-tier1 {
background: #ff5c58;
}
&.dot-tier2 {
background: #ff8259;
}
&.dot-tier3 {
background: #ffa454;
}
}
:deep(.box){
color: #fff;
position: absolute;
z-index: 2;
.summary-text {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
> span {
font-size: 16px;
font-weight: bold;
.label {
font-size: 11px;
color: #909399;
line-height: 1.2;
}
.val {
font-size: 13px;
font-weight: 600;
color: #303133;
line-height: 1.3;
}
}
.summary-arrow {
color: #dcdfe6;
font-size: 12px;
}
}
@@ -685,13 +839,9 @@ h4 {
margin: 0 0 20px 0;
}
.transactionList {
flex: 7;
padding: 0 20px;
}
.shap {
width: 400px;
margin-left: 20px;
margin-top: 50px;
flex: 1;
min-width: 0;
padding-right: 20px;
}
.transaction-card {
@@ -702,7 +852,7 @@ h4 {
}
.card-item-label {
font-weight: bold;
font-size: #666;
color: #666;
font-size: 15px;
margin-bottom: 10px;
}

View File

@@ -65,10 +65,11 @@
</div>
</el-card>
<el-dialog
<el-drawer
v-model="modalVisible"
:title="modalTitle"
width="1000px"
size="1000px"
append-to-body
:close-on-click-modal="false"
destroy-on-close
>
@@ -130,7 +131,7 @@
<el-button @click="modalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="saveAppVersion">保存</el-button>
</template>
</el-dialog>
</el-drawer>
<el-dialog v-model="queryModalVisible" :title="queryModalTitle" width="700px">
<el-form label-width="100px">

View File

@@ -1,113 +1,143 @@
<template>
<div class="search">
<el-card>
<el-row class="operation">
<el-switch
v-model="strict"
active-text="级联"
inactive-text="单选"
style="margin-right: 5px"
/>
<el-button @click="addRoot">添加部门</el-button>
<el-button type="primary" @click="add">添加子部门</el-button>
<el-button @click="delAll">批量删除</el-button>
<el-button @click="getParentList">刷新</el-button>
</el-row>
<el-row type="flex" justify="start">
<el-col :md="8" :lg="8" :xl="6">
<el-alert type="info" show-icon :closable="false">
当前选择编辑:
<span class="select-title">{{ editTitle }}</span>
<a v-if="form.id" class="select-clear" @click="cancelSelect">取消选择</a>
</el-alert>
<div v-loading="loading" class="tree-bar" :style="{ maxHeight: maxHeight }">
<el-tree
ref="treeRef"
:data="data"
:props="treeProps"
node-key="id"
show-checkbox
highlight-current
default-expand-all
:check-strictly="!strict"
:current-node-key="currentNodeKey"
@node-click="onNodeClick"
@check="onCheckChange"
/>
</div>
</el-col>
<el-col :md="15" :lg="13" :xl="9" style="margin-left: 10px">
<el-form ref="formRef" :model="form" label-width="100px" :rules="formValidate">
<el-form-item label="部门名称" prop="title">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item label="选择角色">
<el-select
v-model="selectedRole"
multiple
:loading="userLoading"
placeholder="请选择角色"
style="width: 100%"
>
<el-option
v-for="item in users"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="排序值" prop="sortOrder">
<el-tooltip placement="right" content="值越小越靠前,支持小数">
<el-input-number v-model="form.sortOrder" :max="1000" :min="0" />
</el-tooltip>
</el-form-item>
<el-form-item>
<el-button
type="primary"
style="margin-right: 5px"
:loading="submitLoading"
@click="submitEdit"
>
修改并保存
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-col>
</el-row>
<div class="department-manage">
<el-card shadow="never" class="manage-card">
<!-- 顶部操作工具栏 -->
<div class="table-toolbar">
<div class="toolbar-left">
<el-button type="primary" @click="addRoot">添加一级部门</el-button>
<el-button
type="danger"
plain
:disabled="selectCount === 0"
@click="delAll"
>
批量删除 {{ selectCount > 0 ? `(${selectCount})` : "" }}
</el-button>
<el-button @click="toggleExpandAll">
{{ isAllExpanded ? "折叠全部" : "展开全部" }}
</el-button>
<el-button @click="getParentList">刷新</el-button>
</div>
<div class="toolbar-right">
<el-input
v-model="searchQuery"
placeholder="按部门名称筛选..."
clearable
:prefix-icon="Search"
class="search-input"
/>
</div>
</div>
<!-- 纯表格展示:树形表格 -->
<el-table
ref="tableRef"
v-loading="loading"
:data="filteredData"
row-key="id"
default-expand-all
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
border
stripe
class="dept-table"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="50" align="center" />
<el-table-column prop="title" label="部门名称" min-width="260" />
<el-table-column prop="sortOrder" label="排序值" width="100" align="center">
<template #default="{ row }">
<span v-if="row">{{ row.sortOrder ?? 0 }}</span>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180" align="center">
<template #default="{ row }">
<span>{{ row.createTime || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="210" fixed="right" align="center">
<template #default="{ row }">
<div class="ops-row">
<template v-if="row.parentId === 0 || row.parentId === '0'">
<a class="link-text" @click="addChild(row)">添加子部门</a>
<span class="op-split">|</span>
</template>
<a class="link-text" @click="editRow(row)">编辑</a>
<span class="op-split">|</span>
<a class="link-text error-text" @click="deleteRow(row)">删除</a>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
<!-- 部门新建 / 编辑抽屉 -->
<el-drawer
v-model="modalVisible"
:title="modalTitle"
width="500px"
size="540px"
append-to-body
:close-on-click-modal="false"
destroy-on-close
>
<el-form ref="formAddRef" :model="formAdd" label-width="85px" :rules="formValidate">
<div v-if="showParent">
<el-form-item label="上级部门:">{{ form.title }}</el-form-item>
</div>
<el-form-item label="部门名称" prop="title">
<el-input v-model="formAdd.title" />
<el-form
ref="formRef"
:model="form"
label-width="95px"
:rules="formValidate"
>
<el-form-item v-if="form.parentTitle" label="上级部门">
<el-input :model-value="form.parentTitle" disabled />
</el-form-item>
<el-form-item label="部门名称" prop="title">
<el-input v-model="form.title" placeholder="请输入部门名称" />
</el-form-item>
<el-form-item label="选择角色">
<el-select
v-model="selectedRole"
multiple
:loading="userLoading"
placeholder="请选择部门关联角色"
style="width: 100%"
>
<el-option
v-for="item in users"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="排序值" prop="sortOrder">
<el-tooltip placement="right" content="值越小越靠前,支持小数">
<el-input-number v-model="formAdd.sortOrder" :max="1000" :min="0" />
<el-tooltip placement="right" content="值越小越靠前">
<el-input-number v-model="form.sortOrder" :min="0" :max="1000" />
</el-tooltip>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="cancelAdd">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitAdd">提交</el-button>
<div class="dialog-footer">
<el-button @click="modalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitForm">
确定
</el-button>
</div>
</template>
</el-dialog>
</el-drawer>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ref, reactive, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Search } from "@element-plus/icons-vue";
import {
initDepartment,
addDepartment,
@@ -121,35 +151,34 @@ import {
defineOptions({ name: "department-manage" });
const loading = ref(true);
const maxHeight = ref("500px");
const strict = ref(true);
const submitLoading = ref(false);
const userLoading = ref(false);
const loadingEdit = ref(false);
const modalVisible = ref(false);
const modalTitle = ref("");
const isEditMode = ref(false);
const data = ref([]);
const users = ref([]);
const selectedRole = ref([]);
const selectList = ref([]);
const selectCount = ref(0);
const showParent = ref(false);
const modalTitle = ref("");
const editTitle = ref("");
const selectedRole = ref([]);
const searchKey = ref("");
const currentNodeKey = ref(null);
const treeProps = reactive({
label: "title",
children: "children",
isLeaf: "isLeaf",
});
const searchQuery = ref("");
const isAllExpanded = ref(true);
const tableRef = ref(null);
const formRef = ref(null);
const form = ref({
id: "",
title: "",
parentId: "",
parentId: "0",
parentTitle: "",
sortOrder: 0,
status: 0,
});
const formAdd = ref({});
const formValidate = reactive({
title: [{ required: true, message: "名称不能为空", trigger: "blur" }],
title: [{ required: true, message: "部门名称不能为空", trigger: "blur" }],
sortOrder: [
{
required: true,
@@ -159,26 +188,58 @@ const formValidate = reactive({
},
],
});
const submitLoading = ref(false);
const data = ref([]);
const dataEdit = ref([]);
const users = ref([]);
const treeRef = ref(null);
const formRef = ref(null);
const formAddRef = ref(null);
// 前端树形过滤
const filteredData = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) {
return data.value;
}
const filterTree = (nodes) => {
return nodes
.map((node) => {
const matches = node.title && node.title.toLowerCase().includes(query);
const filteredChildren = node.children ? filterTree(node.children) : [];
if (matches || filteredChildren.length > 0) {
return {
...node,
children: filteredChildren.length > 0 ? filteredChildren : undefined,
};
}
return null;
})
.filter(Boolean);
};
return filterTree(data.value);
});
function init() {
getParentList();
loadRoles();
}
function getParentList() {
loading.value = true;
initDepartment().then((res) => {
loading.value = false;
initDepartment()
.then((res) => {
loading.value = false;
if (res.success) {
const list = Array.isArray(res.result) ? res.result : [];
data.value = bubbleSort(normalizeDepartmentTree(list));
}
})
.catch(() => {
loading.value = false;
});
}
function loadRoles() {
getRoleList({
pageNumber: 1,
pageSize: 10000,
}).then((res) => {
if (res.success) {
const list = Array.isArray(res.result) ? res.result : [];
data.value = bubbleSort(normalizeDepartmentTree(list));
users.value = res.result.records || [];
}
});
}
@@ -195,47 +256,6 @@ function normalizeDepartmentTree(list) {
});
}
function onNodeClick(data) {
if (!data) {
cancelSelect();
return;
}
const row = JSON.parse(JSON.stringify(data));
for (const attr in row) {
if (row[attr] == null) {
row[attr] = "";
}
}
editTitle.value = row.title;
currentNodeKey.value = row.id;
userLoading.value = true;
getUserByDepartmentId(row.id).then((res) => {
const way = [];
res.result &&
res.result.forEach((item) => {
way.push(item.roleId);
});
selectedRole.value = way;
});
getRoleList({
pageNumber: 1,
pageSize: 10000,
}).then((res) => {
userLoading.value = false;
if (res.success) {
users.value = res.result.records;
form.value = row;
}
});
}
function onCheckChange(_data, { checkedNodes }) {
selectList.value = checkedNodes;
selectCount.value = checkedNodes.length;
}
function bubbleSort(array) {
if (!Array.isArray(array)) return [];
const len = array.length;
@@ -252,139 +272,267 @@ function bubbleSort(array) {
return array;
}
function cancelSelect() {
treeRef.value?.setCurrentKey(null);
currentNodeKey.value = null;
formRef.value.resetFields();
delete form.value.id;
editTitle.value = "";
selectedRole.value = [];
function onSelectionChange(selection) {
selectList.value = selection;
selectCount.value = selection.length;
}
function selectTreeEdit(v) {
if (v.length > 0) {
for (const attr in v[0]) {
if (v[0][attr] == null) {
v[0][attr] = "";
function toggleExpandAll() {
isAllExpanded.value = !isAllExpanded.value;
const toggleRows = (rows) => {
rows.forEach((row) => {
tableRef.value?.toggleRowExpansion(row, isAllExpanded.value);
if (row.children && row.children.length) {
toggleRows(row.children);
}
}
const data = JSON.parse(JSON.stringify(v[0]));
form.value.parentId = data.id;
form.value.parentTitle = data.title;
}
});
};
toggleRows(data.value);
}
function cancelAdd() {
modalVisible.value = false;
// 添加一级部门
function addRoot() {
isEditMode.value = false;
modalTitle.value = "添加一级部门";
form.value = {
id: "",
title: "",
parentId: 0,
parentTitle: "",
sortOrder: 0,
status: 0,
};
selectedRole.value = [];
modalVisible.value = true;
}
function handleReset() {
formRef.value.resetFields();
form.value.status = 0;
// 添加子部门
function addChild(row) {
isEditMode.value = false;
modalTitle.value = `添加子部门 (上级: ${row.title})`;
form.value = {
id: "",
title: "",
parentId: row.id,
parentTitle: row.title,
sortOrder: 0,
status: 0,
};
selectedRole.value = [];
modalVisible.value = true;
}
function submitEdit() {
// 编辑当前部门
function editRow(row) {
isEditMode.value = true;
modalTitle.value = `编辑部门 - ${row.title}`;
form.value = {
id: row.id,
title: row.title,
parentId: row.parentId,
parentTitle: "",
sortOrder: row.sortOrder ?? 0,
status: row.status ?? 0,
};
selectedRole.value = [];
userLoading.value = true;
modalVisible.value = true;
getUserByDepartmentId(row.id)
.then((res) => {
userLoading.value = false;
if (res && res.result) {
selectedRole.value = res.result.map((item) => item.roleId);
}
})
.catch(() => {
userLoading.value = false;
});
}
// 提交表单(统一处理新增与修改)
function submitForm() {
formRef.value.validate((valid) => {
if (valid) {
if (!form.value.id) {
ElMessage.warning("请先点击选择要修改的部门");
return;
}
const roleWay = [];
selectedRole.value.forEach((item) => {
roleWay.push({
departmentId: form.value.id,
roleId: item,
});
});
Promise.all([
editDepartment(form.value.id, form.value),
updateDepartmentRole(form.value.id, roleWay),
]).then((res) => {
submitLoading.value = false;
if (res[0].success) {
ElMessage.success("编辑成功");
init();
modalVisible.value = false;
}
});
}
});
}
function submitAdd() {
formAddRef.value.validate((valid) => {
if (valid) {
submitLoading.value = true;
addDepartment(formAdd.value).then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success("添加成功");
init();
modalVisible.value = false;
}
});
if (isEditMode.value) {
// 编辑模式
const roleWay = selectedRole.value.map((roleId) => ({
departmentId: form.value.id,
roleId,
}));
Promise.all([
editDepartment(form.value.id, form.value),
updateDepartmentRole(form.value.id, roleWay),
])
.then((res) => {
submitLoading.value = false;
if (res[0].success) {
ElMessage.success("编辑部门成功");
modalVisible.value = false;
getParentList();
}
})
.catch(() => {
submitLoading.value = false;
});
} else {
// 新增模式
addDepartment(form.value)
.then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success("添加部门成功");
modalVisible.value = false;
getParentList();
}
})
.catch(() => {
submitLoading.value = false;
});
}
}
});
}
function add() {
if (form.value.id == "" || form.value.id == null) {
ElMessage.warning("请先点击选择一个部门");
return;
}
modalTitle.value = "添加子部门";
showParent.value = true;
formAdd.value = {
parentId: form.value.id,
sortOrder: 0,
status: 0,
};
modalVisible.value = true;
}
function addRoot() {
modalTitle.value = "添加一级部门";
showParent.value = false;
formAdd.value = {
parentId: 0,
sortOrder: 0,
status: 0,
};
modalVisible.value = true;
// 单个删除
function deleteRow(row) {
ElMessageBox.confirm(
`您确认要删除部门 "${row.title}" 及其下级所有数据?`,
"确认删除",
{
type: "warning",
confirmButtonText: "确定删除",
cancelButtonText: "取消",
}
)
.then(() => {
deleteDepartment(row.id).then((res) => {
if (res.success) {
ElMessage.success("删除成功");
getParentList();
}
});
})
.catch(() => {});
}
// 批量删除
function delAll() {
if (selectCount.value <= 0) {
ElMessage.warning("您还未勾选要删除的数据");
return;
}
ElMessageBox.confirm("您确认要删除所选的 " + selectCount.value + " 条数据及其下级所有数据?", "确认删除", { type: "warning" }).then(() => {
let ids = "";
selectList.value.forEach(function (e) {
ids += e.id + ",";
});
ids = ids.substring(0, ids.length - 1);
ElMessageBox.confirm(
`您确认要删除所选的 ${selectCount.value} 条部门数据及其下级所有数据?`,
"确认删除",
{
type: "warning",
confirmButtonText: "确定删除",
cancelButtonText: "取消",
}
)
.then(() => {
const ids = selectList.value.map((e) => e.id).join(",");
deleteDepartment(ids).then((res) => {
if (res.success) {
ElMessage.success("删除成功");
selectList.value = [];
selectCount.value = 0;
cancelSelect();
init();
getParentList();
}
});
}).catch(() => {});
})
.catch(() => {});
}
onMounted(() => {
const height = document.documentElement.clientHeight;
maxHeight.value = Number(height - 287) + "px";
init();
});
</script>
<style lang="scss" scoped>
@import "@/styles/tree-common.scss";
.department-manage {
width: 100%;
.manage-card {
border-radius: 8px;
border: 1px solid #ebeef5;
background: #fff;
:deep(.el-card__body) {
padding: 18px 20px 24px;
}
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
.toolbar-left {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
.search-input {
width: 240px;
}
}
}
.dept-table {
width: 100%;
:deep(.el-table__row) {
transition: background-color 0.2s ease;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
}
.link-text {
color: #2d8cf0;
cursor: pointer;
text-decoration: none;
font-size: 14px;
line-height: 1;
display: inline-block;
&:hover {
color: #57a3f3;
}
&.error-text {
color: #ed4014;
&:hover {
color: #f16643;
}
}
}
.ops-row {
white-space: nowrap;
}
.op-split {
margin: 0 8px;
color: #dcdee2;
font-size: 14px;
}
</style>

View File

@@ -1,245 +1,218 @@
<template>
<div class="search">
<el-card>
<el-row class="operation">
<el-switch
v-model="strict"
class="selectModel"
active-text="级联"
inactive-text="单选"
style="margin-right: 5px"
/>
<el-button @click="addRootMenu">添加顶级菜单</el-button>
<el-button type="primary" @click="addMenu">添加子菜单</el-button>
<el-button @click="delAll">批量删除</el-button>
<el-dropdown @command="handleDropdown">
<el-button>
更多操作
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
<div class="menu-manage-container">
<el-card shadow="never" class="manage-card">
<!-- 顶部操作工具栏 -->
<div class="table-toolbar">
<div class="toolbar-left">
<el-button type="primary" @click="addRootMenu">添加顶级菜单</el-button>
<el-button
type="danger"
plain
:disabled="selectCount === 0"
@click="delAll"
>
批量删除 {{ selectCount > 0 ? `(${selectCount})` : "" }}
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="refresh">刷新</el-dropdown-item>
<el-dropdown-item command="expandOne">收合所有</el-dropdown-item>
<el-dropdown-item command="expandTwo">展开一级</el-dropdown-item>
<el-dropdown-item command="expandThree">展开两级</el-dropdown-item>
<el-dropdown-item command="expandAll">展开所有</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-row>
<el-row type="flex" justify="start">
<el-col :md="8" :lg="8" :xl="6">
<el-alert type="info" show-icon :closable="false">
当前选择编辑:
<span class="select-title">{{ editTitle }}</span>
<a class="select-clear" v-if="form.id" @click="cancelEdit">取消选择</a>
</el-alert>
<el-button @click="toggleExpandAll">
{{ isAllExpanded ? "折叠全部" : "展开全部" }}
</el-button>
<el-button @click="getAllList">刷新</el-button>
</div>
<div class="toolbar-right">
<el-input
v-model="searchKey"
v-model="searchQuery"
placeholder="按菜单名称筛选..."
clearable
placeholder="输入菜单名搜索"
@input="search"
@clear="search"
:prefix-icon="Search"
class="search-input"
/>
<div v-loading="loading" class="tree-bar" :style="{ maxHeight: maxHeight }">
<el-tree
:key="treeKey"
ref="treeRef"
:data="data"
:props="treeProps"
node-key="id"
show-checkbox
highlight-current
:check-strictly="!strict"
:default-expanded-keys="expandedKeys"
:current-node-key="currentNodeKey"
@node-click="onNodeClick"
@check="onCheckChange"
>
<template #default="{ data: nodeData }">
<span class="tree-node-label">
<el-icon v-if="nodeData.level == 0" class="tree-node-icon"><Menu /></el-icon>
<el-icon v-else class="tree-node-icon"><List /></el-icon>
<span>{{ nodeData.title }}</span>
</span>
</div>
</div>
<!-- 纯列表树形表格 -->
<el-table
ref="tableRef"
v-loading="loading"
:data="filteredData"
row-key="id"
default-expand-all
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
border
stripe
class="menu-table"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="50" align="center" />
<el-table-column prop="title" label="菜单名称" min-width="220" />
<el-table-column prop="level" label="类型" width="100" align="center">
<template #default="{ row }">
<span v-if="row">{{ row.level === 0 ? "顶级菜单" : row.level === 1 ? "一级页面" : "二级页面" }}</span>
</template>
</el-table-column>
<el-table-column prop="path" label="路由地址" min-width="160">
<template #default="{ row }">
<span>{{ row.path || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="name" label="路由名称" min-width="150">
<template #default="{ row }">
<span>{{ row.name || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="frontRoute" label="前端组件" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.frontRoute || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="sortOrder" label="排序值" width="90" align="center">
<template #default="{ row }">
<span>{{ row.sortOrder ?? 0 }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="210" fixed="right" align="right">
<template #default="{ row }">
<div class="ops-row">
<template v-if="row.level < 2">
<a class="link-text" @click="addChildMenu(row)">添加子菜单</a>
<span class="op-split">|</span>
</template>
</el-tree>
</div>
</el-col>
<el-col :md="15" :lg="13" :xl="9" style="margin-left: 10px">
<el-form ref="formRef" :model="form" label-width="110px" :rules="formValidate">
<el-form-item label="类型" prop="type">
<div v-show="form.level == 0">
<el-icon :size="16" style="margin-right: 5px"><Menu /></el-icon>
<span>顶级菜单</span>
</div>
<div v-show="form.level == 1 || form.level == 2">
<el-icon :size="16" style="margin-right: 5px"><List /></el-icon>
<span>页面菜单</span>
</div>
</el-form-item>
<el-form-item label="菜单名称" prop="title">
<el-input class="menu-input" v-model="form.title" />
</el-form-item>
<el-form-item
v-if="form.level != 0"
label="路由地址"
prop="path"
class="block-tool"
>
<el-tooltip placement="right" content="路由地址,英文唯一,跳转页面,路径展示用 ">
<el-input class="menu-input" v-model="form.path" />
</el-tooltip>
</el-form-item>
<el-form-item label="路由名称" prop="name" class="block-tool">
<el-tooltip placement="right" content="路由name,需英文唯一,跳转页面用">
<el-input class="menu-input" v-model="form.name" />
</el-tooltip>
</el-form-item>
<el-form-item v-if="form.level != 0" label="文件路径" prop="frontRoute">
<el-input class="menu-input" v-model="form.frontRoute" />
</el-form-item>
<el-form-item v-if="form.level != 0" label="权限url" class="block-tool">
<el-tooltip placement="right" content="*号模糊匹配,逗号分割">
<el-input
class="menu-input"
v-model="form.permission"
type="textarea"
maxlength="1000"
/>
</el-tooltip>
</el-form-item>
<el-form-item label="排序值" prop="sortOrder">
<el-tooltip placement="right" content="值越小越靠前,支持小数">
<el-input-number v-model="form.sortOrder" :max="1000" :min="0" />
</el-tooltip>
</el-form-item>
<el-form-item>
<el-button
type="primary"
style="margin-right: 5px"
:loading="submitLoading"
@click="submitEdit"
>
保存
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-col>
</el-row>
<a class="link-text" @click="editRow(row)">编辑</a>
<span class="op-split">|</span>
<a class="link-text error-text" @click="deleteRow(row)">删除</a>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
v-model="menuModalVisible"
<!-- 菜单新建 / 编辑抽屉 -->
<el-drawer
v-model="modalVisible"
:title="modalTitle"
width="500px"
align-center
size="560px"
append-to-body
:close-on-click-modal="false"
class="menu-manage-dialog"
destroy-on-close
>
<el-form ref="formAddRef" :model="formAdd" label-width="100px" :rules="formValidate">
<div v-if="showParent">
<el-form-item label="上级节点:">{{ parentTitle }}</el-form-item>
</div>
<el-form-item label="类型" prop="type">
<div v-show="formAdd.level == 0">
<el-icon :size="16" style="margin-right: 5px"><Menu /></el-icon>
<span>顶级菜单</span>
</div>
<div v-show="formAdd.level != 0">
<el-icon :size="16" style="margin-right: 5px"><List /></el-icon>
<span>页面菜单</span>
</div>
<el-form
ref="formRef"
:model="form"
label-width="100px"
:rules="formValidate"
>
<el-form-item v-if="parentTitle" label="上级菜单">
<el-input :model-value="parentTitle" disabled />
</el-form-item>
<el-form-item label="菜单类型">
<el-tag v-if="form.level === 0" type="primary">顶级菜单</el-tag>
<el-tag v-else type="success">页面菜单 (Level {{ form.level }})</el-tag>
</el-form-item>
<el-form-item label="菜单名称" prop="title">
<el-input class="menu-input" v-model="formAdd.title" />
<el-input v-model="form.title" placeholder="请输入菜单名称" />
</el-form-item>
<el-form-item v-if="formAdd.level != 0" label="路由地址" prop="path">
<el-input v-model="formAdd.path" />
<el-form-item v-if="form.level != 0" label="路由地址" prop="path">
<el-input v-model="form.path" placeholder="英文唯一,如 /goods" />
</el-form-item>
<el-form-item label="路由名称" prop="name" class="block-tool">
<el-tooltip placement="right" content="路由name,需英文唯一,跳转页面用">
<el-input v-model="formAdd.name" />
<el-form-item label="路由名称" prop="name">
<el-tooltip placement="right" content="英文唯一,用于路由跳转">
<el-input v-model="form.name" placeholder="请输入路由名称" />
</el-tooltip>
</el-form-item>
<el-form-item v-if="formAdd.level != 0" label="文件路径" prop="frontRoute">
<el-input v-model="formAdd.frontRoute" />
<el-form-item v-if="form.level != 0" label="前端组件" prop="frontRoute">
<el-input v-model="form.frontRoute" placeholder="例如 Main 或 views/goods/index" />
</el-form-item>
<el-form-item v-if="formAdd.level != 0" label="权限url">
<el-input v-model="formAdd.permission" type="textarea" />
<div class="desc">*号模糊匹配,逗号分割</div>
<el-form-item v-if="form.level != 0" label="权限URL">
<el-input
v-model="form.permission"
type="textarea"
:rows="3"
placeholder="支持 * 号模糊匹配,多个用英文逗号分割"
/>
</el-form-item>
<el-form-item label="排序值" prop="sortOrder">
<el-tooltip placement="right" content="值越小越靠前,支持小数">
<el-input-number v-model="formAdd.sortOrder" :max="1000" :min="0" />
<el-tooltip placement="right" content="值越小越靠前">
<el-input-number v-model="form.sortOrder" :min="0" :max="1000" />
</el-tooltip>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="menuModalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitAdd">提交</el-button>
<div class="dialog-footer">
<el-button @click="modalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitForm">
确定
</el-button>
</div>
</template>
</el-dialog>
</el-drawer>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ref, reactive, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Menu, List, ArrowDown } from "@element-plus/icons-vue";
import { Search } from "@element-plus/icons-vue";
import {
getAllPermissionList,
addPermission,
editPermission,
deletePermission,
searchPermission,
} from "@/api/index";
import util from "@/libs/util.js";
import { useAppStore } from "@/stores/app";
defineOptions({ name: "menu-manage" });
defineOptions({ name: "manager-menu-manage" });
const appStore = useAppStore();
const loading = ref(true);
const strict = ref(true);
const maxHeight = ref("500px");
const expandLevel = ref(1);
const menuModalVisible = ref(false);
const submitLoading = ref(false);
const modalVisible = ref(false);
const modalTitle = ref("");
const isEditMode = ref(false);
const parentTitle = ref("");
const data = ref([]);
const selectList = ref([]);
const selectCount = ref(0);
const showParent = ref(false);
const searchKey = ref("");
const parentTitle = ref("");
const editTitle = ref("");
const modalTitle = ref("");
const expandedKeys = ref([]);
const currentNodeKey = ref(null);
const treeKey = ref(0);
const treeProps = reactive({
label: "title",
children: "children",
});
const searchQuery = ref("");
const isAllExpanded = ref(true);
const tableRef = ref(null);
const formRef = ref(null);
const form = ref({
id: "",
title: "",
name: "",
path: "",
frontRoute: "",
parentId: "",
parentId: "0",
sortOrder: 0,
level: 0,
permission: "",
});
const formAdd = ref({});
const formValidate = reactive({
title: [{ required: true, message: "菜单名称名称不能为空", trigger: "blur" }],
title: [{ required: true, message: "菜单名称不能为空", trigger: "blur" }],
name: [{ required: true, message: "路由名称不能为空", trigger: "blur" }],
path: [{ required: true, message: "路由地址不能为空", trigger: "blur" }],
frontRoute: [{ required: true, message: "文件地址不能为空", trigger: "blur" }],
frontRoute: [{ required: true, message: "前端组件路径不能为空", trigger: "blur" }],
sortOrder: [
{
required: true,
@@ -249,280 +222,217 @@ const formValidate = reactive({
},
],
});
const submitLoading = ref(false);
const data = ref([]);
const treeRef = ref(null);
const formRef = ref(null);
const formAddRef = ref(null);
// 前端树形过滤
const filteredData = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) {
return data.value;
}
const filterTree = (nodes) => {
return nodes
.map((node) => {
const matches =
(node.title && node.title.toLowerCase().includes(query)) ||
(node.name && node.name.toLowerCase().includes(query)) ||
(node.path && node.path.toLowerCase().includes(query));
const filteredChildren = node.children ? filterTree(node.children) : [];
if (matches || filteredChildren.length > 0) {
return {
...node,
children: filteredChildren.length > 0 ? filteredChildren : undefined,
};
}
return null;
})
.filter(Boolean);
};
return filterTree(data.value);
});
function init() {
getAllList();
}
function collectExpandedKeys(nodes, keys = []) {
if (!nodes) return keys;
nodes.forEach((node) => {
if (node.expand === true) {
keys.push(node.id);
}
if (node.children && node.children.length > 0) {
collectExpandedKeys(node.children, keys);
}
});
return keys;
}
function handleDropdown(name) {
if (name == "expandOne") {
expandLevel.value = 1;
getAllList();
} else if (name == "expandTwo") {
expandLevel.value = 2;
getAllList();
} else if (name == "expandThree") {
expandLevel.value = 3;
getAllList();
}
if (name == "expandAll") {
expandLevel.value = 4;
getAllList();
} else if (name == "refresh") {
getAllList();
}
}
function getAllList() {
loading.value = true;
getAllPermissionList().then((res) => {
loading.value = false;
if (res.success) {
const level = expandLevel.value;
res.result.forEach(function (e) {
if (level == 1) {
if (e.level == 0) {
e.expand = false;
}
if (e.children && e.children.length > 0) {
e.children.forEach(function (c) {
if (c.level == 1) {
c.expand = false;
}
if (c.children && c.children.length > 0) {
c.children.forEach(function (b) {
if (b.level == 2) {
b.expand = false;
}
});
}
});
}
} else if (level == 2) {
if (e.level == 0) {
e.expand = true;
}
if (e.children && e.children.length > 0) {
e.children.forEach(function (c) {
if (c.level == 1) {
c.expand = false;
}
if (c.children && c.children.length > 0) {
c.children.forEach(function (b) {
if (b.level == 2) {
b.expand = false;
}
});
}
});
}
} else if (level == 3) {
if (e.level == 0) {
e.expand = true;
}
if (e.children && e.children.length > 0) {
e.children.forEach(function (c) {
if (c.level == 1) {
c.expand = true;
}
if (c.children && c.children.length > 0) {
c.children.forEach(function (b) {
if (b.level == 2) {
b.expand = false;
}
});
}
});
}
} else if (level == 4) {
if (e.level == 0) {
e.expand = true;
}
if (e.children && e.children.length > 0) {
e.children.forEach(function (c) {
if (c.level == 1) {
c.expand = true;
}
if (c.children && c.children.length > 0) {
c.children.forEach(function (b) {
if (b.level == 2) {
b.expand = true;
}
});
}
});
}
}
});
data.value = res.result;
expandedKeys.value = collectExpandedKeys(res.result);
treeKey.value += 1;
}
});
}
function onNodeClick(nodeData) {
if (nodeData) {
const menu = JSON.parse(JSON.stringify(nodeData));
form.value = menu;
editTitle.value = menu.title;
currentNodeKey.value = menu.id;
} else {
cancelEdit();
}
}
function onCheckChange(_data, { checkedNodes }) {
selectList.value = checkedNodes;
selectCount.value = checkedNodes.length;
}
function search() {
if (searchKey.value) {
loading.value = true;
searchPermission({ title: searchKey.value }).then((res) => {
getAllPermissionList()
.then((res) => {
loading.value = false;
if (res.success) {
data.value = res.result;
expandedKeys.value = [];
treeKey.value += 1;
data.value = Array.isArray(res.result) ? res.result : [];
}
})
.catch(() => {
loading.value = false;
});
}
function onSelectionChange(selection) {
selectList.value = selection;
selectCount.value = selection.length;
}
function toggleExpandAll() {
isAllExpanded.value = !isAllExpanded.value;
const toggleRows = (rows) => {
rows.forEach((row) => {
tableRef.value?.toggleRowExpansion(row, isAllExpanded.value);
if (row.children && row.children.length) {
toggleRows(row.children);
}
});
} else {
getAllList();
}
};
toggleRows(data.value);
}
function cancelEdit() {
treeRef.value?.setCurrentKey(null);
currentNodeKey.value = null;
formRef.value.resetFields();
form.value.id = "";
editTitle.value = "";
}
function handleReset() {
formRef.value.resetFields();
form.value.frontRoute = "";
}
function submitEdit() {
formRef.value.validate((valid) => {
if (valid) {
if (!form.value.id) {
ElMessage.warning("请先点击选择要修改的菜单节点");
return;
}
submitLoading.value = true;
if (form.value.sortOrder == null) {
form.value.sortOrder = 0;
}
delete form.value.icon;
delete form.value.frontComponent;
delete form.value.buttonType;
delete form.value.updateTime;
delete form.value.selected;
delete form.value.description;
delete form.value.children;
editPermission(form.value).then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success("编辑成功");
appStore.setAdded(false);
util.initRouter();
init();
menuModalVisible.value = false;
}
});
}
});
}
function submitAdd() {
formAddRef.value.validate((valid) => {
if (valid) {
submitLoading.value = true;
addPermission(formAdd.value).then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success("添加成功");
appStore.setAdded(false);
util.initRouter();
init();
menuModalVisible.value = false;
}
});
}
});
}
function addMenu() {
if (!form.value.id) {
ElMessage.warning("请先点击选择一个菜单权限节点");
return;
}
parentTitle.value = form.value.title;
modalTitle.value = "添加子节点";
showParent.value = true;
if (form.value.level == 2) {
ElMessageBox.alert("仅支持2级菜单目录", "抱歉,不能添加啦", { type: "warning" });
return;
}
formAdd.value = {
parentId: form.value.id,
level: Number(form.value.level) + 1,
// 添加顶级菜单
function addRootMenu() {
isEditMode.value = false;
parentTitle.value = "";
modalTitle.value = "添加顶级菜单";
form.value = {
id: "",
title: "",
name: "",
path: "/",
frontRoute: "Main",
parentId: 0,
level: 0,
sortOrder: 0,
permission: "",
};
if (form.value.level == 0) {
formAdd.value.path = "/";
formAdd.value.frontRoute = "Main";
modalVisible.value = true;
}
// 添加子菜单
function addChildMenu(row) {
if (row.level >= 2) {
ElMessageBox.alert("仅支持2级菜单目录,不能再添加子菜单", "提示", {
type: "warning",
});
return;
}
menuModalVisible.value = true;
}
function addRootMenu() {
modalTitle.value = "添加顶级菜单";
showParent.value = false;
formAdd.value = {
level: 0,
isEditMode.value = false;
parentTitle.value = row.title;
modalTitle.value = `添加子菜单 (上级: ${row.title})`;
form.value = {
id: "",
title: "",
name: "",
path: "",
frontRoute: "",
parentId: row.id,
level: Number(row.level) + 1,
sortOrder: 0,
permission: "",
};
menuModalVisible.value = true;
if (row.level === 0) {
form.value.path = "/";
form.value.frontRoute = "Main";
}
modalVisible.value = true;
}
// 编辑当前菜单
function editRow(row) {
isEditMode.value = true;
parentTitle.value = "";
modalTitle.value = `编辑菜单 - ${row.title}`;
form.value = {
id: row.id,
title: row.title,
name: row.name,
path: row.path,
frontRoute: row.frontRoute,
parentId: row.parentId,
level: row.level,
sortOrder: row.sortOrder ?? 0,
permission: row.permission || "",
};
modalVisible.value = true;
}
// 提交保存
function submitForm() {
formRef.value.validate((valid) => {
if (valid) {
submitLoading.value = true;
const payload = { ...form.value };
if (payload.sortOrder == null) {
payload.sortOrder = 0;
}
delete payload.children;
delete payload.icon;
delete payload.frontComponent;
delete payload.buttonType;
delete payload.updateTime;
delete payload.selected;
delete payload.description;
const request = isEditMode.value
? editPermission(payload)
: addPermission(payload);
request
.then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success(isEditMode.value ? "编辑成功" : "添加成功");
appStore.setAdded(false);
util.initRouter();
modalVisible.value = false;
getAllList();
}
})
.catch(() => {
submitLoading.value = false;
});
}
});
}
// 删除单个
function deleteRow(row) {
ElMessageBox.confirm(
`您确认要删除菜单 "${row.title}" 及其下级所有数据?`,
"确认删除",
{
type: "warning",
confirmButtonText: "确定删除",
cancelButtonText: "取消",
}
)
.then(() => {
deletePermission(row.id).then((res) => {
if (res.success) {
ElMessage.success("删除成功");
appStore.setAdded(false);
util.initRouter();
getAllList();
}
});
})
.catch(() => {});
}
// 批量删除
function delAll() {
if (selectCount.value <= 0) {
ElMessage.warning("您还未勾选要删除的数据");
return;
}
ElMessageBox.confirm("您确认要删除所选的 " + selectCount.value + " 条数据?", "确认删除", { type: "warning" }).then(() => {
let ids = "";
selectList.value.forEach(function (e) {
ids += e.id + ",";
});
ids = ids.substring(0, ids.length - 1);
ElMessageBox.confirm(
`您确认要删除所选的 ${selectCount.value} 条数据?`,
"确认删除",
{
type: "warning",
confirmButtonText: "确定删除",
cancelButtonText: "取消",
}
)
.then(() => {
const ids = selectList.value.map((e) => e.id).join(",");
deletePermission(ids).then((res) => {
if (res.success) {
ElMessage.success("删除成功");
@@ -530,42 +440,100 @@ function delAll() {
util.initRouter();
selectList.value = [];
selectCount.value = 0;
cancelEdit();
init();
getAllList();
}
});
}).catch(() => {});
})
.catch(() => {});
}
onMounted(() => {
const height = document.documentElement.clientHeight;
maxHeight.value = Number(height - 287) + "px";
init();
});
</script>
<style lang="scss" scoped>
@import "@/styles/tree-common.scss";
.desc {
font-size: 12px;
color: #999;
}
.menu-input {
width: 362px;
}
.tree-node-icon {
margin-right: 8px;
vertical-align: middle;
}
.tree-node-label {
display: inline-flex;
align-items: center;
}
</style>
.menu-manage-container {
width: 100%;
<style lang="scss">
.menu-manage-dialog .el-dialog__body {
max-height: calc(100vh - 220px);
overflow-y: auto;
.manage-card {
border-radius: 8px;
border: 1px solid #ebeef5;
background: #fff;
:deep(.el-card__body) {
padding: 18px 20px 24px;
}
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
.toolbar-left {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
.search-input {
width: 240px;
}
}
}
.menu-table {
width: 100%;
:deep(.el-table__row) {
transition: background-color 0.2s ease;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
}
.link-text {
color: #2d8cf0;
cursor: pointer;
text-decoration: none;
font-size: 14px;
line-height: 1;
display: inline-block;
&:hover {
color: #57a3f3;
}
&.error-text {
color: #ed4014;
&:hover {
color: #f16643;
}
}
}
.ops-row {
white-space: nowrap;
}
.op-split {
margin: 0 8px;
color: #dcdee2;
font-size: 14px;
}
</style>

View File

@@ -1,201 +1,177 @@
<template>
<div class="search">
<el-card>
<el-row class="operation">
<el-switch
v-model="strict"
class="selectModel"
active-text="级联"
inactive-text="单选"
style="margin-right: 5px"
/>
<el-button @click="addRootMenu">添加顶级菜单</el-button>
<el-button type="primary" @click="addMenu">添加子菜单</el-button>
<el-button @click="delAll">批量删除</el-button>
<el-dropdown @command="handleDropdown">
<el-button>
更多操作
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
<div class="menu-manage-container">
<el-card shadow="never" class="manage-card">
<!-- 顶部操作工具栏 -->
<div class="table-toolbar">
<div class="toolbar-left">
<el-button type="primary" @click="addRootMenu">添加顶级菜单</el-button>
<el-button
type="danger"
plain
:disabled="selectCount === 0"
@click="delAll"
>
批量删除 {{ selectCount > 0 ? `(${selectCount})` : "" }}
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="refresh">刷新</el-dropdown-item>
<el-dropdown-item command="expandOne">收合所有</el-dropdown-item>
<el-dropdown-item command="expandTwo">展开一级</el-dropdown-item>
<el-dropdown-item command="expandThree">展开两级</el-dropdown-item>
<el-dropdown-item command="expandAll">展开所有</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-row>
<el-row type="flex" justify="start">
<el-col :md="8" :lg="8" :xl="6">
<el-alert type="info" show-icon :closable="false">
当前选择编辑:
<span class="select-title">{{ editTitle }}</span>
<a class="select-clear" v-if="form.id" @click="cancelEdit">取消选择</a>
</el-alert>
<el-button @click="toggleExpandAll">
{{ isAllExpanded ? "折叠全部" : "展开全部" }}
</el-button>
<el-button @click="getAllList">刷新</el-button>
</div>
<div class="toolbar-right">
<el-input
v-model="searchKey"
v-model="searchQuery"
placeholder="按菜单名称筛选..."
clearable
placeholder="输入菜单名搜索"
@input="search"
@clear="search"
:prefix-icon="Search"
class="search-input"
/>
<div v-loading="loading" class="tree-bar" :style="{ maxHeight: maxHeight }">
<el-tree
:key="treeKey"
ref="treeRef"
:data="data"
:props="treeProps"
node-key="id"
show-checkbox
highlight-current
:check-strictly="!strict"
:default-expanded-keys="expandedKeys"
:current-node-key="currentNodeKey"
@node-click="onNodeClick"
@check="onCheckChange"
>
<template #default="{ data: nodeData }">
<span class="tree-node-label">
<el-icon v-if="nodeData.level == 0" class="tree-node-icon"><Menu /></el-icon>
<el-icon v-else class="tree-node-icon"><List /></el-icon>
<span>{{ nodeData.title }}</span>
</span>
</div>
</div>
<!-- 纯列表树形表格 -->
<el-table
ref="tableRef"
v-loading="loading"
:data="filteredData"
row-key="id"
default-expand-all
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
border
stripe
class="menu-table"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="50" align="center" />
<el-table-column prop="title" label="菜单名称" min-width="220" />
<el-table-column prop="level" label="类型" width="100" align="center">
<template #default="{ row }">
<span v-if="row">{{ row.level === 0 ? "顶级菜单" : row.level === 1 ? "一级页面" : "二级页面" }}</span>
</template>
</el-table-column>
<el-table-column prop="path" label="路由地址" min-width="160">
<template #default="{ row }">
<span>{{ row.path || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="name" label="路由名称" min-width="150">
<template #default="{ row }">
<span>{{ row.name || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="frontRoute" label="前端组件" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.frontRoute || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="sortOrder" label="排序值" width="90" align="center">
<template #default="{ row }">
<span>{{ row.sortOrder ?? 0 }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="210" fixed="right" align="right">
<template #default="{ row }">
<div class="ops-row">
<template v-if="row.level < 2">
<a class="link-text" @click="addChildMenu(row)">添加子菜单</a>
<span class="op-split">|</span>
</template>
</el-tree>
</div>
</el-col>
<el-col :md="15" :lg="13" :xl="9" style="margin-left: 10px">
<el-form ref="formRef" :model="form" label-width="110px" :rules="formValidate">
<el-form-item label="类型" prop="type">
<div v-show="form.level == 0">
<el-icon :size="16" style="margin-right: 5px"><Menu /></el-icon>
<span>顶级菜单</span>
</div>
<div v-show="form.level == 1 || form.level == 2">
<el-icon :size="16" style="margin-right: 5px"><List /></el-icon>
<span>页面菜单</span>
</div>
</el-form-item>
<el-form-item label="菜单名称" prop="title">
<el-input class="menu-input" v-model="form.title" />
</el-form-item>
<el-form-item
v-if="form.level != 0"
label="路由地址"
prop="path"
class="block-tool"
>
<el-tooltip placement="right" content="路由地址,英文唯一,跳转页面,路径展示用 ">
<el-input class="menu-input" v-model="form.path" />
</el-tooltip>
</el-form-item>
<el-form-item label="路由名称" prop="name" class="block-tool">
<el-tooltip placement="right" content="路由name,需英文唯一,跳转页面用">
<el-input class="menu-input" v-model="form.name" />
</el-tooltip>
</el-form-item>
<el-form-item v-if="form.level != 0" label="文件路径" prop="frontRoute">
<el-input class="menu-input" v-model="form.frontRoute" />
</el-form-item>
<el-form-item v-if="form.level != 0" label="权限url" class="block-tool">
<el-tooltip placement="right" content="*号模糊匹配,逗号分割">
<el-input
class="menu-input"
v-model="form.permission"
type="textarea"
maxlength="1000"
/>
</el-tooltip>
</el-form-item>
<el-form-item label="排序值" prop="sortOrder">
<el-tooltip placement="right" content="值越小越靠前,支持小数">
<el-input-number v-model="form.sortOrder" :max="1000" :min="0" />
</el-tooltip>
</el-form-item>
<el-form-item>
<el-button
type="primary"
style="margin-right: 5px"
:loading="submitLoading"
@click="submitEdit"
>
保存
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-col>
</el-row>
<a class="link-text" @click="editRow(row)">编辑</a>
<span class="op-split">|</span>
<a class="link-text error-text" @click="deleteRow(row)">删除</a>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
v-model="menuModalVisible"
<!-- 菜单新建 / 编辑抽屉 -->
<el-drawer
v-model="modalVisible"
:title="modalTitle"
width="500px"
align-center
size="560px"
append-to-body
:close-on-click-modal="false"
class="menu-manage-dialog"
destroy-on-close
>
<el-form ref="formAddRef" :model="formAdd" label-width="100px" :rules="formValidate">
<div v-if="showParent">
<el-form-item label="上级节点:">{{ parentTitle }}</el-form-item>
</div>
<el-form-item label="类型" prop="type">
<div v-show="formAdd.level == 0">
<el-icon :size="16" style="margin-right: 5px"><Menu /></el-icon>
<span>顶级菜单</span>
</div>
<div v-show="formAdd.level != 0">
<el-icon :size="16" style="margin-right: 5px"><List /></el-icon>
<span>页面菜单</span>
</div>
<el-form
ref="formRef"
:model="form"
label-width="100px"
:rules="formValidate"
>
<el-form-item v-if="parentTitle" label="上级菜单">
<el-input :model-value="parentTitle" disabled />
</el-form-item>
<el-form-item label="菜单类型">
<el-tag v-if="form.level === 0" type="primary">顶级菜单</el-tag>
<el-tag v-else type="success">页面菜单 (Level {{ form.level }})</el-tag>
</el-form-item>
<el-form-item label="菜单名称" prop="title">
<el-input class="menu-input" v-model="formAdd.title" />
<el-input v-model="form.title" placeholder="请输入菜单名称" />
</el-form-item>
<el-form-item v-if="formAdd.level != 0" label="路由地址" prop="path">
<el-input v-model="formAdd.path" />
<el-form-item v-if="form.level != 0" label="路由地址" prop="path">
<el-input v-model="form.path" placeholder="英文唯一,如 /goods" />
</el-form-item>
<el-form-item label="路由名称" prop="name" class="block-tool">
<el-tooltip placement="right" content="路由name,需英文唯一,跳转页面用">
<el-input v-model="formAdd.name" />
<el-form-item label="路由名称" prop="name">
<el-tooltip placement="right" content="英文唯一,用于路由跳转">
<el-input v-model="form.name" placeholder="请输入路由名称" />
</el-tooltip>
</el-form-item>
<el-form-item v-if="formAdd.level != 0" label="文件路径" prop="frontRoute">
<el-input v-model="formAdd.frontRoute" />
<el-form-item v-if="form.level != 0" label="前端组件" prop="frontRoute">
<el-input v-model="form.frontRoute" placeholder="例如 Main 或 views/goods/index" />
</el-form-item>
<el-form-item v-if="formAdd.level != 0" label="权限url">
<el-input v-model="formAdd.permission" type="textarea" />
<div class="desc">*号模糊匹配,逗号分割</div>
<el-form-item v-if="form.level != 0" label="权限URL">
<el-input
v-model="form.permission"
type="textarea"
:rows="3"
placeholder="支持 * 号模糊匹配,多个用英文逗号分割"
/>
</el-form-item>
<el-form-item label="排序值" prop="sortOrder">
<el-tooltip placement="right" content="值越小越靠前,支持小数">
<el-input-number v-model="formAdd.sortOrder" :max="1000" :min="0" />
<el-tooltip placement="right" content="值越小越靠前">
<el-input-number v-model="form.sortOrder" :min="0" :max="1000" />
</el-tooltip>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="menuModalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitAdd">提交</el-button>
<div class="dialog-footer">
<el-button @click="modalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitForm">
确定
</el-button>
</div>
</template>
</el-dialog>
</el-drawer>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ref, reactive, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Menu, List, ArrowDown } from "@element-plus/icons-vue";
import { Search } from "@element-plus/icons-vue";
import {
getStoreAllPermissionList,
addStorePermission,
editStorePermission,
deleteStorePermission,
searchStorePermission,
} from "@/api/index";
import util from "@/libs/util.js";
import { useAppStore } from "@/stores/app";
@@ -205,41 +181,38 @@ defineOptions({ name: "store-menu-manage" });
const appStore = useAppStore();
const loading = ref(true);
const strict = ref(true);
const maxHeight = ref("500px");
const expandLevel = ref(1);
const menuModalVisible = ref(false);
const submitLoading = ref(false);
const modalVisible = ref(false);
const modalTitle = ref("");
const isEditMode = ref(false);
const parentTitle = ref("");
const data = ref([]);
const selectList = ref([]);
const selectCount = ref(0);
const showParent = ref(false);
const searchKey = ref("");
const parentTitle = ref("");
const editTitle = ref("");
const modalTitle = ref("");
const expandedKeys = ref([]);
const currentNodeKey = ref(null);
const treeKey = ref(0);
const treeProps = reactive({
label: "title",
children: "children",
});
const searchQuery = ref("");
const isAllExpanded = ref(true);
const tableRef = ref(null);
const formRef = ref(null);
const form = ref({
id: "",
title: "",
name: "",
path: "",
frontRoute: "",
parentId: "",
parentId: "0",
sortOrder: 0,
level: 0,
permission: "",
});
const formAdd = ref({});
const formValidate = reactive({
title: [{ required: true, message: "菜单名称名称不能为空", trigger: "blur" }],
title: [{ required: true, message: "菜单名称不能为空", trigger: "blur" }],
name: [{ required: true, message: "路由名称不能为空", trigger: "blur" }],
path: [{ required: true, message: "路由地址不能为空", trigger: "blur" }],
frontRoute: [{ required: true, message: "文件地址不能为空", trigger: "blur" }],
frontRoute: [{ required: true, message: "前端组件路径不能为空", trigger: "blur" }],
sortOrder: [
{
required: true,
@@ -249,267 +222,217 @@ const formValidate = reactive({
},
],
});
const submitLoading = ref(false);
const data = ref([]);
const treeRef = ref(null);
const formRef = ref(null);
const formAddRef = ref(null);
// 前端树形过滤
const filteredData = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) {
return data.value;
}
const filterTree = (nodes) => {
return nodes
.map((node) => {
const matches =
(node.title && node.title.toLowerCase().includes(query)) ||
(node.name && node.name.toLowerCase().includes(query)) ||
(node.path && node.path.toLowerCase().includes(query));
const filteredChildren = node.children ? filterTree(node.children) : [];
if (matches || filteredChildren.length > 0) {
return {
...node,
children: filteredChildren.length > 0 ? filteredChildren : undefined,
};
}
return null;
})
.filter(Boolean);
};
return filterTree(data.value);
});
function init() {
getAllList();
}
function collectExpandedKeys(nodes, keys = []) {
if (!nodes) return keys;
nodes.forEach((node) => {
if (node.expand === true) {
keys.push(node.id);
}
if (node.children && node.children.length > 0) {
collectExpandedKeys(node.children, keys);
}
});
return keys;
}
function handleDropdown(name) {
if (name == "expandOne") {
expandLevel.value = 1;
getAllList();
} else if (name == "expandTwo") {
expandLevel.value = 2;
getAllList();
} else if (name == "expandThree") {
expandLevel.value = 3;
getAllList();
}
if (name == "expandAll") {
expandLevel.value = 4;
getAllList();
} else if (name == "refresh") {
getAllList();
}
}
function getAllList() {
loading.value = true;
getStoreAllPermissionList().then((res) => {
loading.value = false;
if (res.success) {
const level = expandLevel.value;
res.result.forEach(function (e) {
if (level == 1) {
if (e.level == 0) {
e.expand = false;
}
if (e.children && e.children.length > 0) {
e.children.forEach(function (c) {
if (c.level == 1) {
c.expand = false;
}
if (c.children && c.children.length > 0) {
c.children.forEach(function (b) {
if (b.level == 2) {
b.expand = false;
}
});
}
});
}
} else if (level == 2) {
if (e.level == 0) {
e.expand = true;
}
if (e.children && e.children.length > 0) {
e.children.forEach(function (c) {
if (c.level == 1) {
c.expand = false;
}
if (c.children && c.children.length > 0) {
c.children.forEach(function (b) {
if (b.level == 2) {
b.expand = false;
}
});
}
});
}
} else if (level == 3) {
if (e.level == 0) {
e.expand = true;
}
if (e.children && e.children.length > 0) {
e.children.forEach(function (c) {
if (c.level == 1) {
c.expand = true;
}
if (c.children && c.children.length > 0) {
c.children.forEach(function (b) {
if (b.level == 2) {
b.expand = false;
}
});
}
});
}
} else if (level == 4) {
if (e.level == 0) {
e.expand = true;
}
if (e.children && e.children.length > 0) {
e.children.forEach(function (c) {
if (c.level == 1) {
c.expand = true;
}
if (c.children && c.children.length > 0) {
c.children.forEach(function (b) {
if (b.level == 2) {
b.expand = true;
}
});
}
});
}
}
});
data.value = res.result;
expandedKeys.value = collectExpandedKeys(res.result);
treeKey.value += 1;
}
});
}
function onNodeClick(nodeData) {
if (nodeData) {
const menu = JSON.parse(JSON.stringify(nodeData));
form.value = menu;
editTitle.value = menu.title;
currentNodeKey.value = menu.id;
} else {
cancelEdit();
}
}
function onCheckChange(_data, { checkedNodes }) {
selectList.value = checkedNodes;
selectCount.value = checkedNodes.length;
}
function search() {
if (searchKey.value) {
loading.value = true;
searchStorePermission({ title: searchKey.value }).then((res) => {
getStoreAllPermissionList()
.then((res) => {
loading.value = false;
if (res.success) {
data.value = res.result;
expandedKeys.value = [];
treeKey.value += 1;
data.value = Array.isArray(res.result) ? res.result : [];
}
})
.catch(() => {
loading.value = false;
});
}
function onSelectionChange(selection) {
selectList.value = selection;
selectCount.value = selection.length;
}
function toggleExpandAll() {
isAllExpanded.value = !isAllExpanded.value;
const toggleRows = (rows) => {
rows.forEach((row) => {
tableRef.value?.toggleRowExpansion(row, isAllExpanded.value);
if (row.children && row.children.length) {
toggleRows(row.children);
}
});
} else {
getAllList();
}
};
toggleRows(data.value);
}
function cancelEdit() {
treeRef.value?.setCurrentKey(null);
currentNodeKey.value = null;
formRef.value.resetFields();
form.value.id = "";
editTitle.value = "";
}
function handleReset() {
formRef.value.resetFields();
form.value.frontRoute = "";
}
function submitEdit() {
formRef.value.validate((valid) => {
if (valid) {
if (!form.value.id) {
ElMessage.warning("请先点击选择要修改的菜单节点");
return;
}
submitLoading.value = true;
if (form.value.sortOrder == null) {
form.value.sortOrder = 0;
}
delete form.value.icon;
delete form.value.frontComponent;
delete form.value.buttonType;
delete form.value.updateTime;
delete form.value.selected;
delete form.value.description;
delete form.value.children;
editStorePermission(form.value).then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success("编辑成功");
appStore.setAdded(false);
util.initRouter();
init();
menuModalVisible.value = false;
}
});
}
});
}
function submitAdd() {
formAddRef.value.validate((valid) => {
if (valid) {
submitLoading.value = true;
addStorePermission(formAdd.value).then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success("添加成功");
appStore.setAdded(false);
util.initRouter();
init();
menuModalVisible.value = false;
}
});
}
});
}
function addMenu() {
if (!form.value.id) {
ElMessage.warning("请先点击选择一个菜单权限节点");
return;
}
parentTitle.value = form.value.title;
modalTitle.value = "添加子节点";
showParent.value = true;
if (form.value.level == 2) {
ElMessageBox.alert("仅支持2级菜单目录", "抱歉,不能添加啦", { type: "warning" });
return;
}
formAdd.value = {
parentId: form.value.id,
level: Number(form.value.level) + 1,
// 添加顶级菜单
function addRootMenu() {
isEditMode.value = false;
parentTitle.value = "";
modalTitle.value = "添加顶级菜单";
form.value = {
id: "",
title: "",
name: "",
path: "/",
frontRoute: "Main",
parentId: 0,
level: 0,
sortOrder: 0,
permission: "",
};
if (form.value.level == 0) {
formAdd.value.path = "/";
formAdd.value.frontRoute = "Main";
modalVisible.value = true;
}
// 添加子菜单
function addChildMenu(row) {
if (row.level >= 2) {
ElMessageBox.alert("仅支持2级菜单目录,不能再添加子菜单", "提示", {
type: "warning",
});
return;
}
menuModalVisible.value = true;
}
function addRootMenu() {
modalTitle.value = "添加顶级菜单";
showParent.value = false;
formAdd.value = {
level: 0,
isEditMode.value = false;
parentTitle.value = row.title;
modalTitle.value = `添加子菜单 (上级: ${row.title})`;
form.value = {
id: "",
title: "",
name: "",
path: "",
frontRoute: "",
parentId: row.id,
level: Number(row.level) + 1,
sortOrder: 0,
permission: "",
};
menuModalVisible.value = true;
if (row.level === 0) {
form.value.path = "/";
form.value.frontRoute = "Main";
}
modalVisible.value = true;
}
// 编辑当前菜单
function editRow(row) {
isEditMode.value = true;
parentTitle.value = "";
modalTitle.value = `编辑菜单 - ${row.title}`;
form.value = {
id: row.id,
title: row.title,
name: row.name,
path: row.path,
frontRoute: row.frontRoute,
parentId: row.parentId,
level: row.level,
sortOrder: row.sortOrder ?? 0,
permission: row.permission || "",
};
modalVisible.value = true;
}
// 提交保存
function submitForm() {
formRef.value.validate((valid) => {
if (valid) {
submitLoading.value = true;
const payload = { ...form.value };
if (payload.sortOrder == null) {
payload.sortOrder = 0;
}
delete payload.children;
delete payload.icon;
delete payload.frontComponent;
delete payload.buttonType;
delete payload.updateTime;
delete payload.selected;
delete payload.description;
const request = isEditMode.value
? editStorePermission(payload)
: addStorePermission(payload);
request
.then((res) => {
submitLoading.value = false;
if (res.success) {
ElMessage.success(isEditMode.value ? "编辑成功" : "添加成功");
appStore.setAdded(false);
util.initRouter();
modalVisible.value = false;
getAllList();
}
})
.catch(() => {
submitLoading.value = false;
});
}
});
}
// 删除单个
function deleteRow(row) {
ElMessageBox.confirm(
`您确认要删除菜单 "${row.title}" 及其下级所有数据?`,
"确认删除",
{
type: "warning",
confirmButtonText: "确定删除",
cancelButtonText: "取消",
}
)
.then(() => {
deleteStorePermission(row.id).then((res) => {
if (res.success) {
ElMessage.success("删除成功");
appStore.setAdded(false);
util.initRouter();
getAllList();
}
});
})
.catch(() => {});
}
// 批量删除
function delAll() {
if (selectCount.value <= 0) {
ElMessage.warning("您还未勾选要删除的数据");
return;
}
ElMessageBox.confirm("您确认要删除所选的 " + selectCount.value + " 条数据?", "确认删除", { type: "warning" }).then(() => {
let ids = "";
selectList.value.forEach(function (e) {
ids += e.id + ",";
});
ids = ids.substring(0, ids.length - 1);
ElMessageBox.confirm(
`您确认要删除所选的 ${selectCount.value} 条数据?`,
"确认删除",
{
type: "warning",
confirmButtonText: "确定删除",
cancelButtonText: "取消",
}
)
.then(() => {
const ids = selectList.value.map((e) => e.id).join(",");
deleteStorePermission(ids).then((res) => {
if (res.success) {
ElMessage.success("删除成功");
@@ -517,42 +440,100 @@ function delAll() {
util.initRouter();
selectList.value = [];
selectCount.value = 0;
cancelEdit();
init();
getAllList();
}
});
}).catch(() => {});
})
.catch(() => {});
}
onMounted(() => {
const height = document.documentElement.clientHeight;
maxHeight.value = Number(height - 287) + "px";
init();
});
</script>
<style lang="scss" scoped>
@import "@/styles/tree-common.scss";
.desc {
font-size: 12px;
color: #999;
}
.menu-input {
width: 362px;
}
.tree-node-icon {
margin-right: 8px;
vertical-align: middle;
}
.tree-node-label {
display: inline-flex;
align-items: center;
}
</style>
.menu-manage-container {
width: 100%;
<style lang="scss">
.menu-manage-dialog .el-dialog__body {
max-height: calc(100vh - 220px);
overflow-y: auto;
.manage-card {
border-radius: 8px;
border: 1px solid #ebeef5;
background: #fff;
:deep(.el-card__body) {
padding: 18px 20px 24px;
}
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
.toolbar-left {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
.search-input {
width: 240px;
}
}
}
.menu-table {
width: 100%;
:deep(.el-table__row) {
transition: background-color 0.2s ease;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
}
.link-text {
color: #2d8cf0;
cursor: pointer;
text-decoration: none;
font-size: 14px;
line-height: 1;
display: inline-block;
&:hover {
color: #57a3f3;
}
&.error-text {
color: #ed4014;
&:hover {
color: #f16643;
}
}
}
.ops-row {
white-space: nowrap;
}
.op-split {
margin: 0 8px;
color: #dcdee2;
font-size: 14px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,45 +1,149 @@
.search {
.oss-operation {
margin-bottom: 2vh;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
width: 100%;
button {
margin-right: 5px;
.oss-header-card {
margin-bottom: 16px;
border-radius: 8px;
border: 1px solid #ebeef5;
background: #fff;
:deep(.el-card__body) {
padding: 14px 20px;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
.divider {
width: 1px;
height: 20px;
background: #e2e8f0;
}
}
.header-right {
display: flex;
align-items: center;
gap: 10px;
.search-input {
width: 240px;
}
}
}
}
.none {
display: none;
.oss-main-card {
border-radius: 8px;
border: 1px solid #ebeef5;
background: #fff;
min-height: 600px;
:deep(.el-card__body) {
padding: 16px 20px;
}
}
.oss-manage-box {
display: flex;
.file-list {
height: 100%;
box-sizing: border-box;
border-right: 1px solid #e5e6eb;
padding: 20px 12px 28px;
border-right: 1px solid #f0f2f5;
padding: 0 16px 0 0;
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 6px 12px;
margin-bottom: 4px;
border-bottom: 1px solid #f0f2f5;
.group-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.add-group-btn {
padding: 4px 8px;
height: auto;
font-size: 13px;
}
}
}
.pic-list {
height: 100%;
box-sizing: border-box;
padding: 0 0 24px 0;
padding: 0 0 0 18px;
display: flex;
flex-direction: column;
min-width: 0;
.oss-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 0 14px;
border-bottom: 1px solid #f0f2f5;
gap: 12px;
.toolbar-left {
display: flex;
align-items: center;
gap: 14px;
.select-all-checkbox {
font-weight: 500;
}
.selection-badge {
font-size: 13px;
color: #64748b;
b {
color: #ff6a00;
font-weight: 600;
}
}
}
.toolbar-right {
display: flex;
align-items: center;
gap: 10px;
}
}
}
.img-box {
width: 100%;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
gap: 16px 14px;
margin-top: 16px;
box-sizing: border-box;
.img-item {
width: 100%;
box-sizing: border-box;
margin: 0;
position: relative;
display: flex;
flex-direction: column;
@@ -47,10 +151,11 @@
.card {
width: 100%;
max-width: 220px;
aspect-ratio: 1 / 1;
height: auto;
border-radius: 8px;
border: 2px solid #eef2f6;
border: 1.5px solid #edf2f7;
overflow: hidden;
box-sizing: border-box;
position: relative;
@@ -58,130 +163,343 @@
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
transition: all 0.2s ease;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
img,
.card-media {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
display: block;
margin: auto;
&:hover {
border-color: #ffaa66;
box-shadow: 0 6px 16px -3px rgba(255, 106, 0, 0.16);
.card-actions {
opacity: 1;
transform: translateY(0);
}
.card-checkbox {
opacity: 1;
}
}
.card-file {
&.custom-checkbox-card-checked {
border-color: #ff6a00 !important;
background: #fffaf5 !important;
box-shadow: 0 0 0 1px #ff6a00, 0 4px 12px -2px rgba(255, 106, 0, 0.25);
.card-checkbox {
opacity: 1;
}
}
.card-preview-media {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
color: #909399;
position: relative;
overflow: hidden;
padding: 6px;
box-sizing: border-box;
img,
.card-media {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
display: block;
border-radius: 4px;
transition: transform 0.3s ease;
}
.video-play-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 36px;
height: 36px;
background: rgba(15, 23, 42, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
backdrop-filter: blur(2px);
transition: all 0.2s ease;
pointer-events: none;
}
&:hover .video-play-overlay {
transform: translate(-50%, -50%) scale(1.1);
background: rgba(255, 106, 0, 0.85);
}
}
.card-file-generic {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f8fafc 0%, #edf2f7 100%);
gap: 6px;
.file-badge {
font-size: 10px;
font-weight: 700;
padding: 1px 6px;
border-radius: 3px;
background: #e2e8f0;
color: #475569;
letter-spacing: 0.5px;
}
&.pdf .file-badge { background: #fee2e2; color: #dc2626; }
&.doc .file-badge { background: #dbeafe; color: #2563eb; }
&.xls .file-badge { background: #dcfce7; color: #16a34a; }
&.zip .file-badge { background: #ffedd5; color: #ea580c; }
&.code .file-badge { background: #f3e8ff; color: #9333ea; }
}
.card-checkbox {
position: absolute;
top: 8px;
right: 8px;
z-index: 1000;
top: 6px;
right: 6px;
z-index: 10;
height: auto;
margin: 0;
opacity: 0;
transition: opacity 0.2s ease;
:deep(.el-checkbox__inner) {
border-radius: 3px;
width: 16px;
height: 16px;
border: 1.5px solid #cbd5e1;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(4px);
&::after {
top: 1.5px;
left: 4.5px;
width: 3.5px;
height: 7px;
}
}
:deep(.is-checked .el-checkbox__inner) {
background-color: #ff6a00;
border-color: #ff6a00;
}
:deep(.el-checkbox__label) {
display: none;
}
}
}
.card:hover {
border-color: #ff6a00;
}
.card-actions {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 15;
padding: 6px 8px;
background: linear-gradient(to top, rgba(15, 23, 42, 0.88) 0%, rgba(15, 23, 42, 0.65) 85%, transparent 100%);
backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: space-around;
opacity: 0;
transform: translateY(100%);
transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1);
.custom-checkbox-card-checked {
border: 2px solid #ff6a00 !important;
background: #fffaf5 !important;
}
.action-btn {
color: #f1f5f9;
padding: 3px 5px;
border-radius: 3px;
transition: all 0.15s ease;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
.oss-card-selectable {
cursor: pointer;
&:hover {
color: #ff6a00;
background: rgba(255, 255, 255, 0.2);
transform: scale(1.15);
}
&.btn-delete:hover {
color: #f87171;
}
}
}
}
.text {
width: 100%;
max-width: 100%;
height: 20px;
cursor: pointer;
color: #252931;
font-size: 13px;
line-height: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
margin-top: 6px;
}
.sub-info {
width: 100%;
max-width: 100%;
max-width: 220px;
height: 18px;
color: #94a3b8;
font-size: 11px;
cursor: pointer;
color: #1e293b;
font-size: 12px;
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
margin-top: 2px;
margin-top: 6px;
transition: color 0.15s ease;
&:hover {
color: #ff6a00;
}
}
.sub-info {
width: 100%;
max-width: 220px;
height: 16px;
color: #94a3b8;
font-size: 11px;
line-height: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
margin-top: 1px;
}
}
}
.empty-box {
padding: 60px 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.page-box {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.pagination-box {
display: flex;
flex-direction: row-reverse;
}
.demo-tree-render .el-tree-node__label {
width: 94%;
margin-top: 24px;
padding-top: 14px;
border-top: 1px solid #f0f2f5;
}
}
.oss-group-tree {
padding: 4px 0 0;
.pc-image-picker-dialog {
margin-top: 16px !important;
margin-bottom: 16px !important;
max-height: calc(100vh - 32px);
display: flex;
flex-direction: column;
.el-dialog__header {
padding: 12px 20px 8px;
flex-shrink: 0;
:deep(.el-tree-node__expand-icon) {
display: none;
}
.el-dialog__body {
padding: 6px 16px 12px;
overflow: hidden !important;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
flex: 1;
:deep(.el-tree-node) {
margin-bottom: 4px;
}
&::-webkit-scrollbar {
display: none !important; /* Chrome, Safari, Edge */
width: 0 !important;
height: 0 !important;
:deep(.el-tree-node__content) {
padding: 0 10px !important;
height: 38px;
border-radius: 6px;
font-size: 14px;
line-height: 38px;
color: #334155;
transition: all 0.15s ease;
}
:deep(.el-tree-node__content:hover) {
background: #f1f5f9;
}
:deep(.el-tree-node.is-current > .el-tree-node__content) {
background: #fff7ed;
color: #ff6a00;
font-weight: 600;
.oss-tree-icon {
color: #ff6a00;
}
}
}
.oss-tree-node {
display: inline-flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: 4px;
font-size: 13px;
.node-left {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.oss-tree-icon {
font-size: 16px;
color: #94a3b8;
transition: color 0.15s ease;
}
.node-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.oss-tree-more {
cursor: pointer;
color: #94a3b8;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
opacity: 0;
transition: all 0.15s ease;
&:hover {
background: #e2e8f0;
color: #1e293b;
}
}
&:hover .oss-tree-more {
opacity: 1;
}
}
.file-preview-fallback {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 16px;
color: #606266;
.file-preview-name {
margin: 16px 0 8px;
font-size: 16px;
font-weight: 500;
word-break: break-all;
text-align: center;
}
.file-preview-tip {
margin: 0 0 20px;
color: #909399;
}
}
// 弹窗组件调用时的兼容样式
.is-component-search {
.is-component-card {
border: none !important;
@@ -196,22 +514,32 @@
.oss-manage-box {
.file-list {
padding: 0 10px 10px;
padding: 0 10px 10px 0;
}
.pic-list {
padding: 0;
padding: 0 0 0 10px;
}
.oss-operation {
margin-bottom: 6px;
.oss-toolbar {
padding: 0 0 8px;
margin-bottom: 4px;
}
.img-box {
margin-top: 6px;
margin-top: 8px;
gap: 10px 12px;
.img-item {
.card {
max-width: 128px;
max-width: 130px;
margin: 0 auto;
// 在选图组件模式下,不需要hover遮罩把选择搞复杂
.card-actions {
display: none !important;
}
.card-checkbox {
opacity: 1 !important;
}
}
.text {
height: 18px;
@@ -228,12 +556,17 @@
}
}
.page-box {
margin-top: 8px;
margin-top: 10px;
padding-top: 8px;
}
.oss-select-footer {
margin-top: 8px;
padding-top: 8px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid #ededed;
display: flex;
justify-content: flex-end;
gap: 10px;
}
}
@@ -241,13 +574,13 @@
padding: 0;
.el-tree-node {
margin-bottom: 4px;
margin-bottom: 2px;
}
.el-tree-node__content {
height: 36px;
line-height: 36px;
font-size: 14px;
height: 34px;
line-height: 34px;
font-size: 13px;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -49,10 +49,11 @@
</div>
</el-card>
<el-dialog
<el-drawer
v-model="roleModalVisible"
:title="modalTitle"
width="500px"
size="500px"
append-to-body
:close-on-click-modal="false"
destroy-on-close
>
@@ -68,7 +69,7 @@
<el-button @click="roleModalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitRole">提交</el-button>
</template>
</el-dialog>
</el-drawer>
<el-dialog
v-model="permModalVisible"

View File

@@ -1,48 +1,32 @@
<template>
<div class="layout">
<div class="row" v-for="(item, index) in formValidate" :key="index">
<div class="col">
<el-card shadow="never" :body-style="{ padding: '0 16px' }">
<div class="icon-item" v-if="item.client == 'APP'">
<img class="icon" src="../../../../assets/setting/app.svg" alt="" />
</div>
<div class="icon-item" v-if="item.client == 'PC'">
<img class="icon" src="../../../../assets/setting/pc.svg" alt="" />
</div>
<div class="icon-item" v-if="item.client == 'WECHAT_MP'">
<img class="icon" src="../../../../assets/setting/wechat_mp.svg" alt="" />
</div>
<div class="icon-item" v-if="item.client == 'H5'">
<img class="icon" src="../../../../assets/setting/h5.svg" alt="" />
</div>
<div class="pay-title">{{ way[item.client] }}</div>
<div>
<el-divider content-position="left">支付设置</el-divider>
<div class="pay-list">
<el-checkbox-group v-model="item.supports" @change="handleChangePayType">
<el-checkbox
v-for="(support, i) in supportForm.payments"
:key="i"
:value="support"
>
{{ payWay[support] || support }}
</el-checkbox>
</el-checkbox-group>
</div>
</div>
</el-card>
<div class="setting-page">
<div class="setting-section" v-for="(item, index) in formValidate" :key="index">
<div class="section-title">
<img class="client-icon" :src="clientIcons[item.client]" alt="" />
<span>{{ way[item.client] || item.client }}</span>
</div>
<div class="pay-options">
<el-checkbox
v-for="payment in paymentOptions"
:key="payment"
:model-value="item.supports.includes(payment)"
:label="payWay[payment] || payment"
@change="(val) => togglePay(item, payment, val)"
/>
</div>
</div>
</div>
</template>
<script setup>
import { getCurrentInstance, ref } from "vue";
import { computed, getCurrentInstance, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { setSetting } from "@/api/index";
import { handleSubmit } from "../setting/validate";
import { getPaymentSupportForm } from "@/api/setting";
import iconApp from "../../../../assets/setting/app.svg";
import iconPc from "../../../../assets/setting/pc.svg";
import iconWechatMp from "../../../../assets/setting/wechat_mp.svg";
import iconH5 from "../../../../assets/setting/h5.svg";
const DEFAULT_PAYMENT_SUPPORT_ITEMS = [
{ client: "PC", supports: [] },
@@ -58,10 +42,16 @@ const { proxy } = getCurrentInstance();
const ruleValidate = ref({});
const way = {
APP: "移动应用端",
H5: "移动端",
WECHAT_MP: "小程序端",
PC: "PC端",
APP: "APP",
H5: "手机网页",
WECHAT_MP: "微信小程序",
PC: "电脑网页",
};
const clientIcons = {
APP: iconApp,
PC: iconPc,
WECHAT_MP: iconWechatMp,
H5: iconH5,
};
const formValidate = ref([]);
const payWay = {
@@ -70,7 +60,8 @@ const payWay = {
WALLET: "余额支付",
};
const supportForm = ref("");
const checkSupport = ref({});
const paymentOptions = computed(() => supportForm.value.payments || []);
function submit(name) {
handleSubmit(proxy, name)
@@ -79,17 +70,21 @@ function submit(name) {
})
.catch(() => {});
}
function handleChangePayType(val) {
ElMessageBox.confirm("您是否修改此项?", "修改支付设置", { type: "warning" }).then(() => {
setupSetting();
}).catch(() => {
formValidate.value = JSON.parse(JSON.stringify(checkSupport.value));
});
function togglePay(row, payment, checked) {
ElMessageBox.confirm("您是否修改此项?", "修改支付设置", { type: "warning" })
.then(() => {
if (checked) {
row.supports.push(payment);
} else {
row.supports = row.supports.filter((p) => p !== payment);
}
setupSetting();
})
.catch(() => {});
}
function setupSetting() {
setSetting(props.type, { paymentSupportItems: formValidate.value }).then((res) => {
if (res.success) {
checkSupport.value = JSON.parse(JSON.stringify(formValidate.value));
ElMessage.success("保存成功!");
} else {
ElMessage.error("保存失败!");
@@ -101,7 +96,10 @@ function init() {
const items = setting.paymentSupportItems;
formValidate.value =
Array.isArray(items) && items.length ? items : DEFAULT_PAYMENT_SUPPORT_ITEMS;
checkSupport.value = JSON.parse(JSON.stringify(formValidate.value));
// 清理历史已保存的失效支付方式,下次保存时一并去除
formValidate.value.forEach((item) => {
item.supports = (item.supports || []).filter((p) => p in payWay);
});
getPaymentSupportForm().then((res) => {
if (res.success && res.result) {
@@ -115,48 +113,37 @@ init();
<style lang="scss" scoped>
@import "../setting/style.scss";
.pay-title {
text-align: center;
margin: 10px 0;
}
.layout {
padding: 20px;
.setting-page {
padding: 16px;
}
.setting-section {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 20px 24px;
margin-bottom: 16px;
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 16px;
}
.client-icon {
width: 28px;
height: 28px;
}
.pay-options {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
}
.row {
width: 300px;
margin-right: 20px;
display: flex;
margin-bottom: 20px;
}
.label-item {
display: flex;
align-items: center;
}
.pay-list {
display: flex;
justify-content: center;
padding-bottom: 10px;
}
.icon-item {
width: 100%;
padding: 30px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.el-form-item {
display: flex;
align-items: center;
}
.icon {
width: 100px;
height: 100px;
gap: 8px 32px;
}
</style>

View File

@@ -98,23 +98,11 @@
<el-radio-button value="CLOSE">关闭</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="商品页面小图宽度" prop="smallPictureWidth">
<el-input v-model="goods.smallPictureWidth" placeholder="商品页面小图宽度" style="width: 350px" />
</el-form-item>
<el-form-item label="商品页面小图高度" prop="smallPictureHeight">
<el-input v-model="goods.smallPictureHeight" placeholder="商品页面小图高度" style="width: 350px" />
</el-form-item>
<el-form-item label="缩略图宽度" prop="abbreviationPictureWidth">
<el-input v-model="goods.abbreviationPictureWidth" placeholder="缩略图宽度" style="width: 350px" />
</el-form-item>
<el-form-item label="缩略图高度" prop="abbreviationPictureHeight">
<el-input v-model="goods.abbreviationPictureHeight" placeholder="缩略图高度" style="width: 350px" />
</el-form-item>
<el-form-item label="原图宽" prop="originalPictureWidth">
<el-input v-model="goods.originalPictureWidth" placeholder="原图宽" style="width: 350px" />
</el-form-item>
<el-form-item label="原图高" prop="originalPictureHeight">
<el-input v-model="goods.originalPictureHeight" placeholder="原图高" style="width: 350px" />
<el-form-item label="自营店商品审核" prop="selfOperatedGoodsCheck">
<el-radio-group v-model="goods.selfOperatedGoodsCheck">
<el-radio-button value="OPEN">需要审核</el-radio-button>
<el-radio-button value="CLOSE">无需审核</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button type="primary" style="width: 100px;margin-right:5px" :loading="saveLoading" @click="saveGoods">
@@ -162,12 +150,7 @@ const order = ref({
});
const goods = ref({
goodsCheck: "OPEN",
smallPictureWidth: "",
smallPictureHeight: "",
abbreviationPictureWidth: "",
abbreviationPictureHeight: "",
originalPictureWidth: "",
originalPictureHeight: "",
selfOperatedGoodsCheck: "OPEN",
});
// 历史遗留:wechat/qq/weibo/alipay 未在 data 中声明,直接挂 this,且模板中无对应表单项
let wechat;
@@ -262,86 +245,7 @@ const orderValidate = {
},
],
};
const goodsValidate = {
smallPictureWidth: [
{
required: true,
validator: (rule, value, callback) => {
if (!regular.integer.test(value)) {
callback(new Error("请输入正整数,且不为零!"));
} else {
callback();
}
},
trigger: "blur",
},
],
smallPictureHeight: [
{
required: true,
validator: (rule, value, callback) => {
if (!regular.integer.test(value)) {
callback(new Error("请输入正整数,且不为零!"));
} else {
callback();
}
},
trigger: "blur",
},
],
abbreviationPictureWidth: [
{
required: true,
validator: (rule, value, callback) => {
if (!regular.integer.test(value)) {
callback(new Error("请输入正整数,且不为零!"));
} else {
callback();
}
},
trigger: "blur",
},
],
abbreviationPictureHeight: [
{
required: true,
validator: (rule, value, callback) => {
if (!regular.integer.test(value)) {
callback(new Error("请输入正整数,且不为零!"));
} else {
callback();
}
},
trigger: "blur",
},
],
originalPictureWidth: [
{
required: true,
validator: (rule, value, callback) => {
if (!regular.integer.test(value)) {
callback(new Error("请输入正整数,且不为零!"));
} else {
callback();
}
},
trigger: "blur",
},
],
originalPictureHeight: [
{
required: true,
validator: (rule, value, callback) => {
if (!regular.integer.test(value)) {
callback(new Error("请输入正整数,且不为零!"));
} else {
callback();
}
},
trigger: "blur",
},
],
};
const goodsValidate = {};
const baseForm = ref(null);
const pointForm = ref(null);
@@ -401,7 +305,10 @@ function initGoods() {
if (res.success) {
loading.value = false;
if (res.result) {
goods.value = res.result;
goods.value = {
goodsCheck: res.result.goodsCheck,
selfOperatedGoodsCheck: res.result.selfOperatedGoodsCheck ?? res.result.goodsCheck,
};
}
}
});

View File

@@ -1,81 +1,61 @@
<template>
<div class="layout">
<div class="setting-page">
<el-form ref="formValidateRef" label-width="150px" label-position="right" :model="formValidate" :rules="ruleValidate">
<el-form-item label="站点名称" prop="siteName">
<el-input style="width:200px;" v-model="formValidate.siteName" />
<span class="desc">配置买家端站点名称</span>
</el-form-item>
<el-form-item label="icp" prop="icp">
<el-input v-model="formValidate.icp" />
</el-form-item>
<div class="setting-section">
<div class="section-title">商城信息</div>
<el-form-item label="商城名称" prop="siteName">
<el-input v-model="formValidate.siteName" />
</el-form-item>
<el-form-item label="站点地址" prop="staticPageAddress">
<el-input v-model="formValidate.staticPageAddress" />
</el-form-item>
<el-form-item label="wap站点地址" prop="staticPageWapAddress">
<el-input v-model="formValidate.staticPageWapAddress" />
</el-form-item>
<el-form-item label="后台Logo" prop="domainLogo">
<div class="label-item-upload">
<img alt="平台 Logo" v-if="formValidate.domainLogo" class="img" :src="formValidate.domainLogo" />
<img v-else class="img" src="../../../../assets/emptyImg.png" alt="" />
<el-button @click="onClickImg('domainLogo')">选择图片</el-button>
<span class="desc">后台管理左上角logo展示 17∶6</span>
</div>
</el-form-item>
<el-form-item label="后台Icon" prop="domainIcon">
<div class="label-item-upload">
<img alt="平台图标" v-if="formValidate.domainIcon" class="img" :src="formValidate.domainIcon" />
<img v-else class="img" src="../../../../assets/emptyImg.png" alt="" />
<el-button @click="onClickImg('domainIcon')">选择图片</el-button>
<span class="desc">后台管理网站icon展示 1∶1</span>
</div>
</el-form-item>
<el-form-item label="买家端Logo" prop="buyerSideLogo">
<div class="label-item-upload">
<img alt="买家端 Logo" v-if="formValidate.buyerSideLogo" class="img" :src="formValidate.buyerSideLogo" />
<img v-else class="img" src="../../../../assets/emptyImg.png" alt="" />
<el-button @click="onClickImg('buyerSideLogo')">选择图片</el-button>
<span class="desc">买家端logo展示 17∶6</span>
</div>
</el-form-item>
<el-form-item label="买家端Icon" prop="buyerSideIcon">
<div class="label-item-upload">
<img alt="买家端图标" v-if="formValidate.buyerSideIcon" class="img" :src="formValidate.buyerSideIcon" />
<img v-else class="img" src="../../../../assets/emptyImg.png" alt="" />
<el-button @click="onClickImg('buyerSideIcon')">选择图片</el-button>
<span class="desc">买家端网站icon展示 1∶1</span>
</div>
</el-form-item>
<el-form-item label="商家端Logo" prop="storeSideLogo">
<div class="label-item-upload">
<img alt="商家端 Logo" v-if="formValidate.storeSideLogo" class="img" :src="formValidate.storeSideLogo" />
<img v-else class="img" src="../../../../assets/emptyImg.png" alt="" />
<el-button @click="onClickImg('storeSideLogo')">选择图片</el-button>
<span class="desc">商家端logo展示 17∶6</span>
</div>
</el-form-item>
<el-form-item label="商家端Icon" prop="storeSideIcon">
<div class="label-item-upload">
<img alt="商家端图标" v-if="formValidate.storeSideIcon" class="img" :src="formValidate.storeSideIcon" />
<img v-else class="img" src="../../../../assets/emptyImg.png" alt="" />
<el-button @click="onClickImg('storeSideIcon')">选择图片</el-button>
<span class="desc">商家端icon展示 1∶1</span>
</div>
</el-form-item>
<el-form-item label="商城Logo" prop="domainLogo">
<div class="upload-item">
<div class="upload-row">
<img alt="商城 Logo" v-if="formValidate.domainLogo" class="img img-logo" :src="formValidate.domainLogo" />
<img v-else class="img img-logo" src="../../../../assets/emptyImg.png" alt="" />
<el-button @click="onClickImg('domainLogo')">选择图片</el-button>
</div>
<span class="field-desc">各端logo统一展示,建议比例 17:6</span>
</div>
</el-form-item>
<el-form-item label="商城Icon" prop="domainIcon">
<div class="upload-item">
<div class="upload-row">
<img alt="商城图标" v-if="formValidate.domainIcon" class="img" :src="formValidate.domainIcon" />
<img v-else class="img" src="../../../../assets/emptyImg.png" alt="" />
<el-button @click="onClickImg('domainIcon')">选择图片</el-button>
</div>
<span class="field-desc">各端网站icon统一展示,建议比例 1:1</span>
</div>
</el-form-item>
</div>
<el-form-item label="站点地址" prop="staticPageAddress">
<el-input style="width:200px;" v-model="formValidate.staticPageAddress" />
</el-form-item>
<el-form-item label="wap站点地址" prop="staticPageWapAddress">
<el-input v-model="formValidate.staticPageWapAddress" />
</el-form-item>
<el-form-item label="公司名称" prop="companyName">
<el-input style="width:350px;" v-model="formValidate.companyName" />
<span class="desc">页脚展示的公司名称</span>
</el-form-item>
<el-form-item label="公司链接" prop="companyUrl">
<el-input style="width:350px;" v-model="formValidate.companyUrl" />
<span class="desc">页脚公司名称的跳转链接</span>
</el-form-item>
<el-form-item label="官网地址" prop="website">
<el-input style="width:350px;" v-model="formValidate.website" />
</el-form-item>
<div class="label-btns">
<div class="setting-section">
<div class="section-title">公司信息</div>
<el-form-item label="公司名称" prop="companyName">
<el-input v-model="formValidate.companyName" />
</el-form-item>
<el-form-item label="公司链接" prop="companyUrl">
<el-input v-model="formValidate.companyUrl" />
</el-form-item>
<el-form-item label="官网地址" prop="website">
<el-input v-model="formValidate.website" />
</el-form-item>
<el-form-item label="ICP备案号" prop="icp">
<el-input v-model="formValidate.icp" />
</el-form-item>
<el-form-item label="版权归属" prop="copyrightOwner">
<el-input v-model="formValidate.copyrightOwner" placeholder="留空时使用商城名称" maxlength="100" />
<span class="field-desc">仅填写版权归属名称,Copyright © 和当前年份由系统自动显示</span>
</el-form-item>
</div>
<div class="submit-bar">
<el-button type="primary" @click="submit('formValidateRef')">保存</el-button>
</div>
</el-form>
@@ -115,6 +95,7 @@ const formValidate = ref({
buyerSideLogo: "",
companyName: "",
companyUrl: "",
copyrightOwner: "",
domainLogo: "",
icp: "",
storeSideLogo: "",
@@ -152,6 +133,10 @@ function callbackSelected(val) {
formValidate.value[selected.value] = val.url;
}
function setupSetting() {
formValidate.value.buyerSideLogo = formValidate.value.domainLogo;
formValidate.value.storeSideLogo = formValidate.value.domainLogo;
formValidate.value.buyerSideIcon = formValidate.value.domainIcon;
formValidate.value.storeSideIcon = formValidate.value.domainIcon;
setSetting(props.type, formValidate.value).then((res) => {
if (res.success) {
ElMessage.success("保存成功!");
@@ -165,8 +150,9 @@ function setupSetting() {
}
function init() {
result.value = JSON.parse(props.res);
formValidate.value = { ...result.value };
formValidate.value = { copyrightOwner: "", ...result.value };
Object.keys(result.value).forEach((item) => {
if (item === "copyrightOwner") return;
ruleValidate[item] = [
{
required: true,
@@ -181,20 +167,65 @@ init();
</script>
<style scoped lang="scss">
@import "./style.scss";
.label-item {
display: flex;
> .el-input {
width: 200px;
margin: 0 10px;
}
.setting-page {
padding: 16px 0;
}
.label-item-upload {
.setting-section {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 20px 24px 4px;
margin-bottom: 16px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 20px;
}
.field-desc {
margin-top: 8px;
color: #999;
font-size: 12px;
line-height: 1.4;
}
.upload-item {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.upload-row {
display: flex;
align-items: flex-end;
img {
margin-right: 10px;
width: 100px;
height: 100px;
object-fit: contain;
}
// Logo 按建议比例 17:6 展示
img.img-logo {
width: 170px;
height: 60px;
border: 1px solid #ebeef5;
border-radius: 4px;
background: #fafafa;
}
}
// 覆盖共享样式里 :deep(.el-input) 的 200px !important,统一输入框宽度
:deep(.el-input) {
width: 400px !important;
}
.submit-bar {
text-align: center;
margin: 24px 0 8px;
}
</style>

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