mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-ui.git
synced 2026-11-11 03:18:56 +08:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb017244d3 | ||
|
|
2be2227644 | ||
|
|
9761479c82 | ||
|
|
f33c969d1e | ||
|
|
3c4604a342 | ||
|
|
a094a470f7 | ||
|
|
ecbb80e24a | ||
|
|
b2981b3ad7 | ||
|
|
76b0d6efc8 | ||
|
|
fd241bc36a | ||
|
|
d799b37f0c | ||
|
|
0232ddeb27 | ||
|
|
2497f33cb4 | ||
|
|
0f08807f00 | ||
|
|
29a6b00a21 | ||
|
|
6be934d00b | ||
|
|
560286b7f6 | ||
|
|
961565149c | ||
|
|
1a457a60f8 | ||
|
|
d521587ad8 | ||
|
|
cbaf89c835 | ||
|
|
f6f106e12b | ||
|
|
484b6a6dad | ||
|
|
d64dd08b42 | ||
|
|
5300988643 | ||
|
|
fb99824a3a | ||
|
|
22f3e9b5e9 | ||
|
|
7db1ee4fb7 | ||
|
|
4e33ab19b6 | ||
|
|
ce11f50942 | ||
|
|
ccf243f31a | ||
|
|
c0cc8d8598 |
216
LICENSE
216
LICENSE
@@ -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.
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
# Lilishop UI — Vue 3 迁移与上线前检查清单
|
||||
|
||||
四个子项目(buyer / seller / manager / im)已完成 **Vue 3 + Element Plus** 迁移,并完成依赖小版本对齐。本文档供上线前统一回归使用。
|
||||
|
||||
## 技术栈(四端一致)
|
||||
|
||||
| 类别 | 包 | 版本(package.json) |
|
||||
|------|-----|---------------------|
|
||||
| 框架 | vue | ^3.5.35 |
|
||||
| 编译 | @vue/compiler-sfc | ^3.5.35 |
|
||||
| 路由 | vue-router | ^4.6.4 |
|
||||
| 状态 | pinia | ^4.0.3(四端已全部从 vuex 迁移完成) |
|
||||
| 组件写法 | `<script setup>` | 四端 597 个组件已全部完成 Options API → 组合式 API 迁移;各端 eslint 已启用 `vue/component-api-style: ['warn', ['script-setup']]` 防止回退 |
|
||||
| UI | element-plus | ^2.14.1 |
|
||||
| 图标 | @element-plus/icons-vue | ^2.3.2 |
|
||||
| 请求 | axios | ^1.7.9 |
|
||||
| 兼容 | core-js | ^3.49.0 |
|
||||
| 构建 | @vue/cli-service | ^5.0.9 |
|
||||
|
||||
> 使用 `^` 范围,实际安装版本以各目录 `yarn.lock` 为准。
|
||||
> 要求 **Node >= 16**,包管理统一 **yarn**。
|
||||
|
||||
## 本地启动
|
||||
|
||||
```bash
|
||||
# 买家端
|
||||
cd buyer && yarn install && yarn dev # 端口 10000
|
||||
|
||||
# 商家端
|
||||
cd seller && yarn install && yarn dev # 端口 10002
|
||||
|
||||
# 管理端
|
||||
cd manager && yarn install && yarn dev # 端口 10003
|
||||
|
||||
# IM 客服
|
||||
cd im && yarn install && yarn dev # 端口 8000
|
||||
```
|
||||
|
||||
生产构建(上线前必跑):
|
||||
|
||||
```bash
|
||||
cd buyer && yarn build
|
||||
cd seller && yarn build
|
||||
cd manager && yarn build
|
||||
cd im && yarn build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 一、通用检查(四端都要过)
|
||||
|
||||
### 1. 认证与请求(axios 1.x 重点)
|
||||
|
||||
各端请求封装位置:
|
||||
|
||||
| 端 | 文件 |
|
||||
|----|------|
|
||||
| buyer | `buyer/src/plugins/request.js` |
|
||||
| seller | `seller/src/libs/axios.js` |
|
||||
| manager | `manager/src/libs/axios.js` |
|
||||
| im | `im/src/utils/request.js` |
|
||||
|
||||
- [ ] **登录**:账号密码登录成功,token 写入正常
|
||||
- [ ] **登出**:清除 token,跳转登录页
|
||||
- [ ] **401**:token 过期后跳转登录 / 刷新 token(buyer/manager/seller 有刷新逻辑)
|
||||
- [ ] **403**:无权限时提示文案正确、不白屏
|
||||
- [ ] **400 / 5xx**:接口报错能弹出 `Message` / `ElMessage` / `Notification`
|
||||
- [ ] **网络超时**:断网或超时后有错误提示,页面不卡死
|
||||
- [ ] **GET 带参**:列表筛选、分页参数序列化正常
|
||||
- [ ] **POST JSON**:`Content-Type: application/json` 请求体正确
|
||||
- [ ] **POST 表单**:`application/x-www-form-urlencoded` 提交正常(im 等)
|
||||
|
||||
### 2. Element Plus UI(2.6 → 2.14 重点)
|
||||
|
||||
- [ ] **表格** `el-table`:列展示、排序、空数据、加载态
|
||||
- [ ] **分页** `el-pagination`:翻页、每页条数切换
|
||||
- [ ] **表单** `el-form`:必填校验、错误提示位置
|
||||
- [ ] **弹窗** `el-dialog` / `ElMessageBox`:打开、确认、取消、关闭
|
||||
- [ ] **下拉** `el-select` / `el-dropdown`:选项、点击外部关闭
|
||||
- [ ] **消息** `ElMessage` / `$Message` 兼容层:success / error / warning
|
||||
- [ ] **加载** `ElLoading` / `v-loading`:请求期间遮罩正常消失
|
||||
- [ ] **日期** `el-date-picker`:选择、清空、范围选择
|
||||
- [ ] **上传** `el-upload`:选文件、进度、成功 / 失败回调
|
||||
|
||||
### 3. 路由与页面
|
||||
|
||||
- [ ] 浏览器 **刷新** 当前路由不 404(history 模式 + 服务端配置)
|
||||
- [ ] **动态路由** 加载正常(seller / manager 菜单权限路由)
|
||||
- [ ] 浏览器 **前进 / 后退** 正常
|
||||
- [ ] 各端 **404 页** 可回到首页
|
||||
|
||||
### 4. 构建与部署
|
||||
|
||||
- [ ] 四端 `yarn build` 无 error(warning 可记录,不阻断)
|
||||
- [ ] 生产环境 API 地址配置正确(`config` / 环境变量)
|
||||
- [ ] 静态资源 `publicPath` 与 Nginx / CDN 路径一致
|
||||
- [ ] gzip 压缩包(如有)可正常访问
|
||||
|
||||
---
|
||||
|
||||
## 二、分端专项检查
|
||||
|
||||
### Buyer 买家端(10000)
|
||||
|
||||
详见 [buyer/MIGRATION-VUE3.md](./buyer/MIGRATION-VUE3.md)
|
||||
|
||||
- [ ] 首页装修模块渲染(轮播、商品列表、秒杀区等)
|
||||
- [ ] 商品详情:规格选择、加购、收藏、`el-image` 预览
|
||||
- [ ] 购物车 → 结算 → 支付流程
|
||||
- [ ] 用户中心:订单列表、地址、优惠券、售后
|
||||
- [ ] 登录 / 注册 / 忘记密码
|
||||
- [ ] 店铺入驻、商户页
|
||||
- [ ] 地图(高德 `@amap/amap-jsapi-loader`)定位与选点
|
||||
- [ ] 侧边栏 `el-menu`、头部 `el-dropdown`、动态图标 `iconMap.js`
|
||||
|
||||
### Seller 商家端(10002)
|
||||
|
||||
详见 [seller/MIGRATION-VUE3.md](./seller/MIGRATION-VUE3.md)
|
||||
|
||||
- [ ] 商家登录、店铺信息
|
||||
- [ ] 商品发布三步流程(`goodsOperation` 步骤条)
|
||||
- [ ] 订单管理:发货、退款、详情
|
||||
- [ ] 促销 / 分销 / 统计图表(`@antv/g2`)
|
||||
- [ ] 页面装修(wap / modelList,从 manager 同步部分)
|
||||
- [ ] 图片懒加载 `vue-lazyload`
|
||||
- [ ] Excel 导出 `vue-json-excel`
|
||||
|
||||
### Manager 管理端(10003)
|
||||
|
||||
详见 [manager/MIGRATION-VUE3.md](./manager/MIGRATION-VUE3.md)
|
||||
|
||||
- [ ] 管理员登录、权限菜单、动态路由
|
||||
- [ ] 系统设置(`sys/setting-manage` 各 Tab)
|
||||
- [ ] 菜单 / 部门管理
|
||||
- [ ] 页面装修(PC + H5)
|
||||
- [ ] 文章管理
|
||||
- [ ] 商品 / 订单 / 会员 / 促销等核心列表 CRUD
|
||||
- [ ] 富文本 TinyMCE 编辑、图片上传
|
||||
- [ ] 多语言 `vue-i18n` 切换(如有启用)
|
||||
|
||||
### IM 客服端(8000)
|
||||
|
||||
详见 [im/MIGRATION-VUE3.md](./im/MIGRATION-VUE3.md)
|
||||
|
||||
- [ ] 带 token 参数进入会话页
|
||||
- [ ] 对话列表加载、切换会话、未读数
|
||||
- [ ] 发送文字、表情、图片消息
|
||||
- [ ] 商品 / 订单卡片链接跳转(`linkToGoods` / `linkToOrders`)
|
||||
- [ ] 右键菜单(复制、撤回、删除等)
|
||||
- [ ] WebSocket 连接 / 断线重连 / 新消息通知
|
||||
- [ ] 最近浏览、订单列表侧栏(商家场景)
|
||||
- [ ] 用户名片弹层 `$user()`
|
||||
|
||||
---
|
||||
|
||||
## 三、已知可忽略项
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| `vue-awesome-swiper` peer warning | 历史依赖,Vue 3 下仍有 peer 告警,功能正常可暂忽略 |
|
||||
| im 内 `<i class="el-icon-*">` | Element Plus 无字体图标,部分旧图标可能不显示,不影响核心功能 |
|
||||
| `vue-prism-editor@2.0.0-alpha.2` | im 代码块编辑器刻意锁定 alpha 版 |
|
||||
| 打包体积 warning | chunk-vendors 偏大,属 Element Plus 全量引入,可后续做按需优化 |
|
||||
|
||||
---
|
||||
|
||||
## 四、问题记录模板
|
||||
|
||||
上线测试时建议按端记录:
|
||||
|
||||
```markdown
|
||||
### [端名] 问题标题
|
||||
- 页面/路由:
|
||||
- 操作步骤:
|
||||
- 预期:
|
||||
- 实际:
|
||||
- 控制台报错:
|
||||
- 优先级:P0 / P1 / P2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、子项目文档索引
|
||||
|
||||
| 端 | 迁移说明 |
|
||||
|----|----------|
|
||||
| buyer | [buyer/MIGRATION-VUE3.md](./buyer/MIGRATION-VUE3.md) |
|
||||
| seller | [seller/MIGRATION-VUE3.md](./seller/MIGRATION-VUE3.md) |
|
||||
| manager | [manager/MIGRATION-VUE3.md](./manager/MIGRATION-VUE3.md) |
|
||||
| im | [im/MIGRATION-VUE3.md](./im/MIGRATION-VUE3.md) |
|
||||
|
||||
---
|
||||
|
||||
## 六、建议上线顺序
|
||||
|
||||
1. **manager**(后台配置源头)
|
||||
2. **seller**(商家经营)
|
||||
3. **buyer**(C 端流量最大)
|
||||
4. **im**(客服,依赖前后端 WebSocket)
|
||||
|
||||
每上一端,至少完成本文 **第一节通用检查** + 对应 **第二节分端检查** 后再切下一端。
|
||||
@@ -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.**
|
||||
|
||||
58
README.md
58
README.md
@@ -5,7 +5,7 @@
|
||||
<p>
|
||||
<a href="https://github.com/lilishop/lilishop"><img src="https://img.shields.io/github/stars/lilishop/lilishop.svg?style=social&logo=github" alt="GitHub Stars" /></a>
|
||||
<a href="https://gitee.com/beijing_hongye_huicheng/lilishop"><img src="https://gitee.com/beijing_hongye_huicheng/lilishop/badge/star.svg?theme=dark" alt="Gitee Stars" /></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License" /></a>
|
||||
<a href="https://vuejs.org/"><img src="https://img.shields.io/badge/Vue-3.5-brightgreen.svg" alt="Vue 3" /></a>
|
||||
<a href="https://element-plus.org/"><img src="https://img.shields.io/badge/Element%20Plus-2.x-409EFF.svg" alt="Element Plus" /></a>
|
||||
<a href="https://pinia.vuejs.org/"><img src="https://img.shields.io/badge/Pinia-4.x-yellow.svg" alt="Pinia" /></a>
|
||||
@@ -28,7 +28,7 @@
|
||||
**LILISHOP** 是基于 Spring Boot / Vue 3 / uni-app 开发的开源商城系统,支持 B2B2C 多商户商城、微信小程序商城、H5 商城、APP 商城、直播电商、分销返佣、秒杀活动。
|
||||
|
||||
### ✨ 为什么选择 Lilishop
|
||||
- 🟢 **真 MIT 协议**:支持免费商用与私有化闭源二开,无版权后顾之忧。
|
||||
- 🟢 **真 Apache-2.0 协议**:支持免费商用与私有化闭源二开,无版权后顾之忧。
|
||||
- 🚀 **Vue 3 全新栈**:全面基于 `Vue 3.5 + Pinia 4 + Vue Router 4 + Element Plus` 构建,告别老旧 Vue 2 架构。
|
||||
- 🧩 **四端合一生产级闭环**:完整覆盖 **PC 买家端 (`buyer`)**、**商家端 (`seller`)**、**平台运营管理端 (`manager`)** 与 **在线客服端 (`im`)**,支持拖拽式可视化楼层装修与多维数据统计报表。
|
||||
|
||||
@@ -146,7 +146,7 @@ lilishop-ui
|
||||
|
||||
## 6. 开源与授权
|
||||
|
||||
本项目遵循 [MIT](./LICENSE) 开源协议,版权归 **北京宏业汇成科技有限公司** 所有。允许个人及企业免费用于学习、研究、修改和商业用途(需保留原始版权和许可声明)。
|
||||
本项目遵循 [Apache-2.0](./LICENSE) 开源协议,版权归 **北京宏业汇成科技有限公司** 所有。允许个人及企业免费用于学习、研究、修改和商业用途(需保留原始版权和许可声明)。
|
||||
|
||||
---
|
||||
|
||||
@@ -162,20 +162,46 @@ lilishop-ui
|
||||
|
||||
## 8. 升级计划
|
||||
|
||||
### ✅ 已完成特性
|
||||
| 功能模块 | 功能模块 |
|
||||
| :--- | :--- |
|
||||
| ✅ 商品定时上下架 | ✅ 商品分组 / 会员分组 |
|
||||
| ✅ 商品虚拟销量 | ✅ 专用发票(发票上传) |
|
||||
| ✅ 置顶评论 | ✅ 客户等级体系 |
|
||||
| ✅ 购物卡 | ✅ 腾讯云-云直播 |
|
||||
| ✅ 卡密商品 | ✅ 二级分销 |
|
||||
| ✅ 定时任务管理 | ✅ 预约服务商品 |
|
||||
| ✅ 主题色管理 | ✅ 商家发货改造(整单/拆单/无需配送) |
|
||||
| ✅ 多维统计(营业/商品/会员/积分/储值/营销/分销) | ✅ 经营报表(店铺业绩/商品同比环比/订单明细/销售汇总) |
|
||||
### ✅ 已完成功能
|
||||
|
||||
按可核实的实现或完善时间倒序记录;日期表示代码完成或功能更新时间,不代表生产上线时间。
|
||||
|
||||
| 时间 | 功能模块 | 完成内容 |
|
||||
| :--- | :--- | :--- |
|
||||
| 2026-11-06 | 提货卡 | 支持提货卡活动与候选商品配置、制卡发卡、兑换核销及使用记录管理。 |
|
||||
| 2026-11-05 | 付费会员 | 单一付费会员、开通与续费、有效期、关联权益、商家与商品折扣、平台补贴及付费记录。 |
|
||||
| 2026-11-04 | 客户等级体系 / 客户权益 | 等级配置、权益介绍、经验值任务与明细、权益发放及重试,提供默认等级与权益数据。 |
|
||||
| 2026-11-04 | 小程序订单管理 | 增加小程序订单查询入口及微信订单自动上报开关。 |
|
||||
| 2026-11-04 | 立减优惠券 | 新增立减券,完善优惠券校验、领取与订单优惠计算。 |
|
||||
| 2026-11-04 | 商品定时上下架 | 完善定时上下架计划管理、独立列表及执行校验。 |
|
||||
| 2026-11-04 | 微信小程序原生能力 | 接入微信地址导入、头像选择和昵称填写。 |
|
||||
| 2026-11-02 | 消息模板 | 支持多渠道模板矩阵维护,统一站内信、短信、微信公众号及小程序订阅消息的场景配置和异步发送。 |
|
||||
| 2026-10-30 | PC 页面装修 | 重构楼层组件,支持自适应高度、可视化热区绘制及配置交互优化。 |
|
||||
| 2026-10-29 | 定时任务管理 | 重构为数据库配置驱动,支持运营后台维护任务。 |
|
||||
| 2026-10-29 | 敏感词管理 | 支持按业务场景配置过滤开关,并优化词库加载。 |
|
||||
| 2026-10-29 | PC 店铺搜索 | 新增店铺列表,支持商品与店铺双模式搜索。 |
|
||||
| 2026-10-22 | 分类设置 | 支持四种分类页模板选择、商品列表展示字段开关及模板图片预览。 |
|
||||
| 2026-10-20 | 移动端页面装修 | 完善可视化编辑器,新增首页组合组件及优惠券、店铺、文章等楼层。 |
|
||||
| 2026-10-09 | 主题色管理 | 完善主题设置,支持自定义模式。 |
|
||||
| 2026-10-02 | 预约服务商品 | 完善预约商品、预约交易及订单详情功能。 |
|
||||
| 2026-08-05 | 二级分销 | 由一级分销扩展为二级分销,增加节点配置及分销员中心。 |
|
||||
| 2026-08-03 | 卡密商品 | 完善电子卡券履约、订单状态及卡池管理。 |
|
||||
| 2026-07-23 | 素材库管理 | 增加文件分类、目录管理及目录归属校验。 |
|
||||
| 2026-07-20 | 经营统计报表 | 新增店铺业绩、商品同比环比、销售订单明细及商品销售汇总报表。 |
|
||||
| 2026-07-13 | 腾讯云直播 | 接入并完善直播、聊天与直播商品相关功能。 |
|
||||
| 2026-06-30 | 移动端技术升级 | 升级 Vue 3 与 uView Plus,适配组件和页面。 |
|
||||
| 2026-06-29 | 电子面单发货 | 完善快递鸟电子面单,支持自动携带运单号发货。 |
|
||||
| 2026-06-05 | 后端与管理端框架升级 | 5 月 25 日至 6 月 5 日完成 Spring Boot 4、管理端 Vue 3 与 Element Plus 升级。 |
|
||||
| 2026-05-22 | 购物卡 | 新增礼品卡接口,以及选择、绑定、激活和列表管理。 |
|
||||
| 2026-05-20 | 微信提现 | 完善转账状态查询、确认回调和重试处理,后续补充提现规则说明配置。 |
|
||||
| 2026-04-16 | 置顶评论 | 支持评论置顶及商品评价管理。 |
|
||||
| 2026-04-13 | 专用发票(发票上传) | 完善发票信息字段、发票详情及管理展示。 |
|
||||
| 2026-04-01 | 商品虚拟销量 | 支持按商品规格设置虚拟销量,提供单个及批量设置入口。 |
|
||||
| 2026-03-30 | 商品分组 | 新增商品分组管理及商品关联功能。 |
|
||||
| 2026-03-24 | 会员分组 | 新增会员分组管理。 |
|
||||
|
||||
### 🔜 近期规划中
|
||||
- 🔜 店铺结算汇总 & 结算台账
|
||||
- 🔜 支付方式汇总 & 钱包流水 & 财务汇总
|
||||
- 🔜 微信小店对接
|
||||
- 🔜 抖店对接
|
||||
- 🔜 微信小程序原生重构与体验对齐(持续完善;已接入预约交易、分类多模板与首页楼层装修)
|
||||
- 🔜 OpenAPI 开放平台与开发者中心
|
||||
|
||||
@@ -45,32 +45,36 @@ export function getRegion (id) {
|
||||
|
||||
/**
|
||||
* 分页获取文章列表
|
||||
* @param cateId 文章分类id
|
||||
* @param params 分页及分类查询条件
|
||||
* @param options 请求选项,skipMessage 为 true 时由页面展示错误
|
||||
*/
|
||||
export function articleList (params) {
|
||||
export function articleList (params, options = {}) {
|
||||
return request({
|
||||
url: `/buyer/other/article`,
|
||||
method: Method.GET,
|
||||
params
|
||||
params,
|
||||
skipMessage: options.skipMessage
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取帮助中心文章分类列表
|
||||
* @param cateId 文章分类id
|
||||
* @param options 请求选项,skipMessage 为 true 时由页面展示错误
|
||||
*/
|
||||
export function articleCateList () {
|
||||
export function articleCateList (options = {}) {
|
||||
return request({
|
||||
url: `/buyer/other/article/articleCategory/list`,
|
||||
method: Method.GET
|
||||
method: Method.GET,
|
||||
skipMessage: options.skipMessage
|
||||
});
|
||||
}
|
||||
|
||||
// 通过id获取文章
|
||||
export function articleDetail (id) {
|
||||
/** 通过文章 ID 获取正文;可由页面接管错误提示。 */
|
||||
export function articleDetail (id, options = {}) {
|
||||
return request({
|
||||
url: `/buyer/other/article/get/${id}`,
|
||||
method: Method.GET
|
||||
method: Method.GET,
|
||||
skipMessage: options.skipMessage
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
13
buyer/src/api/pickupCard.js
Normal file
13
buyer/src/api/pickupCard.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 提货卡选品、试算及一次兑换接口。
|
||||
* @author Bulbasaur
|
||||
* @since 2026-11-05
|
||||
*/
|
||||
import request, { Method } from '@/plugins/request.js';
|
||||
|
||||
/** 查询当前会员的卡规则及候选 SKU。 */
|
||||
export const getPickupCard = id => request({ url: `/buyer/promotion/pickupCard/${encodeURIComponent(id)}`, method: Method.GET, needToken: true });
|
||||
/** 服务端校验商品与地址,返回零现金试算。 */
|
||||
export const previewPickupCard = data => request({ url: '/buyer/promotion/pickupCard/preview', method: Method.POST, needToken: true, headers: { 'Content-Type': 'application/json' }, data: { ...data, client: 'PC' } });
|
||||
/** 一次兑换;重试返回同一卡已创建的交易。 */
|
||||
export const redeemPickupCard = data => request({ url: '/buyer/promotion/pickupCard/redeem', method: Method.POST, needToken: true, headers: { 'Content-Type': 'application/json' }, data: { ...data, client: 'PC' } });
|
||||
@@ -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);
|
||||
|
||||
@@ -449,12 +449,12 @@ onUnmounted(() => {
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: $theme_color;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
background: $theme_color;
|
||||
background: #409EFF;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<div>
|
||||
<span v-if="coupon.couponType === 'PRICE'" class="fontsize_12 global_color">¥<span class="price">{{ $filters.unitPrice(coupon.price) }}</span></span>
|
||||
<span v-if="coupon.couponType === 'DISCOUNT'" class="fontsize_12 global_color"><span class="price">{{coupon.couponDiscount}}</span>折</span>
|
||||
<span class="describe">满{{coupon.consumeThreshold}}元可用</span>
|
||||
<span class="describe">{{ coupon.consumeThreshold > 0 ? '满' + coupon.consumeThreshold + '元可用' : '无门槛使用' }}</span>
|
||||
</div>
|
||||
<p>使用范围:{{useScope(coupon.scopeType, coupon.storeName)}}</p>
|
||||
<p>有效期:{{coupon.endTime}}</p>
|
||||
|
||||
@@ -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([
|
||||
// 导航链接
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
v-if="promotionMap['SECKILL']"
|
||||
:time="promotionMap['SECKILL'].endTime"
|
||||
></Promotion>
|
||||
<!-- 商品详细 价格、优惠券、促销 -->
|
||||
<!-- 商品详细 价格 -->
|
||||
<div class="item-detail-price-row">
|
||||
<div class="item-price-left">
|
||||
<!-- 秒杀价格 -->
|
||||
@@ -89,9 +89,23 @@
|
||||
<div class="item-price-row" v-else>
|
||||
<span class="item-price">{{ $filters.unitPrice(skuDetail.price, "¥") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-price-right">
|
||||
<div class="item-remarks-sum">
|
||||
<p>累计评价</p>
|
||||
<p>
|
||||
<span class="item-remarks-num"
|
||||
>{{ skuDetail.commentNum || 0 }} 条</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 独立促销与优惠模块 -->
|
||||
<div class="goods-promotions-box" v-if="hasPromotions">
|
||||
<!-- 优惠券展示 -->
|
||||
<div class="item-price-coupon-row" v-if="promotionMap['COUPON'].length">
|
||||
<span class="item-price-title">优惠券</span>
|
||||
<div class="promo-item-row" v-if="promotionMap['COUPON'] && promotionMap['COUPON'].length">
|
||||
<span class="promo-title">优惠券</span>
|
||||
<div class="coupon-tags">
|
||||
<span
|
||||
class="item-coupon"
|
||||
@@ -100,7 +114,7 @@
|
||||
@click="handleReceiveCoupon(item.id)"
|
||||
>
|
||||
<span v-if="item.couponType == 'PRICE'"
|
||||
>满{{ item.consumeThreshold }}减{{ item.price }}</span>
|
||||
>{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '减' + item.price : '无门槛,立减' + item.price + '元' }}</span>
|
||||
<span v-if="item.couponType == 'DISCOUNT'"
|
||||
>满{{ item.consumeThreshold }}打{{
|
||||
item.couponDiscount
|
||||
@@ -117,7 +131,7 @@
|
||||
@click="handleReceiveCoupon(item.id)"
|
||||
>
|
||||
<span v-if="item.couponType == 'PRICE'"
|
||||
>满{{ item.consumeThreshold }}减{{ item.price }}</span>
|
||||
>{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '减' + item.price : '无门槛,立减' + item.price + '元' }}</span>
|
||||
<span v-if="item.couponType == 'DISCOUNT'"
|
||||
>满{{ item.consumeThreshold }}打{{
|
||||
item.couponDiscount
|
||||
@@ -127,66 +141,25 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 限时折扣 -->
|
||||
<div class="item-price-row" v-if="promotionMap['DISCOUNT']">
|
||||
<p>
|
||||
<span class="item-price-title">促销</span>
|
||||
<span class="item-promotion">限时折扣</span>
|
||||
<span class="item-desc-pintuan" v-if="promotionMap['DISCOUNT'].discount">
|
||||
享 {{ promotionMap["DISCOUNT"].discount }} 折
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- 第N件优惠 -->
|
||||
<div class="item-price-row" v-if="promotionMap['NTH']">
|
||||
<p>
|
||||
<span class="item-price-title">促销</span>
|
||||
<span class="item-promotion">第N件</span>
|
||||
<span class="item-desc-pintuan">
|
||||
第 {{ promotionMap["NTH"].nthNum || promotionMap["NTH"].num }} 件优惠
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<!-- 促销活动行(满减/满折/满赠、拼团) -->
|
||||
<div class="promo-item-row" v-if="promotionMap['FULL_DISCOUNT'] || promotionMap['PINTUAN']">
|
||||
<span class="promo-title">促 销</span>
|
||||
<div class="promo-content-column">
|
||||
<!-- 满减/满折/满赠 -->
|
||||
<div class="promo-activity-entry" v-if="promotionMap['FULL_DISCOUNT']">
|
||||
<span class="promo-badge">{{ getFullDiscountTag(promotionMap['FULL_DISCOUNT']) }}</span>
|
||||
<span class="promo-desc">{{ formatFullDiscountDesc(promotionMap['FULL_DISCOUNT']) }}</span>
|
||||
</div>
|
||||
<!-- 拼团 -->
|
||||
<div class="item-price-row" v-if="promotionMap['PINTUAN']">
|
||||
<p>
|
||||
<span class="item-price-title">拼团</span>
|
||||
<span class="item-promotion">拼团价</span>
|
||||
<span class="item-desc-pintuan" v-if="promotionMap['PINTUAN'].price">
|
||||
{{ $filters.unitPrice(promotionMap["PINTUAN"].price, "¥") }}
|
||||
<div class="promo-activity-entry" v-if="promotionMap['PINTUAN']">
|
||||
<span class="promo-badge">拼团</span>
|
||||
<span class="promo-desc">
|
||||
拼团价
|
||||
<span class="promo-price-text">{{ $filters.unitPrice(promotionMap["PINTUAN"].price, "¥") }}</span>
|
||||
,多人拼单更实惠
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- 满减展示 -->
|
||||
<div class="item-price-row" v-if="promotionMap['FULL_DISCOUNT']">
|
||||
<p>
|
||||
<span class="item-price-title">促销</span>
|
||||
<span class="item-promotion">满减</span>
|
||||
<span
|
||||
class="item-desc-pintuan"
|
||||
v-if="promotionMap['FULL_DISCOUNT'].fullMinus"
|
||||
>满{{ promotionMap["FULL_DISCOUNT"].fullMoney }}元,立减现金{{
|
||||
promotionMap["FULL_DISCOUNT"].fullMinus
|
||||
}}元</span>
|
||||
<span
|
||||
class="item-desc-pintuan"
|
||||
v-if="
|
||||
promotionMap['FULL_DISCOUNT'].fullRate &&
|
||||
promotionMap['FULL_DISCOUNT'].fullRateFlag
|
||||
"
|
||||
>满{{ promotionMap["FULL_DISCOUNT"].fullMoney }}元,立享{{
|
||||
promotionMap["FULL_DISCOUNT"].fullRate
|
||||
}}折</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-price-right">
|
||||
<div class="item-remarks-sum">
|
||||
<p>累计评价</p>
|
||||
<p>
|
||||
<span class="item-remarks-num"
|
||||
>{{ skuDetail.commentNum || 0 }} 条</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -384,6 +357,14 @@ const currentImg = computed(() => {
|
||||
return item.type === "video" ? item.poster : item.url || item;
|
||||
});
|
||||
|
||||
/** 是否包含有效促销/优惠(优惠券、满减/满折/满赠、拼团) */
|
||||
const hasPromotions = computed(() => {
|
||||
const hasCoupon = Array.isArray(promotionMap.value?.COUPON) && promotionMap.value.COUPON.length > 0;
|
||||
const hasFullDiscount = !!promotionMap.value?.FULL_DISCOUNT;
|
||||
const hasPintuan = !!promotionMap.value?.PINTUAN;
|
||||
return hasCoupon || hasFullDiscount || hasPintuan;
|
||||
});
|
||||
|
||||
|
||||
/** 卡密商品:禁加购,仅立即购买;库存读 quantity(M-02 起支持促销) */
|
||||
const isECouponGoods = computed(() => {
|
||||
@@ -553,6 +534,53 @@ function pointPay() {
|
||||
startCheckout(E_COUPON_CART_POINTS, "loading");
|
||||
}
|
||||
|
||||
function getFullDiscountTag(fd) {
|
||||
if (!fd) return "满优惠";
|
||||
if (fd.activityType === "FULL_RATE") return "满折";
|
||||
if (fd.activityType === "FULL_GIFT") return "满赠";
|
||||
if (fd.activityType === "FULL_MINUS") return "满减";
|
||||
if (fd.fullRateFlag) return "满折";
|
||||
if (fd.giftFlag) return "满赠";
|
||||
return "满减";
|
||||
}
|
||||
|
||||
function formatFullDiscountDesc(fd) {
|
||||
if (!fd) return "";
|
||||
let rules = fd.ruleList;
|
||||
if (!rules && fd.rulesJson) {
|
||||
try {
|
||||
rules = JSON.parse(fd.rulesJson);
|
||||
} catch (e) {
|
||||
rules = null;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(rules) && rules.length > 0) {
|
||||
return rules
|
||||
.map((rule) => {
|
||||
if (fd.activityType === "FULL_RATE") {
|
||||
return `满${rule.fullMoney}元打${rule.fullRate}折`;
|
||||
}
|
||||
if (fd.activityType === "FULL_GIFT") {
|
||||
const gifts = Array.isArray(rule.giftItems)
|
||||
? rule.giftItems
|
||||
.map((g) => `${g.goodsName || "赠品"}x${g.num || 1}`)
|
||||
.join("、")
|
||||
: "赠品";
|
||||
return `满${rule.fullMoney}元送${gifts}`;
|
||||
}
|
||||
return `满${rule.fullMoney}元减${rule.fullMinus}元`;
|
||||
})
|
||||
.join(";");
|
||||
}
|
||||
if (fd.fullMinusFlag && fd.fullMinus) {
|
||||
return `满${fd.fullMoney}元,立减现金${fd.fullMinus}元`;
|
||||
}
|
||||
if (fd.fullRateFlag && fd.fullRate) {
|
||||
return `满${fd.fullMoney}元,立享${fd.fullRate}折`;
|
||||
}
|
||||
return fd.title || "";
|
||||
}
|
||||
|
||||
function startCheckout(action, loadingKey) {
|
||||
const params = {
|
||||
num: count.value,
|
||||
@@ -926,6 +954,7 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -968,15 +997,70 @@ onBeforeUnmount(() => {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.item-price-coupon-row {
|
||||
/* 促销与优惠独立展示模块 */
|
||||
.goods-promotions-box {
|
||||
margin-top: 12px;
|
||||
padding: 12px 20px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.promo-item-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin: 8px 0px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.item-price-title {
|
||||
.promo-title {
|
||||
flex-shrink: 0;
|
||||
width: 56px;
|
||||
margin-right: 12px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.promo-content-column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.promo-activity-entry {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.promo-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: $theme_color;
|
||||
background: rgba(235, 69, 73, 0.08);
|
||||
border: 1px solid $theme_color;
|
||||
border-radius: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.promo-desc {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
line-height: 22px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.promo-price-text {
|
||||
color: $theme_color;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.coupon-tags {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
class="component-cell"
|
||||
|
||||
:style="getComponentCellStyle(comp.componentStyle, row.rowStyle, row.components ? row.components.length : 1)"
|
||||
:style="getComponentCellStyle(comp.componentStyle, row.rowStyle, row.components ? row.components.length : 1, comp.type)"
|
||||
|
||||
>
|
||||
|
||||
@@ -69,6 +69,10 @@ const props = defineProps({
|
||||
|
||||
|
||||
|
||||
const hasAutoComp = computed(() => {
|
||||
return Array.isArray(props.row.components) && props.row.components.some(c => ['goodsGrid', 'gridFloor', 'bannerFloor', 'goodsBar', 'channelFloor', 'goodsList', 'recommendFeed', 'articleContent', 'brandWall', 'channelGrid', 'couponList'].includes(c?.type));
|
||||
});
|
||||
|
||||
const rowWrapperStyle = computed(() => {
|
||||
const s = props.row.rowStyle || {};
|
||||
const style = {};
|
||||
@@ -96,10 +100,14 @@ const rowWrapperStyle = computed(() => {
|
||||
const h = String(s.height).trim();
|
||||
if (h && Number(h) > 0) {
|
||||
const hVal = /^\d+$/.test(h) ? `${h}px` : h;
|
||||
if (hasAutoComp.value) {
|
||||
style.minHeight = hVal;
|
||||
} else {
|
||||
style.height = hVal;
|
||||
style.minHeight = hVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 外边距 (margin)
|
||||
const isNarrow = style.width !== '100%';
|
||||
@@ -164,10 +172,14 @@ const rowInnerFlexStyle = computed(() => {
|
||||
const h = String(s.height).trim();
|
||||
if (h && Number(h) > 0) {
|
||||
const hVal = /^\d+$/.test(h) ? `${h}px` : h;
|
||||
if (hasAutoComp.value) {
|
||||
styleObj.minHeight = hVal;
|
||||
} else {
|
||||
styleObj.height = hVal;
|
||||
styleObj.minHeight = hVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return styleObj;
|
||||
|
||||
@@ -175,7 +187,8 @@ const rowInnerFlexStyle = computed(() => {
|
||||
|
||||
|
||||
|
||||
function getComponentCellStyle(style, rowStyle = {}, totalComps = 1) {
|
||||
function getComponentCellStyle(style, rowStyle = {}, totalComps = 1, compType = '') {
|
||||
const isAutoHeightComp = compType && ['goodsGrid', 'gridFloor', 'bannerFloor', 'goodsBar', 'channelFloor', 'goodsList', 'recommendFeed', 'articleContent', 'brandWall', 'channelGrid', 'couponList'].includes(compType);
|
||||
|
||||
if (!style) return { flex: '1 1 0%' };
|
||||
|
||||
@@ -185,10 +198,14 @@ function getComponentCellStyle(style, rowStyle = {}, totalComps = 1) {
|
||||
const rh = String(rowStyle.height).trim();
|
||||
if (rh && Number(rh) > 0) {
|
||||
const rhVal = /^\d+$/.test(rh) ? `${rh}px` : rh;
|
||||
if (isAutoHeightComp) {
|
||||
s.minHeight = rhVal;
|
||||
} else {
|
||||
s.height = rhVal;
|
||||
s.minHeight = rhVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
const gap = Number(rowStyle?.gap) || 0;
|
||||
const totalGap = (totalComps > 1 && gap > 0) ? gap * (totalComps - 1) : 0;
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ const brandList = computed(() => {
|
||||
{ title: '数码潮玩 · 爆款立减500', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_1.png', url: '/goodsList?category=1' },
|
||||
{ title: '美妆奢宠 · 每满300减50', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_2.png', url: '/goodsList?category=2' },
|
||||
{ title: '运动潮品 · 爆款低至5折', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_3.png', url: '/goodsList?category=3' },
|
||||
{ title: '品质生活 · 限时第2件半价', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_4.png', url: '/goodsList?category=4' }
|
||||
{ title: '品质生活 · 精选好物', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_4.png', url: '/goodsList?category=4' }
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -50,10 +50,10 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const defaultMenus = [
|
||||
{ title: '基本信息', url: '/home/UserInfo' },
|
||||
{ title: '基本信息', url: '/home/Profile' },
|
||||
{ title: '我的订单', url: '/home/MyOrder' },
|
||||
{ title: '账户安全', url: '/home/AccountSafe' },
|
||||
{ title: '消息', url: '/home/Msg' }
|
||||
{ title: '消息', url: '/home/MsgList' }
|
||||
];
|
||||
|
||||
const username = computed(() => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- 1. 用户头像与信息区 -->
|
||||
<div class="user-main-info">
|
||||
<div class="avatar-box">
|
||||
<img v-if="options.avatar" :src="options.avatar" alt="avatar" />
|
||||
<img v-if="displayAvatar" :src="displayAvatar" alt="用户头像" />
|
||||
<div v-else class="avatar-placeholder">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="#ffffff">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
@@ -12,18 +12,22 @@
|
||||
</div>
|
||||
|
||||
<div class="user-greeting">
|
||||
<div class="badge-tag">{{ options.badgeText || '请登录' }}</div>
|
||||
<div class="welcome-text" :title="options.welcomeText || '欢迎来到商城'">
|
||||
{{ options.welcomeText || '欢迎来到商城' }}
|
||||
<div class="badge-tag">{{ isLoggedIn ? '已登录' : (options.badgeText || '请登录') }}</div>
|
||||
<div class="welcome-text" :title="welcomeText">
|
||||
{{ welcomeText }}
|
||||
</div>
|
||||
<div class="sub-text" :title="options.subtitle || '登录后查看专属权益'">
|
||||
{{ options.subtitle || '登录后查看专属权益' }}
|
||||
<div class="sub-text" :title="subtitle">
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 登录 / 注册按钮组 -->
|
||||
<div class="action-btn-row">
|
||||
<!-- 已登录时提供会员入口,装修中的登录与注册文案仅用于游客。 -->
|
||||
<div v-if="isLoggedIn" class="action-btn-row">
|
||||
<router-link to="/home" class="action-btn login-btn">会员中心</router-link>
|
||||
<router-link to="/home/MyOrder" class="action-btn register-btn">我的订单</router-link>
|
||||
</div>
|
||||
<div v-else class="action-btn-row">
|
||||
<a href="javascript:;" class="action-btn login-btn" @click="handleNavigate(options.loginUrl || '/login')">
|
||||
{{ options.loginText || '登录' }}
|
||||
</a>
|
||||
@@ -35,19 +39,23 @@
|
||||
<!-- 3. 指标数据卡 (购物车 / 优惠券 / 订单) -->
|
||||
<div class="metrics-cards-row">
|
||||
<a href="javascript:;" class="metric-card" v-if="options.showCart !== false" @click="handleNavigate(options.cartUrl || '/cart')">
|
||||
<div class="metric-val">0</div>
|
||||
<div class="metric-val">{{ metricText('cart') }}</div>
|
||||
<div class="metric-label">{{ options.cartText || '购物车' }}</div>
|
||||
</a>
|
||||
<a href="javascript:;" class="metric-card" v-if="options.showCoupons !== false" @click="handleNavigate(options.couponUrl || '/member/coupon')">
|
||||
<div class="metric-val">0</div>
|
||||
<a href="javascript:;" class="metric-card" v-if="options.showCoupons !== false" @click="handleNavigate(metricUrl('coupons'))">
|
||||
<div class="metric-val">{{ metricText('coupons') }}</div>
|
||||
<div class="metric-label">{{ options.couponText || '优惠券' }}</div>
|
||||
</a>
|
||||
<a href="javascript:;" class="metric-card" v-if="options.showOrders !== false" @click="handleNavigate(options.orderUrl || '/member/order')">
|
||||
<div class="metric-val">0</div>
|
||||
<a href="javascript:;" class="metric-card" v-if="options.showOrders !== false" @click="handleNavigate(metricUrl('orders'))">
|
||||
<div class="metric-val">{{ metricText('orders') }}</div>
|
||||
<div class="metric-label">{{ options.orderText || '订单' }}</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button v-if="hasMetricError" type="button" class="metrics-retry" @click="loadMetrics">
|
||||
数量加载失败,点击重试
|
||||
</button>
|
||||
|
||||
<!-- 4. 快捷便民服务入口(底部图标排) -->
|
||||
<div class="quick-service-row">
|
||||
<a
|
||||
@@ -65,11 +73,17 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Star, Clock, Present, Service } from '@element-plus/icons-vue';
|
||||
import storage from '@/plugins/storage';
|
||||
import { cartCount } from '@/api/cart';
|
||||
import { memberCouponList } from '@/api/member';
|
||||
import { getOrderList } from '@/api/order';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
@@ -86,6 +100,113 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
const userInfo = ref({});
|
||||
const isLoggedIn = ref(false);
|
||||
|
||||
/** 读取现有登录缓存;缺少令牌时按游客展示,损坏的资料缓存不阻断首页。 */
|
||||
function syncLoginState() {
|
||||
isLoggedIn.value = Boolean(storage.getItem('accessToken'));
|
||||
userInfo.value = {};
|
||||
if (!isLoggedIn.value) return;
|
||||
try {
|
||||
const cached = JSON.parse(storage.getItem('userInfo') || '{}');
|
||||
userInfo.value = cached && typeof cached === 'object' ? cached : {};
|
||||
} catch {
|
||||
userInfo.value = {};
|
||||
}
|
||||
}
|
||||
|
||||
syncLoginState();
|
||||
|
||||
// 购物车沿用顶部购物车的有效 SKU 种数;优惠券统计未使用张数,订单统计全部订单。
|
||||
const metrics = reactive({
|
||||
cart: { status: 'idle', value: 0 },
|
||||
coupons: { status: 'idle', value: 0 },
|
||||
orders: { status: 'idle', value: 0 }
|
||||
});
|
||||
let metricRequestId = 0;
|
||||
let metricsLoading = false;
|
||||
const hasMetricError = computed(() => Object.values(metrics).some(item => item.status === 'error'));
|
||||
|
||||
/** 游客显示 0;已登录时区分加载、失败和真实的零值,购物车随公共状态更新。 */
|
||||
function metricText(key) {
|
||||
if (!isLoggedIn.value) return 0;
|
||||
const metric = metrics[key];
|
||||
if (metric.status === 'loading' || metric.status === 'idle') return '…';
|
||||
if (metric.status === 'error') return '—';
|
||||
return key === 'cart' ? appStore.cartNum : metric.value;
|
||||
}
|
||||
|
||||
/** 将装修默认的历史地址映射到现有列表,保留商家配置的其他自定义链接。 */
|
||||
function metricUrl(key) {
|
||||
const isCoupon = key === 'coupons';
|
||||
const configured = isCoupon ? props.options.couponUrl : props.options.orderUrl;
|
||||
const legacy = isCoupon ? '/member/coupon' : '/member/order';
|
||||
return !configured || configured === legacy
|
||||
? (isCoupon ? '/home/Coupons' : '/home/MyOrder')
|
||||
: configured;
|
||||
}
|
||||
|
||||
/** 并行读取汇总;失败独立展示,离开页面后忽略旧请求,重入首页时刷新数量。 */
|
||||
async function loadMetrics() {
|
||||
if (metricsLoading) return;
|
||||
syncLoginState();
|
||||
const requestId = ++metricRequestId;
|
||||
if (!isLoggedIn.value) {
|
||||
Object.values(metrics).forEach(item => { item.status = 'idle'; item.value = 0; });
|
||||
return;
|
||||
}
|
||||
metricsLoading = true;
|
||||
const page = { pageNumber: 1, pageSize: 1 };
|
||||
const queries = [
|
||||
['cart', props.options.showCart !== false, () => cartCount()],
|
||||
['coupons', props.options.showCoupons !== false, () => memberCouponList({ ...page, memberCouponStatus: 'NEW' })],
|
||||
['orders', props.options.showOrders !== false, () => getOrderList({ ...page, tag: 'ALL' })]
|
||||
];
|
||||
await Promise.all(queries.filter(([, visible]) => visible).map(async ([key, , fetchCount]) => {
|
||||
metrics[key].status = 'loading';
|
||||
try {
|
||||
const response = await fetchCount();
|
||||
const raw = key === 'cart' ? response?.result : response?.result?.total;
|
||||
const count = Number(raw);
|
||||
if (!response?.success || raw == null || !Number.isSafeInteger(count) || count < 0) {
|
||||
throw new Error('无效的数量响应');
|
||||
}
|
||||
if (requestId !== metricRequestId) return;
|
||||
metrics[key].value = count;
|
||||
metrics[key].status = 'ready';
|
||||
if (key === 'cart') {
|
||||
appStore.setCartNum(count);
|
||||
storage.setItem('cartNum', count);
|
||||
}
|
||||
} catch {
|
||||
if (requestId === metricRequestId) metrics[key].status = 'error';
|
||||
}
|
||||
}));
|
||||
if (requestId === metricRequestId) metricsLoading = false;
|
||||
}
|
||||
|
||||
/** 使离开首页前的异步响应失效,避免覆盖下一次进入后的数据。 */
|
||||
function invalidateMetrics() {
|
||||
metricRequestId += 1;
|
||||
metricsLoading = false;
|
||||
}
|
||||
|
||||
onMounted(loadMetrics);
|
||||
onActivated(loadMetrics);
|
||||
onDeactivated(invalidateMetrics);
|
||||
onBeforeUnmount(invalidateMetrics);
|
||||
|
||||
const displayAvatar = computed(() => isLoggedIn.value
|
||||
? userInfo.value.face
|
||||
: props.options.avatar);
|
||||
const welcomeText = computed(() => isLoggedIn.value
|
||||
? `你好,${userInfo.value.nickName || userInfo.value.username || '会员'}`
|
||||
: (props.options.welcomeText || '欢迎来到商城'));
|
||||
const subtitle = computed(() => isLoggedIn.value
|
||||
? '查看我的订单与专属权益'
|
||||
: (props.options.subtitle || '登录后查看专属权益'));
|
||||
|
||||
function handleNavigate(url) {
|
||||
if (!url) return;
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
@@ -96,10 +217,10 @@ function handleNavigate(url) {
|
||||
}
|
||||
|
||||
const defaultServices = [
|
||||
{ name: '收藏', url: '/member/favorites', icon: 'Star' },
|
||||
{ name: '足迹', url: '/member/footprint', icon: 'Clock' },
|
||||
{ name: '红包', url: '/couponCenter', icon: 'Present' },
|
||||
{ name: '客服', url: '/article', icon: 'Service' }
|
||||
{ name: '收藏', url: '/home/Favorites', icon: 'Star' },
|
||||
{ name: '足迹', url: '/home/MyTracks', icon: 'Clock' },
|
||||
{ name: '领券', url: '/coupon', icon: 'Present' },
|
||||
{ name: '帮助', url: '/article', icon: 'Service' }
|
||||
];
|
||||
|
||||
const iconMap = {
|
||||
@@ -114,12 +235,26 @@ function getServiceIcon(iconName) {
|
||||
}
|
||||
|
||||
const displayServices = computed(() => {
|
||||
if (Array.isArray(props.options?.services) && props.options.services.length > 0) {
|
||||
return props.options.services;
|
||||
}
|
||||
return defaultServices;
|
||||
const services = Array.isArray(props.options?.services) && props.options.services.length > 0
|
||||
? props.options.services
|
||||
: defaultServices;
|
||||
return services.map(normalizeService);
|
||||
});
|
||||
|
||||
/** 兼容装修中保存的旧默认入口,仅修正已知地址,不覆盖其他自定义链接。 */
|
||||
function normalizeService(service) {
|
||||
const legacyRoutes = {
|
||||
'/member/favorites': '/home/Favorites',
|
||||
'/member/footprint': '/home/MyTracks',
|
||||
'/couponCenter': '/coupon'
|
||||
};
|
||||
const url = legacyRoutes[service.url] || service.url;
|
||||
let name = service.name;
|
||||
if (name === '红包' && url === '/coupon') name = '领券';
|
||||
if (name === '客服' && url === '/article') name = '帮助';
|
||||
return { ...service, url, name };
|
||||
}
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
const hasRowHeight = Boolean(props.rowStyle?.height && Number(props.rowStyle.height) > 0);
|
||||
@@ -135,6 +270,14 @@ const customStyle = computed(() => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.metrics-retry {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #409eff;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.decor-user-benefits {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
33
buyer/src/composables/useSiteSettings.js
Normal file
33
buyer/src/composables/useSiteSettings.js
Normal 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 };
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export const E_COUPON_ORDER_TYPE = "E_COUPON";
|
||||
/** 单次购买数量上限(O-09 默认:min(quantity, 999)) */
|
||||
export const E_COUPON_MAX_BUY_NUM = 999;
|
||||
|
||||
/** 常规立即购买(秒杀/满减/券/限时/第N件等) */
|
||||
/** 常规立即购买(秒杀/满减/券等) */
|
||||
export const E_COUPON_CART_BUY_NOW = "BUY_NOW";
|
||||
export const E_COUPON_CART_PINTUAN = "PINTUAN";
|
||||
export const E_COUPON_CART_POINTS = "POINTS";
|
||||
@@ -72,8 +72,6 @@ export function createEmptyPromotionMap() {
|
||||
SECKILL: null,
|
||||
FULL_DISCOUNT: null,
|
||||
COUPON: [],
|
||||
DISCOUNT: null,
|
||||
NTH: null,
|
||||
PINTUAN: null,
|
||||
KANJIA: null,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { setupRequestErrorHandling } from "@/utils/requestError";
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import "core-js/stable";
|
||||
@@ -24,6 +25,7 @@ if (aMapSecurityJsCode) {
|
||||
}
|
||||
|
||||
const app = createApp(App);
|
||||
setupRequestErrorHandling(app);
|
||||
|
||||
setupElementPlus(app);
|
||||
setupLegacyMessage(app);
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
>¥{{ item.price }}</span>
|
||||
<span v-if="item.couponType === 'DISCOUNT'"
|
||||
>{{ item.couponDiscount }}折</span>
|
||||
<span>满{{ item.consumeThreshold }}元可用</span>
|
||||
<span>{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '元可用' : '无门槛使用' }}</span>
|
||||
<el-button
|
||||
class="coupon-btn"
|
||||
size="small"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="coupon-center">
|
||||
<BaseHeader></BaseHeader>
|
||||
<SearchHeader></SearchHeader>
|
||||
<div class="content">
|
||||
<h2 class="coupon-page-title">领券中心</h2>
|
||||
|
||||
@@ -48,6 +49,7 @@ import { onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Modal } from "@/utils/message";
|
||||
import storage from "@/plugins/storage";
|
||||
import SearchHeader from "@/components/indexDecorate/components/SearchHeader.vue";
|
||||
import { couponList, receiveCoupon } from "@/api/member.js";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
@@ -200,7 +200,7 @@ function handleSubmit () { // 提交密码
|
||||
Message.success('修改密码成功');
|
||||
router.push('login');
|
||||
}
|
||||
}).catch(() => { loading.value = false; });
|
||||
}).catch(() => { loading1.value = false; });
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<el-card shadow="never" v-for="(item, index) in autoCoupList" :key="index" style="margin: 10px 15px">
|
||||
<span v-if="item.couponType === 'PRICE'" class="fontsize_12 global_color" style="font-size: 15px;">¥<span class="price">{{ $filters.unitPrice(item.price) }}</span></span>
|
||||
<span v-if="item.couponType === 'DISCOUNT'" class="fontsize_12 global_color" style="font-size: 15px;"><span class="price">{{item.discount}}</span>折</span>
|
||||
<span class="describe" style="font-size: 15px;margin-left: 5px;">满{{item.consumeThreshold}}元可用</span>
|
||||
<span class="describe" style="font-size: 15px;margin-left: 5px;">{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '元可用' : '无门槛使用' }}</span>
|
||||
<p style="font-size: 14px;">使用范围:{{useScope(item.scopeType, item.storeName)}}</p>
|
||||
<p style="font-size: 14px;">有效期:{{item.endTime}}</p>
|
||||
</el-card>
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
<div class="layoutAll">
|
||||
<el-aside class="side-bar" width="220px">
|
||||
<div class="article-cate">文章分类</div>
|
||||
<p v-if="categoryError" role="alert" class="load-message">
|
||||
分类加载失败,可先浏览全部文章。
|
||||
<el-button link type="primary" :loading="categoryLoading" @click="getCateList">重试分类</el-button>
|
||||
</p>
|
||||
<el-button link type="primary" @click="onSelect('')">全部文章</el-button>
|
||||
<el-menu
|
||||
class="side-menu"
|
||||
:key="menuKey"
|
||||
@@ -31,14 +36,22 @@
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-main class="content">
|
||||
<ul class="article-list" v-show="showList">
|
||||
<div v-if="contentError" role="alert" class="load-message">
|
||||
{{ contentError }}
|
||||
<el-button link type="primary" @click="retryContent">重新加载</el-button>
|
||||
</div>
|
||||
<el-skeleton v-if="contentLoading" :rows="5" animated />
|
||||
<ul class="article-list" v-show="showList && !contentLoading && !contentError">
|
||||
<li v-if="!articleList.length" class="article-empty">暂无文章</li>
|
||||
<li v-for="(article, index) in articleList" :key="article.id || index" @click="getDetail(article.id)">
|
||||
{{ article.title }}
|
||||
</li>
|
||||
</ul>
|
||||
<div v-show="!showList" class="article-detail">
|
||||
<button type="button" class="back-btn" @click="showList = true">返回列表</button>
|
||||
<el-pagination v-if="showList && !contentLoading && !contentError && total > params.pageSize"
|
||||
:current-page="params.pageNumber" :page-size="params.pageSize" :total="total"
|
||||
layout="prev, pager, next" @current-change="changePage" />
|
||||
<div v-show="!showList && !contentLoading && !contentError" class="article-detail">
|
||||
<button type="button" class="back-btn" @click="getList()">返回列表</button>
|
||||
<h2>{{ detail.title }}</h2>
|
||||
<div class="article-html" v-html="detail.content"></div>
|
||||
</div>
|
||||
@@ -60,82 +73,111 @@ defineOptions({ name: 'ArticleIndex' })
|
||||
const route = useRoute()
|
||||
|
||||
const list = ref([])
|
||||
const detail = ref('')
|
||||
const detail = ref({})
|
||||
const articleList = ref([])
|
||||
const activeName = ref('1347456734864367616')
|
||||
const activeName = ref('')
|
||||
const openName = ref([])
|
||||
const menuKey = ref(0)
|
||||
const params = reactive({
|
||||
pageNumber: 1,
|
||||
pageSize: 100,
|
||||
categoryId: '',
|
||||
sort: 'sort'
|
||||
})
|
||||
const params = reactive({ pageNumber: 1, pageSize: 20, categoryId: '', sort: 'sort' })
|
||||
const showList = ref(true)
|
||||
const total = ref(0)
|
||||
const categoryError = ref(false)
|
||||
const categoryLoading = ref(false)
|
||||
const contentError = ref('')
|
||||
const contentLoading = ref(false)
|
||||
const detailId = ref('')
|
||||
let contentRequestId = 0
|
||||
|
||||
/** 切换分类时重置分页,并使之前的列表或正文请求失效。 */
|
||||
function onSelect(id) {
|
||||
getList(id)
|
||||
detail.value = ''
|
||||
showList.value = true
|
||||
activeName.value = id
|
||||
params.pageNumber = 1
|
||||
return getList(id)
|
||||
}
|
||||
|
||||
function getCateList () {
|
||||
articleCateList().then(res => {
|
||||
if (res.success) {
|
||||
list.value = res.result || []
|
||||
openName.value = []
|
||||
if (route.query.id && detail.value && detail.value.categoryId) {
|
||||
activeName.value = detail.value.categoryId
|
||||
list.value.forEach(e => {
|
||||
const children = e.children || []
|
||||
const hitParent = String(e.id) === String(activeName.value)
|
||||
const hitChild = children.some(i => i && String(i.id) === String(activeName.value))
|
||||
if ((hitParent || hitChild) && children.length) {
|
||||
openName.value.push(String(e.id))
|
||||
}
|
||||
})
|
||||
} else if (list.value[0] && list.value[0].id) {
|
||||
activeName.value = list.value[0].id
|
||||
if (list.value[0].children && list.value[0].children.length) {
|
||||
openName.value.push(String(list.value[0].id))
|
||||
}
|
||||
}
|
||||
/** 独立加载分类;分类服务不可用时保留全部文章入口,不阻断正文和列表。 */
|
||||
async function getCateList() {
|
||||
if (categoryLoading.value) return
|
||||
categoryLoading.value = true
|
||||
categoryError.value = false
|
||||
try {
|
||||
const res = await articleCateList({ skipMessage: true })
|
||||
if (!res.success || !Array.isArray(res.result)) throw new Error('分类响应无效')
|
||||
list.value = res.result
|
||||
openName.value = list.value.filter(item => Array.isArray(item.children) && item.children.length)
|
||||
.map(item => String(item.id))
|
||||
menuKey.value++
|
||||
if (activeName.value) {
|
||||
getList(activeName.value)
|
||||
} catch {
|
||||
categoryError.value = true
|
||||
} finally {
|
||||
categoryLoading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getList (id) {
|
||||
params.categoryId = id
|
||||
fetchArticleList(params).then(res => {
|
||||
if (res.success) {
|
||||
/** 按分类分页查询公开文章;只接收最新一次请求结果,失败不冒充空列表。 */
|
||||
async function getList(id = activeName.value) {
|
||||
const requestId = ++contentRequestId
|
||||
showList.value = true
|
||||
contentLoading.value = true
|
||||
contentError.value = ''
|
||||
params.categoryId = id || ''
|
||||
try {
|
||||
const res = await fetchArticleList({ ...params }, { skipMessage: true })
|
||||
if (!res.success || !Array.isArray(res.result?.records)) throw new Error('列表响应无效')
|
||||
if (requestId !== contentRequestId) return
|
||||
articleList.value = res.result.records
|
||||
total.value = Number(res.result.total) || 0
|
||||
} catch {
|
||||
if (requestId === contentRequestId) contentError.value = '文章加载失败,请稍后重试。'
|
||||
} finally {
|
||||
if (requestId === contentRequestId) contentLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 加载指定文章;失败可原地重试,过时的正文响应不得覆盖用户新选择。 */
|
||||
async function getDetail(id) {
|
||||
await articleDetail(id).then(res => {
|
||||
if (res.success) {
|
||||
detail.value = res.result
|
||||
const requestId = ++contentRequestId
|
||||
detailId.value = id
|
||||
showList.value = false
|
||||
contentLoading.value = true
|
||||
contentError.value = ''
|
||||
try {
|
||||
const res = await articleDetail(id, { skipMessage: true })
|
||||
if (!res.success || !res.result) throw new Error('文章不存在')
|
||||
if (requestId !== contentRequestId) return
|
||||
detail.value = res.result
|
||||
} catch {
|
||||
if (requestId === contentRequestId) contentError.value = '文章详情加载失败或已不存在,请稍后重试。'
|
||||
} finally {
|
||||
if (requestId === contentRequestId) contentLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const articleId = route.query.id
|
||||
if (articleId) {
|
||||
await getDetail(articleId)
|
||||
/** 重试当前列表或正文,保留分类和分页条件。 */
|
||||
function retryContent() {
|
||||
return showList.value ? getList() : getDetail(detailId.value)
|
||||
}
|
||||
|
||||
/** 切换文章列表页码,沿用当前分类。 */
|
||||
function changePage(page) {
|
||||
params.pageNumber = page
|
||||
return getList()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getCateList()
|
||||
if (route.query.id) getDetail(route.query.id)
|
||||
else getList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.load-message {
|
||||
padding: 12px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 0 auto;
|
||||
padding: 20px 0 40px;
|
||||
|
||||
@@ -137,6 +137,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { catchHandledRequestError } from "@/utils/requestError";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "@/utils/message";
|
||||
@@ -230,6 +231,8 @@ function handleSubmit() {
|
||||
params.content = "";
|
||||
getDetail();
|
||||
}
|
||||
}).catch(catchHandledRequestError).finally(() => {
|
||||
submitLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
51
buyer/src/pages/home/memberCenter/MemberBenefitPanel.vue
Normal file
51
buyer/src/pages/home/memberCenter/MemberBenefitPanel.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<section class="benefits-panel">
|
||||
<h3>会员权益</h3>
|
||||
<div class="grade-options">
|
||||
<button v-for="item in enabledGrades" :key="item.grade.id" :class="{active: selectedId === item.grade.id}" @click="selectedId = item.grade.id">
|
||||
{{ item.grade.gradeName }}<small v-if="item.grade.id === currentGradeId">当前等级</small>
|
||||
</button>
|
||||
</div>
|
||||
<p class="benefit-subtitle">{{ selected?.grade?.gradeName || '暂无等级' }} · 点击查看权益说明</p>
|
||||
<el-empty v-if="!selected?.benefits?.length" description="暂无权益" />
|
||||
<div v-else class="benefits-grid">
|
||||
<button v-for="benefit in selected.benefits" :key="benefit.id" class="benefit-card" :class="{locked: !owned(benefit)}" @click="detail = benefit">
|
||||
<img v-if="benefit.benefitLogo" :src="benefit.benefitLogo" alt="" />
|
||||
<strong>{{ benefit.benefitName }}</strong>
|
||||
<span>{{ owned(benefit) ? '已享有' : '该等级专享' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<el-dialog :model-value="!!detail" :title="detail?.benefitName || '权益说明'" width="520px" @close="detail = null">
|
||||
<template v-if="detail">
|
||||
<p>{{ owned(detail) ? '当前等级已享有该权益' : '此权益属于所选等级,当前尚未享有' }}</p>
|
||||
<p v-if="detail.benefitType === 'POINT_MULTIPLIER'">购物积分倍率:{{ config(detail).multiplier || 1 }} 倍,同类权益取最高值。</p>
|
||||
<p v-if="detail.benefitType === 'GIFT_POINT'">首次达到等级赠送 {{ config(detail).giftPoint || 0 }} 积分。</p>
|
||||
<div class="benefit-description" v-html="detail.benefitDesc || '暂无说明'" />
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
const props = defineProps({gradeDetails:{type:Array,default:()=>[]},currentGradeId:{type:String,default:''}})
|
||||
const selectedId = ref(''), detail = ref(null)
|
||||
const enabledGrades = computed(()=>props.gradeDetails.filter(item=>item.grade?.gradeState === 'OPEN'))
|
||||
const selected = computed(()=>enabledGrades.value.find(item=>item.grade.id === selectedId.value))
|
||||
const current = computed(()=>enabledGrades.value.find(item=>item.grade.id === props.currentGradeId))
|
||||
const owned = benefit => (current.value?.benefits || []).some(item=>item.id === benefit.id)
|
||||
function config(benefit) { try { return JSON.parse(benefit.benefitConfig || '{}') } catch { return {} } }
|
||||
watch([()=>props.currentGradeId,enabledGrades],()=>{
|
||||
if(!selectedId.value || !enabledGrades.value.some(item=>item.grade.id===selectedId.value)) selectedId.value=props.currentGradeId || enabledGrades.value[0]?.grade.id || ''
|
||||
},{immediate:true})
|
||||
</script>
|
||||
<style scoped>
|
||||
.benefits-panel { margin:20px 0; padding:24px; background:#fff; border:1px solid #eee; border-radius:8px; }
|
||||
h3 { margin:0 0 20px; font-size:18px; } .grade-options { display:flex; flex-wrap:wrap; gap:10px; }
|
||||
.grade-options button { padding:10px 18px; border:1px solid #ddd; background:#fff; border-radius:6px; cursor:pointer; }
|
||||
.grade-options button.active { background:#fff8eb; border-color:#c99a47; color:#866021; } small { margin-left:8px; }
|
||||
.benefit-subtitle { color:#777; margin:20px 0; }.benefits-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr)); gap:16px; }
|
||||
.benefit-card { padding:20px 10px; display:flex; align-items:center; flex-direction:column; gap:12px; border:1px solid #f0e3cb; background:#fffcf6; border-radius:8px; cursor:pointer; }
|
||||
.benefit-card img { width:48px; height:48px; object-fit:contain; }.benefit-card span { font-size:12px; color:#986d2a; }
|
||||
.benefit-card.locked { background:#fafafa; border-color:#eee; color:#888; }.locked img { filter:grayscale(1); opacity:.5; }
|
||||
.benefit-description { line-height:1.8; overflow-wrap:anywhere; }.benefit-description :deep(img) { max-width:100%; }
|
||||
</style>
|
||||
@@ -25,6 +25,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MemberBenefitPanel :grade-details="gradeDetails" :current-grade-id="String(currentGradeId)" />
|
||||
|
||||
<div class="grade-panel">
|
||||
<div class="grade-tabs">
|
||||
<div class="grade-tab" :class="{ active: activeGradeTab === 'rules' }" @click="activeGradeTab = 'rules'">获取经验值方式</div>
|
||||
@@ -87,6 +89,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import MemberBenefitPanel from './MemberBenefitPanel.vue'
|
||||
import { memberGradeCurrent, memberGradeList, memberGradeRules } from '@/api/member.js'
|
||||
|
||||
defineOptions({ name: 'MemberGrade' })
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
</div>
|
||||
</template>
|
||||
<!-- 列表 -->
|
||||
<empty v-if="orderList.length === 0"/>
|
||||
<el-skeleton v-if="spinShow" :rows="5" animated />
|
||||
<el-empty v-else-if="queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
|
||||
<empty v-else-if="orderList.length === 0" />
|
||||
<div class="order-content" v-else>
|
||||
<div class="order-cols">
|
||||
<span>订单详情</span>
|
||||
@@ -77,7 +79,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
|
||||
|
||||
</div>
|
||||
<!-- 分页 -->
|
||||
<div class="page-size">
|
||||
@@ -134,6 +136,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { catchHandledRequestError } from "@/utils/requestError";
|
||||
import { nextTick, onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message, Modal } from "@/utils/message";
|
||||
@@ -157,6 +160,8 @@ const params = reactive({ // 请求参数
|
||||
order: 'desc'
|
||||
});
|
||||
const total = ref(0); // 订单总数
|
||||
const queryFailed = ref(false); // 区分加载失败与暂无记录,支持重新查询
|
||||
let listRequestVersion = 0; // 仅允许最后一次查询更新列表与加载状态
|
||||
const spinShow = ref(false); // 加载状态
|
||||
const companyList = ref([]); // 物流公司列表
|
||||
const logisticsShow = ref(false); // 物流信息modal
|
||||
@@ -230,14 +235,27 @@ function change(index) {
|
||||
getList()
|
||||
}
|
||||
function getList() { // 获取售后列表
|
||||
const version = ++listRequestVersion;
|
||||
queryFailed.value = false;
|
||||
spinShow.value = true;
|
||||
let paramsData = JSON.parse(JSON.stringify(params))
|
||||
afterSaleList(paramsData).then(res => {
|
||||
spinShow.value = false
|
||||
if (version !== listRequestVersion) return;
|
||||
if (res.success) {
|
||||
orderList.value = res.result.records;
|
||||
total.value = res.result.total;
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (version !== listRequestVersion) {
|
||||
catchHandledRequestError(error);
|
||||
return;
|
||||
}
|
||||
queryFailed.value = true;
|
||||
orderList.value = [];
|
||||
total.value = 0;
|
||||
catchHandledRequestError(error);
|
||||
}).finally(() => {
|
||||
if (version === listRequestVersion) spinShow.value = false;
|
||||
});
|
||||
}
|
||||
function changePageNum(val) { // 修改页码
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
</template>
|
||||
|
||||
<!-- 订单列表 -->
|
||||
<empty v-if="orderList.length === 0" />
|
||||
<el-skeleton v-if="spinShow" :rows="5" animated />
|
||||
<el-empty v-else-if="queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
|
||||
<empty v-else-if="orderList.length === 0" />
|
||||
<div class="order-content" v-else>
|
||||
<div class="order-cols">
|
||||
<span>订单详情</span>
|
||||
@@ -65,7 +67,10 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="goods-info">
|
||||
<div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">{{ goods.name }}</div>
|
||||
<div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">
|
||||
<el-tag v-if="isGiftItem(goods)" size="small" type="danger" effect="plain" class="mr_5">赠品</el-tag>
|
||||
{{ goods.name }}
|
||||
</div>
|
||||
<div class="goods-num">共{{ goods.num }}件</div>
|
||||
<div class="goods-extra">
|
||||
<el-tag v-if="refundPriceList(goods.isRefund) && !isUnpaid(order)" type="warning" size="small">{{ refundPriceList(goods.isRefund) }}</el-tag>
|
||||
@@ -91,7 +96,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
@@ -103,7 +108,9 @@
|
||||
</UserCenterLayout>
|
||||
|
||||
<!-- 个人中心首页订单列表 -->
|
||||
<empty v-if="homePage && orderList.length === 0" />
|
||||
<el-skeleton v-if="homePage && spinShow" :rows="5" animated />
|
||||
<el-empty v-else-if="homePage && queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
|
||||
<empty v-else-if="homePage && orderList.length === 0" />
|
||||
<div class="order-content" v-else-if="homePage">
|
||||
<div class="order-cols">
|
||||
<span>订单详情</span>
|
||||
@@ -147,7 +154,10 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="goods-info">
|
||||
<div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">{{ goods.name }}</div>
|
||||
<div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">
|
||||
<el-tag v-if="isGiftItem(goods)" size="small" type="danger" effect="plain" class="mr_5">赠品</el-tag>
|
||||
{{ goods.name }}
|
||||
</div>
|
||||
<div class="goods-num">共{{ goods.num }}件</div>
|
||||
<div class="goods-extra">
|
||||
<el-tag v-if="refundPriceList(goods.isRefund) && !isUnpaid(order)" type="warning" size="small">{{ refundPriceList(goods.isRefund) }}</el-tag>
|
||||
@@ -173,9 +183,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
|
||||
|
||||
</div>
|
||||
<el-dialog v-model="cancelAvail" title="请选择取消订单原因">
|
||||
<el-alert v-if="cancelIsPickup" title="此订单使用提货卡兑换,取消不退还提货权益,无法再次兑换。" type="warning" :closable="false" style="margin-bottom:16px" />
|
||||
<el-radio-group v-model="cancelParams.reason">
|
||||
<el-radio :label="item.reason" v-for="item in cancelReason" :key="item.id">
|
||||
{{ item.reason }}
|
||||
@@ -192,6 +203,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { catchHandledRequestError } from "@/utils/requestError";
|
||||
import { onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message, Modal } from "@/utils/message";
|
||||
@@ -226,11 +238,18 @@ const cancelParams = reactive({ // 取消售后参数
|
||||
const changeWay = ref(['全部', '待付款', '待发货', '待收货/使用']);
|
||||
const activeOrderTab = ref(0);
|
||||
const total = ref(0); // 数据总数
|
||||
const queryFailed = ref(false); // 区分加载失败与暂无记录,支持重新查询
|
||||
let listRequestVersion = 0; // 仅允许最后一次查询更新列表与加载状态
|
||||
const spinShow = ref(false); // 加载状态
|
||||
const cancelIsPickup = ref(false);
|
||||
const cancelAvail = ref(false); // 取消订单modal控制
|
||||
const cancelReason = ref([]); // 取消订单原因
|
||||
|
||||
// 退款状态枚举
|
||||
function isGiftItem(goods) {
|
||||
if (!goods) return false;
|
||||
return goods.promotionType === 'GIFT' || (Number(goods.flowPrice) === 0 && goods.promotionType === 'GIFT');
|
||||
}
|
||||
function refundPriceList(status) {
|
||||
switch (status) {
|
||||
case 'ALL_REFUND':
|
||||
@@ -320,17 +339,30 @@ function delOrder (sn) { // 删除订单
|
||||
});
|
||||
}
|
||||
function getList () {
|
||||
const version = ++listRequestVersion;
|
||||
queryFailed.value = false;
|
||||
spinShow.value = true;
|
||||
let _params = JSON.parse(JSON.stringify(params))
|
||||
if (_params.orderStatus === 'ALL') {
|
||||
delete _params.orderStatus
|
||||
}
|
||||
getOrderList(_params).then(res => {
|
||||
spinShow.value = false
|
||||
if (version !== listRequestVersion) return;
|
||||
if (res.success) {
|
||||
orderList.value = res.result.records;
|
||||
total.value = res.result.total;
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (version !== listRequestVersion) {
|
||||
catchHandledRequestError(error);
|
||||
return;
|
||||
}
|
||||
queryFailed.value = true;
|
||||
orderList.value = [];
|
||||
total.value = 0;
|
||||
catchHandledRequestError(error);
|
||||
}).finally(() => {
|
||||
if (version === listRequestVersion) spinShow.value = false;
|
||||
});
|
||||
}
|
||||
function mapAfterSaleOrder (item) {
|
||||
@@ -365,6 +397,7 @@ function changePageSize (val) { // 修改页数
|
||||
}
|
||||
function handleCancelOrder (sn) {
|
||||
// 取消订单
|
||||
cancelIsPickup.value = !!orderList.value.find(order => order.sn === sn)?.pickupCardId;
|
||||
cancelParams.orderSn = sn;
|
||||
afterSaleReason('CANCEL').then(res => {
|
||||
if (res.success) {
|
||||
|
||||
@@ -269,17 +269,27 @@
|
||||
@click="goodsDetail(goods.skuId, goods.goodsId)"
|
||||
class="hover-color goods-name"
|
||||
>
|
||||
<el-tag v-if="isGiftItem(goods)" size="small" type="danger" effect="plain" class="mr_5">赠品</el-tag>
|
||||
{{ goods.goodsName }}
|
||||
</p>
|
||||
<p class="goods-sn">货号:{{ goods.id }}</p>
|
||||
</div>
|
||||
<div class="goods-price">
|
||||
<template v-if="isGiftItem(goods)">
|
||||
<span class="unit-price gift-original-price">
|
||||
{{ $filters.unitPrice(goods.goodsPrice, "¥") }}
|
||||
</span>
|
||||
<span class="subtotal gift-subtotal">赠品 (¥0.00 × {{ goods.num }})</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="unit-price">
|
||||
{{ $filters.unitPrice(goods.goodsPrice, "¥") }} × {{ goods.num }}
|
||||
</span>
|
||||
<span class="subtotal">{{ $filters.unitPrice((goods.goodsPrice * goods.num), "¥") }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="goods-actions">
|
||||
<template v-if="!isGiftItem(goods)">
|
||||
<button
|
||||
v-if="goods.commentStatus == 'UNFINISHED'"
|
||||
type="button"
|
||||
@@ -303,6 +313,10 @@
|
||||
size="small"
|
||||
>申请售后</el-button
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="gift-action-tip">随单赠送,不支持售后</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -341,6 +355,7 @@
|
||||
v-model="cancelAvail"
|
||||
title="请选择取消订单原因"
|
||||
>
|
||||
<el-alert v-if="order.order?.pickupCardId" title="此订单使用提货卡兑换,取消不退还提货权益,无法再次兑换。" type="warning" :closable="false" style="margin-bottom:16px" />
|
||||
<el-radio-group
|
||||
v-model="cancelParams.reason"
|
||||
>
|
||||
@@ -725,6 +740,11 @@ function goPay(sn) {
|
||||
});
|
||||
}
|
||||
|
||||
function isGiftItem(goods) {
|
||||
if (!goods) return false;
|
||||
return goods.promotionType === 'GIFT' || (Number(goods.flowPrice) === 0 && goods.promotionType === 'GIFT');
|
||||
}
|
||||
|
||||
function applyAfterSale(sn) {
|
||||
// 申请售后
|
||||
router.push({ name: "ApplyAfterSale", query: { sn: sn } });
|
||||
@@ -1177,4 +1197,16 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
.gift-original-price {
|
||||
text-decoration: line-through;
|
||||
color: #999;
|
||||
}
|
||||
.gift-subtotal {
|
||||
color: #e4393c;
|
||||
font-weight: bold;
|
||||
}
|
||||
.gift-action-tip {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
v-if="item.couponType === 'DISCOUNT'"
|
||||
class="global_color coupon-amount"
|
||||
><span class="price">{{ item.discount }}</span>折</span>
|
||||
<span class="describe">满{{ item.consumeThreshold }}元可用</span>
|
||||
<span class="describe">{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '元可用' : '无门槛使用' }}</span>
|
||||
</div>
|
||||
<p>使用范围:{{ useScope(item.scopeType, item.storeName) }}</p>
|
||||
<p class="coupon-expire">有效期:{{ formatValidity(item) }}</p>
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
<template>
|
||||
<div class="wrapper my-gift-cards">
|
||||
<UserCenterLayout>
|
||||
|
||||
<div class="gift-page-heading">
|
||||
<div><h2>我的礼品卡</h2><p>查看卡片权益,兑换你的心意好礼</p></div>
|
||||
</div>
|
||||
<div class="bind-section mb_20">
|
||||
<div class="section-title">兑换礼品卡</div>
|
||||
<el-form ref="bindFormRef" :model="bindForm" :rules="bindRules" inline class="bind-form">
|
||||
<el-form ref="bindFormRef" :model="bindForm" :rules="bindRules" class="bind-form">
|
||||
<el-form-item label="卡号" prop="cardNo" label-width="60px">
|
||||
<el-input v-model="bindForm.cardNo" placeholder="请输入卡号" maxlength="64" style="width: 220px" />
|
||||
<el-input v-model="bindForm.cardNo" placeholder="请输入卡号" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="兑换码" prop="cardSecret" label-width="70px">
|
||||
<el-input
|
||||
v-model="bindForm.cardSecret"
|
||||
placeholder="请输入兑换码(卡密)"
|
||||
maxlength="128"
|
||||
style="width: 220px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label-width="0px">
|
||||
@@ -25,15 +26,20 @@
|
||||
<el-tabs v-model="statusTab" class="status-tabs mb_16" @tab-click="(tab) => onStatusTab(tab.paneName)">
|
||||
<el-tab-pane label="可用" name="AVAILABLE" />
|
||||
<el-tab-pane label="不可用" name="UNAVAILABLE" />
|
||||
<el-tab-pane label="待激活" name="PENDING_ACTIVATION" />
|
||||
<el-tab-pane name="PENDING_ACTIVATION">
|
||||
<template #label>
|
||||
<span class="pending-tab-label">待激活<span v-if="pendingCount !== null" class="pending-count">{{ pendingCount }}</span></span>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div class="card-list-wrap">
|
||||
<el-skeleton v-if="loading" fix />
|
||||
<el-skeleton v-if="loading" :rows="5" animated />
|
||||
<template v-if="!loading">
|
||||
<empty v-if="list.length === 0" />
|
||||
<div v-if="loadFailed" class="gift-load-error"><p>卡片加载失败,请稍后重试</p><el-button @click="getList">重新加载</el-button></div>
|
||||
<el-empty v-else-if="list.length === 0" :description="emptyDescription" :image-size="100" />
|
||||
<div v-else class="gift-card-grid">
|
||||
<div v-for="item in list" :key="item.id || item.cardNo" class="gcc-card-item" :class="{ 'is-unavailable': statusTab === 'UNAVAILABLE' }">
|
||||
<div v-for="item in list" :key="item.id || item.cardNo" class="gcc-card-item" :class="{ 'is-unavailable': statusTab === 'UNAVAILABLE', 'is-pickup': item.cardType === 'PICKUP' }">
|
||||
<div class="gcc-card-header">
|
||||
<div class="gcc-header-pattern" aria-hidden="true" />
|
||||
<div class="gcc-header-inner">
|
||||
@@ -42,17 +48,19 @@
|
||||
<div class="gcc-title">{{ item.giftCardName || "礼品卡" }}</div>
|
||||
<span class="gcc-status-chip">{{ statusChipLabel }}</span>
|
||||
</div>
|
||||
<div class="gcc-face"><span class="gcc-face-symbol">¥</span>{{ formatYuan(item.faceValue) }}</div>
|
||||
<div class="gcc-cardno">{{ item.cardNo }}</div>
|
||||
<div v-if="item.cardType === 'PICKUP'" class="gcc-face">兑换 <strong>{{ item.pickupCount }}</strong> 种商品</div>
|
||||
<div v-else class="gcc-face"><span class="gcc-face-symbol">¥</span>{{ formatYuan(item.faceValue) }}</div>
|
||||
<div class="gcc-cardno">卡号 {{ item.cardNo }}</div>
|
||||
<div class="gcc-expire">{{ formatValidity(item) }}</div>
|
||||
</div>
|
||||
<div class="gcc-header-right">
|
||||
<div class="gcc-type">现金卡</div>
|
||||
<div class="gcc-expire">{{ formatValidity(item) }}</div>
|
||||
<div class="gcc-type">{{ item.cardType === 'PICKUP' ? '提货卡' : '现金卡' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gcc-card-body">
|
||||
<div class="gcc-row">
|
||||
<div v-if="item.cardType === 'PICKUP'" class="gcc-row">每种一件 · 一次兑完 · 免邮</div>
|
||||
<div v-else class="gcc-row">
|
||||
当前余额:<span class="gcc-strong" v-if="item.balance != null">¥{{ $filters.unitPrice(item.balance) }}</span><span class="gcc-strong" v-else>—</span>
|
||||
</div>
|
||||
|
||||
@@ -112,6 +120,10 @@ const router = useRouter();
|
||||
const statusTab = ref("AVAILABLE");
|
||||
const loading = ref(false);
|
||||
const total = ref(0);
|
||||
const pendingCount = ref(null);
|
||||
const loadFailed = ref(false);
|
||||
let listRequestId = 0;
|
||||
let pendingCountRequestId = 0;
|
||||
const list = ref([]);
|
||||
const params = reactive({
|
||||
pageNumber: 1,
|
||||
@@ -130,7 +142,6 @@ const bindRules = {
|
||||
cardSecret: [{ required: true, message: "请输入兑换码", trigger: "blur" }],
|
||||
};
|
||||
const noticeModal = ref(false);
|
||||
const serviceHotline = ref("12221111");
|
||||
const activatingId = ref("");
|
||||
const bindFormRef = ref(null);
|
||||
|
||||
@@ -153,11 +164,30 @@ const statusChipLabel = computed(() => {
|
||||
const map = { AVAILABLE: "可用", UNAVAILABLE: "不可用", PENDING_ACTIVATION: "待激活" };
|
||||
return map[statusTab.value] || "";
|
||||
});
|
||||
const emptyDescription = computed(() => ({
|
||||
AVAILABLE: "暂无可用礼品卡,可输入卡号和兑换码添加",
|
||||
UNAVAILABLE: "暂无不可用礼品卡",
|
||||
PENDING_ACTIVATION: "暂无待激活礼品卡",
|
||||
}[statusTab.value]));
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
refreshPendingCount();
|
||||
});
|
||||
|
||||
/** 复用会员分页接口的 total 获取全部待激活数量,独立于当前标签和每页条数。 */
|
||||
async function refreshPendingCount() {
|
||||
const requestId = ++pendingCountRequestId;
|
||||
try {
|
||||
const res = await getGiftCardCashMemberCardPage({ memberCardStatus: "PENDING_ACTIVATION", pageNumber: 1, pageSize: 1 });
|
||||
if (requestId === pendingCountRequestId && res.success && res.result) {
|
||||
pendingCount.value = Number(res.result.total) || 0;
|
||||
}
|
||||
} catch {
|
||||
// 统计失败时保留已知数量,不将未知数量显示成零。
|
||||
}
|
||||
}
|
||||
|
||||
function formatYuan(val) {
|
||||
if (val == null || val === "") {
|
||||
return "—";
|
||||
@@ -195,21 +225,32 @@ function onStatusTab(name) {
|
||||
}
|
||||
|
||||
function getList() {
|
||||
const requestId = ++listRequestId;
|
||||
loading.value = true;
|
||||
loadFailed.value = false;
|
||||
const p = { ...params };
|
||||
if (p.memberCardStatus) {
|
||||
p.memberCardStatus = String(p.memberCardStatus);
|
||||
}
|
||||
getGiftCardCashMemberCardPage(p)
|
||||
.then((res) => {
|
||||
if (requestId !== listRequestId) return;
|
||||
loading.value = false;
|
||||
if (res.success && res.result) {
|
||||
list.value = res.result.records || [];
|
||||
total.value = res.result.total || 0;
|
||||
if (p.memberCardStatus === "PENDING_ACTIVATION") {
|
||||
++pendingCountRequestId;
|
||||
pendingCount.value = Number(total.value);
|
||||
}
|
||||
} else {
|
||||
loadFailed.value = true;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestId !== listRequestId) return;
|
||||
loading.value = false;
|
||||
loadFailed.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -250,6 +291,7 @@ function submitBind() {
|
||||
resetBindForm();
|
||||
params.pageNumber = 1;
|
||||
getList();
|
||||
refreshPendingCount();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -274,6 +316,7 @@ function onCardPrimaryAction(item) {
|
||||
if (res.success) {
|
||||
Message.success(res.message || "激活成功");
|
||||
getList();
|
||||
refreshPendingCount();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -285,7 +328,7 @@ function onCardPrimaryAction(item) {
|
||||
return;
|
||||
}
|
||||
if (statusTab.value === "AVAILABLE") {
|
||||
router.push({ path: "/goodsList" });
|
||||
router.push(item.cardType === "PICKUP" ? { name: "PickupCard", query: { id: resolveMemberCardId(item) } } : { path: "/goodsList" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,195 +351,125 @@ function isActivating(item) {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.my-gift-cards {
|
||||
.gift-page-heading {
|
||||
padding: 10px 4px 22px;
|
||||
h2 { margin: 0 0 8px; font-size: 22px; font-weight: 600; color: #252833; }
|
||||
p { margin: 0; font-size: 13px; color: #909399; }
|
||||
}
|
||||
.bind-section {
|
||||
padding: 16px 18px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #eee;
|
||||
@include white_background_color();
|
||||
}
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 14px;
|
||||
@include title_color($light_title_color);
|
||||
padding: 20px 24px 24px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f3e7e8;
|
||||
background: #fff9f9;
|
||||
}
|
||||
.section-title { margin-bottom: 18px; font-size: 15px; font-weight: 600; color: #303133; }
|
||||
.bind-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 4px 8px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
:deep(.el-form-item) { margin: 0; min-width: 0; }
|
||||
:deep(.el-form-item__content) { min-width: 0; }
|
||||
:deep(.el-input__wrapper) { min-height: 36px; box-sizing: border-box; background: #fff; }
|
||||
:deep(.el-button) { height: 36px; min-width: 88px; border-radius: 6px; }
|
||||
}
|
||||
.status-tabs {
|
||||
:deep(.el-tabs__header) {
|
||||
margin-bottom: 0;
|
||||
margin-top: 28px;
|
||||
:deep(.el-tabs__header) { margin-bottom: 0; }
|
||||
:deep(.el-tabs__item) { height: 48px; font-size: 15px; }
|
||||
:deep(.el-tabs__nav-wrap::after) { height: 1px; background: #eceef2; }
|
||||
}
|
||||
.pending-tab-label { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.pending-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
padding: 0 6px;
|
||||
height: 20px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
color: $theme_color;
|
||||
background: #fff0f1;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.card-list-wrap {
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.card-list-wrap { position: relative; min-height: 290px; margin-top: 24px; }
|
||||
.gift-load-error { padding: 64px 0; text-align: center; color: #909399; }
|
||||
.gift-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
.gcc-card-item {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
|
||||
@include white_background_color();
|
||||
}
|
||||
.gcc-card-header {
|
||||
position: relative;
|
||||
min-height: 128px;
|
||||
padding: 0;
|
||||
background: linear-gradient(125deg, #ff9a4a 0%, #ff7729 42%, #ff8f3d 100%);
|
||||
border: 1px solid #f0e3e4;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
transition: box-shadow .2s, border-color .2s;
|
||||
&:hover { border-color: #edc9cd; box-shadow: 0 6px 20px rgba(100, 33, 40, .06); }
|
||||
}
|
||||
.gcc-card-header { position: relative; overflow: hidden; background: linear-gradient(120deg, #fff3f3, #fff9f7); }
|
||||
.gcc-header-pattern {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.22;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
right: -70px;
|
||||
bottom: -110px;
|
||||
border: 24px solid rgba(211, 134, 141, .07);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
background-image: repeating-linear-gradient(
|
||||
-36deg,
|
||||
transparent,
|
||||
transparent 5px,
|
||||
rgba(255, 255, 255, 0.45) 5px,
|
||||
rgba(255, 255, 255, 0.45) 6px
|
||||
);
|
||||
}
|
||||
.gcc-header-inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 16px 14px 14px;
|
||||
color: #fff;
|
||||
}
|
||||
.gcc-header-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-right: 8px;
|
||||
}
|
||||
.gcc-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.gcc-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.gcc-status-chip {
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.gcc-face {
|
||||
margin-top: 10px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.gcc-face-symbol {
|
||||
margin-right: 2px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.gcc-cardno {
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
opacity: 0.75;
|
||||
word-break: break-all;
|
||||
}
|
||||
.gcc-header-right {
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gcc-type {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.gcc-expire {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gcc-card-body {
|
||||
padding: 14px 14px 16px;
|
||||
}
|
||||
.gcc-row {
|
||||
font-size: 13px;
|
||||
line-height: 1.85;
|
||||
@include title_color($light_title_color);
|
||||
}
|
||||
.gcc-muted {
|
||||
color: #999;
|
||||
}
|
||||
.gcc-strong {
|
||||
color: #ff6b22;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gcc-link {
|
||||
color: $theme_color;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gcc-service {
|
||||
color: #515a6e;
|
||||
}
|
||||
.gcc-header-inner { position: relative; display: flex; gap: 12px; padding: 22px 20px 18px; }
|
||||
.gcc-header-left { flex: 1; min-width: 0; }
|
||||
.gcc-name-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.gcc-title { max-width: 100%; overflow-wrap: anywhere; font-size: 15px; font-weight: 600; line-height: 1.5; color: #523338; }
|
||||
.gcc-status-chip { padding: 1px 7px; border-radius: 4px; background: rgba(255,255,255,.8); color: #a9757b; font-size: 11px; line-height: 18px; }
|
||||
.gcc-face { margin-top: 18px; color: #a63e4a; font-size: 30px; font-weight: 600; line-height: 1.3; }
|
||||
.gcc-face strong { font-size: 32px; font-weight: 600; }
|
||||
.gcc-face-symbol { margin-right: 3px; font-size: 18px; }
|
||||
.gcc-cardno { margin-top: 18px; color: #96777b; font-size: 12px; overflow-wrap: anywhere; letter-spacing: .03em; }
|
||||
.gcc-header-right { flex-shrink: 0; text-align: right; }
|
||||
.gcc-type { padding: 3px 7px; border: 1px solid #e9cdd0; border-radius: 4px; font-size: 12px; color: #a46f75; white-space: nowrap; }
|
||||
.gcc-expire { margin-top: 8px; color: #a08a8d; font-size: 12px; }
|
||||
.gcc-card-body { padding: 16px 20px 20px; }
|
||||
.gcc-row { min-height: 22px; color: #777e89; font-size: 13px; line-height: 22px; }
|
||||
.gcc-strong { color: #a63e4a; font-weight: 600; }
|
||||
.gcc-action {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
padding: 9px 16px;
|
||||
margin-top: 16px;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
border-radius: 6px;
|
||||
background: $theme_color;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
background: $theme_color;
|
||||
transition: opacity 0.2s, background 0.2s;
|
||||
}
|
||||
.gcc-action:hover:not(.is-disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.gcc-action.is-disabled {
|
||||
color: #c5c8ce;
|
||||
cursor: not-allowed;
|
||||
background: #f7f7f7;
|
||||
transition: opacity .2s;
|
||||
&:hover:not(.is-disabled) { opacity: .85; }
|
||||
&:focus-visible { outline: 2px solid $theme_color; outline-offset: 3px; }
|
||||
&.is-disabled { color: #aaa; background: #f3f4f6; cursor: not-allowed; }
|
||||
}
|
||||
.is-pickup .gcc-face { font-size: 22px; }
|
||||
.gcc-card-item.is-unavailable {
|
||||
.gcc-card-header {
|
||||
background: linear-gradient(125deg, #c9c9c9 0%, #a8a8a8 100%);
|
||||
border-color: #e8eaed;
|
||||
.gcc-card-header { background: #f5f6f8; }
|
||||
.gcc-title, .gcc-face, .gcc-strong { color: #92969e; }
|
||||
.gcc-cardno, .gcc-expire, .gcc-status-chip, .gcc-type { color: #a1a5ad; }
|
||||
.gcc-type { border-color: #dddfe4; }
|
||||
}
|
||||
.gcc-strong {
|
||||
color: #999;
|
||||
.page-size { display: flex; justify-content: flex-end; margin-top: 28px; padding-top: 20px; border-top: 1px solid #f0f1f4; }
|
||||
@media (max-width: 900px) {
|
||||
.bind-form { grid-template-columns: minmax(0, 1fr); gap: 22px; }
|
||||
.bind-form :deep(.el-form-item__label) { width: 70px !important; }
|
||||
.bind-form :deep(.el-form-item:last-child .el-form-item__content) { padding-left: 70px; }
|
||||
.gift-card-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }
|
||||
}
|
||||
}
|
||||
.page-size {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
.gcc-notice-text {
|
||||
line-height: 1.7;
|
||||
color: #515a6e;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.gcc-notice-footer {
|
||||
text-align: right;
|
||||
}
|
||||
.gcc-notice-text { line-height: 1.7; color: #515a6e; margin-bottom: 16px; }
|
||||
.gcc-notice-footer { text-align: right; }
|
||||
</style>
|
||||
@@ -5,7 +5,9 @@
|
||||
<el-button class="del-btn" @click="clearAll" type="primary">删除全部</el-button>
|
||||
</template>
|
||||
<!-- 订单列表 -->
|
||||
<empty v-if="list.length === 0" />
|
||||
<el-skeleton v-if="spinShow" :rows="5" animated />
|
||||
<el-empty v-else-if="queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
|
||||
<empty v-else-if="list.length === 0" />
|
||||
<ul class="track-list" v-else>
|
||||
<li
|
||||
v-for="(item, index) in list"
|
||||
@@ -39,6 +41,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { catchHandledRequestError } from "@/utils/requestError";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message, Modal } from "@/utils/message";
|
||||
@@ -50,6 +53,8 @@ defineOptions({ name: "MyTrack" });
|
||||
const router = useRouter();
|
||||
|
||||
const list = ref([]); // 我的足迹,商品列表
|
||||
const queryFailed = ref(false); // 区分加载失败与暂无记录,支持重新查询
|
||||
let listRequestVersion = 0; // 仅允许最后一次查询更新列表与加载状态
|
||||
const spinShow = ref(false); // 控制loading是否加载
|
||||
const params = reactive({
|
||||
pageNumber: 1,
|
||||
@@ -118,9 +123,11 @@ function changePageSize(val) {
|
||||
|
||||
function getList() {
|
||||
// 获取足迹列表
|
||||
const version = ++listRequestVersion;
|
||||
queryFailed.value = false;
|
||||
spinShow.value = true;
|
||||
tracksList(params).then((res) => {
|
||||
spinShow.value = false;
|
||||
if (version !== listRequestVersion) return;
|
||||
if (res.success && res.result.records.length) {
|
||||
list.value = res.result.records.filter(item =>{
|
||||
return item != null
|
||||
@@ -129,6 +136,17 @@ function getList() {
|
||||
} else {
|
||||
list.value = [];
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (version !== listRequestVersion) {
|
||||
catchHandledRequestError(error);
|
||||
return;
|
||||
}
|
||||
queryFailed.value = true;
|
||||
list.value = [];
|
||||
total.value = 0;
|
||||
catchHandledRequestError(error);
|
||||
}).finally(() => {
|
||||
if (version === listRequestVersion) spinShow.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
194
buyer/src/pages/home/userCenter/PickupCard.vue
Normal file
194
buyer/src/pages/home/userCenter/PickupCard.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<!--
|
||||
提货卡选品与收货地址确认,兑换成功后进入普通订单履约。
|
||||
@author Bulbasaur
|
||||
@since 2026-11-05
|
||||
-->
|
||||
<template>
|
||||
<UserCenterLayout>
|
||||
<div v-loading="loading" class="pickup-page">
|
||||
<header class="pickup-heading">
|
||||
<div><div class="pickup-eyebrow">提货卡兑换</div><h2>{{ detail.giftCardName || '选择你的好礼' }}</h2></div>
|
||||
<span v-if="detail.credentialId" class="pickup-validity">提货权益</span>
|
||||
</header>
|
||||
<el-result v-if="error" icon="warning" :title="error"><template #extra><el-button @click="load">重新加载</el-button></template></el-result>
|
||||
<template v-else-if="detail.credentialId">
|
||||
<el-result v-if="completed || detail.tradeSn" icon="success" title="该提货卡已兑换"><template #extra><el-button type="primary" @click="router.push({name:'MyOrder'})">查看订单</el-button></template></el-result>
|
||||
<template v-else>
|
||||
<div class="pickup-rules"><strong>{{ ruleText }}</strong><span>每种一件</span><span>一次兑完</span><span>免邮</span></div>
|
||||
<section class="pickup-products">
|
||||
<div class="pickup-section-title"><h3>选择兑换商品</h3><span>共 {{ detail.goods?.length || 0 }} 种商品 · 已选 <b>{{ selected.length }}</b> / {{ detail.pickupCount }} 种</span></div>
|
||||
<el-checkbox-group v-model="selected" :max="detail.pickupCount" :disabled="busy || detail.pickupMode === 'ALL'" aria-label="可兑换商品">
|
||||
<div class="pickup-grid">
|
||||
<el-checkbox v-for="goods in detail.goods" :key="goods.id" :value="goods.id" :disabled="!goods.available" class="pickup-item" :class="{'is-selected': selected.includes(goods.id), 'is-unavailable': !goods.available}">
|
||||
<div class="pickup-image"><el-image :src="goods.thumbnail" fit="contain" :alt="goods.goodsName"><template #error><span class="pickup-image-fallback">暂无图片</span></template></el-image></div>
|
||||
<div class="pickup-goods-info">
|
||||
<strong class="pickup-goods-name" :title="goods.goodsName">{{ goods.goodsName }}</strong>
|
||||
<div class="pickup-specs" :title="specsText(goods.specs)">{{ specsText(goods.specs) }}</div>
|
||||
<div class="pickup-store">{{ goods.storeName }}</div>
|
||||
<div v-if="!goods.available" class="pickup-goods-unavailable">暂不可兑换</div>
|
||||
</div>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
</section>
|
||||
<section class="pickup-address">
|
||||
<div class="pickup-section-title"><h3>收货地址</h3><div><el-button link :disabled="busy" @click="router.push({name:'MyAddress'})">管理地址</el-button><el-button link :disabled="busy" @click="loadAddresses">刷新</el-button></div></div>
|
||||
<el-select v-model="addressId" placeholder="请选择收货地址" :disabled="busy" aria-label="收货地址" class="pickup-address-select">
|
||||
<el-option v-for="address in addresses" :key="address.id" :value="address.id" :label="addressText(address)" />
|
||||
</el-select>
|
||||
<p v-if="!addresses.length" class="pickup-address-empty">还没有收货地址,请先添加地址后刷新。</p>
|
||||
</section>
|
||||
<footer class="pickup-checkout">
|
||||
<div class="pickup-selection"><strong>已选 <b>{{ selected.length }}</b> / {{ detail.pickupCount }} 种商品</strong><span>{{ selectionHint }}</span></div>
|
||||
<div class="pickup-submit"><div class="pickup-payable">实付 <strong>¥0<span>.00</span></strong><small>免运费</small></div><el-button type="primary" :loading="busy" :disabled="!canSubmit" @click="submit">确认兑换</el-button></div>
|
||||
</footer>
|
||||
<p class="pickup-terms">兑换成功后取消不退权益,不支持退款或退货。</p>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</UserCenterLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { memberAddress } from '@/api/address';
|
||||
import storage from '@/plugins/storage';
|
||||
import { getPickupCard, previewPickupCard, redeemPickupCard } from '@/api/pickupCard';
|
||||
|
||||
const route = useRoute(), router = useRouter();
|
||||
const detail = ref({}), selected = ref([]), addresses = ref([]), addressId = ref('');
|
||||
const loading = ref(false), busy = ref(false), error = ref(''), completed = ref(false);
|
||||
let active = true, requestVersion = 0;
|
||||
const owner = storage.getItem('userInfo') || '';
|
||||
/** 账号切换后丢弃旧请求结果,避免展示上一个账号的卡和地址。 */
|
||||
function sameOwner() { return active && owner === (storage.getItem('userInfo') || ''); }
|
||||
const canSubmit = computed(() => selected.value.length === detail.value.pickupCount && !!addressId.value && !loading.value && !detail.value.goods?.some(g => selected.value.includes(g.id) && !g.available));
|
||||
const ruleText = computed(() => detail.value.pickupMode === 'ALL' ? '兑换以下全部商品' : `从以下商品任选 ${detail.value.pickupCount || 1} 种`);
|
||||
const selectionHint = computed(() => {
|
||||
const remaining = (detail.value.pickupCount || 0) - selected.value.length;
|
||||
if (remaining > 0) return `还需选择 ${remaining} 种商品`;
|
||||
return addressId.value ? '商品已选齐,请核对收货地址' : '请选择收货地址';
|
||||
});
|
||||
/** 兼容键值及旧数组规格,过滤图片元数据,避免展示原始 JSON。 */
|
||||
function specsText(value) {
|
||||
try {
|
||||
const specs = typeof value === 'string' ? JSON.parse(value || '{}') : value;
|
||||
const values = Array.isArray(specs) ? specs.filter(s => s && s.specName !== 'images').map(s => s.specValue) : Object.entries(specs || {}).filter(([key]) => key !== 'images').map(([, val]) => val);
|
||||
return values.filter(v => typeof v === 'string' || typeof v === 'number').join(' / ') || '默认规格';
|
||||
} catch { return typeof value === 'string' && !/^[\s]*[\[{]/.test(value) ? value : '默认规格'; }
|
||||
}
|
||||
/** 地址仅用于展示,提交后仍由服务端校验当前会员归属。 */
|
||||
function addressText(address) { return `${address.name} ${address.mobile} ${address.consigneeAddressPath || ''} ${address.detail || ''}`; }
|
||||
/** 刷新地址,保留仍存在的选择。 */
|
||||
async function loadAddresses() {
|
||||
const response = await memberAddress();
|
||||
if (!sameOwner()) return;
|
||||
if (!response.success) throw new Error(response.message || '地址加载失败');
|
||||
addresses.value = response.result?.records || response.result || [];
|
||||
if (!addresses.value.some(a => a.id === addressId.value)) addressId.value = (addresses.value.find(a => a.isDefault) || addresses.value[0])?.id || '';
|
||||
}
|
||||
/** 加载卡详情;离页或新请求发起后丢弃旧响应。 */
|
||||
async function load() {
|
||||
const version = ++requestVersion;
|
||||
loading.value = true; error.value = '';
|
||||
try {
|
||||
const response = await getPickupCard(route.query.id);
|
||||
if (!sameOwner() || version !== requestVersion) return;
|
||||
if (!response.success) throw new Error(response.message || '提货卡加载失败');
|
||||
detail.value = response.result;
|
||||
selected.value = detail.value.pickupMode === 'ALL' ? detail.value.goods.map(g => g.id) : [];
|
||||
await loadAddresses();
|
||||
} catch (e) { if (sameOwner() && version === requestVersion) error.value = e.message || '加载失败,请重试'; }
|
||||
finally { if (sameOwner() && version === requestVersion) loading.value = false; }
|
||||
}
|
||||
/** 先服务端试算再确认核销,网络异常保留选项,允许幂等重试。 */
|
||||
async function submit() {
|
||||
if (busy.value || !canSubmit.value || !sameOwner()) return;
|
||||
busy.value = true;
|
||||
const payload = { credentialId: detail.value.credentialId, skuIds: [...selected.value], addressId: addressId.value };
|
||||
try {
|
||||
const preview = await previewPickupCard(payload);
|
||||
if (!sameOwner()) return;
|
||||
if (!preview.success) throw new Error(preview.message || '兑换校验失败');
|
||||
await ElMessageBox.confirm(`本次兑换 ${selected.value.length} 种商品,每种一件,免邮且实付为零。兑换后取消不退权益,不支持退款或退货。`, '确认兑换', { confirmButtonText: '确认兑换', cancelButtonText: '返回检查' });
|
||||
if (!sameOwner()) return;
|
||||
const response = await redeemPickupCard(payload);
|
||||
if (!sameOwner()) return;
|
||||
if (!response.success) throw new Error(response.message || '兑换失败');
|
||||
completed.value = true;
|
||||
} catch (e) {
|
||||
if (sameOwner() && e !== 'cancel' && e !== 'close') {
|
||||
// 网络超时可能发生在成功提交后,查询原卡确认结果,不重复创建订单。
|
||||
try { const status = await getPickupCard(payload.credentialId); if (sameOwner() && status.success && status.result?.tradeSn) { detail.value = status.result; completed.value = true; return; } } catch { /* 保留原错误供重试 */ }
|
||||
ElMessage.error(e.message || '兑换结果待确认,请重试或查看订单');
|
||||
}
|
||||
}
|
||||
finally { if (sameOwner()) busy.value = false; }
|
||||
}
|
||||
onMounted(load);
|
||||
onBeforeUnmount(() => { active = false; requestVersion++; });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pickup-page { padding: 10px 4px 0; color: #303133; }
|
||||
.pickup-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-bottom: 20px; }
|
||||
.pickup-eyebrow { margin-bottom: 8px; color: #909399; font-size: 12px; }
|
||||
.pickup-heading h2 { margin: 0; color: #252833; font-size: 22px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.pickup-validity { padding: 5px 10px; border: 1px solid #eee; border-radius: 4px; font-size: 12px; color: #777e89; white-space: nowrap; }
|
||||
.pickup-rules { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 20px; padding: 16px 18px; border-radius: 6px; background: #f7f8fa; font-size: 13px; color: #858b95; }
|
||||
.pickup-rules strong { color: #434953; font-weight: 500; }
|
||||
.pickup-products { margin-top: 28px; }
|
||||
.pickup-section-title { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; }
|
||||
.pickup-section-title h3 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||
.pickup-section-title > span { color: #909399; font-size: 13px; }
|
||||
.pickup-section-title b { color: $theme_color; font-weight: 500; }
|
||||
.pickup-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; max-height: 570px; overflow-y: auto; padding: 2px 4px 2px 2px; }
|
||||
.pickup-item {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #e9ebef;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
&:hover:not(.is-unavailable) { border-color: #e7acb1; }
|
||||
&.is-selected { border-color: $theme_color; box-shadow: 0 0 0 1px $theme_color; }
|
||||
&.is-unavailable { background: #f8f9fa; }
|
||||
:deep(.el-checkbox__input) { position: absolute; z-index: 1; top: 12px; right: 12px; }
|
||||
:deep(.el-checkbox__label) { display: block; padding: 0; width: 100%; white-space: normal; color: inherit; }
|
||||
}
|
||||
.pickup-image { display: flex; justify-content: center; align-items: center; height: 140px; margin: 14px 20px 0; background: #fff; }
|
||||
.pickup-image :deep(.el-image) { width: 100%; height: 100%; }
|
||||
.pickup-image-fallback { display: flex; height: 100%; align-items: center; justify-content: center; color: #a8abb2; background: #f7f8fa; font-size: 12px; }
|
||||
.pickup-goods-info { padding: 14px; }
|
||||
.pickup-goods-name { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; min-height: 40px; line-height: 20px; font-size: 14px; font-weight: 500; color: #303133; }
|
||||
.pickup-specs { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 8px; color: #6b7280; font-size: 12px; line-height: 18px; }
|
||||
.pickup-store { margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #a0a5ae; font-size: 12px; line-height: 18px; }
|
||||
.pickup-goods-unavailable { margin-top: 8px; color: #909399; font-size: 12px; }
|
||||
.is-unavailable .pickup-image { opacity: .5; }
|
||||
.pickup-address { margin-top: 28px; padding-top: 24px; border-top: 1px solid #eee; }
|
||||
.pickup-address-select { width: 100%; }
|
||||
.pickup-address-select :deep(.el-select__wrapper) { min-height: 42px; }
|
||||
.pickup-address-empty { color: #909399; font-size: 13px; }
|
||||
.pickup-checkout { position: sticky; bottom: 0; z-index: 2; display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-top: 24px; padding: 20px 0; border-top: 1px solid #eee; background: #fff; }
|
||||
.pickup-selection { display: flex; flex-direction: column; gap: 8px; font-size: 14px; }
|
||||
.pickup-selection strong { font-weight: 500; }
|
||||
.pickup-selection b { color: $theme_color; font-weight: 600; }
|
||||
.pickup-selection > span { color: #909399; font-size: 12px; }
|
||||
.pickup-submit { display: flex; align-items: center; gap: 24px; }
|
||||
.pickup-payable { font-size: 13px; color: #606266; }
|
||||
.pickup-payable strong { margin-left: 6px; color: $theme_color; font-size: 26px; font-weight: 600; }
|
||||
.pickup-payable strong span { font-size: 16px; }
|
||||
.pickup-payable small { display: block; margin-top: 4px; text-align: right; font-size: 12px; color: #909399; }
|
||||
.pickup-submit :deep(.el-button) { min-width: 136px; height: 42px; border-radius: 6px; font-size: 15px; }
|
||||
.pickup-terms { margin: 0; padding-bottom: 6px; color: #a0a5ae; font-size: 12px; line-height: 1.6; }
|
||||
@media (max-width: 900px) { .pickup-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 600px) { .pickup-grid { grid-template-columns: minmax(0, 1fr); } .pickup-checkout { flex-wrap: wrap; } .pickup-submit { width: 100%; justify-content: space-between; } .pickup-section-title { flex-wrap: wrap; } }
|
||||
</style>
|
||||
@@ -147,6 +147,24 @@
|
||||
<span>{{ goods.goodsSku?.quantity > 0 ? "有货" : "无货" }}</span>
|
||||
<span class="goods-price">{{ $filters.unitPrice(goods.subTotal, "¥") }}</span>
|
||||
</div>
|
||||
<!-- 随单实物赠品行 -->
|
||||
<div
|
||||
class="goods-item gift-item"
|
||||
v-for="(gift, giftIndex) in getShopGifts(shop)"
|
||||
:key="'gift-' + (gift.skuId || giftIndex)"
|
||||
>
|
||||
<span class="goods-main hover-color" @click="goGoodsDetail(gift.skuId, gift.goodsId)">
|
||||
<img :src="gift.thumbnail || gift.image" alt="" />
|
||||
<span class="goods-name">
|
||||
<el-tag size="small" type="danger" effect="plain" class="mr_5">赠品</el-tag>
|
||||
{{ gift.goodsName || gift.name || '随单实物赠品' }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="goods-price gift-zero-price">¥0.00</span>
|
||||
<span>{{ gift.num || 1 }}</span>
|
||||
<span>有货</span>
|
||||
<span class="goods-price gift-zero-price">¥0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-mark">
|
||||
<div v-if="isRemarkOpen(shop)" class="mark-field">
|
||||
@@ -202,7 +220,7 @@
|
||||
<template v-else>{{ item.discount }}折</template>
|
||||
</div>
|
||||
<div class="pay-coupon-info">
|
||||
<p>满{{ item.consumeThreshold }}元可用</p>
|
||||
<p>{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '元可用' : '无门槛使用' }}</p>
|
||||
<p>使用范围:{{ useScope(item.scopeType, item.storeName) }}</p>
|
||||
<p>有效期:{{ item.endTime }}</p>
|
||||
</div>
|
||||
@@ -876,6 +894,13 @@ function delAddress(item) {
|
||||
onCancel: () => { },
|
||||
});
|
||||
}
|
||||
function getShopGifts(shop) {
|
||||
if (!shop) return [];
|
||||
if (Array.isArray(shop.giftItemList) && shop.giftItemList.length > 0) {
|
||||
return shop.giftItemList;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
function goGoodsDetail(skuId, goodsId) {
|
||||
// 跳转商品详情
|
||||
let routeUrl = router.resolve({
|
||||
@@ -1800,4 +1825,8 @@ function useScope(type, storeName) {
|
||||
.pay-gcc-notice-foot {
|
||||
text-align: right;
|
||||
}
|
||||
.gift-zero-price {
|
||||
color: #e4393c !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -163,6 +163,7 @@ function getGoodsDetail() {
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
isLoading.value = false;
|
||||
router.push("/");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,70 +1,134 @@
|
||||
<template>
|
||||
<div class="seckill-list">
|
||||
<BaseHeader></BaseHeader>
|
||||
<Search />
|
||||
<!-- 秒杀时间段 -->
|
||||
<div class="promotion-decorate">限时秒杀</div>
|
||||
<ul class="time-line">
|
||||
<template v-for="(time, index) in list">
|
||||
<li v-if="index < 5" @click="currIndex = index" :key="index" :class="{ currTimeline: currIndex === index }">
|
||||
<div>{{ time.timeLine + ":00" }}</div>
|
||||
<div v-if="currIndex === index">
|
||||
<p>{{ nowHour >= time.timeLine ? "秒杀中" : "即将开始" }}</p>
|
||||
<p>
|
||||
{{ nowHour >= time.timeLine ? "距结束" : "距开始" }} {{
|
||||
currTime
|
||||
}}
|
||||
</p>
|
||||
<SearchHeader></SearchHeader>
|
||||
|
||||
<!-- 秒杀顶部氛围横幅 -->
|
||||
<div class="seckill-hero">
|
||||
<div class="hero-inner">
|
||||
<div class="hero-left">
|
||||
<div class="hero-tag">
|
||||
<span class="lightning">⚡</span>
|
||||
<span>限时特惠 · 爆款直降</span>
|
||||
</div>
|
||||
<div v-else class="not-curr">
|
||||
{{ nowHour >= time.timeLine ? "秒杀中" : "即将开始" }}
|
||||
<h1 class="hero-title">限时秒杀</h1>
|
||||
<p class="hero-subtitle">正品严选 · 限量低价 · 抢完即止</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-countdown" v-if="list.length && list[currIndex]">
|
||||
<div class="countdown-badge">
|
||||
<span class="status-dot" :class="{ 'is-live': isCurrentSessionActive }"></span>
|
||||
<span>{{ isCurrentSessionActive ? "正在抢购中" : "即将准点开抢" }}</span>
|
||||
</div>
|
||||
<div class="countdown-label">
|
||||
{{ isCurrentSessionActive ? "距本场结束仅剩" : "距本场开始仅剩" }}
|
||||
</div>
|
||||
<div class="countdown-boxes">
|
||||
<span class="time-block">{{ countdownHours }}</span>
|
||||
<span class="time-sep">:</span>
|
||||
<span class="time-block">{{ countdownMinutes }}</span>
|
||||
<span class="time-sep">:</span>
|
||||
<span class="time-block">{{ countdownSeconds }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 秒杀时间段导航 -->
|
||||
<div class="time-line-wrap" v-if="list.length">
|
||||
<ul class="time-line" :class="{ 'single-session': list.length === 1 }">
|
||||
<template v-for="(time, index) in list">
|
||||
<li
|
||||
v-if="index < 5"
|
||||
:key="index"
|
||||
@click="currIndex = index"
|
||||
:class="{ currTimeline: currIndex === index }"
|
||||
>
|
||||
<div class="timeline-hour">{{ time.timeLine + ":00" }}</div>
|
||||
<div class="timeline-status">
|
||||
<span class="status-pill">{{ nowHour >= time.timeLine ? "秒杀中" : "即将开始" }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 秒杀商品列表 -->
|
||||
<div class="seckill-goods-wrap">
|
||||
<div class="goods-list">
|
||||
<empty v-if="goodsList.length === 0" />
|
||||
<div v-else class="goods-show-info1" v-for="(item, index) in goodsList" :key="index"
|
||||
@click="goGoodsDetail(item.skuId, item.goodsId)">
|
||||
<div class="goods-show-img">
|
||||
<div v-if="goodsList.length === 0" class="empty-wrap">
|
||||
<empty :_Title="list.length ? '当前场次暂无秒杀商品,去其他场次看看吧' : '今日暂无秒杀活动,去首页逛逛吧'" />
|
||||
</div>
|
||||
|
||||
<div v-else class="goods-grid">
|
||||
<div
|
||||
class="goods-card"
|
||||
v-for="(item, index) in goodsList"
|
||||
:key="index"
|
||||
@click="goGoodsDetail(item.skuId, item.goodsId)"
|
||||
>
|
||||
<div class="goods-img-box">
|
||||
<img :src="item.goodsImage" loading="lazy" decoding="async" :alt="item.goodsName" />
|
||||
<div v-if="isSoldOut(item)" class="sold-out-overlay">
|
||||
<span>已抢光</span>
|
||||
</div>
|
||||
<div class="goods-show-price">
|
||||
<span class="seckill-price text-danger">{{ $filters.unitPrice(item.price, "¥") }}</span>
|
||||
<span class="origin-price">{{ $filters.unitPrice(item.originalPrice, "¥") }}</span>
|
||||
</div>
|
||||
<div class="goods-show-detail">
|
||||
<span>{{ item.goodsName }}</span>
|
||||
|
||||
<div class="goods-price-row">
|
||||
<div class="price-main">
|
||||
<span class="price-symbol">¥</span>
|
||||
<span class="price-val">{{ $filters.unitPrice(item.price, "") }}</span>
|
||||
</div>
|
||||
<div v-if="nowHour >= list[currIndex].timeLine" class="goods-show-num1">
|
||||
<span>已售{{ salePercent(item) }}%</span>
|
||||
<div class="price-origin">
|
||||
{{ $filters.unitPrice(item.originalPrice, "¥") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="goods-name-row" :title="item.goodsName">
|
||||
{{ item.goodsName }}
|
||||
</div>
|
||||
|
||||
<div class="goods-progress-row">
|
||||
<template v-if="nowHour >= list[currIndex].timeLine">
|
||||
<span class="progress-label">已抢 {{ salePercent(item) }}%</span>
|
||||
<el-progress
|
||||
:show-text="false"
|
||||
:stroke-width="6"
|
||||
:color="'var(--theme-color, #ff3c2a)'"
|
||||
:percentage="salePercent(item)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="upcoming-tip">限量抢购 · 准点开抢</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="goods-show-seller">
|
||||
<span>{{ item.storeName }}</span>
|
||||
|
||||
<div class="goods-store-row" :title="item.storeName">
|
||||
<span class="store-name">{{ item.storeName }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="goods-seckill-btn"
|
||||
:class="{ 'is-disabled': !canBuy(item) }"
|
||||
class="goods-action-btn"
|
||||
:class="{
|
||||
'btn-disabled': !canBuy(item),
|
||||
'btn-upcoming': nowHour < list[currIndex].timeLine,
|
||||
'btn-active': canBuy(item)
|
||||
}"
|
||||
>
|
||||
{{ buyText(item) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { seckillByDay } from "@/api/promotion";
|
||||
import SearchHeader from "@/components/indexDecorate/components/SearchHeader.vue";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -72,47 +136,52 @@ const list = ref([]); // 秒杀时段列表
|
||||
const goodsList = ref([]); // 商品列表
|
||||
let interval = null; // 定时器
|
||||
const currIndex = ref(0); // 当前时间段的下标
|
||||
const currTime = ref(0); // 当前显示的倒计时
|
||||
const diffSeconds = ref(0); // 倒计时时间戳
|
||||
const currTime = ref("00:00:00"); // 当前显示的倒计时格式字符串
|
||||
const countdownHours = ref("00");
|
||||
const countdownMinutes = ref("00");
|
||||
const countdownSeconds = ref("00");
|
||||
const diffSeconds = ref(0); // 倒计时时间戳差值
|
||||
const nowHour = ref(new Date().getHours()); // 当前小时数
|
||||
|
||||
// 当前选中场次是否处于开抢中
|
||||
const isCurrentSessionActive = computed(() => {
|
||||
if (!list.value.length || !list.value[currIndex.value]) return false;
|
||||
return nowHour.value >= list.value[currIndex.value].timeLine;
|
||||
});
|
||||
|
||||
watch(currIndex, (val) => {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
nowHour.value = new Date().getHours();
|
||||
countDown(val);
|
||||
goodsList.value = list.value[val].seckillGoodsList;
|
||||
goodsList.value = list.value[val]?.seckillGoodsList || [];
|
||||
});
|
||||
|
||||
watch(diffSeconds, (val) => {
|
||||
const hours = Math.floor(val / 3600);
|
||||
// 当前秒数 / 60,向下取整
|
||||
// 获取到所有分钟数 3600 / 60 = 60分钟
|
||||
// 对60取模,超过小时数的分钟数
|
||||
const minutes = Math.floor(val / 60) % 60;
|
||||
// 当前的秒数 % 60,获取到 超过小时数、分钟数的秒数(秒数)
|
||||
const seconds = val % 60;
|
||||
currTime.value =
|
||||
filteTime(hours) + ":" + filteTime(minutes) + ":" + filteTime(seconds);
|
||||
if (val <= 0) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
countdownHours.value = "00";
|
||||
countdownMinutes.value = "00";
|
||||
countdownSeconds.value = "00";
|
||||
currTime.value = "00:00:00";
|
||||
return;
|
||||
}
|
||||
function filteTime (time) {
|
||||
if (time < 10) {
|
||||
return "0" + time;
|
||||
} else {
|
||||
return time;
|
||||
}
|
||||
}
|
||||
const hours = Math.floor(val / 3600);
|
||||
const minutes = Math.floor(val / 60) % 60;
|
||||
const seconds = val % 60;
|
||||
countdownHours.value = hours < 10 ? "0" + hours : String(hours);
|
||||
countdownMinutes.value = minutes < 10 ? "0" + minutes : String(minutes);
|
||||
countdownSeconds.value = seconds < 10 ? "0" + seconds : String(seconds);
|
||||
currTime.value = `${countdownHours.value}:${countdownMinutes.value}:${countdownSeconds.value}`;
|
||||
});
|
||||
|
||||
function getListByDay () {
|
||||
// 当天秒杀活动
|
||||
// 当天秒杀活动;result 可能为空数组(当天无活动),需判空避免取下标 0 报错
|
||||
seckillByDay().then((res) => {
|
||||
if (res.success) {
|
||||
if (res.success && res.result && res.result.length) {
|
||||
list.value = res.result;
|
||||
goodsList.value = list.value[0].seckillGoodsList;
|
||||
goodsList.value = list.value[0].seckillGoodsList || [];
|
||||
countDown(currIndex.value);
|
||||
}
|
||||
});
|
||||
@@ -120,7 +189,7 @@ function getListByDay () {
|
||||
|
||||
function goGoodsDetail (skuId, goodsId) {
|
||||
// 跳转商品详情
|
||||
let routeUrl = router.resolve({
|
||||
const routeUrl = router.resolve({
|
||||
path: "/goodsDetail",
|
||||
query: { skuId, goodsId },
|
||||
});
|
||||
@@ -135,41 +204,46 @@ function salePercent (item) {
|
||||
|
||||
// 场次已开始,且销量达到库存
|
||||
function isSoldOut (item) {
|
||||
if (!list.value[currIndex.value]) return false;
|
||||
return nowHour.value >= list.value[currIndex.value].timeLine && item.quantity <= item.salesNum;
|
||||
}
|
||||
|
||||
// 场次已开始且未售罄才可抢
|
||||
function canBuy (item) {
|
||||
if (!list.value[currIndex.value]) return false;
|
||||
return nowHour.value >= list.value[currIndex.value].timeLine && !isSoldOut(item);
|
||||
}
|
||||
|
||||
// 按钮文案:未开始 / 已售罄 / 立即抢购
|
||||
function buyText (item) {
|
||||
if (!list.value[currIndex.value]) return "即将开始";
|
||||
if (nowHour.value < list.value[currIndex.value].timeLine) return "即将开始";
|
||||
if (isSoldOut(item)) return "已售罄";
|
||||
return "立即抢购";
|
||||
}
|
||||
|
||||
function countDown (currIndex) {
|
||||
// 倒计时
|
||||
// 0点时间戳
|
||||
let zeroTime = new Date(new Date().toLocaleDateString()).getTime();
|
||||
let currTime = new Date().getTime();
|
||||
if (!list.value[currIndex]) return;
|
||||
// 倒计时:0点时间戳
|
||||
const zeroTime = new Date(new Date().toLocaleDateString()).getTime();
|
||||
const currTimeMs = new Date().getTime();
|
||||
let actTime = 0;
|
||||
let nowHour = new Date().getHours(); // 当前小时数
|
||||
if (list.value[currIndex].timeLine > nowHour) {
|
||||
// 活动未开始
|
||||
const currentHour = new Date().getHours();
|
||||
nowHour.value = currentHour;
|
||||
|
||||
if (list.value[currIndex].timeLine > currentHour) {
|
||||
// 活动未开始:距开始时间
|
||||
actTime = zeroTime + list.value[currIndex].timeLine * 3600 * 1000;
|
||||
} else if (list.value[currIndex].timeLine <= nowHour) {
|
||||
// 活动进行中
|
||||
} else {
|
||||
// 活动进行中:距结束时间
|
||||
if (currIndex === list.value.length - 1) {
|
||||
// 如果是最后一个活动,直到24点结束
|
||||
actTime = zeroTime + 24 * 3600 * 1000;
|
||||
} else {
|
||||
actTime = zeroTime + list.value[currIndex + 1].timeLine * 3600 * 1000;
|
||||
}
|
||||
}
|
||||
diffSeconds.value = Math.floor((actTime - currTime) / 1000);
|
||||
|
||||
diffSeconds.value = Math.max(0, Math.floor((actTime - currTimeMs) / 1000));
|
||||
interval = setInterval(() => {
|
||||
diffSeconds.value--;
|
||||
}, 1000);
|
||||
@@ -183,216 +257,434 @@ onBeforeUnmount(() => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../assets/styles/goodsList.scss";
|
||||
|
||||
.seckill-list {
|
||||
background: #f5f5f5;
|
||||
background: #f7f8fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.promotion-decorate {
|
||||
width: auto;
|
||||
margin: 28px auto 16px;
|
||||
color: #171A26;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
/* 顶部氛围 Hero 横幅 */
|
||||
.seckill-hero {
|
||||
background: linear-gradient(135deg, #e52d27 0%, #b31217 60%, #790c10 100%);
|
||||
color: #fff;
|
||||
padding: 30px 0 38px;
|
||||
box-shadow: inset 0 -4px 12px rgba(0, 0, 0, 0.15);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
display: none;
|
||||
.hero-inner {
|
||||
width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hero-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.hero-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
width: fit-content;
|
||||
backdrop-filter: blur(4px);
|
||||
|
||||
.lightning {
|
||||
color: #ffeb3b;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
margin: 0;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 2px;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-countdown {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 16px 24px;
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);
|
||||
|
||||
.countdown-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #ffc107;
|
||||
|
||||
&.is-live {
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 8px #4caf50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.countdown-label {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.countdown-boxes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.time-block {
|
||||
display: inline-block;
|
||||
min-width: 38px;
|
||||
height: 38px;
|
||||
line-height: 38px;
|
||||
text-align: center;
|
||||
background: #1e1e1e;
|
||||
color: #ffeb3b;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
border-radius: 6px;
|
||||
padding: 0 4px;
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.time-sep {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 时间轴导航 */
|
||||
.time-line-wrap {
|
||||
width: 1200px;
|
||||
margin: -18px auto 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
.time-line {
|
||||
width: 1200px;
|
||||
max-width: 1200px;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
&.single-session {
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-height: 78px;
|
||||
padding: 12px 20px;
|
||||
min-width: 160px;
|
||||
max-width: 240px;
|
||||
height: 68px;
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #171A26;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
|
||||
& + li {
|
||||
box-shadow: inset 1px 0 0 #f2f2f2;
|
||||
box-shadow: inset 1px 0 0 #f0f0f0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
&:hover:not(.currTimeline) {
|
||||
background: #fafafa;
|
||||
color: var(--theme-color, #e52d27);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
.timeline-hour {
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.not-curr {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: #999;
|
||||
.timeline-status {
|
||||
.status-pill {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
background: #f5f5f5;
|
||||
color: #888;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.currTimeline {
|
||||
background: $theme_color;
|
||||
background: linear-gradient(135deg, var(--theme-color, #e52d27) 0%, #ff5252 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(229, 45, 39, 0.3);
|
||||
|
||||
&:hover {
|
||||
background: $theme_color;
|
||||
background: linear-gradient(135deg, var(--theme-color, #e52d27) 0%, #ff5252 100%);
|
||||
}
|
||||
|
||||
> div:nth-child(1) {
|
||||
font-size: 22px;
|
||||
line-height: 28px;
|
||||
.timeline-hour {
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
> div:nth-child(2) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.not-curr {
|
||||
.timeline-status {
|
||||
.status-pill {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 秒杀商品容器 */
|
||||
.seckill-goods-wrap {
|
||||
width: 1200px;
|
||||
max-width: 1200px;
|
||||
margin: 16px auto 40px;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
margin: 20px auto 50px;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
min-height: 380px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
|
||||
|
||||
.empty-wrap {
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.goods-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 商品卡片 */
|
||||
.goods-card {
|
||||
background: #fff;
|
||||
border: 1px solid #ebebeb;
|
||||
border-radius: 8px;
|
||||
min-height: 320px;
|
||||
|
||||
.goods-list {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
column-gap: 16px;
|
||||
}
|
||||
|
||||
:deep(.empty) {
|
||||
margin: 48px 0;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.goods-show-info1 {
|
||||
width: calc(25% - 12px);
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
margin: 0 0 16px;
|
||||
position: relative;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: #eee;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
border-color: #ffd0d0;
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 24px rgba(229, 45, 39, 0.12);
|
||||
}
|
||||
|
||||
.goods-show-img {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
.goods-img-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #fafafa;
|
||||
background: #f9f9f9;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 1;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.sold-out-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.goods-show-price {
|
||||
.goods-price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
margin-top: 12px;
|
||||
|
||||
.price-main {
|
||||
color: var(--theme-color, #e52d27);
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
.price-symbol {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.origin-price {
|
||||
color: #999;
|
||||
.price-val {
|
||||
font-size: 22px;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.price-origin {
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.goods-show-detail {
|
||||
height: 40px;
|
||||
margin: 8px 0;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.goods-show-num1 {
|
||||
.goods-name-row {
|
||||
margin-top: 8px;
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-all;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.goods-progress-row {
|
||||
margin-top: 10px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
color: #666;
|
||||
color: #777;
|
||||
|
||||
span {
|
||||
.progress-label {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.el-progress) {
|
||||
flex: 1;
|
||||
}
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.goods-show-seller {
|
||||
margin-bottom: 10px;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.goods-seckill-btn {
|
||||
margin-top: auto;
|
||||
height: 34px;
|
||||
.upcoming-tip {
|
||||
color: #e67e22;
|
||||
font-size: 12px;
|
||||
background: #fff8f0;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
line-height: 34px;
|
||||
font-size: 14px;
|
||||
background-color: $theme_color;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
background: #c8c8c8;
|
||||
.goods-store-row {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
.store-name {
|
||||
&:hover {
|
||||
color: var(--theme-color, #e52d27);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.goods-action-btn {
|
||||
margin-top: auto;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
|
||||
&.btn-active {
|
||||
background: linear-gradient(135deg, var(--theme-color, #e52d27) 0%, #ff5252 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 10px rgba(229, 45, 39, 0.25);
|
||||
|
||||
&:hover {
|
||||
opacity: 0.92;
|
||||
box-shadow: 0 6px 14px rgba(229, 45, 39, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-upcoming {
|
||||
background: #ff9800;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.92;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-disabled {
|
||||
background: #e0e0e0;
|
||||
color: #888;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { markHandledRequestError, isHandledRequestError } from "@/utils/requestError";
|
||||
import { ElLoading } from "element-plus";
|
||||
import { Message, Modal } from "@/utils/message";
|
||||
import Storage from "./storage";
|
||||
@@ -14,7 +15,7 @@ export const buyerUrl =
|
||||
? BASE.API_DEV.buyer
|
||||
: BASE.API_PROD.buyer;
|
||||
|
||||
let isRefreshToken = 0;
|
||||
let loginPrompt = null;
|
||||
const refreshToken = getTokenDebounce();
|
||||
|
||||
/** 会员中心(/home/*)页面使用局部 loading,不启用全屏遮罩 */
|
||||
@@ -37,20 +38,22 @@ const service = axios.create({
|
||||
});
|
||||
|
||||
service.interceptors.request.use(
|
||||
(config) => {
|
||||
async (config) => {
|
||||
const { loading } = config;
|
||||
const isPutPost = config.method === "put" || config.method === "post";
|
||||
const isJson = config.headers["Content-Type"] === "application/json";
|
||||
const isFile = config.headers["Content-Type"] === "multipart/form-data";
|
||||
if (isPutPost && isJson) {
|
||||
if (!config._dataSerialized && isPutPost && isJson) {
|
||||
config.data = JSON.stringify(config.data);
|
||||
}
|
||||
if (isPutPost && !isFile && !isJson) {
|
||||
if (!config._dataSerialized && isPutPost && !isFile && !isJson) {
|
||||
config.data = qs.stringify(config.data, {
|
||||
arrayFormat: "repeat",
|
||||
});
|
||||
}
|
||||
|
||||
config._dataSerialized = true;
|
||||
|
||||
const method = (config.method || "get").toLowerCase();
|
||||
const isReadRequest = method === "get" || method === "head";
|
||||
const shouldShowLoading =
|
||||
@@ -58,7 +61,7 @@ service.interceptors.request.use(
|
||||
(!isReadRequest && loading !== false && !isMemberCenterRoute());
|
||||
|
||||
if (typeof window !== "undefined" && shouldShowLoading) {
|
||||
config.loading = ElLoading.service({ fullscreen: true });
|
||||
config._loadingInstance = ElLoading.service({ fullscreen: true });
|
||||
}
|
||||
|
||||
let uuid = Storage.getItem("uuid");
|
||||
@@ -82,7 +85,7 @@ service.interceptors.request.use(
|
||||
const expired = jwtData.exp < Math.round(new Date() / 1000);
|
||||
if (expired) {
|
||||
if (config.needToken) {
|
||||
return refresh({ response: { config } });
|
||||
return refresh(config);
|
||||
}
|
||||
// 公开接口:丢弃过期 token,匿名继续请求,避免浏览商品时被引导登录
|
||||
Storage.removeItem("accessToken");
|
||||
@@ -94,7 +97,7 @@ service.interceptors.request.use(
|
||||
Storage.removeItem("accessToken");
|
||||
Storage.removeItem("refreshToken");
|
||||
if (config.needToken) {
|
||||
return refresh({ response: { config } });
|
||||
return refresh(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,59 +107,64 @@ service.interceptors.request.use(
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
/** 保留请求配置、状态与错误码,供页面判断业务失败并定位原因。 */
|
||||
function buildRejectError(error, errorData = {}) {
|
||||
const message =
|
||||
errorData.message ||
|
||||
(error?.code === "ECONNABORTED" ? "连接超时,请稍候再试!" : "网络错误,请稍后再试!");
|
||||
return Object.assign(new Error(message), { data: errorData });
|
||||
return Object.assign(new Error(message), { data: errorData, config: error.config, response: error.response, code: error.code });
|
||||
}
|
||||
|
||||
async function refresh(error) {
|
||||
const getTokenRes = await refreshToken();
|
||||
if (getTokenRes === "success") {
|
||||
if (error?.response?.config) {
|
||||
error.response.config.headers.accessToken = Storage.getItem("accessToken");
|
||||
return service(error.response.config);
|
||||
/**
|
||||
* 刷新凭证并返回请求配置;请求拦截器不能返回业务响应。每个业务请求最多刷新一次。
|
||||
* 刷新失败时保持 Promise 拒绝,阻止后续支付、订单等成功回调继续执行。
|
||||
*/
|
||||
async function refresh(config) {
|
||||
if (!config._retryAfterRefresh) {
|
||||
config._retryAfterRefresh = true;
|
||||
const result = await refreshToken();
|
||||
if (result === "success") {
|
||||
config.headers.accessToken = Storage.getItem("accessToken");
|
||||
return config;
|
||||
}
|
||||
router.go(0);
|
||||
return new Promise(() => {});
|
||||
}
|
||||
Storage.removeItem("accessToken");
|
||||
Storage.removeItem("refreshToken");
|
||||
Storage.removeItem("userInfo");
|
||||
Storage.setItem("cartNum", 0);
|
||||
useAppStore().setCartNum(0);
|
||||
// 多个受保护接口同时失败时共用一个弹窗,关闭后允许再次提示。
|
||||
if (!loginPrompt) {
|
||||
const currentRoute = router.currentRoute.value;
|
||||
Modal.confirm({
|
||||
loginPrompt = Modal.confirm({
|
||||
title: "请登录",
|
||||
content: "请登录后执行此操作",
|
||||
okText: "立即登录",
|
||||
cancelText: "继续浏览",
|
||||
onOk: () => {
|
||||
router.push({
|
||||
onOk: () => router.push({
|
||||
path: "/login",
|
||||
query: {
|
||||
rePath: currentRoute.path,
|
||||
query: JSON.stringify(currentRoute.query),
|
||||
},
|
||||
});
|
||||
},
|
||||
onCancel: () => {
|
||||
router.push("/");
|
||||
Modal.remove();
|
||||
},
|
||||
});
|
||||
return Promise.reject(new Error("登录已过期"));
|
||||
query: { rePath: currentRoute.path, query: JSON.stringify(currentRoute.query) },
|
||||
}),
|
||||
onCancel: () => router.push("/"),
|
||||
}).finally(() => { loginPrompt = null; });
|
||||
}
|
||||
const error = Object.assign(new Error("登录已过期"), { code: "AUTH_EXPIRED", config });
|
||||
throw markHandledRequestError(error);
|
||||
}
|
||||
|
||||
service.interceptors.response.use(
|
||||
async (response) => {
|
||||
await closeLoading(response);
|
||||
const config = response.config;
|
||||
if (!config.skipAuthHandler && [401, 403, 20004].includes(response.data?.code)) {
|
||||
return service(await refresh(config));
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
async (error) => {
|
||||
if (typeof window === "undefined") return Promise.reject(error);
|
||||
await closeLoading(error);
|
||||
if (isHandledRequestError(error)) return Promise.reject(error);
|
||||
const errorResponse = error.response || {};
|
||||
const errorData = errorResponse.data || {};
|
||||
|
||||
@@ -166,32 +174,26 @@ service.interceptors.response.use(
|
||||
errorResponse.status === 403 ||
|
||||
error.response?.data?.code === 20004)
|
||||
) {
|
||||
isRefreshToken++;
|
||||
if (isRefreshToken === 1) {
|
||||
isRefreshToken = 0;
|
||||
return refresh(error);
|
||||
return service(await refresh(error.config || errorResponse.config));
|
||||
}
|
||||
return Promise.reject(buildRejectError(error, errorData));
|
||||
if (!axios.isAxiosError(error) || axios.isCancel(error)) return Promise.reject(error);
|
||||
const rejected = buildRejectError(error, errorResponse.status === 404
|
||||
? { ...errorData, message: "请求的资源不存在" }
|
||||
: errorData);
|
||||
if (!error.config?.skipMessage && axios.isAxiosError(error) && !axios.isCancel(error)) {
|
||||
Message.error(rejected.message);
|
||||
markHandledRequestError(rejected);
|
||||
}
|
||||
if (errorResponse.status === 404) {
|
||||
return Promise.reject(buildRejectError(error, { message: "请求的资源不存在" }));
|
||||
}
|
||||
if (error.message && !error.config?.skipMessage) {
|
||||
const _message =
|
||||
error.code === "ECONNABORTED"
|
||||
? "连接超时,请稍候再试!"
|
||||
: "网络错误,请稍后再试!";
|
||||
Message.error(errorData.message || _message);
|
||||
}
|
||||
return Promise.reject(buildRejectError(error, errorData));
|
||||
return Promise.reject(rejected);
|
||||
}
|
||||
);
|
||||
|
||||
const closeLoading = (target) => {
|
||||
if (!target.config || !target.config.loading) return true;
|
||||
if (typeof target.config?._loadingInstance?.close !== "function") return true;
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
target.config.loading.close();
|
||||
target.config._loadingInstance.close();
|
||||
delete target.config._loadingInstance;
|
||||
resolve();
|
||||
}, 200);
|
||||
});
|
||||
@@ -208,42 +210,22 @@ export default function request(options) {
|
||||
return service(options);
|
||||
}
|
||||
|
||||
/** 并发刷新共用同一个 Promise;不轮询,不保留上次刷新结果,失败后允许下次重试。 */
|
||||
function getTokenDebounce() {
|
||||
let lock = false;
|
||||
let success = false;
|
||||
let pending = null;
|
||||
return function () {
|
||||
if (!lock) {
|
||||
const oldRefreshToken = Storage.getItem("refreshToken");
|
||||
// 无 refreshToken(未登录)时不发起刷新请求,直接判失败,避免请求 /refresh/undefined 造成拦截器死锁
|
||||
if (!oldRefreshToken) {
|
||||
return Promise.resolve("fail");
|
||||
}
|
||||
lock = true;
|
||||
handleRefreshToken(oldRefreshToken)
|
||||
if (pending) return pending;
|
||||
const token = Storage.getItem("refreshToken");
|
||||
if (!token) return Promise.resolve("fail");
|
||||
pending = handleRefreshToken(token)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
const { accessToken, refreshToken: newRefreshToken } = res.result;
|
||||
Storage.setItem("accessToken", accessToken);
|
||||
Storage.setItem("refreshToken", newRefreshToken);
|
||||
success = true;
|
||||
lock = false;
|
||||
} else {
|
||||
success = false;
|
||||
lock = false;
|
||||
}
|
||||
if (!res.success || !res.result?.accessToken || !res.result?.refreshToken) return "fail";
|
||||
Storage.setItem("accessToken", res.result.accessToken);
|
||||
Storage.setItem("refreshToken", res.result.refreshToken);
|
||||
return "success";
|
||||
})
|
||||
.catch(() => {
|
||||
success = false;
|
||||
lock = false;
|
||||
});
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const timer = setInterval(() => {
|
||||
if (!lock) {
|
||||
clearInterval(timer);
|
||||
resolve(success ? "success" : "fail");
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
.catch(() => "fail")
|
||||
.finally(() => { pending = null; });
|
||||
return pending;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,6 +200,12 @@ export const router = createRouter({
|
||||
component: MoneyManagement,
|
||||
meta: { title: "我的余额" },
|
||||
},
|
||||
{
|
||||
path: "PickupCard",
|
||||
name: "PickupCard",
|
||||
component: () => import("@/pages/home/userCenter/PickupCard.vue"),
|
||||
meta: { title: "提货卡兑换" },
|
||||
},
|
||||
{
|
||||
path: "MyGiftCards",
|
||||
name: "MyGiftCards",
|
||||
@@ -273,6 +279,10 @@ export const router = createRouter({
|
||||
component: MemberGrade,
|
||||
meta: { title: "我的等级" },
|
||||
},
|
||||
{
|
||||
path: "UserInfo",
|
||||
redirect: { name: "Profile" },
|
||||
},
|
||||
{
|
||||
path: "Profile",
|
||||
name: "Profile",
|
||||
@@ -301,6 +311,11 @@ export const router = createRouter({
|
||||
name: "AddAddress",
|
||||
component: AddAddress,
|
||||
},
|
||||
{
|
||||
// 兼容已保存的装修顶部菜单中的旧消息地址,统一进入现有消息列表。
|
||||
path: "Msg",
|
||||
redirect: { name: "MsgList" },
|
||||
},
|
||||
{
|
||||
path: "MsgList",
|
||||
name: "MsgList",
|
||||
|
||||
40
buyer/src/utils/requestError.js
Normal file
40
buyer/src/utils/requestError.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 已向用户提示的请求异常兜底;只识别本模块登记的错误,不屏蔽代码异常。
|
||||
* @author Bulbasaur
|
||||
* @since 2026-11-09
|
||||
*/
|
||||
const handledErrors = new WeakSet();
|
||||
|
||||
/** 登记已由请求层提示的错误,仍保持原请求的拒绝语义。 */
|
||||
export function markHandledRequestError(error) {
|
||||
handledErrors.add(error);
|
||||
// 开发服务器的错误遮罩有独立监听器,通过同一 Symbol 识别已提示的异常。
|
||||
Object.defineProperty(error, Symbol.for("lilishop.buyer.handledRequestError"), { value: true });
|
||||
return error;
|
||||
}
|
||||
|
||||
/** 判断错误是否已由请求层向用户展示。 */
|
||||
export function isHandledRequestError(error) {
|
||||
return !!error && typeof error === "object" && handledErrors.has(error);
|
||||
}
|
||||
|
||||
/** 页面处理预期请求失败;程序错误继续向上抛出,避免被静默吞掉。 */
|
||||
export function catchHandledRequestError(error) {
|
||||
if (!isHandledRequestError(error)) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 覆盖未返回 Promise 的事件回调及 Vue 异步钩子;只有已提示的请求异常免于重复报错。
|
||||
* 未登记异常沿用已有 Vue 错误处理,无处理器时重新抛出供开发环境定位。
|
||||
*/
|
||||
export function setupRequestErrorHandling(app, target = window) {
|
||||
target.addEventListener("unhandledrejection", (event) => {
|
||||
if (isHandledRequestError(event.reason)) event.preventDefault();
|
||||
});
|
||||
const previous = app.config.errorHandler;
|
||||
app.config.errorHandler = (error, instance, info) => {
|
||||
if (isHandledRequestError(error)) return;
|
||||
if (previous) return previous(error, instance, info);
|
||||
throw error;
|
||||
};
|
||||
}
|
||||
194
buyer/tests/request-auth.test.cjs
Normal file
194
buyer/tests/request-auth.test.cjs
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 买家端请求认证回归:验证真实 Axios 拦截链、并发刷新和已提示异常边界。
|
||||
* @author Bulbasaur
|
||||
* @since 2026-11-09
|
||||
*/
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const axios = require('axios');
|
||||
|
||||
/** 加载业务源码,替换浏览器与界面依赖;请求拦截和重试仍使用真实 Axios。 */
|
||||
function harness({ expired = false, refresh = async () => ({ success: false }), adapter } = {}) {
|
||||
const values = new Map();
|
||||
const jwt = (exp) => 'header.' + Buffer.from(JSON.stringify({ exp })).toString('base64url') + '.sig';
|
||||
if (expired) values.set('accessToken', jwt(1));
|
||||
values.set('refreshToken', 'refresh-old');
|
||||
const Storage = { getItem: k => values.get(k), setItem: (k,v) => values.set(k,v), removeItem: k => values.delete(k) };
|
||||
const helperSource = fs.readFileSync(path.join(__dirname, '../src/utils/requestError.js'), 'utf8').replace(/export /g, '');
|
||||
const helper = new Function(helperSource + '; return { markHandledRequestError, isHandledRequestError, catchHandledRequestError, setupRequestErrorHandling };')();
|
||||
let prompts = 0, refreshCalls = 0, closes = 0, opens = 0;
|
||||
const messages = [], sent = [];
|
||||
let dismiss, promptOptions;
|
||||
const Modal = { confirm(options) { prompts++; promptOptions = options; return new Promise(resolve => { dismiss = resolve; }); } };
|
||||
const router = { currentRoute: { value: { path: '/home/MyOrder', query: { tag: 'ALL' } } }, push: async () => {} };
|
||||
const axiosProxy = { ...axios, create(options) { return axios.create({ ...options, adapter: async config => {
|
||||
sent.push(config);
|
||||
return adapter ? adapter(config, sent.length) : { data: { success: true }, status: 200, config, headers: {} };
|
||||
} }); } };
|
||||
const source = fs.readFileSync(path.join(__dirname, '../src/plugins/request.js'), 'utf8')
|
||||
.replace(/^import .*;\r?\n/gm, '').replace(/export default function request/, 'function request').replace(/export const /g, 'const ');
|
||||
const names = ['axios','markHandledRequestError','isHandledRequestError','ElLoading','Message','Modal','Storage','router','useAppStore','handleRefreshToken','uuidv4','require','process','BASE','window','setTimeout'];
|
||||
const request = new Function(...names, source + '; return request;')(
|
||||
axiosProxy, helper.markHandledRequestError, helper.isHandledRequestError,
|
||||
{ service() { opens++; return { close() { closes++; } }; } }, { error: m => messages.push(m) }, Modal, Storage, router,
|
||||
() => ({ setCartNum() {} }), async () => { refreshCalls++; return refresh(jwt); }, () => 'uuid', require,
|
||||
{ env: { NODE_ENV: 'development' } }, { API_DEV: { buyer: '/api' } }, { atob: v => Buffer.from(v,'base64').toString('binary') }, fn => fn()
|
||||
);
|
||||
return { request, values, helper, sent, messages, get prompts() { return prompts; }, get refreshCalls() { return refreshCalls; }, get opens() { return opens; }, get closes() { return closes; }, dismiss: () => dismiss(), get promptOptions() { return promptOptions; } };
|
||||
}
|
||||
|
||||
/** 构造后端拒绝登录的 HTTP 响应。 */
|
||||
function unauthorized(config) {
|
||||
throw new axios.AxiosError('unauthorized', 'ERR_BAD_REQUEST', config, null, { status: 401, data: {}, config });
|
||||
}
|
||||
|
||||
test('并发过期请求只刷新一次,返回配置并各发送一次业务请求', async () => {
|
||||
const h = harness({ expired: true, refresh: async jwt => {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
return { success: true, result: { accessToken: jwt(4000000000), refreshToken: 'new-refresh' } };
|
||||
} });
|
||||
const results = await Promise.all([1,2,3].map(() => h.request({ url: '/orders', needToken: true })));
|
||||
assert.equal(h.refreshCalls, 1);
|
||||
assert.equal(h.sent.length, 3);
|
||||
assert.ok(results.every(r => r.success));
|
||||
assert.equal(h.prompts, 0);
|
||||
});
|
||||
|
||||
test('刷新失败保持拒绝、只弹一次登录框并清理身份与加载', async () => {
|
||||
const h = harness({ expired: true });
|
||||
let successes = 0;
|
||||
const results = await Promise.allSettled([1,2,3].map(() => h.request({ url: '/orders', needToken: true, loading: true }).then(() => { successes++; })));
|
||||
assert.equal(successes, 0);
|
||||
assert.equal(h.sent.length, 0);
|
||||
assert.equal(h.prompts, 1);
|
||||
assert.equal(h.closes, h.opens);
|
||||
assert.equal(h.values.get('accessToken'), undefined);
|
||||
assert.ok(results.every(r => r.status === 'rejected' && h.helper.isHandledRequestError(r.reason)));
|
||||
assert.equal(h.messages.length, 0);
|
||||
h.dismiss();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await assert.rejects(h.request({ url: '/orders', needToken: true, adapter: unauthorized }));
|
||||
assert.equal(h.prompts, 2);
|
||||
assert.equal(h.refreshCalls, 1);
|
||||
});
|
||||
|
||||
test('401 重试上限防止刷新死循环,POST 请求体保持一致', async () => {
|
||||
const h = harness({ adapter: unauthorized, refresh: async jwt => ({ success: true, result: { accessToken: jwt(4000000000), refreshToken: 'new' } }) });
|
||||
await assert.rejects(h.request({ url: '/orders', method: 'post', data: { amount: 12 }, headers: { 'Content-Type': 'application/json' }, needToken: true, loading: true }), e => e.code === 'AUTH_EXPIRED');
|
||||
assert.equal(h.refreshCalls, 1);
|
||||
assert.equal(h.sent.length, 2);
|
||||
assert.equal(h.sent[0].data, '{"amount":12}');
|
||||
assert.equal(h.sent[1].data, h.sent[0].data);
|
||||
assert.equal(h.opens, h.closes);
|
||||
assert.equal(h.prompts, 1);
|
||||
});
|
||||
|
||||
test('HTTP 200 内认证失败进入同一处理,刷新接口不递归', async () => {
|
||||
const h = harness({ adapter: async config => ({ config, status: 200, data: { code: 20004 }, headers: {} }) });
|
||||
await assert.rejects(h.request({ url: '/orders' }), e => e.code === 'AUTH_EXPIRED');
|
||||
const skipped = await h.request({ url: '/refresh', skipAuthHandler: true, skipMessage: true });
|
||||
assert.equal(skipped.code, 20004);
|
||||
assert.equal(h.refreshCalls, 1);
|
||||
});
|
||||
|
||||
test('网络失败只提示一次,skipMessage 与程序错误不登记为已处理', async () => {
|
||||
const h = harness({ adapter: config => { throw new axios.AxiosError('timeout', 'ECONNABORTED', config); } });
|
||||
await assert.rejects(h.request({ url: '/orders' }), e => h.helper.isHandledRequestError(e));
|
||||
assert.deepEqual(h.messages, ['连接超时,请稍候再试!']);
|
||||
await assert.rejects(h.request({ url: '/refresh', skipMessage: true }), e => !h.helper.isHandledRequestError(e));
|
||||
const bug = new TypeError('bug');
|
||||
const b = harness({ adapter: () => { throw bug; } });
|
||||
await assert.rejects(b.request({ url: '/orders' }), e => e === bug);
|
||||
});
|
||||
|
||||
test('全局兜底和开发遮罩仅过滤已提示错误,未知错误仍上报', () => {
|
||||
const h = harness();
|
||||
const handled = h.helper.markHandledRequestError(new Error('登录已过期'));
|
||||
const bug = new TypeError('bug');
|
||||
const listeners = {};
|
||||
const app = { config: {} };
|
||||
h.helper.setupRequestErrorHandling(app, { addEventListener: (name, fn) => { listeners[name] = fn; } });
|
||||
let prevented = 0;
|
||||
listeners.unhandledrejection({ reason: handled, preventDefault: () => prevented++ });
|
||||
listeners.unhandledrejection({ reason: bug, preventDefault: () => prevented++ });
|
||||
assert.equal(prevented, 1);
|
||||
assert.doesNotThrow(() => app.config.errorHandler(handled));
|
||||
assert.throws(() => app.config.errorHandler(bug), e => e === bug);
|
||||
assert.throws(() => h.helper.catchHandledRequestError(bug), e => e === bug);
|
||||
const filter = require('../vue.config.js').devServer.client.overlay.runtimeErrors;
|
||||
assert.equal(filter(handled), false);
|
||||
assert.equal(filter(bug), true);
|
||||
});
|
||||
|
||||
|
||||
test('未登录不刷新 undefined,公开接口保持匿名可访问', async () => {
|
||||
const h = harness({ adapter: config => {
|
||||
if (config.needToken) return unauthorized(config);
|
||||
return { config, status: 200, data: { success: true }, headers: {} };
|
||||
} });
|
||||
h.values.delete('refreshToken');
|
||||
await assert.rejects(h.request({ url: '/orders', needToken: true }), e => e.code === 'AUTH_EXPIRED');
|
||||
const result = await h.request({ url: '/goods', optionalToken: true });
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(h.refreshCalls, 0);
|
||||
assert.equal(h.prompts, 1);
|
||||
});
|
||||
|
||||
test('HTTP 401 并发共用刷新,表单 POST 重试不重复编码', async () => {
|
||||
const h = harness({
|
||||
adapter: config => {
|
||||
if (!config.headers.accessToken) return unauthorized(config);
|
||||
return { config, status: 200, data: { success: true }, headers: {} };
|
||||
},
|
||||
refresh: async jwt => {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
return { success: true, result: { accessToken: jwt(4000000000), refreshToken: 'new' } };
|
||||
},
|
||||
});
|
||||
await Promise.all([1,2].map(() => h.request({ url: '/orders', method: 'post', data: { name: 'a b' }, needToken: true })));
|
||||
assert.equal(h.refreshCalls, 1);
|
||||
assert.equal(h.sent.length, 4);
|
||||
assert.ok(h.sent.every(c => c.data === 'name=a%20b'));
|
||||
});
|
||||
|
||||
|
||||
/** 从真实 Vue 页面提取查询函数,验证失败收尾与请求乱序,界面依赖用响应式状态桩替换。 */
|
||||
function listHarness(file, apiName) {
|
||||
const { parse } = require('@vue/compiler-sfc');
|
||||
const parser = require('@babel/parser');
|
||||
const source = parse(fs.readFileSync(path.join(__dirname, '../src/pages/home', file), 'utf8')).descriptor.scriptSetup.content;
|
||||
const declaration = parser.parse(source, { sourceType: 'module' }).program.body.find(n => n.type === 'FunctionDeclaration' && n.id.name === 'getList');
|
||||
const state = { spinShow: { value: false }, queryFailed: { value: false }, orderList: { value: [] }, list: { value: [] }, total: { value: 0 } };
|
||||
const calls = [];
|
||||
const api = () => new Promise((resolve, reject) => calls.push({ resolve, reject }));
|
||||
const getList = new Function(...Object.keys(state), 'params', apiName, 'catchHandledRequestError',
|
||||
'let listRequestVersion = 0; ' + source.slice(declaration.start, declaration.end) + '; return getList;')(
|
||||
...Object.values(state), { pageNumber: 1 }, api, () => {}
|
||||
);
|
||||
return { ...state, calls, getList };
|
||||
}
|
||||
|
||||
for (const [file, api] of [['orderCenter/MyOrder.vue','getOrderList'], ['orderCenter/AfterSale.vue','afterSaleList'], ['userCenter/MyTracks.vue','tracksList']]) {
|
||||
test(file + ' 失败结束加载并可重试,旧查询不能覆盖新列表', async () => {
|
||||
const h = listHarness(file, api);
|
||||
h.getList();
|
||||
assert.equal(h.spinShow.value, true);
|
||||
h.calls[0].reject(new Error('network'));
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(h.spinShow.value, false);
|
||||
assert.equal(h.queryFailed.value, true);
|
||||
h.getList();
|
||||
h.getList();
|
||||
h.calls[2].resolve({ success: true, result: { records: [{ id: 'new' }], total: 1 } });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
h.calls[1].resolve({ success: true, result: { records: [{ id: 'old' }], total: 20 } });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(h.spinShow.value, false);
|
||||
assert.equal(h.queryFailed.value, false);
|
||||
assert.equal(h.total.value, 1);
|
||||
const list = file.includes('MyTracks') ? h.list : h.orderList;
|
||||
assert.deepEqual(list.value, [{ id: 'new' }]);
|
||||
});
|
||||
}
|
||||
@@ -33,6 +33,12 @@ module.exports = {
|
||||
},
|
||||
devServer: {
|
||||
port: configs.port,
|
||||
client: {
|
||||
overlay: {
|
||||
// 保留编译错误和程序异常遮罩,仅跳过请求层已向用户提示的错误。
|
||||
runtimeErrors: (error) => error?.[Symbol.for("lilishop.buyer.handledRequestError")] !== true,
|
||||
},
|
||||
},
|
||||
},
|
||||
productionSourceMap: false,
|
||||
configureWebpack: {
|
||||
|
||||
216
im/LICENSE
216
im/LICENSE
@@ -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.
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# IM 客服端 Vue 3 迁移说明
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Vue 3.5 + Vue Router 4 + Pinia 4
|
||||
- Element Plus(替代 element-ui)
|
||||
- 自研右键菜单 `src/plugins/contextmenu.js`(替代 vue-contextmenujs)
|
||||
- `vue-virtual-scroller@3`、`vue-cropper@1.x`、`vue-prism-editor@2.0.0-alpha.2`
|
||||
|
||||
## 组合式 API 迁移(2026-10 完成)
|
||||
|
||||
全部 53 个 `.vue` 组件已从 Options API 转换为 `<script setup>`,并完成 vuex → pinia:
|
||||
|
||||
- **状态管理**:`src/stores/` 下 8 个 store(app/user/talk/notify/settings/emoticon/dialogue/note),对应原 vuex 根 state + 7 个模块;mutation 全部转为同名 SCREAMING_CASE action。原 `src/store/` 已删除。
|
||||
- **全局 mixin 保留**:`main.js` 的 `app.mixin(MainMixin)` 依然生效(对 `<script setup>` 组件同样注入),mixin 内部已改用 pinia。**注意**:mixin 经 `this` 回调组件成员时,`<script setup>` 的绑定不会出现在公共实例上——`views/message/index.vue` 采用了 ctx 桥接(`Object.defineProperties(instance.ctx, ...)`)方案,后续若有组件被全局 mixin 回调需用同样方案。
|
||||
- **socket 事件层**(`im-server/event/*`、`plugins/ws-socket.js`、`utils/talk.js` 等纯 JS):vuex 引用全部改为函数内部 `useXxxStore()`。
|
||||
- **$user() 名片弹层**(`components/user/user-card/index.js`):独立子 app 改用独立 `createPinia()`。
|
||||
- **app-bridge.js**:保留了 `$store.state.talks.items` 兼容门面(惰性 getter),供旧调用形态使用。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
cd im
|
||||
yarn install
|
||||
yarn dev # 端口 8000
|
||||
yarn build
|
||||
```
|
||||
|
||||
## 迁移要点
|
||||
|
||||
1. **入口** `src/main.js` — `createApp`,全局过滤器 `$filters`
|
||||
2. **路由** `createRouter` + `createWebHistory`,404 路由 `/:pathMatch(.*)*`
|
||||
3. **通知** `im-server/event/talk.js` 通过 `app-bridge` 发 `ElNotification`,避免循环引用
|
||||
4. **用户卡片** `$user()` 插件用 `createApp` 挂载
|
||||
5. **指令** `v-focus` / `v-paste` / `v-drag` / `v-outside` 已改为 Vue 3 钩子
|
||||
6. **SVG** `svg-sprite-loader` + `svg-icon` 全局组件
|
||||
7. **生命周期** `destroyed` → `beforeUnmount`;`$set` / `$delete` 已移除
|
||||
|
||||
## 上线前检查
|
||||
|
||||
统一清单见仓库根目录:[../MIGRATION-VUE3.md](../MIGRATION-VUE3.md)
|
||||
IM 专项见该文档 **「IM 客服端」** 一节。
|
||||
@@ -37,7 +37,7 @@ a {
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: none;
|
||||
resize: none !important;
|
||||
outline: 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
|
||||
@@ -43,7 +43,8 @@ class Talk extends Base {
|
||||
super();
|
||||
this.sender_id = resource.fromUser; //发送
|
||||
this.receiver_id = resource.toUser; //接收
|
||||
this.talk_type = resource.messageType; //类型
|
||||
// 后端推送的 messageType 是消息内容类型(MESSAGE/PICTURE/GOODS/ORDERS),私聊会话类型固定为 1
|
||||
this.talk_type = Number(resource.talk_type) || 1;
|
||||
|
||||
this.resource = resource;
|
||||
|
||||
@@ -93,12 +94,22 @@ class Talk extends Base {
|
||||
return userId == this.getAccountId() ? "right" : "left";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息内容类型(MESSAGE/PICTURE/GOODS/ORDERS)
|
||||
*/
|
||||
getMessageType () {
|
||||
return this.resource.messageType || this.resource.msg_type || "MESSAGE";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天列表左侧的对话信息
|
||||
*/
|
||||
getTalkText () {
|
||||
let text = this.resource.content || this.resource.text;
|
||||
switch (this.resource.msg_type) {
|
||||
switch (this.getMessageType()) {
|
||||
case 'PICTURE':
|
||||
text = "[图片]";
|
||||
break;
|
||||
case 'GOODS':
|
||||
text = "[商品链接]";
|
||||
break;
|
||||
@@ -224,10 +235,15 @@ class Talk extends Base {
|
||||
}
|
||||
// console.log("%c 准备更新...UPDATE_TALK_ITEM ", "color:red");
|
||||
|
||||
const nowTime = parseTime(new Date());
|
||||
const msgText = this.getTalkText() || record.text;
|
||||
talkStore.UPDATE_TALK_ITEM({
|
||||
index_name: this.getIndexName(),
|
||||
msg_text: this.getTalkText() || record.text,
|
||||
updated_at: parseTime(new Date()),
|
||||
msg_text: msgText,
|
||||
lastTalkMessage: msgText,
|
||||
lastTalkTime: nowTime,
|
||||
lastMessageType: this.getMessageType(),
|
||||
updated_at: nowTime,
|
||||
});
|
||||
|
||||
if (this.talk_type == 1 && this.getAccountId() !== this.sender_id) {
|
||||
@@ -248,6 +264,7 @@ class Talk extends Base {
|
||||
useTalkStore().UPDATE_TALK_MESSAGE({
|
||||
index_name: this.getIndexName(),
|
||||
msg_text: this.getTalkText(),
|
||||
lastMessageType: this.getMessageType(),
|
||||
updated_at: parseTime(new Date()),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ export default {
|
||||
await this.createTalk(this.$route.query.id);
|
||||
} else if (typeof this.loadChatList === "function") {
|
||||
await this.loadChatList();
|
||||
} else if (typeof this.loadChatListInJs === "function") {
|
||||
await this.loadChatListInJs();
|
||||
}
|
||||
}
|
||||
if(this.$route.query.goodsId && this.$route.query.skuId && this.goodsParams){
|
||||
@@ -104,8 +106,8 @@ export default {
|
||||
items: result.map((item) => formatTalkItem(item)),
|
||||
});
|
||||
|
||||
// 判断
|
||||
if (this.$route.query.id) {
|
||||
// 判断(仅在挂载了 clickTab 的页面组件实例上自动切换会话)
|
||||
if (this.$route.query.id && typeof this.clickTab === "function") {
|
||||
let takeData, takeIndex;
|
||||
result.forEach((take, index) => {
|
||||
if (take.id == this.$route.query.id) {
|
||||
@@ -113,9 +115,13 @@ export default {
|
||||
takeIndex = index;
|
||||
}
|
||||
});
|
||||
if (typeof this.$nextTick === "function") {
|
||||
this.$nextTick(() =>
|
||||
this.clickTab(this.$route.query.id, takeData, takeIndex)
|
||||
);
|
||||
} else {
|
||||
this.clickTab(this.$route.query.id, takeData, takeIndex);
|
||||
}
|
||||
}
|
||||
}).finally(() => {
|
||||
this.loadStatus = 1;
|
||||
|
||||
@@ -71,11 +71,17 @@ export const useTalkStore = defineStore("talks", {
|
||||
UPDATE_TALK_MESSAGE(resource) {
|
||||
let enableGetTalkList = true;
|
||||
this.items.forEach((item) => {
|
||||
if (item.userId == resource.index_name.split("_")[1]) {
|
||||
if (
|
||||
item.index_name === resource.index_name ||
|
||||
item.userId == resource.index_name.split("_")[1]
|
||||
) {
|
||||
item.unread++;
|
||||
item.msg_text = resource.msg_text;
|
||||
item.lastTalkTime = resource.updated_at;
|
||||
item.lastTalkMessage = resource.msg_text;
|
||||
if (resource.lastMessageType) {
|
||||
item.lastMessageType = resource.lastMessageType;
|
||||
}
|
||||
item.updated_at = resource.updated_at;
|
||||
enableGetTalkList = false;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ const mixinCtx = {
|
||||
return router.currentRoute.value;
|
||||
},
|
||||
};
|
||||
mixinCtx.loadChatListInJs = MainMixin.methods.loadChatListInJs.bind(mixinCtx);
|
||||
mixinCtx.loadStoreChatList = MainMixin.methods.loadStoreChatList.bind(mixinCtx);
|
||||
|
||||
export function setRefreshView(fn) {
|
||||
refreshViewFn = fn;
|
||||
|
||||
@@ -113,8 +113,9 @@
|
||||
</div>
|
||||
<div class="talk-message">
|
||||
<span v-if="item.lastMessageType === 'MESSAGE'" v-html="textReplaceEmoji(item.lastTalkMessage)"></span>
|
||||
<span v-if="item.lastMessageType === 'GOODS'">[商品链接]</span>
|
||||
<span v-if="item.lastMessageType === 'ORDER'">[订单链接]</span>
|
||||
<span v-else-if="item.lastMessageType === 'PICTURE'">[图片]</span>
|
||||
<span v-else-if="item.lastMessageType === 'GOODS'">[商品链接]</span>
|
||||
<span v-else-if="item.lastMessageType === 'ORDERS' || item.lastMessageType === 'ORDER'">[订单链接]</span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<template v-if="index_name != item.index_name && item.draft_text">
|
||||
@@ -428,7 +429,7 @@ function clickTab(id, val, index) {
|
||||
if (id == index_name.value.split("_")[1]) {
|
||||
talkStore.UPDATE_TALK_ITEM({
|
||||
userId: id,
|
||||
index_name: "MESSAGE_" + index_name.value.split("_")[1],
|
||||
index_name: index_name.value,
|
||||
unread: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -170,6 +170,10 @@ export const deleteMemberGroup = (id) => {
|
||||
return deleteRequest(`/member/memberGroup/delete/${id}`);
|
||||
};
|
||||
|
||||
export const getMemberGroupUsers = (groupId, params) => {
|
||||
return getRequest(`/member/memberGroup/${groupId}/users`, params);
|
||||
};
|
||||
|
||||
export const addMemberGroupUsers = (groupId, memberIds) => {
|
||||
return postRequest(`/member/memberGroup/${groupId}/users`, {
|
||||
memberIds: Array.isArray(memberIds) ? memberIds.join(",") : memberIds,
|
||||
@@ -235,3 +239,6 @@ export const deleteMemberBenefit = (id) => {
|
||||
export const getMemberBenefitTypes = () => {
|
||||
return getRequest("/member/benefit/types");
|
||||
};
|
||||
|
||||
export const getMemberBenefitDeliveries = params => getRequest('/member/benefit/deliveries', params);
|
||||
export const retryMemberBenefitDelivery = id => postRequest('/member/benefit/deliveries/' + id + '/retry');
|
||||
|
||||
6
manager/src/api/paid-member.js
Normal file
6
manager/src/api/paid-member.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { getRequest, putRequestWithNoForm } from '@/libs/axios';
|
||||
export const getPaidConfig = () => getRequest('/member/paid/config');
|
||||
export const savePaidConfig = data => putRequestWithNoForm('/member/paid/config', data);
|
||||
export const getPaidOrders = params => getRequest('/member/paid/orders', params);
|
||||
export const getPaidResources = (type, ids) => getRequest('/member/paid/resources', { type, ids: ids.join(',') });
|
||||
export const getPaidDiscountSkus = params => getRequest('/member/paid/discount-skus', params);
|
||||
@@ -283,11 +283,6 @@ export const getCouponReceiveList = (params) => {
|
||||
return getRequest("/promotion/coupon/received", params);
|
||||
};
|
||||
|
||||
export const getFlashDiscountList = (params) => getRequest("/promotion/flashDiscount", params);
|
||||
export const getFlashDiscountDetail = (id) => getRequest(`/promotion/flashDiscount/${id}`);
|
||||
export const updateFlashDiscountStatus = (id, params) => putRequest(`/promotion/flashDiscount/status/${id}`, params);
|
||||
export const deleteFlashDiscount = (id) => deleteRequest(`/promotion/flashDiscount/${id}`);
|
||||
export const getNthItemDiscountList = (params) => getRequest("/promotion/nthItemDiscount", params);
|
||||
|
||||
// 现金礼品卡活动分页
|
||||
export const getGiftCardCashActivityPage = (params) => {
|
||||
@@ -324,6 +319,11 @@ export const giftCardCashBatchCreate = (activityId, params) => {
|
||||
);
|
||||
};
|
||||
|
||||
/** 导出指定制卡批次的全部卡号、卡密,不受页面筛选和分页限制。 */
|
||||
export const exportGiftCardCashCreateBatch = (createBatchId) => {
|
||||
return getRequest(`/promotion/giftCardCash/createBatch/${encodeURIComponent(createBatchId)}/export`, {}, "blob");
|
||||
};
|
||||
|
||||
// 下载批量发卡 Excel 模板
|
||||
export const downloadGiftCardCashIssueTemplate = () => {
|
||||
return getRequest("/promotion/giftCardCash/issue/template", {}, "blob");
|
||||
|
||||
@@ -5,14 +5,10 @@ import {
|
||||
postRequest
|
||||
} from '@/libs/axios';
|
||||
|
||||
// 查询短信站内信信息
|
||||
// 查询站内信信息
|
||||
export const getMessageData = params => {
|
||||
return getRequest("/other/message", params);
|
||||
};
|
||||
//修改短信模板
|
||||
export const editSmsMessageTemplate = (id, params) => {
|
||||
return putRequest(`/sms/sms/${id}`, params)
|
||||
}
|
||||
//修改站内信模板
|
||||
export const editNoticeMessage = (id, params) => {
|
||||
return putRequest(`/setting/noticeMessage/${id}`, params)
|
||||
@@ -22,37 +18,11 @@ export const editNoticeMessage = (id, params) => {
|
||||
export const wechatMessageSync = (params) => {
|
||||
return getRequest("/wechat/wechatMessage/init", params)
|
||||
}
|
||||
//分页获取微信消息
|
||||
export const getWechatMessagePage = (params) => {
|
||||
return getRequest("/wechat/wechatMessage", params)
|
||||
}
|
||||
//编辑微信消息模版
|
||||
export const editWechatMessageTemplate = (id, params) => {
|
||||
return putRequest(`/wechat/wechatMessage/${id}`, params)
|
||||
}
|
||||
//删除微信消息模版
|
||||
export const delWechatMessageTemplate = (id, params) => {
|
||||
return deleteRequest(`/wechat/wechatMessage/${id}`, params)
|
||||
}
|
||||
|
||||
|
||||
//微信消息订阅同步
|
||||
//微信消息码同步
|
||||
export const wechatMPMessageSync = (params) => {
|
||||
return getRequest("/wechat/wechatMPMessage/init", params)
|
||||
}
|
||||
//分页获取微信消息订阅
|
||||
export const getWechatMPMessagePage = (params) => {
|
||||
return getRequest("/wechat/wechatMPMessage", params)
|
||||
}
|
||||
//编辑微信消息订阅模版
|
||||
export const editWechatMPMessageTemplate = (id, params) => {
|
||||
return putRequest(`/wechat/wechatMPMessage/${id}`, params)
|
||||
}
|
||||
//删除微信消息订阅模版
|
||||
export const delWechatMPMessageTemplate = (id, params) => {
|
||||
return deleteRequest(`/wechat/wechatMPMessage/${id}`, params)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 查询站内信模板信息
|
||||
@@ -71,122 +41,93 @@ export const openNoticeMessage = (id) => {
|
||||
export const updateMessageStatus = (id,status) => {
|
||||
return putRequest(`/setting/noticeMessage/${id}/${status}`);
|
||||
};
|
||||
//获取短信模板
|
||||
export const getSmsTemplatePage = (params) => {
|
||||
return getRequest(`sms/template/querySmsTemplatePage`, params);
|
||||
//获取设置
|
||||
export const getSetting = (key) => {
|
||||
return getRequest(`/setting/setting/get/${key}`);
|
||||
};
|
||||
//添加短信模板
|
||||
export const addSmsTemplatePage = (params) => {
|
||||
return postRequest(`sms/template`, params);
|
||||
//根据类型查看配置信息
|
||||
export const getSettingList = (params) => {
|
||||
return getRequest(`/setting/setting/all`, params);
|
||||
};
|
||||
//删除短信模板
|
||||
export const deleteSmsTemplatePage = (params) => {
|
||||
return deleteRequest(`sms/template`, params);
|
||||
};
|
||||
//删除短信模板
|
||||
export const editSmsTemplatePage = (params) => {
|
||||
return putRequest(`sms/template/modifySmsTemplate`, params);
|
||||
};
|
||||
//分页查询短信签名
|
||||
export const getSmsSignPage = (params) => {
|
||||
return getRequest(`sms/sign/querySmsSignPage`, params);
|
||||
};
|
||||
//更新短信模板
|
||||
export const syncTemplate = () => {
|
||||
return putRequest(`sms/template/querySmsSign`);
|
||||
};
|
||||
//更新短信模板
|
||||
export const addSmsSign = (params) => {
|
||||
return postRequest(`sms/sign`, params);
|
||||
};
|
||||
//同步短信签名
|
||||
export const syncSign = () => {
|
||||
return putRequest(`sms/sign/querySmsSign`);
|
||||
};
|
||||
//删除短信签名
|
||||
export const deleteSign = (id) => {
|
||||
return deleteRequest(`sms/sign/${id}`);
|
||||
};
|
||||
//更新短信签名
|
||||
export const editSmsSign = (params) => {
|
||||
return putRequest(`sms/sign/modifySmsSign`, params);
|
||||
};
|
||||
//查询签名详情
|
||||
export const smsSignDetail = (id) => {
|
||||
return getRequest(`sms/sign/${id}`);
|
||||
//设置配置信息
|
||||
export const setSetting = (key, params) => {
|
||||
return postRequest(`/setting/setting/put/${key}`, params);
|
||||
};
|
||||
|
||||
//分页查询app版本信息
|
||||
export const appVersionPage = (params) => {
|
||||
return getRequest(`/other/appVersion`, params);
|
||||
//获取设置
|
||||
export const getExperienceData = (key) => {
|
||||
return getRequest(`/setting/experience/get/${key}`);
|
||||
};
|
||||
//添加app版本信息
|
||||
export const addVersion = (params) => {
|
||||
return postRequest(`/other/appVersion`, params);
|
||||
};
|
||||
//修改app版本信息
|
||||
export const editVersion = (params, id) => {
|
||||
return putRequest(`/other/appVersion/${id}`, params);
|
||||
};
|
||||
//删除app版本信息
|
||||
export const deleteVersion = (id) => {
|
||||
return deleteRequest(`/other/appVersion/${id}`);
|
||||
};
|
||||
//查询同城配送列表
|
||||
export const getInstantDelivery = (params) => {
|
||||
return getRequest(`instant-delivery/getByPage`, params);
|
||||
//保存
|
||||
export const setExperienceData = (key, params) => {
|
||||
return postRequest(`/setting/experience/put/${key}`, params);
|
||||
};
|
||||
|
||||
//添加同城配送信息
|
||||
export const editInstantDelivery = (bean, params, headers) => {
|
||||
return putRequest(`instant-delivery/${bean}/config`, params, headers);
|
||||
//根据类型获取积分设置
|
||||
export const getPointData = (key) => {
|
||||
return getRequest(`/setting/point/get/${key}`);
|
||||
};
|
||||
//启用配送方案
|
||||
export const openInstantDelivery = (bean) => {
|
||||
return putRequest(`instant-delivery/${bean}/open`);
|
||||
//保存
|
||||
export const setPointData = (key, params) => {
|
||||
return postRequest(`/setting/point/put/${key}`, params);
|
||||
};
|
||||
|
||||
//修改封面图片
|
||||
export const editInstantDeliveryImage = (bean, params) => {
|
||||
return putRequest(`instant-delivery/${bean}/image`, params);
|
||||
//根据类型获取积分设置
|
||||
export const getKuaidiSetting = (key) => {
|
||||
return getRequest(`/setting/kuaidi/get/${key}`);
|
||||
};
|
||||
//保存
|
||||
export const setKuaidiSetting = (key, params) => {
|
||||
return postRequest(`/setting/kuaidi/put/${key}`, params);
|
||||
};
|
||||
|
||||
//查询短信发送记录
|
||||
export const getSmsPage = (params) => {
|
||||
return getRequest(`sms/sms`, params);
|
||||
//获取所有的物流公司
|
||||
export const getLogisticsList = () => {
|
||||
return getRequest(`/setting/logistics/all`);
|
||||
};
|
||||
//分页获取物流公司
|
||||
export const getLogisticsPage = (params) => {
|
||||
return getRequest(`/setting/logistics/getByPage`, params);
|
||||
};
|
||||
//添加物流公司
|
||||
export const addLogistics = (params) => {
|
||||
return postRequest(`/setting/logistics`, params);
|
||||
};
|
||||
//获取物流公司详情
|
||||
export const getLogisticsDetail = (id) => {
|
||||
return getRequest(`/setting/logistics/get/${id}`);
|
||||
};
|
||||
//编辑物流公司
|
||||
export const editLogistics = (id, params) => {
|
||||
return putRequest(`/setting/logistics/${id}`, params);
|
||||
};
|
||||
//删除物流公司
|
||||
export const delLogistics = (id) => {
|
||||
return deleteRequest(`/setting/logistics/delete/${id}`);
|
||||
};
|
||||
|
||||
//发送短信
|
||||
export const sendSms = (params) => {
|
||||
return postRequest(`sms/sms`, params);
|
||||
//禁用启用物流公司
|
||||
export const openLogistics = (id) => {
|
||||
return putRequest(`/setting/logistics/open/${id}`);
|
||||
};
|
||||
/*
|
||||
* 获取 支付支持设置开关
|
||||
*/
|
||||
export const getPaymentSupportForm = () => {
|
||||
return getRequest(`setting/settingx/paymentSupport`);
|
||||
}
|
||||
|
||||
// 验证码源文件删除
|
||||
export const delVerification = (id) => {
|
||||
return deleteRequest(`/other/verificationSource/${id}`)
|
||||
}
|
||||
//获取所有的物流公司
|
||||
export const getLogisticsAll = () => {
|
||||
return getRequest(`/setting/logistics/all`);
|
||||
};
|
||||
|
||||
// 添加验证码源文件
|
||||
export const addVerification = (params) => {
|
||||
// 验证码源文件添加
|
||||
export const createVerification = (params) => {
|
||||
return postRequest(`/other/verificationSource`, params)
|
||||
}
|
||||
|
||||
// 修改验证码源文件
|
||||
export const editVerification = (id, params) => {
|
||||
// 验证码源文件修改
|
||||
export const updateVerification = (id, params) => {
|
||||
return putRequest(`/other/verificationSource/${id}`, params)
|
||||
}
|
||||
|
||||
// 验证码源文件详情
|
||||
export const verificationDetail = (id) => {
|
||||
return getRequest(`/other/verificationSource/${id}`)
|
||||
// 验证码源文件删除
|
||||
export const deleteVerification = (id) => {
|
||||
return deleteRequest(`/other/verificationSource/${id}`)
|
||||
}
|
||||
|
||||
// 验证码源文件列表
|
||||
export const verificationPage = (params) => {
|
||||
return getRequest(`/other/verificationSource`, params)
|
||||
@@ -197,12 +138,64 @@ export const getNoticeMessageDetail = (id, params) => {
|
||||
return getRequest(`/setting/noticeMessage/${id}`, params)
|
||||
}
|
||||
|
||||
// 消息模板聚合分页(站内信 + 微信服务号 + 小程序)
|
||||
// 统一消息模板聚合分页(站内信 + 短信 + 微信服务号 + 小程序订阅)
|
||||
export const getMessageTemplateAggregatePage = params => {
|
||||
return getRequest("/setting/noticeMessage/aggregate", params);
|
||||
return getRequest("/setting/messageTemplate/page", params);
|
||||
};
|
||||
|
||||
// 修改通知类站内信邮箱渠道开关
|
||||
export const updateNoticeMessageEmailStatus = (id, status) => {
|
||||
return putRequest(`/setting/noticeMessage/${id}/email/${status}`);
|
||||
// 快速切换渠道开关 (channel: NOTICE / SMS / WECHAT_OA / WECHAT_MP, status: OPEN / CLOSE)
|
||||
export const updateMessageChannelSwitch = params => {
|
||||
return putRequest("/setting/messageTemplate/switch", params);
|
||||
};
|
||||
|
||||
// 保存站内信配置 (title, content, status)
|
||||
export const updateNoticeMessageTemplate = params => {
|
||||
return putRequest("/setting/messageTemplate/notice", params);
|
||||
};
|
||||
|
||||
// 保存短信配置 (smsTemplateCode, status; 内容只读不可编辑)
|
||||
export const updateSmsMessageTemplateConfig = params => {
|
||||
return putRequest("/setting/messageTemplate/sms", params);
|
||||
};
|
||||
|
||||
// 保存微信公众号服务号配置 (code, enable; 内容只读)
|
||||
export const updateWechatOaMessageTemplate = params => {
|
||||
return putRequest("/setting/messageTemplate/wechat-oa", params);
|
||||
};
|
||||
|
||||
// 保存微信小程序订阅消息配置 (code, enable; 内容只读)
|
||||
export const updateWechatMpMessageTemplate = params => {
|
||||
return putRequest("/setting/messageTemplate/wechat-mp", params);
|
||||
};
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 补充缺失的系统配置、版本及验证码 API
|
||||
// -------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
// 支付支持方式表单 (PAYMENT_SUPPORT.vue)
|
||||
export const getPaymentSupportForm = () => {
|
||||
return getRequest(`/setting/settingx/paymentSupport`);
|
||||
};
|
||||
|
||||
// 验证码源管理别名 (slider.vue)
|
||||
export const addVerification = createVerification;
|
||||
export const editVerification = updateVerification;
|
||||
export const delVerification = deleteVerification;
|
||||
|
||||
// APP版本管理 (appVersion.vue)
|
||||
export const appVersionPage = (params) => {
|
||||
return getRequest(`/other/appVersion`, params);
|
||||
};
|
||||
export const addVersion = (params) => {
|
||||
return postRequest(`/other/appVersion`, params);
|
||||
};
|
||||
export const editVersion = (params, id) => {
|
||||
return putRequest(`/other/appVersion/${id}`, params);
|
||||
};
|
||||
export const deleteVersion = (id) => {
|
||||
return deleteRequest(`/other/appVersion/${id}`);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- 使用 fullscreen 类来控制是否全屏显示 -->
|
||||
<div :class="{ fullscreen: fullscreen }" class="tinymce-container">
|
||||
<!-- 使用 tinymce-textarea 类作为编辑器的文本区域 -->
|
||||
<uploadImage @callback="insertImage" />
|
||||
<uploadImage :use-image-picker="imagePicker" @callback="insertImage" />
|
||||
<textarea :id="tinymceId" class="tinymce-textarea" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -21,10 +21,15 @@ import {
|
||||
} from "vue";
|
||||
import { initEditor } from "@/components/editor/config";
|
||||
import uploadImage from "@/components/editor/upload-image.vue";
|
||||
import { useFormItem } from "element-plus";
|
||||
|
||||
defineOptions({ name: "Tinymce" });
|
||||
|
||||
const props = defineProps({
|
||||
imagePicker: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
@@ -39,6 +44,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(["update:modelValue", "input"]);
|
||||
const { formItem } = useFormItem();
|
||||
|
||||
// 引入编辑器的配置(data 中的 initEditor 与 import 同名,直接使用 import)
|
||||
const hasChange = ref(false); // 标记内容是否有更改
|
||||
@@ -66,6 +72,9 @@ watch(editorContent, (val) => {
|
||||
function emitContent(content) {
|
||||
emit("update:modelValue", content);
|
||||
emit("input", content);
|
||||
if (props.imagePicker && content) {
|
||||
nextTick(() => formItem?.validate("change").catch(() => {}));
|
||||
}
|
||||
}
|
||||
// 数据返回并给富文本框插入图片
|
||||
function insertImage(arr) {
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
|
||||
<el-dialog
|
||||
v-model="showOssManager"
|
||||
title="选择图片"
|
||||
width="1000px"
|
||||
append-to-body
|
||||
:z-index="3600"
|
||||
@@ -94,6 +95,7 @@ import { Message } from "@/utils/message";
|
||||
import OssManage from "@/views/sys/oss-manage/ossManage.vue";
|
||||
|
||||
defineOptions({ name: "upload-image" });
|
||||
const props = defineProps({ useImagePicker: { type: Boolean, default: false } });
|
||||
|
||||
const emit = defineEmits(["callback"]);
|
||||
|
||||
@@ -120,6 +122,10 @@ function draggableItemKey(item) {
|
||||
return item?.url || item;
|
||||
}
|
||||
function handleClickUploadImage() {
|
||||
if (props.useImagePicker) {
|
||||
importOSS();
|
||||
return;
|
||||
}
|
||||
show.value = true;
|
||||
}
|
||||
function handleView(url) {
|
||||
@@ -158,7 +164,16 @@ function handleUploadError(err) {
|
||||
Message.error(err?.message || String(err));
|
||||
}
|
||||
function confirmUrls() {
|
||||
if (props.useImagePicker) {
|
||||
const urls = [...new Set(selectedImage.value.map(parseOssSelectionUrl).filter(Boolean))];
|
||||
if (!urls.length) {
|
||||
Message.warning("请选择图片");
|
||||
return;
|
||||
}
|
||||
emit("callback", urls);
|
||||
} else {
|
||||
applySelectedImages();
|
||||
}
|
||||
showOssManager.value = false;
|
||||
}
|
||||
function cancelOssImport() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,106 +1,151 @@
|
||||
<template>
|
||||
<div class="wrapper">
|
||||
<div class="wap-content">
|
||||
<div class="query-wrapper">
|
||||
<div class="goods-dialog-wrapper">
|
||||
<!-- 顶部搜索筛选栏 -->
|
||||
<div class="goods-query-bar">
|
||||
<div class="query-item">
|
||||
<div>搜索范围</div>
|
||||
<span class="query-label">商品名称:</span>
|
||||
<el-input
|
||||
v-model="goodsParams.goodsName"
|
||||
placeholder="商品名称"
|
||||
placeholder="请输入商品名称"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
style="width: 220px"
|
||||
@clear="onSearchGoods"
|
||||
@keyup.enter="onSearchGoods"
|
||||
/>
|
||||
</div>
|
||||
<div class="query-item">
|
||||
<span class="query-label">商品编号:</span>
|
||||
<el-input
|
||||
v-model="goodsParams.sn"
|
||||
placeholder="请输入商品编号/货号"
|
||||
clearable
|
||||
style="width: 190px"
|
||||
@clear="onSearchGoods"
|
||||
@keyup.enter="onSearchGoods"
|
||||
/>
|
||||
</div>
|
||||
<div class="query-item">
|
||||
<span class="query-label">商品分类:</span>
|
||||
<el-cascader
|
||||
v-model="category"
|
||||
:options="skuList"
|
||||
placeholder="请选择商品分类"
|
||||
popper-class="goods-dialog-cascader-popper"
|
||||
style="width: 250px"
|
||||
style="width: 240px"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="query-item">
|
||||
<div class="query-item query-actions">
|
||||
<el-button type="primary" @click="onSearchGoods">搜索</el-button>
|
||||
<el-button @click="onResetGoods">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<!-- 表格展示区域 (自适应填充剩余高度) -->
|
||||
<div class="goods-table-wrap">
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
class="goods-select-table"
|
||||
:data="goodsData"
|
||||
row-key="id"
|
||||
height="340"
|
||||
empty-text="暂无商品信息"
|
||||
height="100%"
|
||||
stripe
|
||||
border
|
||||
empty-text="暂无匹配商品信息"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<el-table-column
|
||||
type="selection"
|
||||
label="选择"
|
||||
width="60"
|
||||
width="50"
|
||||
align="center"
|
||||
reserve-selection
|
||||
/>
|
||||
<el-table-column label="商品图片" width="90" align="center">
|
||||
<el-table-column label="图片" width="76" align="center">
|
||||
<template #default="{ row }">
|
||||
<img class="goods-thumb" :src="row.thumbnail" alt="" />
|
||||
<el-image
|
||||
class="goods-thumb"
|
||||
:src="row.thumbnail"
|
||||
fit="cover"
|
||||
preview-teleported
|
||||
:preview-src-list="row.thumbnail ? [row.thumbnail] : []"
|
||||
>
|
||||
<template #error>
|
||||
<div class="thumb-slot">无图</div>
|
||||
</template>
|
||||
</el-image>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="goodsName"
|
||||
label="商品名称"
|
||||
min-width="200"
|
||||
min-width="260"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div class="goods-title-line">{{ row.goodsName || "-" }}</div>
|
||||
<div v-if="row.sn" class="goods-sn-line font-mono">货号: {{ row.sn }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商品规格" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.simpleSpecs || "默认规格" }}
|
||||
<span>{{ row.simpleSpecs || "默认规格" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="价格" width="110">
|
||||
<el-table-column label="店铺名称" prop="storeName" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.storeName || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="销售价" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="goods-price">¥{{ $filters.unitPrice(row.price) }}</span>
|
||||
<span class="goods-price font-semibold">
|
||||
¥ {{ Number(row.price || 0).toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="库存" width="90">
|
||||
<el-table-column label="可用库存" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
{{ row.quantity != null ? row.quantity : "—" }}
|
||||
<span>{{ row.quantity != null ? row.quantity : "—" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="销量" width="90">
|
||||
<el-table-column label="累计销量" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
{{ row.buyCount != null ? row.buyCount : 0 }}
|
||||
<span>{{ row.buyCount != null ? row.buyCount : 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商品类型" width="110">
|
||||
<el-table-column label="商品类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ goodsTypeLabel(row.goodsType) }}
|
||||
<span>{{ goodsTypeLabel(row.goodsType) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 底部翻页栏 -->
|
||||
<div class="goods-pagination-bar">
|
||||
<el-pagination
|
||||
v-model:current-page="goodsParams.pageNumber"
|
||||
class="pageration"
|
||||
v-model:page-size="goodsParams.pageSize"
|
||||
:total="total"
|
||||
:page-size="goodsParams.pageSize"
|
||||
layout="total, prev, pager, next"
|
||||
:page-sizes="[15, 30, 50, 100]"
|
||||
popper-class="goods-dialog-pagination-popper"
|
||||
:teleported="true"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
size="small"
|
||||
@current-change="changePageSize"
|
||||
@current-change="changePage"
|
||||
@size-change="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onMounted, reactive, ref, watch } from "vue";
|
||||
import { nextTick, onMounted, onUnmounted, reactive, ref, watch } from "vue";
|
||||
import * as API_Goods from "@/api/goods";
|
||||
import { goodsTypeLabel } from "@/constants/goodsType";
|
||||
|
||||
const props = defineProps({
|
||||
goodsLoader: { type: Function, default: null },
|
||||
queryParams: { type: Object, default: () => ({}) },
|
||||
selectedWay: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
@@ -127,10 +172,13 @@ const category = ref([]);
|
||||
const goodsData = ref([]);
|
||||
const loading = ref(false);
|
||||
const tableRef = ref(null);
|
||||
let syncingSelection = false;
|
||||
let querySequence = 0;
|
||||
|
||||
watch(category, (val) => {
|
||||
goodsParams.categoryPath = val && val.length ? val[2] : "";
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.selectedWay,
|
||||
(val) => {
|
||||
@@ -139,6 +187,7 @@ watch(
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => goodsParams.categoryPath,
|
||||
() => {
|
||||
@@ -152,26 +201,47 @@ watch(
|
||||
onMounted(() => {
|
||||
init();
|
||||
});
|
||||
watch(() => props.queryParams, onSearchGoods, { deep: true });
|
||||
onUnmounted(() => { querySequence++; });
|
||||
|
||||
function onSearchGoods() {
|
||||
goodsData.value = [];
|
||||
goodsParams.pageNumber = 1;
|
||||
getQueryGoodsList();
|
||||
}
|
||||
function changePageSize(v) {
|
||||
|
||||
function onResetGoods() {
|
||||
goodsParams.goodsName = "";
|
||||
goodsParams.sn = "";
|
||||
category.value = [];
|
||||
goodsParams.categoryPath = "";
|
||||
goodsParams.pageNumber = 1;
|
||||
getQueryGoodsList();
|
||||
}
|
||||
|
||||
function changePage(v) {
|
||||
goodsParams.pageNumber = v;
|
||||
getQueryGoodsList();
|
||||
}
|
||||
|
||||
function onPageSizeChange(val) {
|
||||
goodsParams.pageSize = val;
|
||||
goodsParams.pageNumber = 1;
|
||||
getQueryGoodsList();
|
||||
}
|
||||
|
||||
function getQueryGoodsList() {
|
||||
const sequence = ++querySequence;
|
||||
loading.value = true;
|
||||
API_Goods.getGoodsSkuData(goodsParams)
|
||||
(props.goodsLoader || API_Goods.getGoodsSkuData)({ ...goodsParams, ...props.queryParams })
|
||||
.then((res) => {
|
||||
initGoods(res);
|
||||
if (sequence === querySequence) initGoods(res);
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
if (sequence === querySequence) loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function initGoods(res) {
|
||||
const records = res?.result?.records || [];
|
||||
records.forEach((item) => {
|
||||
@@ -181,24 +251,31 @@ function initGoods(res) {
|
||||
goodsData.value = records;
|
||||
nextTick(syncTableSelection);
|
||||
}
|
||||
|
||||
function syncTableSelection() {
|
||||
const table = tableRef.value;
|
||||
if (!table) return;
|
||||
if (!selectedList.value.length) {
|
||||
// 将不在当前页的已选 SKU 也加入保留选择,避免翻页回显时丢失。
|
||||
syncingSelection = true;
|
||||
try {
|
||||
table.clearSelection();
|
||||
return;
|
||||
}
|
||||
goodsData.value.forEach((item) => {
|
||||
const selected = selectedList.value.some((e) => (e.id || e.skuId) && String(e.id || e.skuId) === String(item.id || item.skuId));
|
||||
table.toggleRowSelection(item, selected);
|
||||
selectedList.value.forEach((item) => {
|
||||
const id = String(item.id || item.skuId);
|
||||
const current = goodsData.value.find(row => String(row.id || row.skuId) === id);
|
||||
table.toggleRowSelection(current || item, true);
|
||||
});
|
||||
} finally {
|
||||
syncingSelection = false;
|
||||
}
|
||||
}
|
||||
|
||||
function emitSelected(list) {
|
||||
selectedList.value = list;
|
||||
emit("selected", selectedList.value);
|
||||
}
|
||||
|
||||
function onSelectionChange(rows) {
|
||||
// 选择链接时为单选:保留最后勾选的一项,多选框交互不变
|
||||
if (syncingSelection) return;
|
||||
if (type.value !== "multiple") {
|
||||
if (!rows.length) {
|
||||
emitSelected([]);
|
||||
@@ -214,17 +291,18 @@ function onSelectionChange(rows) {
|
||||
}
|
||||
emitSelected(rows);
|
||||
}
|
||||
|
||||
function init() {
|
||||
API_Goods.getGoodsSkuData(goodsParams).then((res) => {
|
||||
initGoods(res);
|
||||
});
|
||||
getQueryGoodsList();
|
||||
API_Goods.getCategoryTree({ deleteFlag: false }).then((res) => {
|
||||
if (res.success) {
|
||||
if (res.success && res.result) {
|
||||
deepGroup(res.result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deepGroup(val) {
|
||||
skuList.value = [];
|
||||
val.forEach((item) => {
|
||||
let childWay = [];
|
||||
if (item.children) {
|
||||
@@ -252,34 +330,102 @@ function deepGroup(val) {
|
||||
});
|
||||
}
|
||||
|
||||
// 父组件 lili-dialog/index.vue 通过 $refs.goodsDialog.type = "single" 切换单选;
|
||||
// expose 的 ref 外部读写等价于原 data 字段
|
||||
defineExpose({ type });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "./style.scss";
|
||||
.wap-content {
|
||||
.goods-dialog-wrapper {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 顶部搜索栏 */
|
||||
.goods-query-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 0 12px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.query-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.query-label {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
white-space: nowrap;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.query-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 表格容器 */
|
||||
.goods-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.goods-thumb {
|
||||
display: block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.thumb-slot {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.goods-title-line {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.goods-sn-line {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.goods-price {
|
||||
color: $theme_color;
|
||||
color: #f56c6c;
|
||||
}
|
||||
.pageration {
|
||||
|
||||
/* 底部翻页栏 */
|
||||
.goods-pagination-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
padding-right: 8px;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
padding-top: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,18 +2,22 @@
|
||||
<el-dialog
|
||||
v-if="goodsFlag"
|
||||
v-model="flag"
|
||||
width="1160px"
|
||||
top="120px"
|
||||
title="选择商品"
|
||||
width="85%"
|
||||
top="5vh"
|
||||
:z-index="10000"
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
class="goods-selector-dialog"
|
||||
@close="clickClose"
|
||||
>
|
||||
<goodsDialog
|
||||
v-if="flag"
|
||||
ref="goodsDialogRef"
|
||||
:selectedWay="goodsData"
|
||||
:goods-loader="goodsLoader"
|
||||
:query-params="queryParams"
|
||||
@selected="
|
||||
(val) => {
|
||||
goodsData = val;
|
||||
@@ -21,8 +25,15 @@
|
||||
"
|
||||
/>
|
||||
<template #footer>
|
||||
<div class="goods-dialog-footer">
|
||||
<span class="selected-count-tip">
|
||||
已选择 <strong class="text-primary">{{ goodsData.length }}</strong> 件商品
|
||||
</span>
|
||||
<div class="footer-actions">
|
||||
<el-button @click="clickClose">取消</el-button>
|
||||
<el-button type="primary" @click="clickOK">确定</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -53,12 +64,14 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import goodsDialog from "./goods-dialog";
|
||||
import linkDialog from "./link-dialog";
|
||||
|
||||
const emit = defineEmits(["closeFlag", "selectedGoodsData", "selectedLink"]);
|
||||
defineProps({ goodsLoader: { type: Function, default: null }, queryParams: { type: Object, default: () => ({}) } });
|
||||
|
||||
const goodsFlag = ref(false); // 是否商品选择器
|
||||
const goodsData = ref([]); // 选择的商品
|
||||
@@ -112,14 +125,7 @@ function close() {
|
||||
flag.value = false;
|
||||
}
|
||||
|
||||
// 外部通过 ref 访问的成员(grep manager/src 全量确认):
|
||||
// - open:page-decoration 各 modelList、modelForm/modelFormItem、hotzone/Zone、wap/advertising、alertAdvertising 等打开弹窗(goods/link)
|
||||
// - singleGoods:modelList/forYour.vue、mixModel.vue 直接调用
|
||||
// - clearGoodsSelected:wap/decorate.vue、modelList/goodsAndType.vue 调用
|
||||
// - goodsData:modelList/newGoodsSort.vue、notEnough.vue 回写已选商品
|
||||
// - goodsFlag:wap/decorate.vue 直接写入
|
||||
// - flag:wap/decorate.vue 直接写入 true 打开弹窗
|
||||
// - close:未被 grep 到外部调用,原 Options API 实例天然暴露,保守保留
|
||||
// 外部通过 ref 访问的成员:
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
@@ -130,14 +136,73 @@ defineExpose({
|
||||
flag,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.el-dialog__body) {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.goods-dialog-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.selected-count-tip {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
|
||||
.text-primary {
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
/* 商品选择器弹窗全局尺寸与布局增强 */
|
||||
.goods-selector-dialog {
|
||||
max-width: 1360px;
|
||||
min-width: 900px;
|
||||
border-radius: 8px;
|
||||
|
||||
.el-dialog__header {
|
||||
padding: 16px 20px;
|
||||
margin-right: 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
|
||||
.el-dialog__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
width: 100% !important;
|
||||
height: calc(84vh - 120px) !important;
|
||||
min-height: 520px !important;
|
||||
max-height: 760px !important;
|
||||
padding: 16px 20px !important;
|
||||
box-sizing: border-box !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
}
|
||||
|
||||
// 抽屉 append-to-body 后不在当前组件 DOM 内,不能使用 scoped 样式
|
||||
.link-selector-drawer {
|
||||
max-width: 96vw;
|
||||
@@ -152,7 +217,8 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
// 商品分类下拉挂载到 body,需要高于抽屉的 10000 层级
|
||||
.goods-dialog-cascader-popper {
|
||||
.goods-dialog-cascader-popper,
|
||||
.goods-dialog-pagination-popper {
|
||||
z-index: 10001 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<!--
|
||||
楼层装修链接选择器-营销活动选择面板(满额活动拆分为满减、满折、满赠三项)
|
||||
@author Bulbasaur
|
||||
@since 2026-11-09
|
||||
-->
|
||||
<template>
|
||||
<div class="wrapper">
|
||||
<div class="list">
|
||||
<div
|
||||
v-for="(item, index) in Object.keys(promotionList)"
|
||||
v-for="(item, index) in promotionKeys"
|
||||
:key="index"
|
||||
class="list-item"
|
||||
:class="{ active: selectedIndex == index }"
|
||||
@@ -76,7 +81,7 @@ const emit = defineEmits(["selected"]);
|
||||
|
||||
const totals = ref("");
|
||||
const loading = ref(true);
|
||||
const promotionList = ref("");
|
||||
const promotionList = ref({});
|
||||
const selectedIndex = ref(0);
|
||||
const promotions = ref("");
|
||||
const index = ref(999);
|
||||
@@ -86,6 +91,12 @@ const params = ref({
|
||||
});
|
||||
const showPromotionList = ref([]);
|
||||
|
||||
const promotionKeys = computed(() => {
|
||||
return Object.keys(promotionList.value || {}).filter(
|
||||
(k) => k !== "FULL_DISCOUNT" && Boolean(typeOption(k).title)
|
||||
);
|
||||
});
|
||||
|
||||
const isSeckillMode = computed(() => {
|
||||
return promotions.value === "SECKILL";
|
||||
});
|
||||
@@ -106,12 +117,18 @@ function seckillStatusText(v) {
|
||||
}
|
||||
function sortGoods(type) {
|
||||
loading.value = false;
|
||||
showPromotionList.value = promotionList.value[type];
|
||||
showPromotionList.value = promotionList.value[type] || [];
|
||||
}
|
||||
function typeOption(type) {
|
||||
switch (type) {
|
||||
case "FULL_MINUS":
|
||||
return { title: "满减活动", methodsed: () => { showPromotionList.value = []; sortGoods("FULL_MINUS"); } };
|
||||
case "FULL_RATE":
|
||||
return { title: "满折活动", methodsed: () => { showPromotionList.value = []; sortGoods("FULL_RATE"); } };
|
||||
case "FULL_GIFT":
|
||||
return { title: "满赠活动", methodsed: () => { showPromotionList.value = []; sortGoods("FULL_GIFT"); } };
|
||||
case "FULL_DISCOUNT":
|
||||
return { title: "满减", methodsed: () => { showPromotionList.value = []; sortGoods("FULL_DISCOUNT"); } };
|
||||
return { title: "满减活动", methodsed: () => { showPromotionList.value = []; sortGoods("FULL_DISCOUNT"); } };
|
||||
case "PINTUAN":
|
||||
return { title: "拼团", methodsed: () => { showPromotionList.value = []; sortGoods("PINTUAN"); } };
|
||||
case "KANJIA":
|
||||
@@ -143,15 +160,18 @@ function getPromotion(res) {
|
||||
if (res.result) {
|
||||
promotionList.value = res.result;
|
||||
delete promotionList.value.COUPON;
|
||||
Object.keys(res.result)[0] && typeOption(Object.keys(res.result)[0]).methodsed();
|
||||
promotions.value = Object.keys(res.result)[0];
|
||||
const keys = promotionKeys.value;
|
||||
if (keys.length) {
|
||||
promotions.value = keys[0];
|
||||
typeOption(keys[0]) && typeOption(keys[0]).methodsed();
|
||||
}
|
||||
}
|
||||
}
|
||||
function clickPromotion(val, i) {
|
||||
promotions.value = val;
|
||||
selectedIndex.value = i;
|
||||
params.value.pageNumber = 1;
|
||||
typeOption(val) && typeOption(val).methodsed(promotionList.value[val].id);
|
||||
typeOption(val) && typeOption(val).methodsed(promotionList.value[val]?.id);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1,174 +1,10 @@
|
||||
<template>
|
||||
<div class="shop">
|
||||
<div class="wap-content">
|
||||
<div class="query-wrapper">
|
||||
<div class="query-item">
|
||||
<div>店铺名称</div>
|
||||
<el-input
|
||||
v-model="params.storeName"
|
||||
placeholder="请输入店铺名称"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
@clear="resetSearch"
|
||||
@keyup.enter="resetSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="query-item">
|
||||
<el-button type="primary" @click="resetSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
class="shops-select-table"
|
||||
:data="shopsData"
|
||||
row-key="id"
|
||||
height="340"
|
||||
empty-text="暂无店铺信息"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<el-table-column
|
||||
type="selection"
|
||||
label="选择"
|
||||
width="60"
|
||||
align="center"
|
||||
reserve-selection
|
||||
/>
|
||||
<el-table-column label="店铺图片" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<img class="shop-logo" :src="row.storeLogo" alt="" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="storeName"
|
||||
label="店铺名称"
|
||||
min-width="240"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="createTime"
|
||||
label="创建时间"
|
||||
width="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="店铺类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ theme_color: row.selfOperated }">
|
||||
{{ row.selfOperated ? "自营" : "非自营" }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="店铺状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ theme_color: row.storeDisable === 'OPEN' }">
|
||||
{{ row.storeDisable === "OPEN" ? "开启中" : "未开启" }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
class="pageration"
|
||||
size="small"
|
||||
layout="total, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-size="params.pageSize"
|
||||
:current-page="params.pageNumber"
|
||||
@current-change="changePageSize"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StoreSelector embedded :multiple="false" :query-params="shopQuery" @selected="selectShop" />
|
||||
</template>
|
||||
<script setup>
|
||||
import { nextTick, reactive, ref } from "vue";
|
||||
import { getShopListData } from "@/api/shops.js";
|
||||
|
||||
const emit = defineEmits(["selected"]);
|
||||
|
||||
const loading = ref(false);
|
||||
const total = ref(0);
|
||||
const params = reactive({
|
||||
pageNumber: 1,
|
||||
pageSize: 12,
|
||||
storeDisable: "OPEN",
|
||||
storeName: "",
|
||||
});
|
||||
const shopsData = ref([]);
|
||||
const tableRef = ref(null);
|
||||
const selectedShop = ref(null);
|
||||
|
||||
// 无 defineExpose:grep manager/src 全量确认无外部 ref/$refs/$parent 访问本组件成员
|
||||
// (link-dialog.vue 的 $refs["lili-component"][0].type 写入指向首个 tab 的 goods-dialog,与本组件无关)
|
||||
|
||||
// created
|
||||
init();
|
||||
|
||||
function resetSearch() {
|
||||
shopsData.value = [];
|
||||
params.pageNumber = 1;
|
||||
init();
|
||||
}
|
||||
|
||||
function changePageSize(v) {
|
||||
params.pageNumber = v;
|
||||
init();
|
||||
}
|
||||
|
||||
function init() {
|
||||
loading.value = true;
|
||||
getShopListData(params)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
total.value = res.result.total;
|
||||
shopsData.value = res.result.records;
|
||||
nextTick(syncTableSelection);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function syncTableSelection() {
|
||||
const table = tableRef.value;
|
||||
if (!table || !selectedShop.value) return;
|
||||
shopsData.value.forEach((row) => {
|
||||
table.toggleRowSelection(row, row.id === selectedShop.value.id);
|
||||
});
|
||||
}
|
||||
|
||||
function onSelectionChange(rows) {
|
||||
// 选择链接只能保留一个店铺,勾选新店铺时自动取消上一个
|
||||
if (!rows.length) {
|
||||
selectedShop.value = null;
|
||||
emit("selected", []);
|
||||
return;
|
||||
}
|
||||
const row = rows[rows.length - 1];
|
||||
if (rows.length > 1) {
|
||||
tableRef.value?.clearSelection();
|
||||
tableRef.value?.toggleRowSelection(row, true);
|
||||
}
|
||||
selectedShop.value = row;
|
||||
emit("selected", [{ ...row, ___type: "shops" }]);
|
||||
}
|
||||
import StoreSelector from '@/components/store-selector/index.vue';
|
||||
// Author: Bulbasaur; 2026-11-05. 装修链接复用统一店铺选择组件,保留单选及启用店铺范围。
|
||||
const emit = defineEmits(['selected']);
|
||||
const shopQuery = { storeDisable: 'OPEN' };
|
||||
function selectShop(rows) { emit('selected', rows.map(row => ({ ...row, ___type: 'shops' }))); }
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../style.scss";
|
||||
.shop {
|
||||
display: flex;
|
||||
}
|
||||
.shop-logo {
|
||||
display: block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto;
|
||||
border-radius: 4px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.pageration {
|
||||
margin-top: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -105,6 +105,7 @@ const props = defineProps({
|
||||
default: false,
|
||||
},
|
||||
maxlength: Number,
|
||||
beforeSelect: Function,
|
||||
});
|
||||
const emit = defineEmits(["update:modelValue", "input", "on-change"]);
|
||||
const attrs = useAttrs();
|
||||
@@ -131,6 +132,7 @@ function handleCLickImg(val, index) {
|
||||
});
|
||||
}
|
||||
function handleOssCallback(item) {
|
||||
if (item?.url && props.beforeSelect && props.beforeSelect(item) === false) return;
|
||||
picModalFlag.value = false;
|
||||
if (!item?.url) return;
|
||||
currentValue.value = item.url;
|
||||
|
||||
30
manager/src/components/list-action-bar.vue
Normal file
30
manager/src/components/list-action-bar.vue
Normal 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>
|
||||
@@ -119,7 +119,7 @@ function finished() {
|
||||
});
|
||||
}
|
||||
|
||||
// 父组件(member/index、memberRecycle、memberDetail、orderDetail、shopOperation)通过 ref 调用 open() 打开弹窗
|
||||
// 父组件(member/index、memberDetail、orderDetail、shopOperation)通过 ref 调用 open() 打开弹窗
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
|
||||
25
manager/src/components/store-selector/README.md
Normal file
25
manager/src/components/store-selector/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# 统一店铺选择器
|
||||
|
||||
作者:Bulbasaur;2026-11-05。
|
||||
|
||||
默认从右侧打开抽屉,支持搜索、分页、多选和已选回显。取消不提交,确认通过 `selected` 返回完整已选店铺数组。店铺类型、状态均为普通文字,无右侧预览区。
|
||||
|
||||
```vue
|
||||
<StoreSelector ref="selector" @selected="onSelected" />
|
||||
```
|
||||
|
||||
导入 `@/components/store-selector/index.vue`,通过 `selector.value.open(selectedRows)` 打开。`selectedRows` 为包含 `id` 的店铺对象数组;每次打开建立独立选择草稿。
|
||||
|
||||
| 参数 | 默认值 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| multiple | true | false 时单选,返回数组最多一项 |
|
||||
| embedded | false | true 时在父容器展示列表,并在选择时立即通知;适用于装修链接面板 |
|
||||
| selectedWay | [] | 初始店铺数组;内嵌模式监听外部选择变化 |
|
||||
| queryParams | {} | 店铺查询限制,例如 `{ storeDisable: 'OPEN' }` |
|
||||
| title | 选择店铺 | 抽屉标题 |
|
||||
| size | 1080px | 抽屉宽度,最大为视口宽度 |
|
||||
| tableHeight | 自动 | 可选表格高度 |
|
||||
|
||||
已接入付费会员、装修链接单选、移动端装修店铺列表配置。查询复用 `getShopListData`(`/store/store`),不新增后端接口。
|
||||
|
||||
验证:在工作区根目录运行 `node scripts/test-store-selector.cjs`。
|
||||
115
manager/src/components/store-selector/index.vue
Normal file
115
manager/src/components/store-selector/index.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<component :is="embedded ? 'div' : ElDrawer" v-bind="embedded ? {} : { modelValue: visible, title, size, direction: 'rtl', appendToBody: true, destroyOnClose: true }" :class="embedded ? 'store-selector-inline' : 'store-picker-drawer'" @update:model-value="visible = $event" @close="request++">
|
||||
<el-form class="picker-search" inline @submit.prevent="search">
|
||||
<el-form-item label="店铺名称"><el-input v-model="keyword" clearable placeholder="请输入店铺名称" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
<div class="picker-content">
|
||||
<div class="picker-results">
|
||||
<el-alert v-if="error" title="加载失败,请重试" type="error" :closable="false"><el-button link type="primary" @click="load">重新加载</el-button></el-alert>
|
||||
<el-table ref="table" v-loading="loading" :data="rows" row-key="id" border :height="tableHeight || (embedded ? '340px' : 'max(300px, calc(100vh - 270px))')" :empty-text="error ? '加载失败' : '暂无匹配结果'" @select="selectStore" @select-all="selectPage" @row-click="toggleStore">
|
||||
<el-table-column v-if="multiple" type="selection" :reserve-selection="false" width="48" align="center" />
|
||||
<el-table-column v-else label="选择" width="60" align="center"><template #default="{row}"><el-radio :model-value="selectedStores[0]?.id" :value="row.id" :aria-label="'选择' + row.storeName" @click.stop @change="chooseSingle(row)" /></template></el-table-column>
|
||||
<el-table-column label="店铺信息" min-width="265"><template #default="{row}"><div class="store-info"><el-avatar :size="40" shape="square" :src="row.storeLogo || ''">店</el-avatar><div class="store-copy"><div class="store-name" :title="row.storeName">{{ row.storeName || '未命名店铺' }}</div><div class="store-id">ID:{{ row.id }}</div></div></div></template></el-table-column>
|
||||
<el-table-column label="店铺类型" width="96" align="center"><template #default="{row}">{{ selfOperated(row) ? '自营' : '非自营' }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center"><template #default="{row}">{{ storeStatus(row.storeDisable) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="page" :total="total" :page-size="10" layout="total, prev, pager, next" @current-change="load" />
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="!embedded" #footer><div class="picker-footer"><span class="selected-count">已选择 <b>{{ selectedStores.length }}</b> 家店铺</span><div><el-button @click="visible = false">取消</el-button><el-button type="primary" :disabled="loading" @click="confirm">确定</el-button></div></div></template>
|
||||
</component>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { ElDrawer } from 'element-plus';
|
||||
import { getShopListData } from '@/api/shops';
|
||||
// Author: Bulbasaur; 2026-11-05.
|
||||
const props = defineProps({
|
||||
multiple: { type: Boolean, default: true },
|
||||
embedded: { type: Boolean, default: false },
|
||||
selectedWay: { type: Array, default: () => [] },
|
||||
queryParams: { type: Object, default: () => ({}) },
|
||||
title: { type: String, default: '选择店铺' },
|
||||
size: { type: [String, Number], default: '1080px' },
|
||||
tableHeight: { type: String, default: '' },
|
||||
});
|
||||
const emit = defineEmits(['selected']);
|
||||
const visible = ref(false), keyword = ref(''), rows = ref([]), total = ref(0), page = ref(1), loading = ref(false), error = ref(false), table = ref();
|
||||
const storeSelection = ref({});
|
||||
const selectedStores = computed(() => Object.values(storeSelection.value));
|
||||
let request = 0;
|
||||
const selfOperated = row => [true, 1, 'true', '1'].includes(row.selfOperated);
|
||||
const storeStatus = value => ({OPEN:'营业中',CLOSED:'已关闭',APPLY:'待审核',APPLYING:'待审核',REFUSED:'审核拒绝',EXPIRED:'已过期'}[value] || value || '—');
|
||||
async function syncStores() {
|
||||
await nextTick();
|
||||
if (!table.value) return;
|
||||
table.value.clearSelection();
|
||||
rows.value.forEach(row => { if (storeSelection.value[String(row.id)]) table.value.toggleRowSelection(row, true); });
|
||||
}
|
||||
function notifyEmbedded() { if (props.embedded) emit('selected', selectedStores.value.map(row => ({...row}))); }
|
||||
function chooseSingle(row) { storeSelection.value = { [String(row.id)]: {...row} }; notifyEmbedded(); }
|
||||
function selectStore(selected, row) {
|
||||
const key = String(row.id);
|
||||
if (selected.some(item => String(item.id) === key)) storeSelection.value[key] = row;
|
||||
else delete storeSelection.value[key];
|
||||
notifyEmbedded();
|
||||
}
|
||||
function selectPage(selected) {
|
||||
const ids = new Set(selected.map(row => String(row.id)));
|
||||
rows.value.forEach(row => { if (ids.has(String(row.id))) storeSelection.value[String(row.id)] = row; else delete storeSelection.value[String(row.id)]; });
|
||||
notifyEmbedded();
|
||||
}
|
||||
function toggleStore(row, column) {
|
||||
if (column?.type === 'selection') return;
|
||||
if (!props.multiple) { chooseSingle(row); return; }
|
||||
const key = String(row.id);
|
||||
if (storeSelection.value[key]) delete storeSelection.value[key]; else storeSelection.value[key] = row;
|
||||
syncStores();
|
||||
notifyEmbedded();
|
||||
}
|
||||
async function load() {
|
||||
const id = ++request; loading.value = true; error.value = false;
|
||||
try {
|
||||
const res = await getShopListData({ ...props.queryParams, pageNumber: page.value, pageSize: 10, storeName: keyword.value.trim() });
|
||||
if (id !== request || !visible.value) return;
|
||||
if (!res.success) throw new Error('load');
|
||||
rows.value = res.result.records || []; total.value = res.result.total || 0;
|
||||
rows.value.forEach(row => { if (storeSelection.value[String(row.id)]) storeSelection.value[String(row.id)] = row; });
|
||||
await syncStores();
|
||||
} catch { if (id === request) { error.value = true; rows.value = []; total.value = 0; } }
|
||||
finally { if (id === request) loading.value = false; }
|
||||
}
|
||||
function search() { page.value = 1; load(); }
|
||||
function reset() { keyword.value = ''; search(); }
|
||||
async function open(selected = props.selectedWay) {
|
||||
request++; keyword.value = ''; rows.value = []; total.value = 0; page.value = 1; error.value = false;
|
||||
setSelection(selected);
|
||||
visible.value = true;
|
||||
await nextTick(); table.value?.clearSelection();
|
||||
await load();
|
||||
}
|
||||
function setSelection(selected) {
|
||||
const valid = (Array.isArray(selected) ? selected : []).filter(row => row && row.id != null);
|
||||
storeSelection.value = Object.fromEntries((props.multiple ? valid : valid.slice(0,1)).map(row => [String(row.id), {...row}]));
|
||||
}
|
||||
function confirm() { emit('selected', selectedStores.value.map(row => ({...row}))); visible.value = false; }
|
||||
watch(() => props.selectedWay, selected => { if (props.embedded) { setSelection(selected); syncStores(); } }, { deep: true });
|
||||
watch(() => props.queryParams, () => { if (visible.value) search(); }, { deep: true });
|
||||
onMounted(() => { if (props.embedded) open(); });
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
<style scoped>
|
||||
:global(.store-picker-drawer) { max-width: 100vw; }
|
||||
:global(.store-picker-drawer .el-drawer__header) { margin-bottom: 0; padding: 20px; border-bottom: 1px solid #ebeef5; }
|
||||
:global(.store-picker-drawer .el-drawer__body) { padding: 20px; }
|
||||
:global(.store-picker-drawer .el-drawer__footer) { padding: 0 20px 20px; }
|
||||
.picker-search { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 16px 18px 0; margin-bottom: 18px; background: #f7f8fa; border-radius: 6px; }
|
||||
.picker-search .el-form-item { margin-right: 0; margin-bottom: 16px; }.picker-search .el-input { width: 240px; }
|
||||
.picker-content { min-width: 0; }.picker-results { min-width: 0; }
|
||||
.el-pagination { margin-top: 18px; }.store-info { display: flex; align-items: center; gap: 12px; min-height: 50px; }.el-avatar { flex-shrink: 0; background: #edf2f8; color: #7b8da6; }
|
||||
.store-copy { min-width: 0; flex: 1; }.store-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; color: #303133; }.store-id { margin-top: 3px; font-size: 12px; color: #909399; overflow-wrap: anywhere; }
|
||||
.selected-count b { color: var(--el-color-primary); }
|
||||
.picker-footer { display: flex; justify-content: space-between; align-items: center; border-top: 1px solid #ebeef5; padding-top: 16px; }.selected-count { color: #909399; }
|
||||
@media(max-width: 800px) { .picker-search .el-input { width: 180px; } }
|
||||
</style>
|
||||
33
manager/src/composables/useSiteSettings.js
Normal file
33
manager/src/composables/useSiteSettings.js
Normal 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 };
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -7,7 +7,13 @@ let util = {};
|
||||
|
||||
/** 已弃用、前端未实现或已移除的菜单 frontRoute,注册路由时跳过 */
|
||||
const DEPRECATED_FRONT_ROUTES = new Set([
|
||||
"sys/message/sms",
|
||||
"sys/message/smsSign",
|
||||
"goods/goods-manage/spec",
|
||||
"member/list/memberRecycle",
|
||||
"order/order/fictitiousOrderList",
|
||||
"promotions/flash-discount",
|
||||
"promotions/nth-item-discount",
|
||||
]);
|
||||
|
||||
function isDeprecatedMenu(menu) {
|
||||
@@ -402,6 +408,15 @@ util.initRouterNode = function(routers, data) {
|
||||
}
|
||||
const menu = Object.assign({}, item);
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
if (
|
||||
!hasChildren &&
|
||||
(menu.frontRoute === "promotions/full-discount/full-discount" ||
|
||||
menu.name === "full-discount" ||
|
||||
menu.name === "full-cut-activity") &&
|
||||
menu.title === "满额活动"
|
||||
) {
|
||||
menu.title = "满减活动";
|
||||
}
|
||||
|
||||
if (hasChildren) {
|
||||
menu.children = [];
|
||||
|
||||
@@ -38,6 +38,20 @@ export const otherRouter = {
|
||||
redirect: "/home",
|
||||
component: Main,
|
||||
children: [
|
||||
{
|
||||
path: "messageTemplate",
|
||||
title: "消息模板",
|
||||
name: "messageTemplate",
|
||||
meta: { title: "消息模板" },
|
||||
component: () => import("@/views/sys/message/messageTemplate.vue")
|
||||
},
|
||||
{
|
||||
path: "noticeMessageTemplate",
|
||||
title: "消息模板",
|
||||
name: "noticeMessageTemplate",
|
||||
meta: { title: "消息模板" },
|
||||
component: () => import("@/views/sys/message/messageTemplate.vue")
|
||||
},
|
||||
{
|
||||
path: "home",
|
||||
title: '首页',
|
||||
@@ -112,6 +126,12 @@ export const otherRouter = {
|
||||
name: "goods-category",
|
||||
component: () => import("@/views/goods/goods-manage/category.vue")
|
||||
},
|
||||
{
|
||||
path: "goods-scheduled-upper",
|
||||
title: "定时上架",
|
||||
name: "goods-scheduled-upper",
|
||||
component: () => import("@/views/goods/scheduled-upper/index.vue")
|
||||
},
|
||||
{
|
||||
path: "goods-group",
|
||||
title: "商品分组",
|
||||
@@ -149,6 +169,12 @@ export const otherRouter = {
|
||||
name: "order-detail",
|
||||
component: () => import("@/views/order/order/orderDetail.vue")
|
||||
},
|
||||
{
|
||||
path: "wechat-order-list",
|
||||
title: "小程序订单",
|
||||
name: "wechat-order-list",
|
||||
component: () => import("@/views/order/wechat/orderList.vue")
|
||||
},
|
||||
{
|
||||
path: "appointment/work-orders",
|
||||
title: "预约工单",
|
||||
@@ -198,6 +224,20 @@ export const otherRouter = {
|
||||
name: "member-benefit",
|
||||
component: () => import("@/views/member/benefit/index.vue")
|
||||
},
|
||||
{
|
||||
path: "paid-member-settings", title: "付费会员", name: "paid-member-settings",
|
||||
component: () => import("@/views/member/paid/settings.vue")
|
||||
},
|
||||
{
|
||||
path: "paid-member-records", title: "付费记录", name: "paid-member-records",
|
||||
component: () => import("@/views/member/paid/records.vue")
|
||||
},
|
||||
{
|
||||
path: "member-benefit-deliveries",
|
||||
title: "权益发放明细",
|
||||
name: "member-benefit-deliveries",
|
||||
component: () => import("@/views/member/benefit/BenefitDeliveryRecords.vue")
|
||||
},
|
||||
{
|
||||
path: "member-grade-experience",
|
||||
title: "等级权益设置",
|
||||
@@ -346,18 +386,6 @@ export const otherRouter = {
|
||||
name: "full-discount-detail",
|
||||
component: () => import("@/views/promotions/full-discount/full-discount-detail.vue")
|
||||
},
|
||||
{
|
||||
path: "promotions/flash-discount",
|
||||
title: "限时直降",
|
||||
name: "flash-discount",
|
||||
component: () => import("@/views/promotions/flash-discount/flash-discount.vue")
|
||||
},
|
||||
{
|
||||
path: "promotions/nth-item-discount",
|
||||
title: "第N件优惠",
|
||||
name: "nth-item-discount",
|
||||
component: () => import("@/views/promotions/nth-item-discount/nth-item-discount.vue")
|
||||
},
|
||||
{
|
||||
path: "promotions/seckill/manager-seckill-add",
|
||||
title: "编辑秒杀活动",
|
||||
@@ -371,12 +399,6 @@ export const otherRouter = {
|
||||
component: () => import("@/views/promotions/seckill/seckill-goods.vue")
|
||||
},
|
||||
|
||||
{
|
||||
path: "add-sms-sign",
|
||||
title: "短信签名",
|
||||
name: "add-sms-sign",
|
||||
component: () => import("@/views/sys/message/smsSign.vue")
|
||||
},
|
||||
{
|
||||
path: "live-add",
|
||||
title: "创建直播",
|
||||
@@ -471,6 +493,13 @@ export const otherRouter = {
|
||||
meta: { title: "商品销售汇总报表" },
|
||||
component: () => import("@/views/statistics/goods-sales-summary.vue")
|
||||
},
|
||||
{
|
||||
path: "article-edit",
|
||||
title: "文章编辑",
|
||||
name: "article-edit",
|
||||
meta: { title: "文章编辑" },
|
||||
component: () => import("@/views/page/article-manage/articleEdit.vue")
|
||||
},
|
||||
{
|
||||
path: "distributionImportRecord",
|
||||
title: "分销员导入记录",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/* 全局禁止多行输入框拖动缩放,覆盖组件生成的内联 resize 样式。 */
|
||||
.el-textarea__inner {
|
||||
resize: none !important;
|
||||
}
|
||||
|
||||
/* Element Plus 主题覆盖(对齐原 iView 主色) */
|
||||
:root {
|
||||
// --el-color-primary: #ff5c58;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 打印指定 DOM 区域(替代 vue-print-nb)
|
||||
* 打印指定 DOM 区域(支持标准 A4 纵向与完整打印样式隔离)
|
||||
*/
|
||||
export function printElement(elementId, title = "打印") {
|
||||
export function printElement(elementId, title = "打印发货单") {
|
||||
const el = document.getElementById(elementId);
|
||||
if (!el) {
|
||||
return;
|
||||
@@ -13,22 +13,196 @@ export function printElement(elementId, title = "打印") {
|
||||
doc.open();
|
||||
doc.write(
|
||||
`<!DOCTYPE html><html><head><title>${title}</title><style>
|
||||
body{font-family:Arial,sans-serif;padding:12px;color:#333}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
td,th{border:1px solid #ddd;padding:6px}
|
||||
.lineH30{line-height:30px}
|
||||
.f14{font-size:14px;color:#333}
|
||||
.printgoodtitle{font-size:14px;line-height:1.5;margin-top:15px;color:#333}
|
||||
.printgoodinfo{padding:10px}
|
||||
.printgooditem{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #eee}
|
||||
.printgoodname{flex:1}
|
||||
.printgoodguid{margin-top:4px;color:#666;font-size:12px}
|
||||
.printgoodguiditem{margin-right:8px}
|
||||
.printgoodnumber{white-space:nowrap;margin-left:12px}
|
||||
@page {
|
||||
size: portrait;
|
||||
margin: 8mm 10mm;
|
||||
}
|
||||
* {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
html, body {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
background: #ffffff !important;
|
||||
width: 100% !important;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: #000000;
|
||||
}
|
||||
.invoice-wrap {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
color: #000;
|
||||
padding: 4px 6px;
|
||||
background: #fff;
|
||||
line-height: 1.4;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
.inv-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #000;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.inv-title-box h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.inv-sub-title {
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.inv-meta-right {
|
||||
text-align: right;
|
||||
}
|
||||
.barcode-svg {
|
||||
display: inline-block;
|
||||
}
|
||||
.barcode-text {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
margin-top: 2px;
|
||||
letter-spacing: 1px;
|
||||
color: #000;
|
||||
}
|
||||
.inv-meta-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #000;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.inv-meta-table td {
|
||||
border: 1px solid #000;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.inv-meta-table .m-k {
|
||||
width: 75px;
|
||||
background: #f3f4f6;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.inv-meta-table .m-v {
|
||||
color: #000;
|
||||
}
|
||||
.inv-table {
|
||||
width: 100% !important;
|
||||
table-layout: fixed !important;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #000;
|
||||
margin-bottom: 0 !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
.inv-table th {
|
||||
background: #f3f4f6;
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
padding: 6px 4px;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
.inv-table td {
|
||||
padding: 6px 6px;
|
||||
border: 1px solid #000;
|
||||
color: #000;
|
||||
vertical-align: middle;
|
||||
word-break: break-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.inv-table .goods-title {
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.inv-table .goods-specs {
|
||||
font-size: 11px;
|
||||
color: #444;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.check-box-square {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1.5px solid #000;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.inv-summary-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #000;
|
||||
border-top: none;
|
||||
margin-top: -1px !important;
|
||||
margin-bottom: 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.inv-summary-left {
|
||||
color: #111;
|
||||
font-weight: 600;
|
||||
}
|
||||
.inv-summary-right {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: baseline;
|
||||
}
|
||||
.inv-summary-right .sum-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
.inv-summary-right .sum-total {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
}
|
||||
.inv-sign-area {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
padding: 10px 4px 6px;
|
||||
border-top: 1px dashed #666;
|
||||
font-size: 12px;
|
||||
color: #222;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.sign-line {
|
||||
border-bottom: 1px solid #000;
|
||||
display: inline-block;
|
||||
width: 80px;
|
||||
height: 14px;
|
||||
vertical-align: middle;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.inv-footer-note {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
</style></head><body>${el.innerHTML}</body></html>`
|
||||
);
|
||||
doc.close();
|
||||
iframe.contentWindow.focus();
|
||||
setTimeout(() => {
|
||||
iframe.contentWindow.print();
|
||||
setTimeout(() => document.body.removeChild(iframe), 1000);
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(iframe)) {
|
||||
document.body.removeChild(iframe);
|
||||
}
|
||||
}, 1500);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
44
manager/src/utils/specs.js
Normal file
44
manager/src/utils/specs.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 商品规格展示:兼容简要规格、键值 JSON 和旧数组结构,过滤图片数据,不展示原始 JSON。
|
||||
* @author Bulbasaur
|
||||
* @since 2026-11-06
|
||||
*/
|
||||
|
||||
/** 将规格中的简单值转为文字,图片对象等复杂数据不参与规格展示。 */
|
||||
function specValueText(value) {
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
if (typeof value === "boolean") return String(value);
|
||||
if (Array.isArray(value)) return value.filter(item => !item || typeof item !== "object").map(specValueText).filter(Boolean).join("、");
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成可读的 SKU 规格,优先复用接口已有的 simpleSpecs;无有效规格时显示默认规格。
|
||||
* @param {string|object|Array|null} specs SKU 原始规格
|
||||
* @param {string|null} simpleSpecs 接口提供的简要规格
|
||||
* @returns {string} 排除 images 后的规格值,以斜线分隔
|
||||
*/
|
||||
export function formatSkuSpecs(specs, simpleSpecs) {
|
||||
if (typeof simpleSpecs === "string" && simpleSpecs.trim()) return simpleSpecs.trim();
|
||||
let parsed = specs;
|
||||
if (typeof parsed === "string") {
|
||||
const text = parsed.trim();
|
||||
if (!text) return "默认规格";
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return /^[\[{]/.test(text) ? "默认规格" : text;
|
||||
}
|
||||
}
|
||||
let values;
|
||||
if (Array.isArray(parsed)) {
|
||||
values = parsed.filter(item => item && typeof item === "object" && item.specName !== "images")
|
||||
.map(item => specValueText(item.specValue));
|
||||
} else if (parsed && typeof parsed === "object") {
|
||||
values = Object.entries(parsed).filter(([name]) => name !== "images").map(([, value]) => specValueText(value));
|
||||
} else {
|
||||
values = [specValueText(parsed)];
|
||||
}
|
||||
return values.filter(Boolean).join(" / ") || "默认规格";
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,47 +1,77 @@
|
||||
<template>
|
||||
<div class="search">
|
||||
<el-card>
|
||||
<div class="brand-manage-page search">
|
||||
<!-- 顶部筛选表单 -->
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form
|
||||
ref="searchFormRef"
|
||||
:model="searchForm"
|
||||
inline
|
||||
label-width="70px"
|
||||
label-width="76px"
|
||||
class="search-form"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<el-form-item label="品牌名称">
|
||||
<el-form-item label="品牌名称" prop="name">
|
||||
<el-input
|
||||
v-model="searchForm.name"
|
||||
placeholder="请输入品牌名称"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
style="width: 220px"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-form-item class="form-actions">
|
||||
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="mt_10">
|
||||
<div class="operation padding-row" style="margin-bottom: 10px;">
|
||||
<el-button type="primary" @click="add">添加</el-button>
|
||||
</div>
|
||||
<!-- 品牌数据表格卡片 -->
|
||||
<el-card shadow="never" class="table-card mt_10">
|
||||
<list-action-bar>
|
||||
<el-button type="primary" @click="add">添加品牌</el-button>
|
||||
<template #right>
|
||||
<el-button @click="getDataList">刷新</el-button>
|
||||
</template>
|
||||
</list-action-bar>
|
||||
|
||||
<el-table ref="table" v-loading="loading" border :data="data" style="width: 100%">
|
||||
<el-table-column prop="name" label="品牌名称" width="200" />
|
||||
<el-table-column label="品牌图标" align="left">
|
||||
<el-table
|
||||
ref="table"
|
||||
v-loading="loading"
|
||||
border
|
||||
stripe
|
||||
:data="data"
|
||||
style="width: 100%"
|
||||
class="custom-brand-table"
|
||||
>
|
||||
<!-- 品牌ID -->
|
||||
<el-table-column prop="id" label="品牌ID" min-width="190" show-overflow-tooltip />
|
||||
|
||||
<!-- 品牌图标 -->
|
||||
<el-table-column label="品牌图标" width="96" align="center">
|
||||
<template #default="{ row }">
|
||||
<img
|
||||
v-if="row"
|
||||
:src="row.logo || ''"
|
||||
alt="加载图片失败"
|
||||
style="cursor: pointer; width: 80px; height: 60px; margin: 10px 0; object-fit: contain"
|
||||
/>
|
||||
<div v-if="row && row.logo" class="logo-box">
|
||||
<el-image
|
||||
:src="row.logo"
|
||||
fit="contain"
|
||||
preview-teleported
|
||||
:preview-src-list="[row.logo]"
|
||||
class="brand-logo-img"
|
||||
>
|
||||
<template #error>
|
||||
<div class="logo-slot">无图</div>
|
||||
</template>
|
||||
</el-image>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" align="left" width="150">
|
||||
|
||||
<!-- 品牌名称 -->
|
||||
<el-table-column prop="name" label="品牌名称" min-width="180" show-overflow-tooltip />
|
||||
|
||||
<!-- 状态 -->
|
||||
<el-table-column label="状态" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-if="row"
|
||||
@@ -53,8 +83,12 @@
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="230" align="center" fixed="right">
|
||||
|
||||
<!-- 创建时间 -->
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center" show-overflow-tooltip />
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row">
|
||||
<a class="link-text" @click="edit(row)">编辑</a>
|
||||
@@ -67,7 +101,8 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="mt_10" style="display: flex; justify-content: flex-end">
|
||||
<!-- 底部分页 -->
|
||||
<div class="pagination-wrap mt_10">
|
||||
<el-pagination
|
||||
v-model:current-page="searchForm.pageNumber"
|
||||
v-model:page-size="searchForm.pageSize"
|
||||
@@ -81,24 +116,31 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 添加 / 编辑品牌弹窗 -->
|
||||
<el-dialog
|
||||
v-model="modalVisible"
|
||||
:title="modalTitle"
|
||||
width="500px"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" label-width="100px" :rules="formValidate">
|
||||
<el-form ref="formRef" :model="form" label-width="90px" :rules="formValidate">
|
||||
<el-form-item v-if="modalType === 1 && form.id" label="品牌ID">
|
||||
<span class="font-mono text-dark">{{ form.id }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="品牌名称" prop="name">
|
||||
<el-input v-model="form.name" style="width: 100%" />
|
||||
<el-input v-model="form.name" placeholder="请输入品牌名称" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="品牌图标" prop="logo">
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<div class="dialog-logo-picker">
|
||||
<div class="logo-preview-box">
|
||||
<img
|
||||
:src="form.logo || defaultPic"
|
||||
alt=""
|
||||
style="width: 60px; height: 60px; object-fit: contain; border: 1px solid #dcdfe6; border-radius: 4px"
|
||||
alt="品牌图标"
|
||||
class="dialog-logo-img"
|
||||
/>
|
||||
<el-button type="primary" link @click="openLogoPicker">修改</el-button>
|
||||
</div>
|
||||
<el-button type="primary" link @click="openLogoPicker">上传/选择图标</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -108,7 +150,8 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="picModelFlag" width="1200px" :show-close="true">
|
||||
<!-- OSS 图片选择组件弹窗 -->
|
||||
<el-dialog v-model="picModelFlag" width="1200px" title="选择品牌图标" :show-close="true">
|
||||
<ossManage
|
||||
ref="ossManageRef"
|
||||
:is-component="true"
|
||||
@@ -117,13 +160,15 @@
|
||||
/>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 关联分类弹窗 -->
|
||||
<el-dialog
|
||||
v-model="categoryModalVisible"
|
||||
:title="categoryModalTitle"
|
||||
width="700px"
|
||||
width="680px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-loading="categoryTreeLoading" style="position: relative; max-height: 520px; overflow: auto">
|
||||
<div v-loading="categoryTreeLoading" class="category-tree-wrap">
|
||||
<el-tree
|
||||
ref="categoryTree"
|
||||
:key="categoryTreeKey"
|
||||
@@ -139,7 +184,7 @@
|
||||
<template #footer>
|
||||
<el-button @click="categoryModalVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="categorySubmitLoading" @click="submitBrandCategory">
|
||||
提交
|
||||
提交关联
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -147,6 +192,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ListActionBar from "@/components/list-action-bar.vue";
|
||||
import { ref, reactive, nextTick, getCurrentInstance } from "vue";
|
||||
import { useTable } from "@/utils/useTable";
|
||||
import {
|
||||
@@ -299,7 +345,7 @@ function handleStatusSwitchChange(row, checked) {
|
||||
}
|
||||
|
||||
function delBrand(row) {
|
||||
ElMessageBox.confirm("您确认要删除 " + row.name + " ?", "确认删除", { type: "warning" }).then(() => {
|
||||
ElMessageBox.confirm("您确认要删除品牌 " + row.name + " ?", "确认删除", { type: "warning" }).then(() => {
|
||||
return delBrandApi(row.id).then((res) => {
|
||||
if (res.success) {
|
||||
ElMessage.success("品牌删除成功!");
|
||||
@@ -339,7 +385,7 @@ function handleSubmit() {
|
||||
|
||||
function add() {
|
||||
modalType.value = 0;
|
||||
modalTitle.value = "添加";
|
||||
modalTitle.value = "添加品牌";
|
||||
formRef.value?.resetFields();
|
||||
delete form.value.id;
|
||||
modalVisible.value = true;
|
||||
@@ -347,7 +393,7 @@ function add() {
|
||||
|
||||
function edit(v) {
|
||||
modalType.value = 1;
|
||||
modalTitle.value = "编辑";
|
||||
modalTitle.value = "编辑品牌";
|
||||
formRef.value?.resetFields();
|
||||
for (const attr in v) {
|
||||
if (v[attr] === null) {
|
||||
@@ -360,13 +406,108 @@ function edit(v) {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.link-text {
|
||||
color: #409eff;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
.brand-manage-page {
|
||||
.filter-card {
|
||||
.search-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0;
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
margin-right: 16px;
|
||||
}
|
||||
.op-split {
|
||||
margin: 0 8px;
|
||||
color: #dcdee2;
|
||||
|
||||
.form-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-dark {
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.text-placeholder {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
.custom-brand-table {
|
||||
.logo-box {
|
||||
width: 52px;
|
||||
height: 40px;
|
||||
margin: 0 auto;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
.brand-logo-img {
|
||||
width: 48px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.logo-slot {
|
||||
font-size: 11px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-logo-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.logo-preview-box {
|
||||
width: 64px;
|
||||
height: 48px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
.dialog-logo-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.category-tree-wrap {
|
||||
position: relative;
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -1,27 +1,77 @@
|
||||
<template>
|
||||
<div class="search">
|
||||
<el-card>
|
||||
<div class="operation padding-row">
|
||||
<el-button type="primary" @click="openAdd">添加分组</el-button>
|
||||
<div class="goods-group-page search">
|
||||
<!-- 顶部筛选与操作栏 -->
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<div class="card-toolbar">
|
||||
<el-form inline :model="searchForm" class="filter-form" @keyup.enter="handleSearch">
|
||||
<el-form-item label="分组名称">
|
||||
<el-input
|
||||
v-model="searchForm.groupName"
|
||||
placeholder="请输入分组名称"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-table ref="table" v-loading="loading" :data="data" border class="mt_10" style="width: 100%">
|
||||
<!-- 分组数据表格卡片 -->
|
||||
<el-card shadow="never" class="table-card mt_10">
|
||||
<list-action-bar>
|
||||
<el-button type="primary" @click="openAdd">添加分组</el-button>
|
||||
<template #right>
|
||||
<el-button @click="getData">刷新</el-button>
|
||||
</template>
|
||||
</list-action-bar>
|
||||
|
||||
<el-table
|
||||
ref="table"
|
||||
v-loading="loading"
|
||||
:data="data"
|
||||
border
|
||||
stripe
|
||||
style="width: 100%"
|
||||
class="custom-group-table"
|
||||
>
|
||||
<!-- 分组ID -->
|
||||
<el-table-column prop="id" label="分组ID" min-width="190" show-overflow-tooltip />
|
||||
|
||||
<!-- 分组名称 -->
|
||||
<el-table-column prop="groupName" label="分组名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="description" label="分组描述" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column prop="createTime" label="创建时间" width="180" />
|
||||
<el-table-column prop="updateTime" label="更新时间" width="180" />
|
||||
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||
|
||||
<!-- 分组描述 -->
|
||||
<el-table-column prop="description" label="分组描述" min-width="240" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div v-if="row" class="ops" style="display: flex; justify-content: center">
|
||||
{{ row ? row.description || "-" : "-" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 创建时间 -->
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center" />
|
||||
|
||||
<!-- 更新时间 -->
|
||||
<el-table-column prop="updateTime" label="更新时间" min-width="170" align="center" />
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<el-table-column label="操作" width="140" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row">
|
||||
<a class="link-text" @click="openEdit(row)">编辑</a>
|
||||
<span class="op-split">|</span>
|
||||
<a class="link-text" @click="remove(row)">删除</a>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="mt_10" style="display: flex; justify-content: flex-end">
|
||||
<!-- 底部分页 -->
|
||||
<div class="pagination-wrap mt_10">
|
||||
<el-pagination
|
||||
v-model:current-page="searchForm.pageNumber"
|
||||
v-model:page-size="searchForm.pageSize"
|
||||
@@ -35,13 +85,32 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="addFlag" title="添加商品分组" width="500px" :close-on-click-modal="false" destroy-on-close>
|
||||
<!-- 添加商品分组弹窗 -->
|
||||
<el-dialog
|
||||
v-model="addFlag"
|
||||
title="添加商品分组"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="addFormRef" :model="formAdd" :rules="rulesAdd" label-width="90px">
|
||||
<el-form-item label="分组名称" prop="groupName" style="width: 90%">
|
||||
<el-input v-model="formAdd.groupName" maxlength="30" placeholder="请输入分组名称" />
|
||||
<el-form-item label="分组名称" prop="groupName">
|
||||
<el-input
|
||||
v-model="formAdd.groupName"
|
||||
maxlength="30"
|
||||
show-word-limit
|
||||
placeholder="请输入分组名称"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="分组描述" prop="description" style="width: 90%">
|
||||
<el-input v-model="formAdd.description" maxlength="200" placeholder="请输入分组描述" />
|
||||
<el-form-item label="分组描述" prop="description">
|
||||
<el-input
|
||||
v-model="formAdd.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="请输入分组描述信息"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -50,14 +119,35 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="editFlag" title="编辑商品分组" width="500px" :close-on-click-modal="false" destroy-on-close>
|
||||
<!-- 编辑商品分组弹窗 -->
|
||||
<el-dialog
|
||||
v-model="editFlag"
|
||||
title="编辑商品分组"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="editFormRef" :model="formEdit" :rules="rulesEdit" label-width="90px">
|
||||
<el-input v-model="formEdit.id" style="display: none" />
|
||||
<el-form-item label="分组名称" prop="groupName" style="width: 90%">
|
||||
<el-input v-model="formEdit.groupName" maxlength="30" placeholder="请输入分组名称" />
|
||||
<el-form-item label="分组ID">
|
||||
<span>{{ formEdit.id }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="分组描述" prop="description" style="width: 90%">
|
||||
<el-input v-model="formEdit.description" maxlength="200" placeholder="请输入分组描述" />
|
||||
<el-form-item label="分组名称" prop="groupName">
|
||||
<el-input
|
||||
v-model="formEdit.groupName"
|
||||
maxlength="30"
|
||||
show-word-limit
|
||||
placeholder="请输入分组名称"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="分组描述" prop="description">
|
||||
<el-input
|
||||
v-model="formEdit.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="请输入分组描述信息"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -69,6 +159,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ListActionBar from "@/components/list-action-bar.vue";
|
||||
import { ref, reactive, nextTick, onMounted } from "vue";
|
||||
import * as API_Goods from "@/api/goods.js";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
@@ -79,6 +170,7 @@ const loading = ref(true);
|
||||
const searchForm = reactive({
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
groupName: "",
|
||||
});
|
||||
const data = ref([]);
|
||||
const total = ref(0);
|
||||
@@ -86,18 +178,22 @@ const addFlag = ref(false);
|
||||
const editFlag = ref(false);
|
||||
const submitAddLoading = ref(false);
|
||||
const submitEditLoading = ref(false);
|
||||
|
||||
const formAdd = ref({
|
||||
groupName: "",
|
||||
description: "",
|
||||
});
|
||||
|
||||
const formEdit = ref({
|
||||
id: "",
|
||||
groupName: "",
|
||||
description: "",
|
||||
});
|
||||
|
||||
const rulesAdd = reactive({
|
||||
groupName: [{ required: true, message: "请输入分组名称", trigger: "blur" }],
|
||||
});
|
||||
|
||||
const rulesEdit = reactive({
|
||||
groupName: [{ required: true, message: "请输入分组名称", trigger: "blur" }],
|
||||
});
|
||||
@@ -105,7 +201,14 @@ const rulesEdit = reactive({
|
||||
const addFormRef = ref(null);
|
||||
const editFormRef = ref(null);
|
||||
|
||||
function init() {
|
||||
function handleSearch() {
|
||||
searchForm.pageNumber = 1;
|
||||
getData();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.pageNumber = 1;
|
||||
searchForm.groupName = "";
|
||||
getData();
|
||||
}
|
||||
|
||||
@@ -216,7 +319,12 @@ function submitEdit() {
|
||||
}
|
||||
|
||||
function remove(row) {
|
||||
ElMessageBox.confirm("确定删除该分组?", "提示", { type: "warning" }).then(() => {
|
||||
ElMessageBox.confirm(`确定删除分组 "${row.groupName || "该分组"}" 吗?`, "提示", {
|
||||
type: "warning",
|
||||
confirmButtonText: "确定删除",
|
||||
cancelButtonText: "取消",
|
||||
})
|
||||
.then(() => {
|
||||
return API_Goods.deleteGoodsGroup(row.id)
|
||||
.then((res) => {
|
||||
if (res && res.success) {
|
||||
@@ -229,23 +337,41 @@ function remove(row) {
|
||||
.catch(() => {
|
||||
ElMessage.error("删除失败,请检查权限或后端接口");
|
||||
});
|
||||
}).catch(() => {});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init();
|
||||
getData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ops a {
|
||||
color: #2d8cf0;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
<style scoped lang="scss">
|
||||
.goods-group-page {
|
||||
.card-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0;
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
margin-right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.ops span {
|
||||
display: inline-block;
|
||||
margin: 0 8px;
|
||||
color: #dcdee2;
|
||||
}
|
||||
</style>
|
||||
|
||||
45
manager/src/views/goods/scheduled-upper/index.vue
Normal file
45
manager/src/views/goods/scheduled-upper/index.vue
Normal 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>
|
||||
@@ -49,9 +49,13 @@
|
||||
</el-card>
|
||||
|
||||
<el-card class="mt_10">
|
||||
<el-tabs v-model="salesTab" @tab-change="handleSalesTabChange">
|
||||
<el-tab-pane label="全部" name="all" />
|
||||
<el-tab-pane label="已设置" name="configured" />
|
||||
<el-tab-pane label="未设置" name="unconfigured" />
|
||||
</el-tabs>
|
||||
<el-alert
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
title="支持按规格逐条设置虚拟销量,列表总销量 = 真实销量 + 虚拟销量。"
|
||||
style="margin-bottom: 10px"
|
||||
@@ -71,55 +75,65 @@
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
:data="data"
|
||||
border
|
||||
class="mt_10"
|
||||
style="width: 100%"
|
||||
@sort-change="changeSort"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column prop="id" label="SKU ID" width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="goodsId" label="SPU编码" width="180" show-overflow-tooltip />
|
||||
<el-table-column label="商品信息" min-width="360">
|
||||
<el-table-column label="商品信息" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row" class="goods-info">
|
||||
<img
|
||||
<div class="goods-thumb-box">
|
||||
<el-image
|
||||
v-if="row.thumbnail"
|
||||
:src="row.thumbnail"
|
||||
fit="cover"
|
||||
preview-teleported
|
||||
:preview-src-list="[row.thumbnail]"
|
||||
class="goods-thumbnail"
|
||||
alt=""
|
||||
/>
|
||||
>
|
||||
<template #error>
|
||||
<div class="thumb-slot">无图</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div v-else class="thumb-slot">无图</div>
|
||||
</div>
|
||||
<div class="goods-text">
|
||||
<div class="div-zoom">{{ row.goodsName }}</div>
|
||||
<div v-if="row.simpleSpecs" class="sub-title">
|
||||
<div class="goods-title" :title="row.goodsName">{{ row.goodsName || "-" }}</div>
|
||||
<div v-if="row.simpleSpecs" class="goods-specs">
|
||||
规格:{{ row.simpleSpecs.trim() || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="真实销量" width="110">
|
||||
<el-table-column prop="goodsId" label="SPU编码" width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="id" label="SKU ID" width="220" show-overflow-tooltip />
|
||||
<el-table-column label="真实销量" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row">{{ row.buyCount || 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="虚拟销量" width="110">
|
||||
<el-table-column label="虚拟销量" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row">{{ row.virtualSalesInput || 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总销量" width="110">
|
||||
<el-table-column label="总销量" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row">{{ getTotalSales(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row" :type="row.marketEnable === 'UPPER' ? 'success' : 'warning'">
|
||||
<span v-if="row">
|
||||
{{ row.marketEnable === "UPPER" ? "上架" : "下架" }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" align="center" fixed="right">
|
||||
<el-table-column label="操作" width="90" align="center" fixed="right">
|
||||
<template #default="{ row, $index }">
|
||||
<a
|
||||
v-if="row"
|
||||
@@ -147,14 +161,19 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
<el-drawer
|
||||
v-model="editModalVisible"
|
||||
:title="modalMode === 'batch' ? '批量设置虚拟销量' : '设置虚拟销量'"
|
||||
width="480px"
|
||||
class="virtual-sales-drawer"
|
||||
direction="rtl"
|
||||
size="min(480px, 100vw)"
|
||||
append-to-body
|
||||
:before-close="handleBeforeCloseVirtualSales"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!modalSubmitting"
|
||||
:show-close="!modalSubmitting"
|
||||
destroy-on-close
|
||||
@closed="resetVirtualSalesModal"
|
||||
>
|
||||
<div v-if="modalMode === 'single' && currentSku" class="virtual-sales-modal">
|
||||
<div class="sku-info-item">
|
||||
@@ -199,12 +218,14 @@
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="drawer-actions">
|
||||
<el-button :disabled="modalSubmitting" @click="handleCancelVirtualSales">取消</el-button>
|
||||
<el-button type="primary" :loading="modalSubmitting" @click="handleSubmitVirtualSales">
|
||||
提交
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -239,6 +260,7 @@ const modalMode = ref("single");
|
||||
const currentSku = ref(null);
|
||||
const currentIndex = ref(-1);
|
||||
const selectedRows = ref([]);
|
||||
const salesTab = ref("all");
|
||||
const editForm = reactive({
|
||||
virtualSales: 0,
|
||||
});
|
||||
@@ -254,6 +276,7 @@ const searchForm = ref({
|
||||
});
|
||||
const data = ref([]);
|
||||
const total = ref(0);
|
||||
let dataRequestId = 0;
|
||||
|
||||
function init() {
|
||||
getDataList();
|
||||
@@ -271,6 +294,12 @@ function handleSearch() {
|
||||
searchForm.value.pageNumber = 1;
|
||||
getDataList();
|
||||
}
|
||||
function handleSalesTabChange() {
|
||||
searchForm.value.pageNumber = 1;
|
||||
selectedRows.value = [];
|
||||
tableRef.value?.clearSelection();
|
||||
getDataList();
|
||||
}
|
||||
function handleReset() {
|
||||
searchForm.value = {
|
||||
pageNumber: 1,
|
||||
@@ -298,9 +327,15 @@ function handleSelectionChange(selection) {
|
||||
selectedRows.value = selection;
|
||||
}
|
||||
function getDataList() {
|
||||
const requestId = ++dataRequestId;
|
||||
loading.value = true;
|
||||
getGoodsSkuData(searchForm.value)
|
||||
const params = { ...searchForm.value };
|
||||
if (salesTab.value !== "all") {
|
||||
params.virtualSalesSet = salesTab.value === "configured";
|
||||
}
|
||||
getGoodsSkuData(params)
|
||||
.then((res) => {
|
||||
if (requestId !== dataRequestId) return;
|
||||
if (res && res.success && res.result) {
|
||||
data.value = (res.result.records || []).map((item) => ({
|
||||
...item,
|
||||
@@ -308,6 +343,10 @@ function getDataList() {
|
||||
saving: false,
|
||||
}));
|
||||
total.value = res.result.total || 0;
|
||||
if (data.value.length === 0 && total.value > 0 && searchForm.value.pageNumber > 1) {
|
||||
searchForm.value.pageNumber = Math.max(1, Math.ceil(total.value / searchForm.value.pageSize));
|
||||
getDataList();
|
||||
}
|
||||
selectedRows.value = [];
|
||||
} else {
|
||||
data.value = [];
|
||||
@@ -317,13 +356,14 @@ function getDataList() {
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestId !== dataRequestId) return;
|
||||
data.value = [];
|
||||
total.value = 0;
|
||||
selectedRows.value = [];
|
||||
ElMessage.error("加载规格列表失败");
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
if (requestId === dataRequestId) loading.value = false;
|
||||
});
|
||||
}
|
||||
function getVirtualSalesValue(row) {
|
||||
@@ -354,6 +394,10 @@ function handleCancelVirtualSales() {
|
||||
if (modalSubmitting.value) return;
|
||||
resetVirtualSalesModal();
|
||||
}
|
||||
function handleBeforeCloseVirtualSales(done) {
|
||||
if (modalSubmitting.value) return;
|
||||
done();
|
||||
}
|
||||
function resetVirtualSalesModal() {
|
||||
editModalVisible.value = false;
|
||||
modalMode.value = "single";
|
||||
@@ -388,6 +432,7 @@ function handleSubmitVirtualSales() {
|
||||
}
|
||||
ElMessage.success("虚拟销量设置成功");
|
||||
resetVirtualSalesModal();
|
||||
getDataList();
|
||||
} else {
|
||||
ElMessage.error((res && res.message) || "规格虚拟销量设置失败");
|
||||
}
|
||||
@@ -424,6 +469,7 @@ function handleBatchSubmitVirtualSales(virtualSales) {
|
||||
tableRef.value?.clearSelection();
|
||||
ElMessage.success("虚拟销量设置成功");
|
||||
resetVirtualSalesModal();
|
||||
getDataList();
|
||||
} else {
|
||||
ElMessage.error((res && res.message) || "虚拟销量设置失败");
|
||||
}
|
||||
@@ -445,32 +491,85 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-title {
|
||||
margin-top: 6px;
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
.virtual-sales-drawer {
|
||||
:deep(.el-drawer__header) {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
:deep(.el-drawer__footer) {
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
|
||||
:deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.goods-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 5px 0;
|
||||
padding: 10px 0;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.goods-thumb-box {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-width: 44px;
|
||||
margin-right: 12px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ebeef5;
|
||||
background-color: #f8f9fa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.goods-thumbnail {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin-right: 12px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.thumb-slot {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.goods-text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.div-zoom {
|
||||
.goods-title {
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-all;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.goods-specs {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -493,11 +592,28 @@ onMounted(() => {
|
||||
|
||||
.sku-info-label {
|
||||
width: 90px;
|
||||
color: #808695;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mt_10 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.link-text {
|
||||
color: #2d8cf0;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
display: inline-block;
|
||||
|
||||
&:hover {
|
||||
color: #57a3f3;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: #c5c8ce;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -49,6 +49,24 @@ const menuList = computed(() => appStore.menuList);
|
||||
const navList = computed(() => appStore.navList);
|
||||
const currNav = computed(() => appStore.currNav);
|
||||
const activeMenuName = computed(() => {
|
||||
if (route.name === "full-discount-detail") {
|
||||
const type = route.query?.activityType;
|
||||
const candidates =
|
||||
type === "FULL_GIFT"
|
||||
? ["manager-full-gift", "full-gift", "full-discount", "full-cut-activity"]
|
||||
: type === "FULL_RATE"
|
||||
? ["manager-full-rate", "full-rate", "full-discount", "full-cut-activity"]
|
||||
: ["full-discount", "full-cut-activity"];
|
||||
for (const candidate of candidates) {
|
||||
for (const item of menuList.value) {
|
||||
for (const menu of item.children || []) {
|
||||
if (menu.name === candidate || menu.path === candidate) {
|
||||
return menu.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const routePath = util.normalizeRoutePath(route.path);
|
||||
for (const item of menuList.value) {
|
||||
for (const menu of item.children || []) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,75 +1,123 @@
|
||||
<template>
|
||||
<div class="search">
|
||||
<el-card>
|
||||
<div class="member-wallet-log-page search">
|
||||
<!-- 顶部筛选表单 -->
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form
|
||||
ref="searchFormRef"
|
||||
:model="searchForm"
|
||||
inline
|
||||
label-width="70px"
|
||||
label-width="76px"
|
||||
class="search-form"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<el-form-item label="客户ID" prop="memberId">
|
||||
<el-input
|
||||
v-model="searchForm.memberId"
|
||||
placeholder="请输入客户ID"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="客户名称" prop="memberName">
|
||||
<el-input
|
||||
v-model="searchForm.memberName"
|
||||
placeholder="请输入客户名称"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
style="width: 160px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="支付时间">
|
||||
<el-form-item label="变动时间">
|
||||
<el-date-picker
|
||||
v-model="selectDate"
|
||||
type="datetimerange"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
clearable
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
placeholder="选择起始时间"
|
||||
style="width: 360px"
|
||||
style="width: 320px"
|
||||
@change="selectDateRange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
|
||||
<el-form-item class="form-actions">
|
||||
<el-button type="primary" class="search-btn" @click="handleSearch">
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card>
|
||||
|
||||
<!-- 变动记录表格 -->
|
||||
<el-card shadow="never" class="table-card mt_10">
|
||||
<template #header>
|
||||
<div class="card-header-bar">
|
||||
<div class="header-title">
|
||||
<span>预存款变动明细</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button size="small" @click="getDataList">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
ref="table"
|
||||
v-loading="loading"
|
||||
border
|
||||
stripe
|
||||
:data="data"
|
||||
ref="table"
|
||||
class="mt_10"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="memberName" label="客户名称" min-width="100" />
|
||||
<el-table-column label="变动金额" width="150">
|
||||
<!-- 客户ID -->
|
||||
<el-table-column prop="memberId" label="客户ID" min-width="190" show-overflow-tooltip />
|
||||
|
||||
<!-- 客户名称 -->
|
||||
<el-table-column prop="memberName" label="客户名称" min-width="140" show-overflow-tooltip />
|
||||
|
||||
<!-- 变动金额 -->
|
||||
<el-table-column label="变动金额" width="140" align="right" sortable prop="money">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row">
|
||||
<priceColorScheme
|
||||
v-if="row.money > 0"
|
||||
:value="row.money"
|
||||
color="green"
|
||||
/>
|
||||
<priceColorScheme
|
||||
v-else-if="row.money < 0"
|
||||
:value="row.money"
|
||||
:color="$mainColor"
|
||||
/>
|
||||
<span v-if="row.money > 0" class="money-increase">
|
||||
+¥ {{ formatMoney(row.money) }}
|
||||
</span>
|
||||
<span v-else-if="row.money < 0" class="money-decrease">
|
||||
-¥ {{ formatMoney(Math.abs(row.money)) }}
|
||||
</span>
|
||||
<span v-else class="money-zero">
|
||||
¥ 0.00
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="变更时间" width="200" />
|
||||
<el-table-column label="业务类型" width="200">
|
||||
|
||||
<!-- 业务类型 -->
|
||||
<el-table-column label="业务类型" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row">{{ serviceTypeText(row.serviceType) }}</span>
|
||||
{{ row ? serviceTypeText(row.serviceType) : "-" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 日志详细说明 -->
|
||||
<el-table-column prop="detail" label="变动说明" min-width="260" show-overflow-tooltip />
|
||||
|
||||
<!-- 变动时间 -->
|
||||
<el-table-column prop="createTime" label="变动时间" width="170" align="center" />
|
||||
|
||||
<!-- 操作 -->
|
||||
<el-table-column label="操作" width="110" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row">
|
||||
<a v-if="row.memberId" class="link-text" @click="viewMemberDetail(row)">客户详情</a>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="detail" label="详细" min-width="300" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<div class="mt_10" style="display: flex; justify-content: flex-end">
|
||||
|
||||
<!-- 底部分页 -->
|
||||
<div class="pagination-wrap mt_10">
|
||||
<el-pagination
|
||||
v-model:current-page="searchForm.pageNumber"
|
||||
v-model:page-size="searchForm.pageSize"
|
||||
@@ -86,23 +134,42 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useTable } from "@/utils/useTable";
|
||||
import { getUserWallet } from "@/api/member";
|
||||
import { customRouterPush } from "@/utils/filters";
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
defineOptions({ name: "walletLog" });
|
||||
|
||||
// 模板 el-form 的 ref(原 ref="searchForm",与 data 同名会互相覆盖,改名为 searchFormRef)
|
||||
const searchFormRef = ref(null);
|
||||
const table = ref(null);
|
||||
|
||||
const selectDate = ref(null);
|
||||
const { loading, data, total, searchForm, getDataList } = useTable(
|
||||
|
||||
const { loading, data, total, searchForm, getDataList, handleSearch, resetSearch } = useTable(
|
||||
getUserWallet,
|
||||
{
|
||||
pageSize: 20,
|
||||
immediate: false,
|
||||
form: { sort: "createTime", order: "desc", startDate: "", endDate: "", memberName: "" },
|
||||
form: {
|
||||
sort: "createTime",
|
||||
order: "desc",
|
||||
memberId: "",
|
||||
memberName: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
},
|
||||
beforeRequest: (p) => {
|
||||
const params = { ...p };
|
||||
if (selectDate.value && selectDate.value.length === 2) {
|
||||
params.startDate = selectDate.value[0];
|
||||
params.endDate = selectDate.value[1];
|
||||
} else {
|
||||
params.startDate = "";
|
||||
params.endDate = "";
|
||||
}
|
||||
return params;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -113,23 +180,46 @@ function serviceTypeText(type) {
|
||||
if (type === "WALLET_RECHARGE") return "余额充值";
|
||||
return "佣金提成";
|
||||
}
|
||||
function init() {
|
||||
getDataList();
|
||||
|
||||
function getServiceTagType(type) {
|
||||
if (type === "WALLET_RECHARGE") return "success";
|
||||
if (type === "WALLET_REFUND") return "primary";
|
||||
if (type === "WALLET_PAY") return "warning";
|
||||
if (type === "WALLET_WITHDRAWAL") return "danger";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function formatMoney(val) {
|
||||
const num = Number(val || 0);
|
||||
return num.toFixed(2);
|
||||
}
|
||||
|
||||
function viewMemberDetail(row) {
|
||||
if (!row || !row.memberId) return;
|
||||
customRouterPush({ name: "member-detail", query: { id: row.memberId } });
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
selectDate.value = null;
|
||||
searchForm.memberId = "";
|
||||
searchForm.memberName = "";
|
||||
searchForm.startDate = "";
|
||||
searchForm.endDate = "";
|
||||
searchForm.pageNumber = 1;
|
||||
resetSearch();
|
||||
}
|
||||
|
||||
function changePage(v) {
|
||||
searchForm.pageNumber = v;
|
||||
getDataList();
|
||||
}
|
||||
|
||||
function changePageSize(v) {
|
||||
searchForm.pageNumber = 1;
|
||||
searchForm.pageSize = v;
|
||||
getDataList();
|
||||
}
|
||||
function handleSearch() {
|
||||
searchForm.pageNumber = 1;
|
||||
searchForm.pageSize = 20;
|
||||
getDataList();
|
||||
}
|
||||
|
||||
function selectDateRange(v) {
|
||||
if (v && v.length === 2) {
|
||||
searchForm.startDate = v[0];
|
||||
@@ -141,6 +231,93 @@ function selectDateRange(v) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init();
|
||||
getDataList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.member-wallet-log-page {
|
||||
padding: 16px 20px;
|
||||
background-color: #f6f8fa;
|
||||
min-height: calc(100vh - 110px);
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-dark {
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* 筛选卡片 */
|
||||
.filter-card {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ebeef5;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 16px 16px 2px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
|
||||
.el-form-item {
|
||||
margin-bottom: 14px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 表格卡片 */
|
||||
.table-card {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ebeef5;
|
||||
|
||||
.card-header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.money-increase {
|
||||
font-size: 14px;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.money-decrease {
|
||||
font-size: 14px;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.money-zero {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user