9 Commits

Author SHA1 Message Date
pikachu1995@126.com
75b20f494d feat(gift-card): 新增提货卡兑换功能
- 新建提货卡选品及确认兑换页面,支持全选和任选模式
- 新增提货卡 API 接口,包含获取、试算和核销端点
- 我的礼品卡页面增加提货卡类型标识及面值展示适配
- 订单取消弹窗针对提货卡订单显示专属提示文案
- 配置 pages.json 新增提货卡兑换页面路由
2026-11-06 15:16:29 +08:00
pikachu1995@126.com
cc71d20aa6 feat(paid-member): 添加付费会员功能模块
- 新增付费会员API接口层,支持会员查询、下单、记录及报价
- 新增付费会员页面,展示权益、推荐商品及购买流程
- 新增付费会员购买记录页面,支持订单状态查看与继续支付
- 新增 PaidMemberPrice 组件用于商品列表和详情页展示会员价
- 个人中心页添加付费会员入口,支持配置化展示标题与背景
- 商品详情页集成会员价展示,支持秒杀和拼团场景下隐藏
- 商品列表组件集成会员价展示,支持批量请求与防竞态
- 支付页适配付费会员支付流程,支持H5轮询与结果回调
- 订单确认页优惠文案更新为"活动优惠(含会员优惠)"
- pages.json 新增付费会员及购买记录页面路由配置
- README 更新升级计划,按时间倒序补充已完成功能明细
2026-11-05 22:37:12 +08:00
pikachu1995@126.com
06af86af3f feat(member): 完善移动端等级权益与经验任务 2026-11-05 21:29:56 +08:00
pikachu1995@126.com
8188a8af7b refactor: 清理停用促销的移动端展示 2026-11-05 15:20:38 +08:00
pikachu1995@126.com
0637f1da1f docs: 标记立减优惠券升级计划为已完成 2026-11-04 14:50:33 +08:00
pikachu1995@126.com
4adcf0a13a feat(coupon): 支持优惠券无门槛使用条件展示
- 优惠券消费阈值为0时显示"无门槛使用"替代"满0元可用"
- 更新商品详情页促销优惠券组件的条件展示逻辑
- 更新购物车优惠券中心页面门槛显示
- 更新优惠券详情页门槛文案处理
- 更新首页领券模板,增加无门槛条件分支
- 更新我的优惠券页面新增门槛描述字段
- 更新店铺页优惠券领取区域门槛显示
- 更新首页优惠券模板组件门槛文案
2026-11-04 14:03:51 +08:00
pikachu1995@126.com
d72f02f7cf feat(wechat): 集成微信原生地址导入、头像选择与昵称填写能力
- 新增微信收货地址导入功能(chooseAddress),含地区匹配与错误处理
- 个人资料页接入微信头像选择(chooseAvatar)与昵称填写(type="nickname")
- 重构收货地址添加页与管理页的UI布局,抽取公共样式 address-page.scss
- 地址页与个人页增加加载失败提示、请求防抖与保存状态反馈
- 下拉刷新改用 Promise.allSettled 避免单接口失败阻断其他数据更新
- manifest 新增 chooseAddress 隐私权限声明,版本号更新至 5.03
2026-11-04 11:09:34 +08:00
pikachu1995@126.com
7b2b045fd4 chore(license): 将项目许可证 更换为 Apache-2.0
- 将 LICENSE 文件内容替换为 Apache License 2.0 完整文本
2026-11-03 17:48:26 +08:00
pikachu1995@126.com
e1a48d6d11 refactor(after-sales): 删除独立售后页面并整合至订单列表
- 删除 afterSales.vue 独立页面,将售后入口统一至 myOrder 页面
- 修改售后相关页面跳转路径为 /pages/order/myOrder?status=5
- 重构 applySuccess 页面,增加微信订阅消息授权与视觉优化
- 重构预存款明细页面,增加收支筛选与日志解析功能
- 新增 store 端 API 基础地址配置
2026-11-02 16:57:29 +08:00
71 changed files with 10532 additions and 3473 deletions

216
LICENSE
View File

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

View File

@@ -5,7 +5,7 @@
<p>
<a href="https://github.com/lilishop/lilishop"><img src="https://img.shields.io/github/stars/lilishop/lilishop.svg?style=social&logo=github" alt="GitHub Stars" /></a>
<a href="https://gitee.com/beijing_hongye_huicheng/lilishop"><img src="https://gitee.com/beijing_hongye_huicheng/lilishop/badge/star.svg?theme=dark" alt="Gitee Stars" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License" /></a>
<a href="https://vuejs.org/"><img src="https://img.shields.io/badge/Vue-3-brightgreen.svg" alt="Vue 3" /></a>
<a href="https://uniapp.dcloud.net.cn/"><img src="https://img.shields.io/badge/uni--app-Vue3-blue.svg" alt="uni-app" /></a>
</p>
@@ -27,7 +27,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 `uni-app + Vue 3 + uview-plus 3.x`, leaving legacy Vue 2 templates behind.
- 🧩 **Production-Ready Completeness**: Not a static UI template—fully integrated with B2B2C multi-vendor workflows, visual storefront decoration, IM customer service, flash sales, group buying, affiliate distribution, and live streaming.
@@ -137,4 +137,4 @@ lilishop-uniapp
## 6. License
Lilishop is licensed under the [MIT](./LICENSE) License. Copyright © 2020-2026 **Beijing Hongye Huicheng Technology Co., Ltd.**
Lilishop is licensed under the [Apache-2.0](./LICENSE) License. Copyright © 2020-2026 **Beijing Hongye Huicheng Technology Co., Ltd.**

View File

@@ -5,7 +5,7 @@
<p>
<a href="https://github.com/lilishop/lilishop"><img src="https://img.shields.io/github/stars/lilishop/lilishop.svg?style=social&logo=github" alt="GitHub Stars" /></a>
<a href="https://gitee.com/beijing_hongye_huicheng/lilishop"><img src="https://gitee.com/beijing_hongye_huicheng/lilishop/badge/star.svg?theme=dark" alt="Gitee Stars" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License" /></a>
<a href="https://vuejs.org/"><img src="https://img.shields.io/badge/Vue-3-brightgreen.svg" alt="Vue 3" /></a>
<a href="https://uniapp.dcloud.net.cn/"><img src="https://img.shields.io/badge/uni--app-Vue3-blue.svg" alt="uni-app" /></a>
</p>
@@ -27,7 +27,7 @@
**LILISHOP** 是基于 Spring Boot / Vue 3 / uni-app 开发的开源商城系统,支持 B2B2C 多商户商城、微信小程序商城、H5 商城、APP 商城、直播电商、分销返佣、秒杀活动。
### ✨ 为什么选择 Lilishop
- 🟢 **真 MIT 协议**:支持免费商用与私有化闭源二开,无版权后顾之忧。
- 🟢 **真 Apache-2.0 协议**:支持免费商用与私有化闭源二开,无版权后顾之忧。
- 🚀 **Vue 3 全新栈**:基于 `uni-app + Vue 3 + uview-plus 3.x`,告别老旧 Vue 2 模板。
- 🧩 **生产级完整度**:非静态切图模板,完整覆盖 B2B2C 多商户、可视化装修、IM 客服、秒杀拼团、分销直播全闭环。
@@ -144,7 +144,7 @@ lilishop-uniapp
## 6. 开源与授权
本项目遵循 [MIT](./LICENSE) 开源协议,版权归 **北京宏业汇成科技有限公司** 所有。允许个人及企业免费用于学习、研究、修改和商业用途(需保留原始版权和许可声明)。
本项目遵循 [Apache-2.0](./LICENSE) 开源协议,版权归 **北京宏业汇成科技有限公司** 所有。允许个人及企业免费用于学习、研究、修改和商业用途(需保留原始版权和许可声明)。
---
@@ -160,20 +160,47 @@ lilishop-uniapp
## 8. 升级计划
> 更新日期:2026-11-05,依据各端提交记录及当前代码实现同步。
### ✅ 已完成特性
| 功能模块 | 功能模块 |
| :--- | :--- |
| ✅ 商品定时上下架 | ✅ 商品分组 / 会员分组 |
| ✅ 商品虚拟销量 | ✅ 专用发票(发票上传) |
| ✅ 置顶评论 | ✅ 客户等级体系 |
| ✅ 购物卡 | ✅ 腾讯云-云直播 |
| ✅ 卡密商品 | ✅ 二级分销 |
| ✅ 定时任务管理 | ✅ 预约服务商品 |
| ✅ 主题色管理 | ✅ 商家发货改造(整单/拆单/无需配送) |
| ✅ 多维统计(营业/商品/会员/积分/储值/营销/分销) | ✅ 经营报表(店铺业绩/商品同比环比/订单明细/销售汇总) |
按可核实的实现或完善时间倒序记录;日期表示代码完成或功能更新时间,不代表生产上线时间。
| 时间 | 功能模块 | 完成内容 |
| :--- | :--- | :--- |
| 2026-11-05 | ✅ 付费会员 | 单一付费会员、开通与续费、有效期、关联权益、商家与商品折扣、平台补贴及付费记录。 |
| 2026-11-04 | ✅ 客户等级体系 / 客户权益 | 等级配置、权益介绍、经验值任务与明细、权益发放及重试,提供默认等级与权益数据。 |
| 2026-11-04 | ✅ 小程序订单管理 | 增加小程序订单查询入口及微信订单自动上报开关。 |
| 2026-11-04 | ✅ 立减优惠券 | 新增立减券,完善优惠券校验、领取与订单优惠计算。 |
| 2026-11-04 | ✅ 商品定时上下架 | 完善定时上下架计划管理、独立列表及执行校验。 |
| 2026-11-04 | ✅ 微信小程序原生能力 | 接入微信地址导入、头像选择和昵称填写。 |
| 2026-11-02 | ✅ 消息模板 | 支持多渠道模板矩阵维护,统一站内信、短信、微信公众号及小程序订阅消息的场景配置和异步发送。 |
| 2026-10-30 | ✅ PC 页面装修 | 重构楼层组件,支持自适应高度、可视化热区绘制及配置交互优化。 |
| 2026-10-29 | ✅ 定时任务管理 | 重构为数据库配置驱动,支持运营后台维护任务。 |
| 2026-10-29 | ✅ 敏感词管理 | 支持按业务场景配置过滤开关,并优化词库加载。 |
| 2026-10-29 | ✅ PC 店铺搜索 | 新增店铺列表,支持商品与店铺双模式搜索。 |
| 2026-10-22 | ✅ 分类设置 | 支持四种分类页模板选择、商品列表展示字段开关及模板图片预览。 |
| 2026-10-20 | ✅ 移动端页面装修 | 完善可视化编辑器,新增首页组合组件及优惠券、店铺、文章等楼层。 |
| 2026-10-09 | ✅ 主题色管理 | 完善主题设置,支持自定义模式。 |
| 2026-10-02 | ✅ 预约服务商品 | 完善预约商品、预约交易及订单详情功能。 |
| 2026-08-05 | ✅ 二级分销 | 由一级分销扩展为二级分销,增加节点配置及分销员中心。 |
| 2026-08-03 | ✅ 卡密商品 | 完善电子卡券履约、订单状态及卡池管理。 |
| 2026-07-23 | ✅ 素材库管理 | 增加文件分类、目录管理及目录归属校验。 |
| 2026-07-20 | ✅ 经营统计报表 | 新增店铺业绩、商品同比环比、销售订单明细及商品销售汇总报表。 |
| 2026-07-13 | ✅ 腾讯云直播 | 接入并完善直播、聊天与直播商品相关功能。 |
| 2026-06-30 | ✅ 移动端技术升级 | 升级 Vue 3 与 uView Plus,适配组件和页面。 |
| 2026-06-29 | ✅ 电子面单发货 | 完善快递鸟电子面单,支持自动携带运单号发货。 |
| 2026-06-05 | ✅ 后端与管理端框架升级 | 5 月 25 日至 6 月 5 日完成 Spring Boot 4、管理端 Vue 3 与 Element Plus 升级。 |
| 2026-05-22 | ✅ 购物卡 | 新增礼品卡接口,以及选择、绑定、激活和列表管理。 |
| 2026-05-20 | ✅ 微信提现 | 完善转账状态查询、确认回调和重试处理,后续补充提现规则说明配置。 |
| 2026-04-16 | ✅ 置顶评论 | 支持评论置顶及商品评价管理。 |
| 2026-04-13 | ✅ 专用发票(发票上传) | 完善发票信息字段、发票详情及管理展示。 |
| 2026-04-01 | ✅ 商品虚拟销量 | 支持按商品规格设置虚拟销量,提供单个及批量设置入口。 |
| 2026-03-30 | ✅ 商品分组 | 新增商品分组管理及商品关联功能。 |
| 2026-03-24 | ✅ 会员分组 | 新增会员分组管理。 |
### 🔜 近期规划中
- 🔜 店铺结算汇总 & 结算台账
- 🔜 支付方式汇总 & 钱包流水 & 财务汇总
- 🔜 微信小店对接
- 🔜 抖店对接
- 🔜 微信小程序原生重构与体验对齐(持续完善;已接入预约交易、分类多模板与首页楼层装修)
- 🔜 OpenAPI 开放平台与开发者中心

View File

@@ -477,3 +477,11 @@ export function withdrawalSettingVO() {
});
}
export function getCurrentMemberBenefits() { return http.request({url:'/member/benefit/current',method:Method.GET,needToken:true}) }
export function getMemberGradeBenefits() { return http.request({url:'/member/memberGrade/list',method:Method.GET,needToken:true}) }
/** 仅返回后台已开启的经验值规则及当前会员累计获得情况。 */
export function getExperienceTasks() { return http.request({ url: '/member/memberGrade/rules', method: Method.GET, needToken: true }) }
/** 当前会员等级、经验值及分页流水;会员身份由服务端登录态确定。 */
export function getExperienceRecords(params) { return http.request({ url: '/member/memberGrade/current', method: Method.GET, params, needToken: true }) }

View File

@@ -110,3 +110,15 @@ export function getLogisticsMessages(params) {
data
});
}
/**
* 获取微信公众号通知配置及绑定状态
*/
export function getWechatOfficialAccountSetting() {
return http.request({
url: '/message/wechatOfficialAccount',
method: Method.GET,
needToken: true,
});
}

7
api/paid-member.js Normal file
View File

@@ -0,0 +1,7 @@
import { http, Method } from '@/utils/request.js'
export const getPaidMember = () => http.request({ url: '/member/paid/current', method: Method.GET, needToken: true })
export const createPaidMemberOrder = data => http.request({ url: '/member/paid/orders', method: Method.POST, data, needToken: true, header: { 'Content-Type': 'application/json' } })
export const getPaidMemberOrders = params => http.request({ url: '/member/paid/orders', method: Method.GET, params, needToken: true })
export const getPaidMemberOrder = sn => http.request({ url: `/member/paid/orders/${encodeURIComponent(sn)}`, method: Method.GET, needToken: true })
export const cancelPaidMemberOrder = sn => http.request({ url: `/member/paid/orders/${encodeURIComponent(sn)}/cancel`, method: Method.POST, needToken: true })
export const getPaidMemberQuotes = skuIds => http.request({ url: '/member/paid/quotes', method: Method.GET, params: { skuIds: skuIds.join(',') }, needToken: true })

13
api/pickupCard.js Normal file
View File

@@ -0,0 +1,13 @@
/**
* 提货卡选品、试算及一次兑换接口。
* @author Bulbasaur
* @since 2026-11-05
*/
import { http, Method } from '@/utils/request.js'
/** 获取当前会员的卡规则及候选商品。 */
export const getPickupCard = id => http.request({ url: `/promotion/pickupCard/${encodeURIComponent(id)}`, method: Method.GET, needToken: true })
/** 服务端试算,校验商品与地址且不消耗权益。 */
export const previewPickupCard = data => http.request({ url: '/promotion/pickupCard/preview', method: Method.POST, needToken: true, header: { 'content-type': 'application/json' }, data })
/** 原子核销创单,网络重试返回原交易。 */
export const redeemPickupCard = data => http.request({ url: '/promotion/pickupCard/redeem', method: Method.POST, needToken: true, header: { 'content-type': 'application/json' }, data })

View File

@@ -20,6 +20,7 @@
}}
</div>
</view>
<PaidMemberPrice :quote="paidQuotes[item.id]" />
<div class="promotion" @click="navigateToDetailPage(item)">
<div v-for="(promotionItem,promotionIndex) in getPromotion(item)" :key="promotionIndex">
<span class="promotion-tag" v-if="promotionItem.indexOf('COUPON') != -1">劵</span>
@@ -80,6 +81,7 @@
<u-icon name="shopping-cart" size="16" :color="mainColor"></u-icon>
</div>
</view>
<PaidMemberPrice v-if="flag('price')" :quote="paidQuotes[item.id]" />
<div class="store-seller-name" v-if="storeName" @click="navigateToStoreDetailPage(item)">
<view class="store-name-row">
<text class="self-tag" v-if="item.selfOperated && flag('selfTag')">自营</text>
@@ -96,7 +98,11 @@
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { computed, watch, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import PaidMemberPrice from '@/components/paid-member-price/index.vue'
import { getPaidMemberQuotes } from '@/api/paid-member'
import { isLogin } from '@/utils/filters'
import { useStore } from '@/store'
import { useGoodsListCommon } from './common'
import { goodsFormatPrice } from '@/utils/filters.js'
@@ -123,6 +129,21 @@ const props = withDefaults(defineProps<{
})
const emit = defineEmits(['addCart'])
const paidQuotes = ref<Record<string, any>>({})
let paidRequest = 0
async function loadPaidQuotes() {
const request = ++paidRequest, memberId = String(isLogin()?.id || '')
paidQuotes.value = {}
if (!memberId) return
const ids = [...new Set((props.res || []).map(item => String(item.id || '')).filter(Boolean))]
try {
const results = await Promise.all(Array.from({length:Math.ceil(ids.length/100)},(_,i)=>getPaidMemberQuotes(ids.slice(i*100,i*100+100))))
if (request !== paidRequest || String(isLogin()?.id || '') !== memberId) return
paidQuotes.value = Object.assign({}, ...results.filter(res=>res.data?.success).map(res=>res.data.result || {}))
} catch {}
}
watch(() => [(props.res || []).map(item=>item.id).join(','), store.state.userInfo?.id, store.state.hasLogin], loadPaidQuotes, {immediate:true})
onShow(loadPaidQuotes)
// 存量字段(促销/销量/自营/价格):showFields 为 null 时维持现状渲染
const flag = (key: string) => props.showFields == null || props.showFields[key] !== false

View File

@@ -0,0 +1,6 @@
<template><view v-if="quote" class="paid-price" @click.stop="open"><image v-if="quote.label" :src="quote.label" mode="aspectFit" /><text>会员价 ¥{{ Number(quote.price).toFixed(2) }}</text><text v-if="!quote.eligible" class="open">开通享 ›</text></view></template>
<script setup>
defineProps({quote:{type:Object,default:null}})
function open(){uni.navigateTo({url:'/pages/mine/paidMember'})}
</script>
<style scoped>.paid-price{display:flex;align-items:center;flex-wrap:wrap;gap:6rpx;margin-top:8rpx;color:#a77c3e;font-size:22rpx;line-height:1.6}.paid-price image{width:28rpx;height:28rpx}.open{font-size:20rpx;opacity:.8}</style>

View File

@@ -101,12 +101,18 @@ const props = withDefaults(defineProps<{
left?: string
top?: string
business?: string
apiType?: string
}>(), {
height: '80rpx',
width: '350rpx',
left: '180rpx',
top: '30rpx',
business: 'LOGIN'
business: 'LOGIN',
apiType: 'buyer'
})
const sliderBaseUrl = computed(() => {
return props.apiType === 'store' ? api.store : api.buyer
})
const store = useStore()
@@ -182,7 +188,7 @@ function getCode() {
storage.setUuid(uuid.v1())
}
uni.request({
url: api.buyer + '/common/slider/' + props.business,
url: sliderBaseUrl.value + '/common/slider/' + props.business,
header: {
uuid: storage.getUuid()
},
@@ -217,7 +223,7 @@ function end() {
uni.request({
method: 'POST',
url:
api.buyer +
sliderBaseUrl.value +
'/common/slider/' +
props.business +
'?xPos=' +

View File

@@ -1,35 +1,41 @@
/**
* buyer : 买家 API(baseURL 为 buyer + /buyer)
*/
// 开发环境
const dev = {
buyer: "https://buyer-api.pickmall.cn",
im: "https://im-api.pickmall.cn",
mqtt: "wss://lilishop-mqtt-pull.dllll.xyz/mqtt",
};
// 生产环境
const prod = {
buyer: "https://buyer-api.pickmall.cn",
im: "https://im-api.pickmall.cn",
mqtt: "wss://lilishop-mqtt-pull.dllll.xyz/mqtt",
};
let api = dev;
if (process.env.NODE_ENV == "development") {
api = dev;
} else {
api = prod;
}
// 微信小程序、App 打包使用生产环境配置
// #ifdef MP-WEIXIN || APP-PLUS
api = prod;
// #endif
const buyerHost = api.buyer;
api.buyer = buyerHost + "/buyer";
api.im = api.im + "/im";
export default {
...api,
buyerHost,
};
/**
* buyer : 买家 API(baseURL 为 buyer + /buyer)
* store : 商家 API(baseURL 为 store + /store)
*/
// 开发环境
const dev = {
buyer: "https://buyer-api.pickmall.cn",
store: "https://admin-api.pickmall.cn",
im: "https://im-api.pickmall.cn",
mqtt: "wss://lilishop-mqtt-pull.dllll.xyz/mqtt",
};
// 生产环境
const prod = {
buyer: "https://buyer-api.pickmall.cn",
store: "https://admin-api.pickmall.cn",
im: "https://im-api.pickmall.cn",
mqtt: "wss://lilishop-mqtt-pull.dllll.xyz/mqtt",
};
let api = dev;
if (process.env.NODE_ENV == "development") {
api = dev;
} else {
api = prod;
}
// 微信小程序、App 打包使用生产环境配置
// #ifdef MP-WEIXIN || APP-PLUS
api = prod;
// #endif
const buyerHost = api.buyer;
const storeHost = api.store;
api.buyer = buyerHost + "/buyer";
api.store = storeHost + "/store";
api.im = api.im + "/im";
export default {
...api,
buyerHost,
storeHost,
};

View File

@@ -2,18 +2,105 @@
* 微信小程序支付
* 此处针对于微信小程序开发的支付插件
* 第一次支付成功后会跳出订阅的消息 如果用户拒绝或同意都会跳转到支付成功页面
* 如果点击订阅 会将状态写进缓存 之后不再提醒。
*
* @param {sn,price}
*
* @param {sn,price,orderPromotionType}
*/
import { getWeChatMpMessage } from "@/api/message.js";
import { initiatePay } from "@/api/trade";
const SCENE_NAME_MAP = {
ORDER_DELIVER: ["订单发货提醒", "订单发货通知"],
ORDER_COMPLETE: ["订单已完成提醒", "订单完成通知"],
PINTUAN_SUCCESS: ["拼团成功通知"],
AFTER_SALE_AUDIT: ["售后申请审核通知", "售后审核通知"],
POINT_CHANGE: ["积分变更提醒", "积分变更通知"],
WALLET_CHANGE: ["账户余额变动通知", "余额账户变更通知"],
WALLET_WITHDRAWAL_AUDIT_ERROR: ["提现结果通知", "提现申请审核通知"],
};
let cachedMpMessageList = null;
export function prefetchMpMessageTemplates() {
return getWeChatMpMessage()
.then((res) => {
if (res && res.data && Array.isArray(res.data.result)) {
cachedMpMessageList = res.data.result;
}
return cachedMpMessageList || [];
})
.catch(() => cachedMpMessageList || []);
}
function extractTmplIdsByScenes(list, targetScenes) {
const tmplIds = [];
(targetScenes || []).forEach((scene) => {
const aliasNames = SCENE_NAME_MAP[scene] || [];
const matched = (list || []).find(
(item) =>
item &&
item.enable !== false &&
item.code &&
(item.sceneCode === scene || aliasNames.includes(item.name))
);
if (matched && matched.code && !tmplIds.includes(matched.code)) {
tmplIds.push(matched.code);
}
});
return tmplIds.slice(0, 3);
}
export function subscribeMpMessageByScenes(sceneCodes, onComplete) {
const done = typeof onComplete === "function" ? onComplete : () => {};
const targetScenes = Array.isArray(sceneCodes) ? sceneCodes : [];
if (!targetScenes.length) {
done();
return;
}
if (Array.isArray(cachedMpMessageList) && cachedMpMessageList.length) {
const finalTmplIds = extractTmplIdsByScenes(cachedMpMessageList, targetScenes);
if (!finalTmplIds.length) {
done();
return;
}
console.log("[微信订阅消息] 请求订阅 tmplIds:", finalTmplIds);
uni.requestSubscribeMessage({
tmplIds: finalTmplIds,
complete: (res) => {
console.log("[微信订阅消息] 授权返回结果:", res);
done();
},
});
return;
}
prefetchMpMessageTemplates()
.then((list) => {
const finalTmplIds = extractTmplIdsByScenes(list, targetScenes);
if (!finalTmplIds.length) {
done();
return;
}
uni.requestSubscribeMessage({
tmplIds: finalTmplIds,
complete: () => done(),
});
})
.catch(() => done());
}
export function subscribePayMessageThen(onComplete, options = {}) {
const isPintuan =
options &&
String(options.orderPromotionType || "").toUpperCase() === "PINTUAN";
const scenes = isPintuan
? ["ORDER_DELIVER", "ORDER_COMPLETE", "PINTUAN_SUCCESS"]
: ["ORDER_DELIVER", "ORDER_COMPLETE"];
subscribeMpMessageByScenes(scenes, onComplete);
}
class LiLiWXPay {
constructor(...payList) {
this.data = payList[0];
console.log(payList);
// 调用支付
this.pay = () => {
uni.showLoading({
title: "加载中",
@@ -26,7 +113,6 @@ class LiLiWXPay {
};
const paymentMethod = "WECHAT";
const paymentClient = "MP";
// 调用支付
initiatePay(paymentMethod, paymentClient, submitData).then((res) => {
let response = res.data.result;
uni.hideLoading();
@@ -38,16 +124,25 @@ class LiLiWXPay {
package: response.package,
signType: response.signType,
paySign: response.paySign,
success: (e) => {
success: () => {
uni.showToast({
icon: "none",
title: "支付成功!",
});
sendMessage(payList[0].price);
subscribePayMessageThen(
() => {
uni.redirectTo({
url:
"/pages/cart/payment/success?paymentMethod=WECHAT" +
"&payPrice=" +
payList[0].price,
});
},
{ orderPromotionType: this.data.orderPromotionType }
);
},
fail: (e) => {
this.exception = e;
// 支付异常或支付失败之后跳转到订单页面
uni.showModal({
content: "支付失败,如果您已支付,请勿反复支付",
showCancel: false,
@@ -64,41 +159,4 @@ class LiLiWXPay {
}
}
function sendMessage(price) {
//订阅消息
getWeChatMpMessage().then((res) => {
var message = res.data.result;
var templateid = message.map((item) => item.code);
uni.requestSubscribeMessage({
tmplIds: templateid,
success: (res) => {
},
fail: (res) => {
console.log('fail', res)
uni.showToast({
icon: "none",
title: "订阅消息失败",
})
},
complete: (res) => {
console.log('complete', res)
/**
* 已经支付成功
*/
uni.redirectTo({
url:
"/pages/cart/payment/success?paymentMethod=WECHAT" +
"&payPrice=" +
price,
});
},
});
});
}
export default LiLiWXPay;

View File

@@ -3,8 +3,8 @@
"name" : "lilishop",
"appid" : "__UNI__ED807EA",
"description" : "",
"versionName" : "4.5.3",
"versionCode" : 4000503,
"versionName" : "5.0.4",
"versionCode" : 503,
"transformPx" : false,
"app-plus" : {
"compatible" : {
@@ -212,7 +212,7 @@
"plugins" : {},
"libVersion" : "3.5.8",
"__usePrivacyCheck__" : false,
"requiredPrivateInfos" : [ "chooseLocation", "getLocation" ]
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ]
},
"h5" : {
"devServer" : {

2
package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "lilishop-uniapp",
"version": "4.5.3",
"hasInstallScript": true,
"license": "ISC",
"license": "Apache-2.0",
"dependencies": {
"@tencentcloud/chat": "^3.5.5",
"hls.js": "^1.6.16",

View File

@@ -11,7 +11,7 @@
"type": "git",
"url": "https://gitee.com/beijing_hongye_huicheng/lilishop-uniapp"
},
"license": "MIT",
"license": "Apache-2.0",
"dependencies": {
"@tencentcloud/chat": "^3.5.5",
"hls.js": "^1.6.16",

View File

@@ -0,0 +1,26 @@
import { storeHttp } from "../utils/storeRequest.js";
/**
* 分页获取客户评价列表
* @param {Object} params { grade, goodsName, replyStatus, pageNumber, pageSize }
*/
export function getStoreEvaluationList(params) {
return storeHttp.request({
url: "/member/evaluation",
method: "GET",
params,
});
}
/**
* 回复客户评价
* @param {String} id 评价ID
* @param {String} reply 回复文字
* @param {String} replyImage 可选回复图片
*/
export function replyStoreEvaluation(id, reply, replyImage = "") {
return storeHttp.request({
url: `/member/evaluation/reply/${id}?reply=${encodeURIComponent(reply)}&replyImage=${encodeURIComponent(replyImage)}`,
method: "PUT",
});
}

View File

@@ -0,0 +1,70 @@
import { storeHttp } from "../utils/storeRequest.js";
/**
* 获取商家商品列表
* @param {Object} params { goodsName, marketEnable, authFlag, pageNumber, pageSize }
*/
export function getStoreGoodsList(params) {
return storeHttp.request({
url: "/goods/goods/list",
method: "GET",
params,
});
}
/**
* 获取商家商品详情
* @param {String} id 商品ID
*/
export function getStoreGoodsDetail(id) {
return storeHttp.request({
url: `/goods/goods/get/${id}`,
method: "GET",
});
}
/**
* 获取商品关联的 SKU 列表
* @param {String} goodsId 商品ID
*/
export function getStoreGoodsSkuList(goodsId) {
return storeHttp.request({
url: `/goods/goods/sku/${goodsId}/list`,
method: "GET",
});
}
/**
* 上架商品
* @param {String|Array} goodsId
*/
export function upGoods(goodsId) {
const ids = Array.isArray(goodsId) ? goodsId.join(",") : goodsId;
return storeHttp.request({
url: `/goods/goods/up?goodsId=${ids}`,
method: "PUT",
});
}
/**
* 下架商品
* @param {String|Array} goodsId
*/
export function downGoods(goodsId) {
const ids = Array.isArray(goodsId) ? goodsId.join(",") : goodsId;
return storeHttp.request({
url: `/goods/goods/under?goodsId=${ids}`,
method: "PUT",
});
}
/**
* 获取商品统计数量(出售中、已下架等)
*/
export function getStoreGoodsNumber(params) {
return storeHttp.request({
url: "/goods/goods/goodsNumber",
method: "GET",
params,
});
}

View File

@@ -0,0 +1,104 @@
import { storeHttp } from "../utils/storeRequest.js";
/**
* 商家订单列表
* @param {Object} params { orderStatus, orderType, keywords, pageNumber, pageSize }
*/
export function getStoreOrderList(params) {
return storeHttp.request({
url: "/order/order",
method: "GET",
params,
});
}
/**
* 商家订单详情
* @param {String} sn
*/
export function getStoreOrderDetail(sn) {
return storeHttp.request({
url: `/order/order/${sn}`,
method: "GET",
});
}
/**
* 自提 / 虚拟商品单码核销
* @param {String} sn
* @param {String} verificationCode
*/
export function storeOrderTake(sn, verificationCode) {
return storeHttp.request({
url: `/order/order/take/${sn}/${verificationCode}`,
method: "PUT",
});
}
/**
* 通用核销预检(按核销码查询)
* @param {String} code
*/
export function previewStoreVerification(code) {
return storeHttp.request({
url: "/order/order/verification/preview",
method: "GET",
params: { code },
});
}
/**
* 通用确认核销
* @param {String} code
*/
export function confirmStoreVerification(code) {
return storeHttp.request({
url: "/order/order/verification/confirm",
method: "POST",
data: { code },
});
}
/**
* 预约服务 — 根据核销码查询预约订单详情
* @param {String} code
*/
export function getAppointmentOrderByCode(code) {
return storeHttp.request({
url: `/appointment/verification/order/${code}`,
method: "GET",
});
}
/**
* 预约服务 — 执行核销
* @param {Object} params { orderSn, code, serviceStaffId }
*/
export function verifyAppointmentCode(params) {
return storeHttp.request({
url: "/appointment/verification/verify",
method: "POST",
data: params,
});
}
/**
* 预约服务 — 获取已启用的服务人员列表(供核销时指派/确认)
*/
export function getEnabledServiceStaff() {
return storeHttp.request({
url: "/appointment/service-staff/enabled",
method: "GET",
});
}
/**
* 预约服务 — 根据订单号获取预约单核销状态
* @param {String} orderSn
*/
export function getAppointmentVerificationByOrderSn(orderSn) {
return storeHttp.request({
url: `/appointment/verification/order-by-sn/${orderSn}`,
method: "GET",
});
}

View File

@@ -0,0 +1,94 @@
import { storeHttp } from "../utils/storeRequest.js";
/**
* 账号密码登录(支持店主与店员账号)
* @param {Object} params { username, password }
*/
export function userLogin(params) {
return storeHttp.request({
url: "/passport/login/userLogin",
method: "POST",
params,
header: {
"content-type": "application/x-www-form-urlencoded",
},
});
}
/**
* 短信验证码登录(支持店主与店员手机号)
* @param {Object} params { mobile, code }
*/
export function smsLogin(params) {
return storeHttp.request({
url: "/passport/login/smsLogin",
method: "POST",
params,
header: {
"content-type": "application/x-www-form-urlencoded",
},
});
}
/**
* 发送登录短信
* @param {String} mobile
* @param {String} verificationEnums (LOGIN)
*/
export function sendStoreSms(mobile, verificationEnums = "LOGIN") {
return storeHttp.request({
url: `/common/sms/${verificationEnums}/${mobile}`,
method: "GET",
});
}
/**
* 获取滑块验证码
* @param {String} verificationEnums (LOGIN)
*/
export function getStoreSlider(verificationEnums = "LOGIN") {
return storeHttp.request({
url: `/common/slider/${verificationEnums}`,
method: "GET",
});
}
/**
* 登出
*/
export function storeLogout() {
return storeHttp.request({
url: "/passport/login/logout",
method: "POST",
});
}
/**
* 获取店铺基本配置与信息
*/
export function getStoreSettings() {
return storeHttp.request({
url: "/settings/storeSettings",
method: "GET",
});
}
/**
* 获取当前登录人员(店主 / 店员)个人信息
*/
export function getStoreUserInfo() {
return storeHttp.request({
url: "/member/user/info",
method: "GET",
});
}
/**
* 获取当前登录所属店铺详情
*/
export function getStoreDetail() {
return storeHttp.request({
url: "/member/user",
method: "GET",
});
}

View File

@@ -0,0 +1,650 @@
<template>
<view class="merchant-evaluation-page">
<!-- 顶部 Tab 过滤 -->
<view class="sticky-header">
<view class="tab-scroll-wrap">
<view
class="tab-item"
v-for="item in tabList"
:key="item.key"
:class="{ active: currentTab === item.key }"
@click="switchTab(item.key)"
>
<text class="tab-label">{{ item.label }}</text>
<view class="indicator" v-if="currentTab === item.key"></view>
</view>
</view>
</view>
<!-- 评价列表主体 -->
<view class="eval-list-body">
<view
class="eval-card"
v-for="item in evalList"
:key="item.id"
>
<!-- 头部:买家信息与星级 -->
<view class="eval-header">
<view class="user-box">
<image
class="user-avatar"
:src="item.memberProfile || '/static/tabbar/home.png'"
mode="aspectFill"
></image>
<view class="user-meta">
<text class="user-name">{{ maskName(item.memberName) }}</text>
<text class="eval-time">{{ item.createTime || '--' }}</text>
</view>
</view>
<!-- 评分 -->
<view class="rating-stars">
<u-icon
v-for="star in 5"
:key="star"
name="star-fill"
size="14"
:color="star <= (item.descriptionScore || 5) ? '#f59e0b' : '#e5e7eb'"
></u-icon>
</view>
</view>
<!-- 评价文字内容 -->
<view class="eval-content">
<text class="content-text">{{ item.content || '用户未填写文字评价' }}</text>
</view>
<!-- 买家晒图 -->
<view class="eval-images" v-if="getImages(item.images).length > 0">
<image
class="eval-pic"
v-for="(img, idx) in getImages(item.images)"
:key="idx"
:src="img"
mode="aspectFill"
@click="previewImages(getImages(item.images), idx)"
></image>
</view>
<!-- 关联商品简要信息 -->
<view class="goods-snippet" v-if="item.goodsName">
<image
class="goods-snippet-img"
:src="item.goodsImage || '/static/tabbar/home.png'"
mode="aspectFill"
></image>
<text class="goods-snippet-title">{{ item.goodsName }}</text>
</view>
<!-- 商家已回复内容 -->
<view class="seller-reply-box" v-if="item.reply">
<view class="reply-title-row">
<text class="reply-tag">掌柜回复:</text>
<text class="reply-time" v-if="item.replyTime">{{ item.replyTime }}</text>
</view>
<text class="reply-text">{{ item.reply }}</text>
</view>
<!-- 回复动作条 -->
<view class="eval-footer">
<view
class="reply-trigger-btn"
@click="openReplyModal(item)"
>
<u-icon name="chat" size="14" :color="item.reply ? '#6b7280' : '#ffffff'"></u-icon>
<text :class="{ 'is-replied': item.reply }">
{{ item.reply ? '修改回复' : '立即回复' }}
</text>
</view>
</view>
</view>
<!-- 空状态 -->
<view class="empty-state" v-if="!loading && evalList.length === 0">
<u-icon name="chat" size="56" color="#d1d5db"></u-icon>
<text class="empty-text">暂无相关评价数据</text>
</view>
<!-- 加载提示 -->
<view class="loading-more" v-if="loading">
<text>正在加载评价数据...</text>
</view>
<view class="no-more" v-else-if="finished && evalList.length > 0">
<text>没有更多了</text>
</view>
</view>
<!-- 回复弹窗 -->
<view class="reply-modal-mask" v-if="showReplyModal" @click.self="closeReplyModal">
<view class="reply-modal-card">
<view class="modal-header">
<text class="modal-title">回复买家评价</text>
<view class="close-btn" @click="closeReplyModal">
<u-icon name="close" size="18" color="#9ca3af"></u-icon>
</view>
</view>
<view class="modal-body">
<textarea
class="reply-textarea"
v-model="replyText"
placeholder="请输入真诚的掌柜回复内容,让买家感受到热情与专业..."
placeholder-class="placeholder"
maxlength="300"
></textarea>
<view class="char-count">
<text>{{ replyText.length }}/300</text>
</view>
</view>
<view class="modal-footer">
<view class="modal-btn cancel" @click="closeReplyModal">取消</view>
<view
class="modal-btn confirm"
:class="{ loading: submittingReply }"
@click="submitReply"
>
{{ submittingReply ? '提交中...' : '提交回复' }}
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getStoreEvaluationList, replyStoreEvaluation } from '../../api/evaluation.js'
const currentTab = ref('ALL')
const loading = ref(false)
const finished = ref(false)
const pageNumber = ref(1)
const pageSize = ref(10)
const evalList = ref([])
const showReplyModal = ref(false)
const currentEval = ref(null)
const replyText = ref('')
const submittingReply = ref(false)
const tabList = [
{ key: 'ALL', label: '全部' },
{ key: 'GOOD', label: '好评' },
{ key: 'POOR', label: '中差评' },
{ key: 'UNREPLIED', label: '待回复' },
]
onLoad(() => {
fetchEvaluations(true)
})
onPullDownRefresh(async () => {
await fetchEvaluations(true)
uni.stopPullDownRefresh()
})
onReachBottom(() => {
if (!finished.value && !loading.value) {
pageNumber.value++
fetchEvaluations(false)
}
})
const fetchEvaluations = async (reset = false) => {
if (reset) {
pageNumber.value = 1
evalList.value = []
finished.value = false
}
loading.value = true
const params = {
pageNumber: pageNumber.value,
pageSize: pageSize.value,
}
if (currentTab.value === 'GOOD') {
params.grade = 'GOOD'
} else if (currentTab.value === 'POOR') {
params.grade = 'POOR'
} else if (currentTab.value === 'UNREPLIED') {
params.replyStatus = false
}
try {
const res = await getStoreEvaluationList(params)
loading.value = false
if (res.data && res.data.success && res.data.result) {
const records = res.data.result.records || []
if (reset) {
evalList.value = records
} else {
evalList.value = [...evalList.value, ...records]
}
if (records.length < pageSize.value) {
finished.value = true
}
} else {
finished.value = true
}
} catch (e) {
loading.value = false
finished.value = true
}
}
const switchTab = (tabKey) => {
if (currentTab.value === tabKey) return
currentTab.value = tabKey
fetchEvaluations(true)
}
const maskName = (name) => {
if (!name) return '匿名买家'
if (name.length <= 2) return name[0] + '*'
return name[0] + '***' + name[name.length - 1]
}
const getImages = (imgs) => {
if (!imgs) return []
if (Array.isArray(imgs)) return imgs
if (typeof imgs === 'string') {
try {
const parsed = JSON.parse(imgs)
if (Array.isArray(parsed)) return parsed
} catch (e) {
return imgs.split(',').filter(Boolean)
}
}
return []
}
const previewImages = (urls, current) => {
uni.previewImage({
urls,
current,
})
}
// 打开回复弹窗
const openReplyModal = (item) => {
currentEval.value = item
replyText.value = item.reply || ''
showReplyModal.value = true
}
const closeReplyModal = () => {
showReplyModal.value = false
currentEval.value = null
replyText.value = ''
}
// 提交回复
const submitReply = async () => {
if (!replyText.value.trim()) {
uni.showToast({ title: '请输入回复内容', icon: 'none' })
return
}
if (submittingReply.value) return
submittingReply.value = true
uni.showLoading({ title: '提交中...' })
try {
const res = await replyStoreEvaluation(currentEval.value.id, replyText.value.trim())
uni.hideLoading()
submittingReply.value = false
if (res.data && res.data.success) {
uni.showToast({ title: '回复成功', icon: 'success' })
closeReplyModal()
fetchEvaluations(true)
} else {
uni.showToast({
title: res.data?.message || '回复失败',
icon: 'none',
})
}
} catch (e) {
uni.hideLoading()
submittingReply.value = false
uni.showToast({ title: '网络异常,请重试', icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.merchant-evaluation-page {
min-height: 100vh;
background-color: #f3f4f6;
padding-bottom: 40rpx;
}
.sticky-header {
position: sticky;
top: 0;
z-index: 100;
background: #ffffff;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.04);
.tab-scroll-wrap {
display: flex;
border-bottom: 1rpx solid #f0f0f0;
padding: 0 16rpx;
.tab-item {
flex: 1;
text-align: center;
padding: 24rpx 0;
position: relative;
.tab-label {
font-size: 28rpx;
color: #6b7280;
font-weight: 500;
}
&.active {
.tab-label {
color: #111827;
font-weight: 700;
}
.indicator {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 6rpx;
background: #111827;
border-radius: 3rpx;
}
}
}
}
}
.eval-list-body {
padding: 24rpx 28rpx;
.eval-card {
background: #ffffff;
border-radius: 20rpx;
padding: 28rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.03);
.eval-header {
display: flex;
justify-content: space-between;
align-items: center;
.user-box {
display: flex;
align-items: center;
.user-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
background: #f3f4f6;
}
.user-meta {
margin-left: 16rpx;
.user-name {
font-size: 26rpx;
font-weight: 600;
color: #111827;
display: block;
}
.eval-time {
font-size: 20rpx;
color: #9ca3af;
margin-top: 4rpx;
display: block;
}
}
}
.rating-stars {
display: flex;
gap: 4rpx;
}
}
.eval-content {
margin-top: 20rpx;
.content-text {
font-size: 28rpx;
color: #1f2937;
line-height: 1.5;
word-break: break-all;
}
}
.eval-images {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 16rpx;
.eval-pic {
width: 150rpx;
height: 150rpx;
border-radius: 12rpx;
background: #f3f4f6;
}
}
.goods-snippet {
display: flex;
align-items: center;
background: #f9fafb;
border-radius: 12rpx;
padding: 14rpx;
margin-top: 20rpx;
.goods-snippet-img {
width: 64rpx;
height: 64rpx;
border-radius: 8rpx;
background: #e5e7eb;
flex-shrink: 0;
}
.goods-snippet-title {
font-size: 22rpx;
color: #4b5563;
margin-left: 14rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.seller-reply-box {
margin-top: 20rpx;
background: #f3f4f6;
border-left: 6rpx solid #111827;
border-radius: 8rpx;
padding: 16rpx 20rpx;
.reply-title-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8rpx;
.reply-tag {
font-size: 24rpx;
font-weight: 700;
color: #111827;
}
.reply-time {
font-size: 20rpx;
color: #9ca3af;
}
}
.reply-text {
font-size: 26rpx;
color: #4b5563;
line-height: 1.4;
}
}
.eval-footer {
display: flex;
justify-content: flex-end;
margin-top: 24rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f9fafb;
.reply-trigger-btn {
display: flex;
align-items: center;
gap: 8rpx;
padding: 10rpx 28rpx;
border-radius: 12rpx;
background: #111827;
color: #ffffff;
font-size: 24rpx;
font-weight: 600;
.is-replied {
color: #4b5563;
}
&:has(.is-replied) {
background: #f3f4f6;
border: 1rpx solid #e5e7eb;
}
}
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
.empty-text {
font-size: 26rpx;
color: #9ca3af;
margin-top: 20rpx;
}
}
.loading-more,
.no-more {
text-align: center;
padding: 24rpx 0;
font-size: 22rpx;
color: #9ca3af;
}
}
/* 回复弹窗 */
.reply-modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
.reply-modal-card {
background: #ffffff;
width: 100%;
max-width: 640rpx;
border-radius: 28rpx;
padding: 36rpx;
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
.modal-title {
font-size: 32rpx;
font-weight: 700;
color: #111827;
}
}
.modal-body {
position: relative;
.reply-textarea {
width: 100%;
height: 240rpx;
background: #f9fafb;
border: 1rpx solid #e5e7eb;
border-radius: 16rpx;
padding: 20rpx;
font-size: 28rpx;
color: #111827;
box-sizing: border-box;
}
.placeholder {
color: #9ca3af;
font-size: 26rpx;
}
.char-count {
position: absolute;
right: 16rpx;
bottom: 16rpx;
font-size: 20rpx;
color: #9ca3af;
}
}
.modal-footer {
display: flex;
gap: 20rpx;
margin-top: 28rpx;
.modal-btn {
flex: 1;
height: 84rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 600;
&.cancel {
background: #f3f4f6;
color: #4b5563;
}
&.confirm {
background: #111827;
color: #ffffff;
&.loading {
opacity: 0.7;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,737 @@
<template>
<view class="merchant-goods-detail" v-if="goodsDetail">
<!-- 顶部主图轮播 -->
<view class="gallery-wrapper">
<swiper
class="gallery-swiper"
:indicator-dots="false"
:autoplay="false"
@change="onSwiperChange"
>
<swiper-item
v-for="(img, idx) in galleryImages"
:key="idx"
@click="previewGallery(idx)"
>
<image class="swiper-img" :src="img" mode="aspectFill"></image>
</swiper-item>
</swiper>
<view class="swiper-indicator" v-if="galleryImages.length > 1">
<text>{{ currentSwiperIndex + 1 }} / {{ galleryImages.length }}</text>
</view>
</view>
<!-- 核心信息卡片 -->
<view class="info-card core-card">
<view class="price-row">
<view class="main-price">
<text class="symbol">¥</text>
<text class="num">{{ goodsDetail.price || '0.00' }}</text>
</view>
<view class="sub-price" v-if="goodsDetail.cost">
<text>成本价 ¥{{ goodsDetail.cost }}</text>
</view>
<view class="status-tags">
<view
class="badge market"
:class="goodsDetail.marketEnable === 'UPPER' ? 'on-sale' : 'off-sale'"
>
{{ goodsDetail.marketEnable === 'UPPER' ? '出售中' : '已下架' }}
</view>
<view
class="badge auth"
:class="goodsDetail.authFlag === 'PASS' ? 'auth-pass' : 'auth-pending'"
>
{{ getAuthText(goodsDetail.authFlag) }}
</view>
</view>
</view>
<view class="title-row">
<text class="goods-title">{{ goodsDetail.goodsName }}</text>
</view>
<view class="meta-stats-grid">
<view class="stat-col">
<text class="stat-num">{{ goodsDetail.quantity ?? 0 }}</text>
<text class="stat-label">总库存</text>
</view>
<view class="stat-sep"></view>
<view class="stat-col">
<text class="stat-num">{{ goodsDetail.buyCount ?? 0 }}</text>
<text class="stat-label">累计销量</text>
</view>
<view class="stat-sep"></view>
<view class="stat-col">
<text class="stat-num">{{ goodsDetail.sn || '--' }}</text>
<text class="stat-label">商品货号</text>
</view>
</view>
</view>
<!-- 规格与多 SKU 列表 -->
<view class="info-card" v-if="skuList && skuList.length > 0">
<view class="card-head">
<view class="head-left">
<text class="card-title">规格与 SKU 明细</text>
<text class="sku-count-pill">共 {{ skuList.length }} 种规格</text>
</view>
</view>
<view class="sku-list">
<view
class="sku-item"
v-for="(sku, index) in skuList"
:key="sku.id || sku.sn || index"
>
<image
class="sku-thumb"
:src="getSkuThumb(sku)"
mode="aspectFill"
></image>
<view class="sku-info">
<text class="sku-spec">{{ formatSpecs(sku, index) }}</text>
<text class="sku-sn" v-if="sku.sn">SKU编码:{{ sku.sn }}</text>
<view class="sku-nums">
<text class="sku-price">售价:<text class="val">¥{{ sku.price || '0.00' }}</text></text>
<text class="sku-stock">库存:<text class="val">{{ sku.quantity ?? sku.stock ?? sku.enableQuantity ?? 0 }}</text></text>
</view>
</view>
</view>
</view>
</view>
<!-- 基本属性卡片(优化分类展示) -->
<view class="info-card">
<view class="card-head">
<text class="card-title">基本属性</text>
</view>
<view class="prop-row" v-if="goodsDetail.categoryName">
<text class="prop-label">所属分类</text>
<text class="prop-val cat-val">{{ formatCategoryName(goodsDetail.categoryName) }}</text>
</view>
<view class="prop-row" v-if="goodsDetail.brandName">
<text class="prop-label">商品品牌</text>
<text class="prop-val">{{ goodsDetail.brandName }}</text>
</view>
<view class="prop-row" v-if="goodsDetail.goodsUnit">
<text class="prop-label">计量单位</text>
<text class="prop-val">{{ goodsDetail.goodsUnit }}</text>
</view>
<view class="prop-row" v-if="goodsDetail.releaseTime">
<text class="prop-label">发布时间</text>
<text class="prop-val">{{ goodsDetail.releaseTime }}</text>
</view>
</view>
<!-- 图文详情 -->
<view class="info-card intro-card" v-if="goodsDetail.intro || goodsDetail.mobileIntro">
<view class="card-head">
<text class="card-title">商品图文详情</text>
</view>
<view class="intro-html">
<rich-text :nodes="formatRichText(goodsDetail.mobileIntro || goodsDetail.intro)"></rich-text>
</view>
</view>
<!-- 底部固定悬浮操作栏(带安全底间距,防止遮挡内容) -->
<view class="bottom-action-bar">
<view class="preview-btn" @click="previewInMall">
<u-icon name="eye" size="20" color="#4b5563"></u-icon>
<text>买家预览</text>
</view>
<view
class="market-action-btn off"
v-if="goodsDetail.marketEnable === 'UPPER'"
@click="handleToggleMarket('DOWN')"
>
<text>下架商品</text>
</view>
<view
class="market-action-btn on"
v-else
@click="handleToggleMarket('UPPER')"
>
<text>立即上架</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getStoreGoodsDetail, getStoreGoodsSkuList, upGoods, downGoods } from '../../api/goods.js'
import { parseGoodsImageUrl } from '@/utils/filters.js'
const getSkuThumb = (sku: any) => {
const img = sku.thumbnail || sku.original
if (img) {
const parsed = parseGoodsImageUrl(img)
if (parsed) return parsed
}
const rawSpec = sku.simpleSpecs || sku.specs
if (rawSpec && typeof rawSpec === 'string') {
const match = rawSpec.match(/https?:\/\/[^\s"'\\}]+\.(jpeg|jpg|png|webp|gif)/i)
if (match) return match[0]
}
return galleryImages.value[0] || '/static/tabbar/home.png'
}
const goodsId = ref('')
const goodsDetail = ref<any>(null)
const skuList = ref<any[]>([])
const currentSwiperIndex = ref(0)
onLoad((options: any) => {
if (options?.id) {
goodsId.value = options.id
fetchGoodsDetail()
}
})
const galleryImages = computed(() => {
if (!goodsDetail.value) return []
if (Array.isArray(goodsDetail.value.goodsGalleryList) && goodsDetail.value.goodsGalleryList.length > 0) {
return goodsDetail.value.goodsGalleryList
}
const defaultImg = goodsDetail.value.original || goodsDetail.value.thumbnail
return defaultImg ? [defaultImg] : ['/static/tabbar/home.png']
})
const fetchGoodsDetail = async () => {
uni.showLoading({ title: '加载中...' })
try {
const res: any = await getStoreGoodsDetail(goodsId.value)
uni.hideLoading()
if (res?.data && res.data.success && res.data.result) {
goodsDetail.value = res.data.result
skuList.value = res.data.result.skuList || []
if (skuList.value.length === 0) {
fetchSkuList()
}
} else {
uni.showToast({ title: '获取商品详情失败', icon: 'none' })
}
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '网络连接异常', icon: 'none' })
}
}
const fetchSkuList = async () => {
try {
const skuRes: any = await getStoreGoodsSkuList(goodsId.value)
if (skuRes?.data && skuRes.data.success && Array.isArray(skuRes.data.result)) {
skuList.value = skuRes.data.result
}
} catch (e) {}
}
const onSwiperChange = (e: any) => {
currentSwiperIndex.value = e.detail.current
}
const previewGallery = (current: number) => {
uni.previewImage({
urls: galleryImages.value,
current,
})
}
// 智能清洗并优雅格式化规格文本(过滤 images 等系统 JSON 冗余)
const formatSpecs = (sku: any, index: number) => {
// 1. 优先使用规范的 specList 数组
if (sku.specList && Array.isArray(sku.specList) && sku.specList.length > 0) {
const listStr = sku.specList
.map((s: any) => {
if (s.specName && s.specValue) return `${s.specName}: ${s.specValue}`
return s.specValue || s.specName
})
.filter(Boolean)
.join(' / ')
if (listStr) return listStr
}
// 2. 深度清洗 simpleSpecs 或 specs 中的复杂复合 JSON
const rawSpec = sku.simpleSpecs || sku.specs
if (rawSpec && typeof rawSpec === 'string') {
const trimmed = rawSpec.trim()
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const obj = JSON.parse(trimmed)
const ignoreKeys = ['images', 'code', 'message', 'result', 'success', 'showProgress', 'url', 'status', 'specImage', 'specType']
const pairs: string[] = []
for (const [k, v] of Object.entries(obj)) {
if (!ignoreKeys.includes(k) && typeof v === 'string' && !v.startsWith('http') && v.length < 50) {
pairs.push(`${k}: ${v}`)
}
}
if (pairs.length > 0) return pairs.join(' / ')
} catch (e) {}
// JSON 解析失败时正则提取真实的规格键值对
const matches = [...trimmed.matchAll(/"([^"]{1,20})"\s*:\s*"([^"]{1,30})"/g)]
const ignoreKeys = ['code', 'message', 'success', 'url', 'result', 'showProgress', 'status', 'name']
const pairs: string[] = []
for (const m of matches) {
const k = m[1]
const v = m[2]
if (!ignoreKeys.includes(k) && !v.startsWith('http')) {
pairs.push(`${k}: ${v}`)
}
}
if (pairs.length > 0) return pairs.join(' / ')
} else {
// 纯文本规格
return trimmed
}
}
// 3. 提取不同于 SPU 主标题的商品名称差异
if (sku.goodsName && sku.goodsName !== goodsDetail.value?.goodsName) {
return sku.goodsName
}
// 4. 最终兜底
return skuList.value.length > 1 ? `规格 ${index + 1}` : '统一规格'
}
// 格式化分类展示:将原始 JSON 数组 [ "数码办公", "手机通讯", "手机" ] 转为层级面包屑 "数码办公 > 手机通讯 > 手机"
const formatCategoryName = (cat: any) => {
if (!cat) return '--'
if (Array.isArray(cat)) {
return cat.filter(Boolean).join(' > ')
}
if (typeof cat === 'string') {
try {
const parsed = JSON.parse(cat)
if (Array.isArray(parsed)) {
return parsed.filter(Boolean).join(' > ')
}
} catch (e) {}
// 处理带引号或逗号的非标准字符串
return cat.replace(/[\[\]"']/g, '').split(',').map((s: string) => s.trim()).filter(Boolean).join(' > ')
}
return String(cat)
}
const getAuthText = (flag: string) => {
const map: Record<string, string> = {
PASS: '审核通过',
AUDIT: '待审核',
REFUSE: '审核驳回',
}
return map[flag] || flag || '已通过'
}
const formatRichText = (html: string) => {
if (!html) return ''
return html
.replace(/<img[^>]*>/gi, (match) => {
return match
.replace(/style="[^"]*"/gi, '')
.replace(/width="[^"]*"/gi, '')
.replace(/height="[^"]*"/gi, '')
})
.replace(/\<img/gi, '<img style="max-width:100%;height:auto;display:block;border-radius:12rpx;margin:16rpx 0;"')
}
// 上下架切换
const handleToggleMarket = (targetStatus: string) => {
const isUp = targetStatus === 'UPPER'
const actionText = isUp ? '上架' : '下架'
uni.showModal({
title: `${actionText}商品`,
content: `确定要${actionText}当前商品?`,
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: `正在${actionText}...` })
try {
const apiCall = isUp ? upGoods(goodsId.value) : downGoods(goodsId.value)
const resp: any = await apiCall
uni.hideLoading()
if (resp?.data && resp.data.success) {
uni.showToast({ title: `${actionText}成功`, icon: 'success' })
fetchGoodsDetail()
} else {
uni.showToast({
title: resp?.data?.message || `${actionText}失败`,
icon: 'none',
})
}
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' })
}
}
},
})
}
// 前往买家端预览
const previewInMall = () => {
uni.navigateTo({
url: `/pages/product/goods?id=${goodsId.value}`,
})
}
</script>
<style lang="scss" scoped>
.merchant-goods-detail {
min-height: 100vh;
background-color: #f4f5f7;
/* 预留出底部固定操作栏的高以及全面屏安全区,彻底解决内容被底部遮挡 */
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
.gallery-wrapper {
position: relative;
width: 100%;
height: 680rpx;
background: #ffffff;
.gallery-swiper {
width: 100%;
height: 100%;
.swiper-img {
width: 100%;
height: 100%;
}
}
.swiper-indicator {
position: absolute;
right: 28rpx;
bottom: 44rpx;
background: rgba(0, 0, 0, 0.55);
color: #ffffff;
font-size: 22rpx;
font-weight: 500;
padding: 6rpx 20rpx;
border-radius: 20rpx;
backdrop-filter: blur(4px);
}
}
.info-card {
margin: 20rpx 28rpx;
background: #ffffff;
border-radius: 24rpx;
padding: 30rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.03);
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
padding-bottom: 16rpx;
border-bottom: 1rpx solid #f9fafb;
.head-left {
display: flex;
align-items: center;
gap: 16rpx;
}
.card-title {
font-size: 30rpx;
font-weight: 700;
color: #111827;
}
.sku-count-pill {
font-size: 22rpx;
color: #6b7280;
background: #f3f4f6;
padding: 4rpx 14rpx;
border-radius: 12rpx;
}
}
&.core-card {
margin-top: -30rpx;
position: relative;
z-index: 10;
.price-row {
display: flex;
align-items: baseline;
justify-content: space-between;
.main-price {
color: #ef4444;
display: flex;
align-items: baseline;
.symbol {
font-size: 28rpx;
font-weight: 700;
}
.num {
font-size: 48rpx;
font-weight: 700;
margin-left: 4rpx;
}
}
.sub-price {
font-size: 24rpx;
color: #9ca3af;
margin-left: 16rpx;
flex: 1;
}
.status-tags {
display: flex;
gap: 12rpx;
.badge {
font-size: 22rpx;
padding: 6rpx 16rpx;
border-radius: 10rpx;
font-weight: 600;
&.on-sale {
background: #ecfdf5;
color: #059669;
}
&.off-sale {
background: #f3f4f6;
color: #9ca3af;
}
&.auth-pass {
background: #eff6ff;
color: #2563eb;
}
&.auth-pending {
background: #fffbeb;
color: #d97706;
}
}
}
}
.title-row {
margin-top: 20rpx;
.goods-title {
font-size: 32rpx;
font-weight: 700;
color: #111827;
line-height: 1.45;
}
}
.meta-stats-grid {
display: flex;
align-items: center;
background: #f9fafb;
border-radius: 16rpx;
padding: 22rpx 0;
margin-top: 26rpx;
.stat-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
.stat-num {
font-size: 28rpx;
font-weight: 700;
color: #111827;
}
.stat-label {
font-size: 22rpx;
color: #9ca3af;
margin-top: 4rpx;
}
}
.stat-sep {
width: 1rpx;
height: 38rpx;
background: #e5e7eb;
}
}
}
.sku-list {
.sku-item {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f3f4f6;
&:last-child {
border-bottom: none;
}
.sku-thumb {
width: 104rpx;
height: 104rpx;
border-radius: 14rpx;
background: #f9fafb;
border: 1rpx solid #f3f4f6;
flex-shrink: 0;
}
.sku-info {
flex: 1;
margin-left: 22rpx;
display: flex;
flex-direction: column;
.sku-spec {
font-size: 28rpx;
font-weight: 600;
color: #111827;
line-height: 1.35;
}
.sku-sn {
font-size: 22rpx;
color: #9ca3af;
margin-top: 6rpx;
}
.sku-nums {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-top: 10rpx;
.sku-price {
font-size: 24rpx;
color: #6b7280;
.val {
font-size: 28rpx;
font-weight: 700;
color: #ef4444;
}
}
.sku-stock {
font-size: 24rpx;
color: #6b7280;
.val {
font-weight: 600;
color: #111827;
}
}
}
}
}
}
.prop-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 18rpx 0;
border-bottom: 1rpx solid #f9fafb;
&:last-child {
border-bottom: none;
}
.prop-label {
font-size: 26rpx;
color: #6b7280;
width: 160rpx;
flex-shrink: 0;
}
.prop-val {
flex: 1;
text-align: right;
font-size: 26rpx;
color: #111827;
font-weight: 500;
line-height: 1.45;
&.cat-val {
color: #2563eb;
background: #eff6ff;
display: inline-block;
padding: 6rpx 16rpx;
border-radius: 8rpx;
font-size: 24rpx;
font-weight: 500;
}
}
}
&.intro-card {
.intro-html {
margin-top: 16rpx;
font-size: 28rpx;
color: #374151;
line-height: 1.6;
word-break: break-all;
}
}
}
.bottom-action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #ffffff;
display: flex;
align-items: center;
padding: 16rpx 28rpx calc(16rpx + env(safe-area-inset-bottom)) 28rpx;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
z-index: 100;
gap: 20rpx;
.preview-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 20rpx;
text {
font-size: 20rpx;
color: #4b5563;
margin-top: 4rpx;
}
}
.market-action-btn {
flex: 1;
height: 84rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 600;
&.off {
background: #fef2f2;
color: #dc2626;
border: 1rpx solid #fecaca;
}
&.on {
background: #111827;
color: #ffffff;
}
}
}
</style>

View File

@@ -0,0 +1,472 @@
<template>
<view class="merchant-goods-page">
<!-- 顶部吸顶搜索栏与 Tab -->
<view class="sticky-header">
<view class="search-bar">
<u-icon name="search" size="18" color="#9ca3af"></u-icon>
<input
class="search-input"
type="text"
v-model="keywords"
placeholder="搜索商品名称"
placeholder-class="placeholder"
@confirm="onSearch"
/>
<view class="clear-btn" v-if="keywords" @click="clearSearch">
<u-icon name="close-circle-fill" size="16" color="#d1d5db"></u-icon>
</view>
</view>
<view class="tab-scroll-wrap">
<view
class="tab-item"
v-for="item in tabList"
:key="item.key"
:class="{ active: currentTab === item.key }"
@click="switchTab(item.key)"
>
<text class="tab-label">{{ item.label }}</text>
<view class="indicator" v-if="currentTab === item.key"></view>
</view>
</view>
</view>
<!-- 商品列表主体 -->
<view class="goods-list-body">
<view
class="goods-card"
v-for="goods in goodsList"
:key="goods.id"
@click="goToDetail(goods.id)"
>
<view class="card-main">
<image
class="goods-img"
:src="goods.thumbnail || goods.original || '/static/tabbar/home.png'"
mode="aspectFill"
></image>
<view class="goods-meta">
<view class="title-row">
<text class="goods-title">{{ goods.goodsName }}</text>
</view>
<view class="stock-sales-row">
<text class="meta-tag">库存: {{ goods.quantity ?? 0 }}</text>
<text class="meta-tag">销量: {{ goods.buyCount ?? 0 }}</text>
</view>
<view class="price-status-row">
<view class="price-box">
<text class="symbol">¥</text>
<text class="price-num">{{ goods.price || goods.sellingPrice || '0.00' }}</text>
</view>
<view class="status-badge" :class="goods.marketEnable === 'UPPER' ? 'on-sale' : 'off-sale'">
{{ goods.marketEnable === 'UPPER' ? '出售中' : '已下架' }}
</view>
</view>
</view>
</view>
<!-- 底部快捷操作条 -->
<view class="card-footer">
<text class="goods-sn" v-if="goods.sn">商品货号:{{ goods.sn }}</text>
<view class="actions-group">
<view
class="action-btn off"
v-if="goods.marketEnable === 'UPPER'"
@click.stop="toggleMarket(goods, 'DOWN')"
>
<text>下架商品</text>
</view>
<view
class="action-btn on"
v-else
@click.stop="toggleMarket(goods, 'UPPER')"
>
<text>立即上架</text>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view class="empty-state" v-if="!loading && goodsList.length === 0">
<u-icon name="bag" size="56" color="#d1d5db"></u-icon>
<text class="empty-text">暂无相关商品</text>
</view>
<!-- 加载提示 -->
<view class="loading-more" v-if="loading">
<text>正在加载商品列表...</text>
</view>
<view class="no-more" v-else-if="finished && goodsList.length > 0">
<text>没有更多了</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getStoreGoodsList, upGoods, downGoods } from '../../api/goods.js'
const keywords = ref('')
const currentTab = ref('ALL')
const loading = ref(false)
const finished = ref(false)
const pageNumber = ref(1)
const pageSize = ref(10)
const goodsList = ref([])
const tabList = [
{ key: 'ALL', label: '全部' },
{ key: 'UPPER', label: '出售中' },
{ key: 'DOWN', label: '已下架' },
]
onLoad((options) => {
if (options?.tab) {
currentTab.value = options.tab
}
fetchGoods(true)
})
onPullDownRefresh(async () => {
await fetchGoods(true)
uni.stopPullDownRefresh()
})
onReachBottom(() => {
if (!finished.value && !loading.value) {
pageNumber.value++
fetchGoods(false)
}
})
const fetchGoods = async (reset = false) => {
if (reset) {
pageNumber.value = 1
goodsList.value = []
finished.value = false
}
loading.value = true
const params = {
pageNumber: pageNumber.value,
pageSize: pageSize.value,
}
if (keywords.value.trim()) {
params.goodsName = keywords.value.trim()
}
if (currentTab.value !== 'ALL') {
params.marketEnable = currentTab.value
}
try {
const res = await getStoreGoodsList(params)
loading.value = false
if (res.data && res.data.success && res.data.result) {
const records = res.data.result.records || []
if (reset) {
goodsList.value = records
} else {
goodsList.value = [...goodsList.value, ...records]
}
if (records.length < pageSize.value) {
finished.value = true
}
} else {
finished.value = true
}
} catch (e) {
loading.value = false
finished.value = true
}
}
const switchTab = (tabKey) => {
if (currentTab.value === tabKey) return
currentTab.value = tabKey
fetchGoods(true)
}
const onSearch = () => {
fetchGoods(true)
}
const clearSearch = () => {
keywords.value = ''
fetchGoods(true)
}
const goToDetail = (id) => {
uni.navigateTo({
url: `/pages-sub/merchant/pages/goods/detail?id=${id}`,
})
}
// 快捷上下架切换
const toggleMarket = (goods, targetStatus) => {
const isUp = targetStatus === 'UPPER'
const actionText = isUp ? '上架' : '下架'
uni.showModal({
title: `${actionText}提示`,
content: `确定要${actionText}商品「${goods.goodsName}」吗?`,
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: `正在${actionText}...` })
try {
const apiCall = isUp ? upGoods(goods.id) : downGoods(goods.id)
const resp = await apiCall
uni.hideLoading()
if (resp.data && resp.data.success) {
uni.showToast({ title: `${actionText}成功`, icon: 'success' })
fetchGoods(true)
} else {
uni.showToast({
title: resp.data?.message || `${actionText}失败`,
icon: 'none',
})
}
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '网络连接异常', icon: 'none' })
}
}
},
})
}
</script>
<style lang="scss" scoped>
.merchant-goods-page {
min-height: 100vh;
background-color: #f3f4f6;
padding-bottom: 40rpx;
}
.sticky-header {
position: sticky;
top: 0;
z-index: 100;
background: #ffffff;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.04);
.search-bar {
display: flex;
align-items: center;
background: #f3f4f6;
margin: 20rpx 28rpx;
padding: 16rpx 24rpx;
border-radius: 16rpx;
.search-input {
flex: 1;
margin-left: 16rpx;
font-size: 26rpx;
color: #111827;
}
.placeholder {
color: #9ca3af;
font-size: 26rpx;
}
}
.tab-scroll-wrap {
display: flex;
border-bottom: 1rpx solid #f0f0f0;
padding: 0 16rpx;
.tab-item {
flex: 1;
text-align: center;
padding: 20rpx 0;
position: relative;
.tab-label {
font-size: 28rpx;
color: #6b7280;
font-weight: 500;
}
&.active {
.tab-label {
color: #111827;
font-weight: 700;
}
.indicator {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 6rpx;
background: #111827;
border-radius: 3rpx;
}
}
}
}
}
.goods-list-body {
padding: 24rpx 28rpx;
.goods-card {
background: #ffffff;
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.03);
.card-main {
display: flex;
.goods-img {
width: 170rpx;
height: 170rpx;
border-radius: 16rpx;
background: #f3f4f6;
}
.goods-meta {
flex: 1;
margin-left: 20rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
.title-row {
.goods-title {
font-size: 28rpx;
font-weight: 600;
color: #111827;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.stock-sales-row {
display: flex;
gap: 16rpx;
margin-top: 8rpx;
.meta-tag {
font-size: 22rpx;
color: #6b7280;
background: #f3f4f6;
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
}
.price-status-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12rpx;
.price-box {
display: flex;
align-items: baseline;
color: #ff3c2a;
.symbol {
font-size: 22rpx;
font-weight: 700;
}
.price-num {
font-size: 34rpx;
font-weight: 700;
margin-left: 4rpx;
}
}
.status-badge {
font-size: 22rpx;
padding: 4rpx 14rpx;
border-radius: 8rpx;
font-weight: 500;
&.on-sale {
background: #ecfdf5;
color: #059669;
}
&.off-sale {
background: #f3f4f6;
color: #9ca3af;
}
}
}
}
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f9fafb;
.goods-sn {
font-size: 22rpx;
color: #9ca3af;
}
.actions-group {
display: flex;
gap: 16rpx;
.action-btn {
padding: 10rpx 28rpx;
border-radius: 12rpx;
font-size: 24rpx;
font-weight: 600;
&.off {
background: #fef2f2;
color: #dc2626;
border: 1rpx solid #fecaca;
}
&.on {
background: #111827;
color: #ffffff;
}
}
}
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
.empty-text {
font-size: 26rpx;
color: #9ca3af;
margin-top: 20rpx;
}
}
.loading-more,
.no-more {
text-align: center;
padding: 24rpx 0;
font-size: 22rpx;
color: #9ca3af;
}
}
</style>

View File

@@ -0,0 +1,812 @@
<template>
<view class="workbench-page">
<!-- 顶部商家信息栏 -->
<view class="store-hero">
<view class="store-info-row">
<view class="store-avatar">
<image
class="avatar-img"
:src="storeInfo.settings?.storeLogo || '/static/tabbar/home.png'"
mode="aspectFill"
></image>
</view>
<view class="store-meta">
<view class="name-row">
<text class="store-name">{{ storeInfo.settings?.storeName || storeInfo.store?.storeName || '商家移动工作台' }}</text>
<view class="badge" :class="isClerk ? 'clerk-badge' : 'owner-badge'">
{{ isClerk ? '店员' : '店主' }}
</view>
</view>
<text class="clerk-name">当前操作员:{{ operatorName }}</text>
</view>
<view class="logout-btn" @click="handleLogout">
<text>退出</text>
</view>
</view>
</view>
<!-- 核心操作区(快速核销) -->
<view class="core-actions-card">
<view class="card-header">
<text class="card-title">快速核销</text>
<text class="card-subtitle">支持预约单、自提单与虚拟卡密</text>
</view>
<!-- 扫码主按钮 -->
<view class="scan-hero-btn" @click="handleScanCode">
<view class="scan-icon-circle">
<u-icon name="scan" size="38" color="#ffffff"></u-icon>
</view>
<view class="scan-text-box">
<text class="main-text">扫码快速核销</text>
<text class="sub-text">扫描顾客出示的核销码/订单码</text>
</view>
<u-icon name="arrow-right" size="18" color="rgba(255,255,255,0.6)"></u-icon>
</view>
<!-- 手动输码 -->
<view class="manual-input-box">
<view class="input-wrap">
<u-icon name="edit-pen" size="20" color="#9ca3af"></u-icon>
<input
class="code-input"
type="number"
maxlength="12"
v-model="manualCode"
placeholder="输入8位数字核销码"
placeholder-class="placeholder"
@confirm="handleManualVerify"
/>
<view class="clear-icon" v-if="manualCode" @click="clearManualCode">
<u-icon name="close-circle-fill" size="16" color="#d1d5db"></u-icon>
</view>
</view>
<view
class="verify-btn"
:class="{ active: manualCode.trim().length >= 6 }"
@click="handleManualVerify"
>
<text>核销</text>
</view>
</view>
</view>
<!-- 核心业务四大板块 -->
<view class="business-grid-card">
<view class="grid-card-header">
<text class="grid-card-title">经营管理</text>
</view>
<view class="business-grid">
<!-- 订单管理 -->
<view class="biz-item" @click="navigateTo('/pages-sub/merchant/pages/order/list?tab=ALL')">
<view class="biz-icon-box bg-blue">
<u-icon name="order" size="26" color="#2563eb"></u-icon>
</view>
<text class="biz-title">订单管理</text>
</view>
<!-- 商品管理 -->
<view class="biz-item" @click="navigateTo('/pages-sub/merchant/pages/goods/list')">
<view class="biz-icon-box bg-emerald">
<u-icon name="bag" size="26" color="#059669"></u-icon>
</view>
<text class="biz-title">商品管理</text>
</view>
<!-- 评价管理 -->
<view class="biz-item" @click="navigateTo('/pages-sub/merchant/pages/evaluation/list')">
<view class="biz-icon-box bg-amber">
<u-icon name="chat" size="26" color="#d97706"></u-icon>
</view>
<text class="biz-title">评价管理</text>
</view>
<!-- 待核销专区 -->
<view class="biz-item" @click="navigateTo('/pages-sub/merchant/pages/order/list?tab=TAKE')">
<view class="biz-icon-box bg-orange">
<u-icon name="checkmark-circle" size="26" color="#ea580c"></u-icon>
</view>
<text class="biz-title">待核销单</text>
</view>
</view>
</view>
<!-- 核销确认弹窗 -->
<view class="modal-mask" v-if="showModal" @click.self="closeModal">
<view class="modal-card">
<view class="modal-header">
<text class="modal-title">核销信息确认</text>
<view class="close-btn" @click="closeModal">
<u-icon name="close" size="18" color="#9ca3af"></u-icon>
</view>
</view>
<!-- 订单概览 -->
<view class="order-summary" v-if="verifyingOrder">
<view class="summary-row">
<text class="label">核销码:</text>
<text class="value highlight-code">{{ currentCode }}</text>
</view>
<view class="summary-row">
<text class="label">订单编号:</text>
<text class="value">{{ verifyingOrder.sn || verifyingOrder.orderSn || '--' }}</text>
</view>
<view class="summary-row" v-if="verifyingOrder.consigneeMobile || verifyingOrder.memberPhone">
<text class="label">顾客电话:</text>
<text class="value">{{ maskPhone(verifyingOrder.consigneeMobile || verifyingOrder.memberPhone) }}</text>
</view>
<view class="summary-row" v-if="verifyingOrder.goodsName || verifyingOrder.orderItems?.[0]?.goodsName">
<text class="label">商品名称:</text>
<text class="value goods-text">
{{ verifyingOrder.goodsName || verifyingOrder.orderItems?.[0]?.goodsName }}
</text>
</view>
<view class="summary-row" v-if="verifyingOrder.num">
<text class="label">购买数量:</text>
<text class="value">{{ verifyingOrder.num }} 件</text>
</view>
<!-- 预约服务专属选项:选择服务人员 -->
<view class="staff-picker-box" v-if="isAppointmentOrder">
<text class="staff-label">指派/核销服务人员:</text>
<picker
class="picker-ctrl"
mode="selector"
:range="staffList"
range-key="name"
:value="selectedStaffIndex"
@change="onStaffChange"
>
<view class="picker-display">
<text>{{ currentStaffName }}</text>
<u-icon name="arrow-down" size="14" color="#6b7280"></u-icon>
</view>
</picker>
</view>
</view>
<!-- 弹窗操作 -->
<view class="modal-footer">
<view class="modal-btn cancel" @click="closeModal">取消</view>
<view
class="modal-btn confirm"
:class="{ loading: confirming }"
@click="submitConfirmVerification"
>
{{ confirming ? '核销中...' : '确认核销' }}
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import storage from '@/utils/storage.js'
import {
previewStoreVerification,
storeOrderTake,
confirmStoreVerification,
getAppointmentOrderByCode,
verifyAppointmentCode,
getEnabledServiceStaff,
} from '../../api/order.js'
import { storeLogout } from '../../api/passport.js'
const storeInfo = ref({})
const operatorName = computed(() => {
const u = storeInfo.value?.user
return u?.nickName || u?.username || '当前操作员'
})
const isClerk = computed(() => {
const u = storeInfo.value?.user
if (u && (u.isSuper === false || u.shopkeeper === false)) return true
return false
})
const manualCode = ref('')
const showModal = ref(false)
const confirming = ref(false)
const currentCode = ref('')
const verifyingOrder = ref(null)
const isAppointmentOrder = ref(false)
const staffList = ref([])
const selectedStaffIndex = ref(0)
const currentStaffName = computed(() => {
if (staffList.value.length > 0 && staffList.value[selectedStaffIndex.value]) {
return staffList.value[selectedStaffIndex.value].name
}
return '未指派 / 默认'
})
onMounted(() => {
const token = storage.getStoreAccessToken()
if (!token) {
uni.redirectTo({ url: '/pages-sub/merchant/pages/login/index' })
return
}
storeInfo.value = storage.getStoreInfo() || {}
loadStaffList()
})
const loadStaffList = async () => {
try {
const res = await getEnabledServiceStaff()
if (res.data && res.data.success && Array.isArray(res.data.result)) {
staffList.value = res.data.result
}
} catch (e) {
// 忽略加载异常
}
}
const maskPhone = (phone) => {
if (!phone || phone.length < 7) return phone || ''
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
// 扫码快速核销
const handleScanCode = () => {
uni.scanCode({
onlyFromCamera: true,
scanType: ['qrCode', 'barCode'],
success: (res) => {
let code = res.result || ''
if (code.includes('code=')) {
const match = code.match(/code=([a-zA-Z0-9]+)/)
if (match && match[1]) {
code = match[1]
}
}
if (code) {
queryAndShowVerify(code.trim())
} else {
uni.showToast({ title: '未能识别核销码', icon: 'none' })
}
},
fail: () => {},
})
}
// 手动输码核销
const clearManualCode = () => {
manualCode.value = ''
}
const handleManualVerify = () => {
const code = manualCode.value.trim()
if (!code || code.length < 6) {
uni.showToast({ title: '请输入有效的核销码', icon: 'none' })
return
}
queryAndShowVerify(code)
}
// 预检核销码并展示弹窗
const queryAndShowVerify = async (code) => {
currentCode.value = code
uni.showLoading({ title: '正在查询订单...' })
try {
let isAppoint = false
let orderData = null
try {
const appointRes = await getAppointmentOrderByCode(code)
if (appointRes.data && appointRes.data.success && appointRes.data.result) {
isAppoint = true
orderData = appointRes.data.result
}
} catch (e) {}
if (!orderData) {
const previewRes = await previewStoreVerification(code)
if (previewRes.data && previewRes.data.success && previewRes.data.result) {
orderData = previewRes.data.result
}
}
uni.hideLoading()
if (orderData) {
verifyingOrder.value = orderData
isAppointmentOrder.value = isAppoint
showModal.value = true
} else {
verifyingOrder.value = { sn: '以提交核销为准' }
isAppointmentOrder.value = false
showModal.value = true
}
} catch (err) {
uni.hideLoading()
verifyingOrder.value = { sn: '以提交核销为准' }
isAppointmentOrder.value = false
showModal.value = true
}
}
const onStaffChange = (e) => {
selectedStaffIndex.value = e.detail.value
}
const closeModal = () => {
showModal.value = false
verifyingOrder.value = null
manualCode.value = ''
}
// 确认核销提交
const submitConfirmVerification = async () => {
if (confirming.value) return
confirming.value = true
uni.showLoading({ title: '核销中...' })
try {
let success = false
let message = ''
if (isAppointmentOrder.value) {
const staff = staffList.value[selectedStaffIndex.value]
const params = {
orderSn: verifyingOrder.value?.orderSn || verifyingOrder.value?.sn,
code: currentCode.value,
serviceStaffId: staff ? staff.id : undefined,
}
const res = await verifyAppointmentCode(params)
success = res.data && res.data.success
message = res.data?.message
} else {
const sn = verifyingOrder.value?.orderSn || verifyingOrder.value?.sn
if (sn && sn !== '以提交核销为准') {
const res = await storeOrderTake(sn, currentCode.value)
success = res.data && res.data.success
message = res.data?.message
} else {
const res = await confirmStoreVerification(currentCode.value)
success = res.data && res.data.success
message = res.data?.message
}
}
uni.hideLoading()
confirming.value = false
if (success) {
try {
uni.vibrateShort()
} catch (e) {}
uni.showToast({ title: '核销成功!', icon: 'success' })
closeModal()
} else {
uni.showToast({
title: message || '核销失败,请核对核销码',
icon: 'none',
})
}
} catch (e) {
uni.hideLoading()
confirming.value = false
uni.showToast({ title: '请求失败,请稍后重试', icon: 'none' })
}
}
// 退出登录
const handleLogout = () => {
uni.showModal({
title: '退出商家工作台',
content: '退出后将清除本店登录凭证,确认退出?',
success: async (res) => {
if (res.confirm) {
try {
await storeLogout()
} catch (e) {}
storage.clearStoreAuth()
uni.redirectTo({
url: '/pages-sub/merchant/pages/login/index',
})
}
},
})
}
const navigateTo = (url) => {
uni.navigateTo({ url })
}
</script>
<style lang="scss" scoped>
.workbench-page {
min-height: 100vh;
background-color: #f3f4f6;
padding-bottom: 60rpx;
}
.store-hero {
background: linear-gradient(135deg, #1f2937 0%, #111827 100%);
padding: 60rpx 36rpx 100rpx 36rpx;
color: #ffffff;
.store-info-row {
display: flex;
align-items: center;
.store-avatar {
width: 96rpx;
height: 96rpx;
border-radius: 20rpx;
overflow: hidden;
background: #ffffff;
border: 2rpx solid rgba(255, 255, 255, 0.2);
.avatar-img {
width: 100%;
height: 100%;
}
}
.store-meta {
flex: 1;
margin-left: 24rpx;
.name-row {
display: flex;
align-items: center;
gap: 12rpx;
.store-name {
font-size: 34rpx;
font-weight: 700;
color: #ffffff;
}
.badge {
font-size: 20rpx;
padding: 4rpx 14rpx;
border-radius: 8rpx;
font-weight: 500;
&.owner-badge {
background: rgba(255, 255, 255, 0.2);
color: #ffffff;
}
&.clerk-badge {
background: #ecfdf5;
color: #059669;
}
}
}
.clerk-name {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.65);
margin-top: 8rpx;
}
}
.logout-btn {
padding: 10rpx 24rpx;
background: rgba(255, 255, 255, 0.12);
border-radius: 12rpx;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.85);
}
}
}
.core-actions-card {
margin: -60rpx 32rpx 28rpx 32rpx;
background: #ffffff;
border-radius: 28rpx;
padding: 36rpx;
box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.06);
.card-header {
margin-bottom: 28rpx;
.card-title {
font-size: 32rpx;
font-weight: 700;
color: #111827;
display: block;
}
.card-subtitle {
font-size: 22rpx;
color: #9ca3af;
margin-top: 6rpx;
display: block;
}
}
.scan-hero-btn {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border-radius: 22rpx;
padding: 32rpx 28rpx;
display: flex;
align-items: center;
box-shadow: 0 10rpx 24rpx rgba(16, 185, 129, 0.3);
.scan-icon-circle {
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.scan-text-box {
flex: 1;
margin-left: 24rpx;
.main-text {
font-size: 34rpx;
font-weight: 700;
color: #ffffff;
display: block;
}
.sub-text {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.8);
margin-top: 6rpx;
display: block;
}
}
}
.manual-input-box {
display: flex;
align-items: center;
margin-top: 28rpx;
gap: 16rpx;
.input-wrap {
flex: 1;
display: flex;
align-items: center;
background: #f9fafb;
border: 1rpx solid #e5e7eb;
border-radius: 16rpx;
padding: 18rpx 24rpx;
.code-input {
flex: 1;
margin-left: 16rpx;
font-size: 28rpx;
color: #111827;
}
.placeholder {
color: #9ca3af;
font-size: 26rpx;
}
}
.verify-btn {
padding: 20rpx 36rpx;
background: #e5e7eb;
color: #9ca3af;
border-radius: 16rpx;
font-size: 28rpx;
font-weight: 600;
&.active {
background: #111827;
color: #ffffff;
}
}
}
}
.business-grid-card {
margin: 0 32rpx 24rpx 32rpx;
background: #ffffff;
border-radius: 28rpx;
padding: 32rpx 24rpx;
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.04);
.grid-card-header {
margin-bottom: 24rpx;
padding-left: 8rpx;
.grid-card-title {
font-size: 30rpx;
font-weight: 700;
color: #111827;
}
}
.business-grid {
display: flex;
flex-wrap: wrap;
.biz-item {
width: 50%;
box-sizing: border-box;
padding: 20rpx;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
.biz-icon-box {
width: 96rpx;
height: 96rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
&.bg-blue {
background: #eff6ff;
}
&.bg-emerald {
background: #ecfdf5;
}
&.bg-amber {
background: #fffbeb;
}
&.bg-orange {
background: #fff7ed;
}
}
.biz-title {
font-size: 28rpx;
font-weight: 700;
color: #1f2937;
}
.biz-sub {
font-size: 22rpx;
color: #9ca3af;
margin-top: 4rpx;
}
}
}
}
/* 弹窗遮罩 */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
.modal-card {
background: #ffffff;
width: 100%;
max-width: 620rpx;
border-radius: 28rpx;
padding: 36rpx;
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 28rpx;
.modal-title {
font-size: 32rpx;
font-weight: 700;
color: #111827;
}
}
.order-summary {
background: #f9fafb;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 32rpx;
.summary-row {
display: flex;
font-size: 26rpx;
margin-bottom: 14rpx;
&:last-child {
margin-bottom: 0;
}
.label {
color: #6b7280;
width: 150rpx;
}
.value {
color: #111827;
flex: 1;
&.highlight-code {
font-size: 32rpx;
font-weight: 700;
color: #10b981;
}
&.goods-text {
word-break: break-all;
}
}
}
.staff-picker-box {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx dashed #e5e7eb;
.staff-label {
font-size: 24rpx;
color: #6b7280;
display: block;
margin-bottom: 12rpx;
}
.picker-display {
display: flex;
align-items: center;
justify-content: space-between;
background: #ffffff;
border: 1rpx solid #d1d5db;
border-radius: 12rpx;
padding: 16rpx 20rpx;
font-size: 26rpx;
color: #111827;
}
}
}
.modal-footer {
display: flex;
gap: 20rpx;
.modal-btn {
flex: 1;
height: 84rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 600;
&.cancel {
background: #f3f4f6;
color: #4b5563;
}
&.confirm {
background: #111827;
color: #ffffff;
&.loading {
opacity: 0.7;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,550 @@
<template>
<view class="merchant-login-page">
<!-- 顶部状态栏沉浸背景 -->
<view class="header-bg">
<view class="brand-box">
<view class="badge-icon">
<u-icon name="bag" size="32" color="#ffffff"></u-icon>
</view>
<text class="brand-title">商家管理工作台</text>
<text class="brand-desc">随时随地 · 快捷扫码核销与订单管理</text>
</view>
</view>
<!-- 登录卡片 -->
<view class="login-card">
<!-- 模式切换 Tab -->
<view class="tab-header">
<view
class="tab-item"
:class="{ active: loginType === 'account' }"
@click="switchTab('account')"
>
<text>账号密码登录</text>
<view class="active-indicator" v-if="loginType === 'account'"></view>
</view>
<view
class="tab-item"
:class="{ active: loginType === 'sms' }"
@click="switchTab('sms')"
>
<text>短信验证码登录</text>
<view class="active-indicator" v-if="loginType === 'sms'"></view>
</view>
</view>
<!-- 账号密码表单 -->
<view class="form-container" v-if="loginType === 'account'">
<view class="input-item">
<u-input
v-model="accountForm.username"
placeholder="请输入商家账号/手机号"
border="surround"
prefixIcon="account"
:prefixIconStyle="{ color: '#8c8c8c', fontSize: '36rpx', marginRight: '16rpx' }"
:customStyle="customInputStyle"
clearable
/>
</view>
<view class="input-item">
<u-input
v-model="accountForm.password"
placeholder="请输入登录密码"
type="password"
border="surround"
prefixIcon="lock"
:prefixIconStyle="{ color: '#8c8c8c', fontSize: '36rpx', marginRight: '16rpx' }"
:customStyle="customInputStyle"
clearable
/>
</view>
</view>
<!-- 短信验证码表单 -->
<view class="form-container" v-else>
<view class="input-item">
<u-input
v-model="smsForm.mobile"
placeholder="请输入店主手机号"
type="number"
maxlength="11"
border="surround"
prefixIcon="phone"
:prefixIconStyle="{ color: '#8c8c8c', fontSize: '36rpx', marginRight: '16rpx' }"
:customStyle="customInputStyle"
clearable
/>
</view>
<view class="input-item">
<u-input
v-model="smsForm.code"
placeholder="请输入 6 位短信验证码"
type="number"
maxlength="6"
border="surround"
prefixIcon="email"
:prefixIconStyle="{ color: '#8c8c8c', fontSize: '36rpx', marginRight: '16rpx' }"
:customStyle="customInputStyle"
clearable
>
<template #suffix>
<view
class="send-btn"
:class="{ disabled: smsCount > 0 || !smsForm.mobile }"
@click="handleSendSms"
>
<text>{{ smsCount > 0 ? `${smsCount}s后重试` : '获取验证码' }}</text>
</view>
</template>
</u-input>
</view>
</view>
<!-- 登录提交按钮 -->
<view class="action-btn" :class="{ loading: submitting }" @click="handleLogin">
<text class="btn-text">{{ submitting ? '正在登录...' : '立即进入工作台' }}</text>
</view>
<view class="notice-tip">
<u-icon name="info-circle" size="14" color="#8c8c8c"></u-icon>
<text class="notice-text">仅限已入驻商城的店主/店员账号登录</text>
</view>
</view>
<!-- 安全滑块拼图验证码弹窗 -->
<myVerification
v-if="codeFlag"
ref="verificationRef"
apiType="store"
business="LOGIN"
@send="handleVerificationSuccess"
/>
</view>
</template>
<script setup lang="ts">
import { ref, reactive, onBeforeUnmount, nextTick } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import storage from '@/utils/storage.js'
import myVerification from '@/components/verification/verification.vue'
import {
userLogin,
smsLogin,
sendStoreSms,
getStoreSettings,
getStoreUserInfo,
getStoreDetail,
} from '../../api/passport.js'
const loginType = ref<'account' | 'sms'>('account')
const submitting = ref(false)
const smsCount = ref(0)
let timer: any = null
// 滑块验证码相关控制
const codeFlag = ref(false)
const verificationRef = ref<any>(null)
const verifyAction = ref<'login' | 'sms'>('login')
const customInputStyle = {
backgroundColor: '#f9fafb',
borderRadius: '16rpx',
height: '84rpx',
paddingLeft: '16rpx',
fontSize: '28rpx',
}
const accountForm = reactive({
username: '',
password: '',
})
const smsForm = reactive({
mobile: '',
code: '',
})
onLoad(() => {
// 如果已存在商家 token,直接进入工作台
if (storage.getStoreAccessToken()) {
uni.redirectTo({
url: '/pages-sub/merchant/pages/index/index',
})
}
})
onBeforeUnmount(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
function switchTab(type: 'account' | 'sms') {
loginType.value = type
}
// 弹出滑块拼图验证码
function openVerification(action: 'login' | 'sms') {
verifyAction.value = action
codeFlag.value = true
nextTick(() => {
verificationRef.value?.show()
verificationRef.value?.getCode()
})
}
// 点击获取短信验证码
const handleSendSms = () => {
if (smsCount.value > 0) return
if (!/^1[3-9]\d{9}$/.test(smsForm.mobile)) {
uni.showToast({ title: '请输入正确的11位手机号', icon: 'none' })
return
}
// 发送短信验证码前先通过滑块拼图验证
openVerification('sms')
}
// 点击“立即进入工作台”按钮
const handleLogin = () => {
if (submitting.value) return
if (loginType.value === 'account') {
if (!accountForm.username.trim()) {
uni.showToast({ title: '请输入商家账号', icon: 'none' })
return
}
if (!accountForm.password) {
uni.showToast({ title: '请输入登录密码', icon: 'none' })
return
}
// 账号密码登录:必须先通过安全滑块拼图验证
openVerification('login')
} else {
// 短信验证码登录
if (!/^1[3-9]\d{9}$/.test(smsForm.mobile)) {
uni.showToast({ title: '请输入有效的手机号', icon: 'none' })
return
}
if (!smsForm.code.trim()) {
uni.showToast({ title: '请输入验证码', icon: 'none' })
return
}
// 短信验证码登录直接提交
doSmsLogin()
}
}
// 滑块验证码成功回调
const handleVerificationSuccess = () => {
if (verifyAction.value === 'login') {
doAccountLogin()
} else if (verifyAction.value === 'sms') {
doSendSms()
}
}
// 执行账号密码登录
const doAccountLogin = async () => {
submitting.value = true
uni.showLoading({ title: '正在登录...' })
try {
const res: any = await userLogin({
username: accountForm.username.trim(),
password: accountForm.password,
})
uni.hideLoading()
submitting.value = false
if (res?.data && res.data.success && res.data.result) {
codeFlag.value = false
const { accessToken, refreshToken } = res.data.result
storage.setStoreAccessToken(accessToken)
if (refreshToken) {
storage.setStoreRefreshToken(refreshToken)
}
// 获取店铺与店员信息
try {
const [settingsRes, userRes, storeRes] = (await Promise.all([
getStoreSettings().catch(() => null),
getStoreUserInfo().catch(() => null),
getStoreDetail().catch(() => null),
])) || []
const storeInfo = {
settings: settingsRes?.data?.result || {},
user: userRes?.data?.result || {},
store: storeRes?.data?.result || {},
}
storage.setStoreInfo(storeInfo)
} catch (e) {
console.warn('获取店铺额外信息失败', e)
}
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
uni.redirectTo({
url: '/pages-sub/merchant/pages/index/index',
})
}, 500)
} else {
uni.showToast({
title: res?.data?.message || '登录失败,请检查账号密码',
icon: 'none',
})
verificationRef.value?.getCode()
}
} catch (err: any) {
uni.hideLoading()
submitting.value = false
verificationRef.value?.getCode()
uni.showToast({
title: err?.data?.message || '网络连接异常或账号密码错误',
icon: 'none',
})
}
}
// 执行发送短信验证码
const doSendSms = async () => {
try {
uni.showLoading({ title: '发送中...' })
const res = await sendStoreSms(smsForm.mobile, 'LOGIN')
uni.hideLoading()
if (res?.data && res.data.success) {
codeFlag.value = false
uni.showToast({ title: '验证码已发送', icon: 'success' })
smsCount.value = 60
timer = setInterval(() => {
smsCount.value--
if (smsCount.value <= 0) {
clearInterval(timer)
timer = null
}
}, 1000)
} else {
verificationRef.value?.getCode()
uni.showToast({
title: res?.data?.message || '发送失败,请稍后重试',
icon: 'none',
})
}
} catch (e: any) {
uni.hideLoading()
verificationRef.value?.getCode()
uni.showToast({ title: e?.data?.message || '网络异常,发送失败', icon: 'none' })
}
}
// 执行短信登录
const doSmsLogin = async () => {
submitting.value = true
uni.showLoading({ title: '正在登录...' })
try {
const res: any = await smsLogin({
mobile: smsForm.mobile.trim(),
code: smsForm.code.trim(),
})
uni.hideLoading()
submitting.value = false
if (res?.data && res.data.success && res.data.result) {
const { accessToken, refreshToken } = res.data.result
storage.setStoreAccessToken(accessToken)
if (refreshToken) {
storage.setStoreRefreshToken(refreshToken)
}
try {
const [settingsRes, userRes, storeRes] = (await Promise.all([
getStoreSettings().catch(() => null),
getStoreUserInfo().catch(() => null),
getStoreDetail().catch(() => null),
])) || []
const storeInfo = {
settings: settingsRes?.data?.result || {},
user: userRes?.data?.result || {},
store: storeRes?.data?.result || {},
}
storage.setStoreInfo(storeInfo)
} catch (e) {
console.warn('获取店铺额外信息失败', e)
}
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
uni.redirectTo({
url: '/pages-sub/merchant/pages/index/index',
})
}, 500)
} else {
uni.showToast({
title: res?.data?.message || '登录失败,请检查验证码',
icon: 'none',
})
}
} catch (err: any) {
uni.hideLoading()
submitting.value = false
uni.showToast({ title: err?.data?.message || '网络连接异常', icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.merchant-login-page {
min-height: 100vh;
background-color: #f7f8fa;
display: flex;
flex-direction: column;
}
.header-bg {
background: linear-gradient(135deg, #1f2937 0%, #111827 100%);
padding: 100rpx 40rpx 140rpx 40rpx;
color: #ffffff;
display: flex;
justify-content: center;
align-items: center;
.brand-box {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
.badge-icon {
width: 100rpx;
height: 100rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.12);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24rpx;
}
.brand-title {
font-size: 38rpx;
font-weight: 700;
letter-spacing: 2rpx;
margin-bottom: 12rpx;
}
.brand-desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.65);
}
}
}
.login-card {
margin: -80rpx 32rpx 40rpx 32rpx;
background: #ffffff;
border-radius: 28rpx;
padding: 40rpx 36rpx;
box-shadow: 0 12rpx 36rpx rgba(0, 0, 0, 0.06);
.tab-header {
display: flex;
border-bottom: 1rpx solid #f0f0f0;
margin-bottom: 36rpx;
.tab-item {
flex: 1;
text-align: center;
padding-bottom: 24rpx;
font-size: 30rpx;
color: #8c8c8c;
font-weight: 500;
position: relative;
&.active {
color: #111827;
font-weight: 700;
}
.active-indicator {
position: absolute;
bottom: -1rpx;
left: 50%;
transform: translateX(-50%);
width: 48rpx;
height: 6rpx;
background: #111827;
border-radius: 3rpx;
}
}
}
.form-container {
.input-item {
margin-bottom: 28rpx;
.send-btn {
padding: 10rpx 20rpx;
background: #111827;
color: #ffffff;
border-radius: 10rpx;
font-size: 24rpx;
white-space: nowrap;
&.disabled {
background: #d1d5db;
color: #9ca3af;
}
}
}
}
.action-btn {
background: #111827;
border-radius: 18rpx;
height: 92rpx;
display: flex;
align-items: center;
justify-content: center;
margin-top: 32rpx;
box-shadow: 0 8rpx 20rpx rgba(17, 24, 39, 0.2);
.btn-text {
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
}
&.loading {
opacity: 0.7;
}
}
.notice-tip {
display: flex;
align-items: center;
justify-content: center;
margin-top: 28rpx;
gap: 8rpx;
.notice-text {
font-size: 22rpx;
color: #9ca3af;
}
}
}
</style>

View File

@@ -0,0 +1,586 @@
<template>
<view class="merchant-order-detail">
<!-- 状态头部 -->
<view class="status-banner" :class="getStatusClass(orderDetail?.order?.orderStatus)">
<view class="status-icon">
<u-icon :name="getStatusIcon(orderDetail?.order?.orderStatus)" size="28" color="#ffffff"></u-icon>
</view>
<view class="status-text-box">
<text class="status-title">{{ getStatusText(orderDetail?.order?.orderStatus) }}</text>
<view class="sn-row" @click="copySn(orderSn)">
<text class="status-sub">订单编号:{{ orderSn }}</text>
<u-icon name="file-text" size="13" color="rgba(255,255,255,0.8)"></u-icon>
</view>
</view>
</view>
<!-- 核销码专属卡片 -->
<view class="verify-code-card" v-if="orderDetail?.order?.verificationCode || isAppointment">
<view class="card-title-row">
<text class="card-title">核销凭证</text>
<view class="code-badge">
{{ isAppointment ? '预约核销' : '自提核销' }}
</view>
</view>
<view class="code-box">
<text class="code-number">{{ orderDetail?.order?.verificationCode || '--' }}</text>
<view
class="verify-now-btn"
v-if="orderDetail?.order?.orderStatus === 'TAKE'"
@click="handleDirectVerify"
>
立即核销
</view>
</view>
</view>
<!-- 买家 / 收货信息 -->
<view class="info-card">
<view class="card-title-row">
<text class="card-title">买家信息</text>
</view>
<view class="info-row">
<text class="info-label">收货人/自提人:</text>
<text class="info-value">{{ orderDetail?.order?.consigneeName || '匿名买家' }}</text>
</view>
<view class="info-row">
<text class="info-label">联系电话:</text>
<text class="info-value">{{ maskPhone(orderDetail?.order?.consigneeMobile) }}</text>
</view>
<view class="info-row" v-if="orderDetail?.order?.consigneeAddressPath">
<text class="info-label">地址:</text>
<text class="info-value">{{ orderDetail?.order?.consigneeAddressPath }} {{ orderDetail?.order?.consigneeDetail || '' }}</text>
</view>
</view>
<!-- 商品清单(干净规范,拒绝裸露 JSON) -->
<view class="info-card">
<view class="card-title-row">
<text class="card-title">商品清单</text>
</view>
<view
class="goods-row"
v-for="(item, idx) in (orderDetail?.orderItems || [])"
:key="item.id || item.goodsId || idx"
>
<image class="goods-img" :src="getGoodsImage(item)" mode="aspectFill"></image>
<view class="goods-detail">
<text class="goods-title">{{ getGoodsTitle(item) }}</text>
<view class="sku-tag" v-if="getCleanSpecs(item)">
<text>{{ getCleanSpecs(item) }}</text>
</view>
<view class="goods-bottom">
<text class="price">¥{{ item.unitPrice || item.goodsPrice || '0.00' }}</text>
<text class="qty">x{{ item.num || 1 }}</text>
</view>
</view>
</view>
</view>
<!-- 订单财务明细 -->
<view class="info-card">
<view class="card-title-row">
<text class="card-title">费用明细</text>
</view>
<view class="fee-row">
<text class="fee-label">商品总价</text>
<text class="fee-val">¥{{ orderDetail?.order?.goodsPrice || '0.00' }}</text>
</view>
<view class="fee-row" v-if="orderDetail?.order?.discountPrice">
<text class="fee-label">优惠金额</text>
<text class="fee-val discount">-¥{{ orderDetail?.order?.discountPrice }}</text>
</view>
<view class="fee-row" v-if="orderDetail?.order?.freightPrice">
<text class="fee-label">运费</text>
<text class="fee-val">+¥{{ orderDetail?.order?.freightPrice }}</text>
</view>
<view class="fee-divider"></view>
<view class="fee-row total">
<text class="fee-label">实收金额</text>
<text class="fee-val total-price">¥{{ orderDetail?.order?.flowPrice || '0.00' }}</text>
</view>
</view>
<!-- 订单参数(支付方式中文人性化映射) -->
<view class="info-card">
<view class="info-row">
<text class="info-label">下单时间:</text>
<text class="info-value">{{ orderDetail?.order?.createTime || '--' }}</text>
</view>
<view class="info-row" v-if="orderDetail?.order?.paymentTime">
<text class="info-label">支付时间:</text>
<text class="info-value">{{ orderDetail?.order?.paymentTime }}</text>
</view>
<view class="info-row" v-if="orderDetail?.order?.paymentMethod">
<text class="info-label">支付方式:</text>
<text class="info-value pay-method-val">{{ formatPaymentMethod(orderDetail?.order?.paymentMethod) }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getStoreOrderDetail, storeOrderTake } from '../../api/order.js'
import { parseGoodsImageUrl } from '@/utils/filters.js'
const orderSn = ref('')
const orderDetail = ref<any>(null)
const isAppointment = computed(() => {
return orderDetail.value?.order?.orderType === 'APPOINTMENT_GOODS'
})
onLoad((options: any) => {
if (options?.sn) {
orderSn.value = options.sn
fetchDetail()
}
})
const fetchDetail = async () => {
uni.showLoading({ title: '加载中...' })
try {
const res: any = await getStoreOrderDetail(orderSn.value)
uni.hideLoading()
if (res?.data && res.data.success && res.data.result) {
orderDetail.value = res.data.result
} else {
uni.showToast({ title: '获取订单详情失败', icon: 'none' })
}
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '网络连接异常', icon: 'none' })
}
}
// 复制单号
const copySn = (sn: string) => {
if (!sn) return
uni.setClipboardData({
data: sn,
success: () => {
uni.showToast({ title: '单号已复制', icon: 'none' })
},
})
}
// 提取商品标题
const getGoodsTitle = (item: any) => {
if (item.goodsName && item.goodsName.trim()) return item.goodsName
if (item.name && item.name.trim()) return item.name
if (orderDetail.value?.order?.orderName) return orderDetail.value.order.orderName
return '精选商品'
}
// 智能提取商品图片,过滤并兼顾富文本/JSON 结构
const getGoodsImage = (item: any) => {
if (item.image) {
const parsed = parseGoodsImageUrl(item.image)
if (parsed) return parsed
}
// 若 image 为空,尝试从 specs JSON 里抓取合法的图片 url
if (item.specs && typeof item.specs === 'string') {
const match = item.specs.match(/https?:\/\/[^\s"'\\}]+\.(jpeg|jpg|png|webp|gif)/i)
if (match) return match[0]
}
return '/static/tabbar/home.png'
}
// 智能提炼清洁规格,绝不裸露冗长的原始 JSON
const getCleanSpecs = (item: any) => {
const specs = item.specs
if (!specs || !String(specs).trim()) return ''
const trimmed = String(specs).trim()
// 若为 JSON 格式
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const obj = JSON.parse(trimmed)
const ignoreKeys = ['images', 'code', 'message', 'result', 'success', 'showProgress', 'url', 'status']
const pairs: string[] = []
for (const [k, v] of Object.entries(obj)) {
if (!ignoreKeys.includes(k) && typeof v === 'string' && !v.startsWith('http') && v.length < 50) {
pairs.push(`${k}: ${v}`)
}
}
if (pairs.length > 0) return pairs.join(' / ')
} catch (e) {}
// 解析失败正则提取业务键值对
const matches = [...trimmed.matchAll(/"([^"]{1,20})"\s*:\s*"([^"]{1,30})"/g)]
const ignoreKeys = ['code', 'message', 'success', 'url', 'result', 'showProgress', 'status', 'name']
const pairs: string[] = []
for (const m of matches) {
const k = m[1]
const v = m[2]
if (!ignoreKeys.includes(k) && !v.startsWith('http')) {
pairs.push(`${k}: ${v}`)
}
}
if (pairs.length > 0) return pairs.join(' / ')
return ''
}
// 纯文本规格
return trimmed
}
// 支付方式中文映射
const formatPaymentMethod = (method: string) => {
if (!method) return '--'
const map: Record<string, string> = {
WALLET: '余额支付',
ALIPAY: '支付宝支付',
ALIPAY_PC: '支付宝支付',
ALIPAY_WAP: '支付宝支付',
ALIPAY_APP: '支付宝支付',
WECHAT: '微信支付',
WEIXIN: '微信支付',
WECHAT_MP: '微信支付',
WEIXIN_MP: '微信支付',
WX_JSAPI: '微信支付',
WX_APP: '微信支付',
WX_NATIVE: '微信支付',
WX_H5: '微信支付',
BANK_TRANSFER: '银行转账',
POINT: '积分兑换',
CASH: '现金支付',
OFFLINE: '线下支付',
FREE: '免费免付',
}
const key = String(method).toUpperCase().trim()
return map[key] || method
}
const maskPhone = (phone: string) => {
if (!phone || phone.length < 7) return phone || ''
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
const getStatusText = (status: string) => {
const map: Record<string, string> = {
UNPAID: '待付款',
PAID: '已付款',
UNDELIVERED: '待发货',
DELIVERED: '待收货',
COMPLETED: '已完成',
CANCELLED: '已取消',
TAKE: '待核销',
}
return map[status] || status || '处理中'
}
const getStatusClass = (status: string) => {
if (status === 'TAKE') return 'bg-take'
if (status === 'COMPLETED') return 'bg-done'
if (status === 'CANCELLED') return 'bg-cancel'
return 'bg-normal'
}
const getStatusIcon = (status: string) => {
if (status === 'TAKE') return 'checkmark-circle-fill'
if (status === 'COMPLETED') return 'checkmark'
if (status === 'CANCELLED') return 'close'
return 'clock'
}
// 直接在详情页核销
const handleDirectVerify = async () => {
const code = orderDetail.value?.order?.verificationCode
if (!code) {
uni.showToast({ title: '该订单暂无可核销码', icon: 'none' })
return
}
uni.showModal({
title: '确认核销',
content: `确认核销此单?核销码:${code}`,
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '正在核销...' })
try {
const takeRes: any = await storeOrderTake(orderSn.value, code)
uni.hideLoading()
if (takeRes?.data && takeRes.data.success) {
uni.showToast({ title: '核销成功', icon: 'success' })
fetchDetail()
} else {
uni.showToast({
title: takeRes?.data?.message || '核销失败',
icon: 'none',
})
}
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '核销异常,请稍后重试', icon: 'none' })
}
}
},
})
}
</script>
<style lang="scss" scoped>
.merchant-order-detail {
min-height: 100vh;
background-color: #f4f5f7;
padding-bottom: calc(50rpx + env(safe-area-inset-bottom));
}
.status-banner {
padding: 44rpx 36rpx;
color: #ffffff;
display: flex;
align-items: center;
&.bg-take {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
&.bg-normal {
background: linear-gradient(135deg, #1f2937 0%, #111827 100%);
}
&.bg-done {
background: #6b7280;
}
&.bg-cancel {
background: #9ca3af;
}
.status-icon {
width: 68rpx;
height: 68rpx;
border-radius: 34rpx;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.status-text-box {
display: flex;
flex-direction: column;
.status-title {
font-size: 36rpx;
font-weight: 700;
}
.sn-row {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 8rpx;
.status-sub {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.85);
}
}
}
}
.verify-code-card {
margin: -24rpx 28rpx 24rpx 28rpx;
background: #ffffff;
border-radius: 24rpx;
padding: 28rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.05);
.card-title-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
.card-title {
font-size: 28rpx;
font-weight: 700;
color: #111827;
}
.code-badge {
font-size: 22rpx;
font-weight: 500;
color: #059669;
background: #ecfdf5;
padding: 4rpx 14rpx;
border-radius: 8rpx;
}
}
.code-box {
display: flex;
justify-content: space-between;
align-items: center;
background: #f9fafb;
border-radius: 16rpx;
padding: 20rpx 28rpx;
.code-number {
font-size: 44rpx;
font-weight: 700;
color: #10b981;
letter-spacing: 4rpx;
}
.verify-now-btn {
background: #111827;
color: #ffffff;
padding: 14rpx 32rpx;
border-radius: 14rpx;
font-size: 26rpx;
font-weight: 600;
box-shadow: 0 4rpx 12rpx rgba(17, 24, 39, 0.2);
}
}
}
.info-card {
margin: 0 28rpx 24rpx 28rpx;
background: #ffffff;
border-radius: 24rpx;
padding: 28rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.02);
.card-title-row {
margin-bottom: 20rpx;
padding-bottom: 14rpx;
border-bottom: 1rpx solid #f9fafb;
.card-title {
font-size: 28rpx;
font-weight: 700;
color: #111827;
}
}
.info-row {
display: flex;
align-items: flex-start;
font-size: 26rpx;
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
}
.info-label {
color: #6b7280;
width: 180rpx;
flex-shrink: 0;
}
.info-value {
color: #111827;
flex: 1;
font-weight: 500;
line-height: 1.4;
&.pay-method-val {
color: #2563eb;
font-weight: 600;
}
}
}
.goods-row {
display: flex;
padding: 18rpx 0;
border-bottom: 1rpx solid #f9fafb;
&:last-child {
border-bottom: none;
}
.goods-img {
width: 140rpx;
height: 140rpx;
border-radius: 14rpx;
background: #f9fafb;
border: 1rpx solid #f3f4f6;
flex-shrink: 0;
}
.goods-detail {
flex: 1;
margin-left: 20rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
.goods-title {
font-size: 28rpx;
color: #111827;
font-weight: 600;
line-height: 1.4;
}
.sku-tag {
margin-top: 6rpx;
text {
font-size: 22rpx;
color: #6b7280;
background: #f3f4f6;
padding: 4rpx 12rpx;
border-radius: 6rpx;
}
}
.goods-bottom {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-top: 10rpx;
.price {
font-size: 30rpx;
font-weight: 700;
color: #111827;
}
.qty {
font-size: 22rpx;
color: #9ca3af;
}
}
}
}
.fee-row {
display: flex;
justify-content: space-between;
font-size: 26rpx;
margin-bottom: 14rpx;
color: #6b7280;
.discount {
color: #10b981;
}
&.total {
margin-top: 16rpx;
margin-bottom: 0;
font-size: 28rpx;
font-weight: 700;
color: #111827;
.total-price {
font-size: 36rpx;
font-weight: 700;
color: #ef4444;
}
}
}
.fee-divider {
height: 1rpx;
background: #f3f4f6;
margin: 16rpx 0;
}
}
</style>

View File

@@ -0,0 +1,653 @@
<template>
<view class="merchant-order-page">
<!-- 顶部吸顶搜索栏与 Tab -->
<view class="sticky-header">
<!-- 搜索栏 -->
<view class="search-box">
<view class="search-input-wrap">
<u-icon name="search" size="18" color="#9ca3af"></u-icon>
<input
class="search-input"
type="text"
v-model="keywords"
placeholder="搜索订单号 / 买家手机号"
placeholder-class="placeholder"
confirm-type="search"
@confirm="onSearch"
/>
<view class="clear-btn" v-if="keywords" @click="clearSearch">
<u-icon name="close-circle-fill" size="16" color="#d1d5db"></u-icon>
</view>
</view>
<view class="search-action-btn" v-if="keywords" @click="onSearch">
<text>搜索</text>
</view>
</view>
<!-- 状态横向滑动 Tab 栏(绝不折行) -->
<scroll-view class="tab-scroll" scroll-x :show-scrollbar="false" :scroll-with-animation="true">
<view class="tab-inner">
<view
class="tab-item"
v-for="item in tabList"
:key="item.key"
:class="{ active: currentTab === item.key }"
@click="switchTab(item.key)"
>
<text class="tab-label">{{ item.label }}</text>
<view class="indicator" v-if="currentTab === item.key"></view>
</view>
</view>
</scroll-view>
</view>
<!-- 订单列表主体 -->
<view class="order-list-body">
<!-- 列表数据 -->
<view
class="order-card"
v-for="order in orderList"
:key="order.sn"
@click="goToDetail(order.sn)"
>
<!-- 头部:单号与状态 -->
<view class="card-head">
<view class="sn-box" @click.stop="copySn(order.sn)">
<text class="order-sn">单号:{{ order.sn }}</text>
<u-icon name="file-text" size="14" color="#9ca3af"></u-icon>
</view>
<view class="status-badge" :class="getStatusClass(order.orderStatus)">
{{ getStatusText(order.orderStatus) }}
</view>
</view>
<!-- 商品信息 -->
<view class="goods-container">
<view
class="goods-item"
v-for="goods in (order.orderItems || [])"
:key="goods.id || goods.goodsId"
>
<image class="goods-thumb" :src="goods.image || '/static/tabbar/home.png'" mode="aspectFill"></image>
<view class="goods-info">
<text class="goods-name">{{ getGoodsName(goods, order) }}</text>
<view class="spec-pill" v-if="getSpecText(goods)">
<text>{{ getSpecText(goods) }}</text>
</view>
<view class="price-qty-row">
<text class="goods-price">¥{{ goods.unitPrice || goods.goodsPrice || '0.00' }}</text>
<text class="goods-qty">x{{ goods.num || 1 }}</text>
</view>
</view>
</view>
</view>
<!-- 订单金额与买家信息 -->
<view class="card-foot">
<view class="buyer-info" v-if="order.consigneeMobile || order.memberPhone">
<u-icon name="account" size="14" color="#9ca3af"></u-icon>
<text class="buyer-tel">{{ maskPhone(order.consigneeMobile || order.memberPhone) }}</text>
</view>
<view class="buyer-info" v-else>
<text class="order-type-tag" v-if="order.orderType === 'APPOINTMENT_GOODS'">预约商品单</text>
<text class="order-type-tag" v-else-if="order.verificationCode">自提单</text>
</view>
<view class="total-bar">
<text class="total-label">实付:</text>
<text class="total-symbol">¥</text>
<text class="total-price">{{ order.flowPrice || '0.00' }}</text>
</view>
</view>
<!-- 核销专属快捷动作 -->
<view class="card-actions" v-if="order.verificationCode || order.orderType === 'APPOINTMENT_GOODS' || order.orderStatus === 'TAKE'">
<view class="action-btn verify" @click.stop="quickVerify(order)">
<u-icon name="scan" size="15" color="#ffffff"></u-icon>
<text class="btn-lbl">扫码/输码核销</text>
</view>
</view>
</view>
<!-- 空状态 -->
<view class="empty-state" v-if="!loading && orderList.length === 0">
<u-icon name="order" size="64" color="#d1d5db"></u-icon>
<text class="empty-text">暂无相关订单记录</text>
</view>
<!-- 加载提示 -->
<view class="loading-more" v-if="loading">
<text>正在加载订单数据...</text>
</view>
<view class="no-more" v-else-if="finished && orderList.length > 0">
<text>— 没有更多了 —</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getStoreOrderList } from '../../api/order.js'
const keywords = ref('')
const currentTab = ref('ALL')
const loading = ref(false)
const finished = ref(false)
const pageNumber = ref(1)
const pageSize = ref(10)
const orderList = ref<any[]>([])
// 优化 Tab 标题,保持精练统一、绝不换行
const tabList = [
{ key: 'ALL', label: '全部' },
{ key: 'TAKE', label: '待核销' },
{ key: 'UNDELIVERED', label: '待发货' },
{ key: 'DELIVERED', label: '待收货' },
{ key: 'COMPLETED', label: '已完成' },
]
onLoad((options: any) => {
if (options?.tab) {
currentTab.value = options.tab
}
fetchOrders(true)
})
onPullDownRefresh(async () => {
await fetchOrders(true)
uni.stopPullDownRefresh()
})
onReachBottom(() => {
if (!finished.value && !loading.value) {
pageNumber.value++
fetchOrders(false)
}
})
const fetchOrders = async (reset = false) => {
if (reset) {
pageNumber.value = 1
orderList.value = []
finished.value = false
}
loading.value = true
const params: any = {
pageNumber: pageNumber.value,
pageSize: pageSize.value,
keywords: keywords.value.trim() || undefined,
}
if (currentTab.value !== 'ALL') {
params.orderStatus = currentTab.value
}
try {
const res: any = await getStoreOrderList(params)
loading.value = false
if (res?.data && res.data.success) {
const records = res.data.result?.records || []
if (reset) {
orderList.value = records
} else {
orderList.value = [...orderList.value, ...records]
}
if (records.length < pageSize.value) {
finished.value = true
}
} else {
uni.showToast({
title: res?.data?.message || '获取订单列表失败',
icon: 'none',
})
}
} catch (e) {
loading.value = false
uni.showToast({ title: '网络连接异常', icon: 'none' })
}
}
const switchTab = (key: string) => {
if (currentTab.value === key) return
currentTab.value = key
fetchOrders(true)
}
const onSearch = () => {
fetchOrders(true)
}
const clearSearch = () => {
keywords.value = ''
fetchOrders(true)
}
const goToDetail = (sn: string) => {
uni.navigateTo({
url: `/pages-sub/merchant/pages/order/detail?sn=${sn}`,
})
}
// 快捷核销
const quickVerify = (order: any) => {
uni.navigateTo({
url: `/pages-sub/merchant/pages/order/detail?sn=${order.sn}&autoVerify=1`,
})
}
// 复制单号
const copySn = (sn: string) => {
if (!sn) return
uni.setClipboardData({
data: sn,
success: () => {
uni.showToast({ title: '单号已复制', icon: 'none' })
},
})
}
// 提取商品名(容错兜底)
const getGoodsName = (goods: any, order: any) => {
if (goods.goodsName && goods.goodsName.trim()) return goods.goodsName
if (goods.name && goods.name.trim()) return goods.name
if (order.orderName && order.orderName.trim()) return order.orderName
if (goods.specs && goods.specs.trim()) return goods.specs
return '精选商品'
}
// 提取规格文本(智能剔除多余字段)
const getSpecText = (goods: any) => {
const specs = goods.specs
if (!specs || !String(specs).trim()) return ''
if (specs === goods.goodsName) return ''
// 处理 JSON 字符串形式
if (typeof specs === 'string' && (specs.startsWith('{') || specs.startsWith('['))) {
try {
const obj = JSON.parse(specs)
if (typeof obj === 'object') {
const values = Object.values(obj).filter(Boolean)
if (values.length) return values.join(' / ')
}
} catch (e) {}
}
return String(specs)
}
// 手机号脱敏
const maskPhone = (phone: string) => {
if (!phone) return ''
const str = String(phone).trim()
if (str.length === 11) {
return str.substring(0, 3) + '****' + str.substring(7)
}
return str
}
// 状态文字映射
const getStatusText = (status: string) => {
const map: Record<string, string> = {
UNPAID: '待付款',
PAID: '已付款',
UNDELIVERED: '待发货',
DELIVERED: '待收货',
COMPLETED: '已完成',
CANCELLED: '已取消',
TAKE: '待核销',
}
return map[status] || status || '处理中'
}
// 状态样式类
const getStatusClass = (status: string) => {
switch (status) {
case 'TAKE':
return 'status-take'
case 'COMPLETED':
return 'status-done'
case 'CANCELLED':
return 'status-cancel'
default:
return 'status-normal'
}
}
</script>
<style lang="scss" scoped>
.merchant-order-page {
min-height: 100vh;
background-color: #f4f5f7;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
}
.sticky-header {
position: sticky;
top: 0;
z-index: 50;
background: #ffffff;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
.search-box {
padding: 16rpx 28rpx 10rpx 28rpx;
display: flex;
align-items: center;
gap: 16rpx;
.search-input-wrap {
flex: 1;
height: 72rpx;
background: #f3f4f6;
border-radius: 36rpx;
display: flex;
align-items: center;
padding: 0 24rpx;
.search-input {
flex: 1;
margin-left: 12rpx;
font-size: 26rpx;
color: #111827;
}
.placeholder {
color: #9ca3af;
font-size: 26rpx;
}
.clear-btn {
padding: 6rpx;
}
}
.search-action-btn {
font-size: 28rpx;
color: #2563eb;
font-weight: 600;
padding: 0 8rpx;
}
}
.tab-scroll {
width: 100%;
white-space: nowrap;
.tab-inner {
display: flex;
align-items: center;
padding: 0 16rpx;
min-width: 100%;
box-sizing: border-box;
.tab-item {
flex: 1;
min-width: 130rpx;
text-align: center;
padding: 22rpx 12rpx;
position: relative;
white-space: nowrap;
.tab-label {
font-size: 28rpx;
color: #6b7280;
font-weight: 500;
transition: all 0.2s ease;
}
&.active {
.tab-label {
color: #111827;
font-weight: 700;
font-size: 30rpx;
}
.indicator {
position: absolute;
bottom: 4rpx;
left: 50%;
transform: translateX(-50%);
width: 44rpx;
height: 6rpx;
background: #111827;
border-radius: 3rpx;
}
}
}
}
}
}
.order-list-body {
padding: 24rpx 28rpx;
.order-card {
background: #ffffff;
border-radius: 24rpx;
padding: 28rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.03);
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #f3f4f6;
.sn-box {
display: flex;
align-items: center;
gap: 8rpx;
.order-sn {
font-size: 24rpx;
color: #6b7280;
font-weight: 500;
}
}
.status-badge {
font-size: 22rpx;
font-weight: 600;
padding: 6rpx 16rpx;
border-radius: 10rpx;
&.status-take {
background: #ecfdf5;
color: #059669;
}
&.status-done {
background: #f3f4f6;
color: #6b7280;
}
&.status-cancel {
background: #f9fafb;
color: #9ca3af;
}
&.status-normal {
background: #fff7ed;
color: #ea580c;
}
}
}
.goods-container {
padding: 10rpx 0;
.goods-item {
display: flex;
padding: 16rpx 0;
border-bottom: 1rpx solid #fafafa;
&:last-child {
border-bottom: none;
}
.goods-thumb {
width: 140rpx;
height: 140rpx;
border-radius: 14rpx;
background: #f9fafb;
border: 1rpx solid #f3f4f6;
flex-shrink: 0;
}
.goods-info {
flex: 1;
margin-left: 20rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
.goods-name {
font-size: 28rpx;
font-weight: 600;
color: #111827;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.spec-pill {
margin-top: 6rpx;
text {
font-size: 22rpx;
color: #6b7280;
background: #f3f4f6;
padding: 4rpx 12rpx;
border-radius: 6rpx;
}
}
.price-qty-row {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-top: 10rpx;
.goods-price {
font-size: 28rpx;
font-weight: 700;
color: #111827;
}
.goods-qty {
font-size: 22rpx;
color: #9ca3af;
}
}
}
}
}
.card-foot {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 20rpx;
border-top: 1rpx solid #f9fafb;
.buyer-info {
display: flex;
align-items: center;
gap: 8rpx;
.buyer-tel {
font-size: 24rpx;
color: #6b7280;
font-weight: 500;
}
.order-type-tag {
font-size: 20rpx;
color: #2563eb;
background: #eff6ff;
padding: 4rpx 12rpx;
border-radius: 6rpx;
}
}
.total-bar {
display: flex;
align-items: baseline;
.total-label {
font-size: 24rpx;
color: #6b7280;
}
.total-symbol {
font-size: 24rpx;
font-weight: 700;
color: #ef4444;
}
.total-price {
font-size: 34rpx;
font-weight: 700;
color: #ef4444;
margin-left: 2rpx;
}
}
}
.card-actions {
display: flex;
justify-content: flex-end;
padding-top: 20rpx;
margin-top: 16rpx;
border-top: 1rpx dashed #e5e7eb;
.action-btn {
display: flex;
align-items: center;
gap: 8rpx;
padding: 14rpx 28rpx;
border-radius: 36rpx;
font-size: 26rpx;
font-weight: 600;
&.verify {
background: #111827;
color: #ffffff;
box-shadow: 0 4rpx 12rpx rgba(17, 24, 39, 0.15);
.btn-lbl {
font-size: 24rpx;
}
}
}
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
.empty-text {
font-size: 28rpx;
color: #9ca3af;
margin-top: 24rpx;
}
}
.loading-more,
.no-more {
text-align: center;
padding: 30rpx 0;
font-size: 24rpx;
color: #9ca3af;
}
}
</style>

View File

@@ -0,0 +1,155 @@
import api from "@/config/api.js";
import Request from "@/lib/request/index.js";
import Foundation from "@/utils/Foundation.js";
import { md5 } from "@/utils/md5.js";
import storage from "@/utils/storage.js";
import uuid from "@/utils/uuid.modified.js";
let isStoreNavigateTo = false;
function cleanStoreAuth() {
uni.showToast({
title: "商家登录状态已过期,请重新登录",
icon: "none",
duration: 1800,
});
storage.clearStoreAuth();
if (!isStoreNavigateTo) {
isStoreNavigateTo = true;
setTimeout(() => {
isStoreNavigateTo = false;
uni.reLaunch({
url: "/pages-sub/merchant/pages/login/index",
});
}, 1200);
}
}
const storeHttp = new Request();
const ensureUuid = () => {
if (!storage.getUuid()) {
storage.setUuid(uuid.v1());
}
};
storeHttp.setConfig((config) => {
ensureUuid();
config.baseURL = api.store;
config.header = {
...config.header,
};
config.validateStatus = () => true;
return config;
});
storeHttp.interceptors.request.use(
(config) => {
ensureUuid();
const token = storage.getStoreAccessToken();
if (token) {
const nonce = Foundation.randomString(6);
const timestamp = parseInt(new Date().getTime() / 1000);
const sign = md5(nonce + timestamp + token);
const _params = { nonce, timestamp, sign };
config.params = { ...(config.params || {}), ..._params };
config.header.accessToken = token;
}
config.header = {
...config.header,
uuid: storage.getUuid(),
};
return config;
},
(config) => Promise.reject(config)
);
let isStoreRefreshing = false;
let storeRequests = [];
function refreshTokenApi(refreshToken) {
return uni.request({
url: `${api.store}/passport/login/refresh/${refreshToken}`,
method: "GET",
header: {
uuid: storage.getUuid(),
},
});
}
storeHttp.interceptors.response.use(
async (response) => {
isStoreNavigateTo = false;
try {
uni.hideLoading();
} catch (e) {}
const token = storage.getStoreAccessToken();
const refreshToken = storage.getStoreRefreshToken();
// 403 / 401 并且有 refreshToken,尝试刷新
if (
token &&
refreshToken &&
(response.statusCode === 403 || response.statusCode === 401 || response.data?.code === 403)
) {
if (!isStoreRefreshing) {
isStoreRefreshing = true;
storage.removeStoreAccessToken();
try {
const [err, res] = await refreshTokenApi(refreshToken);
const data = res?.data || err?.data;
if (data && data.success && data.result) {
const { accessToken: newAccessToken, refreshToken: newRefreshToken } = data.result;
storage.setStoreAccessToken(newAccessToken);
if (newRefreshToken) {
storage.setStoreRefreshToken(newRefreshToken);
}
response.header = response.header || {};
response.header.accessToken = `${newAccessToken}`;
storeRequests.forEach((cb) => cb(newAccessToken));
storeRequests = [];
return storeHttp.request(response.config);
} else {
cleanStoreAuth();
return Promise.reject(data);
}
} catch (e) {
cleanStoreAuth();
return Promise.reject(e);
} finally {
isStoreRefreshing = false;
}
} else {
return new Promise((resolve) => {
storeRequests.push((newToken) => {
response.header = response.header || {};
response.header.accessToken = `${newToken}`;
resolve(storeHttp.request(response.config));
});
});
}
} else if (
(!token && (response.statusCode === 403 || response.statusCode === 401)) ||
response.data?.code === 403 ||
response.data?.code === 401
) {
cleanStoreAuth();
} else if ((response.statusCode === 200 && response.data && !response.data.success) || response.statusCode === 400) {
if (response.data?.message) {
uni.showToast({
title: response.data.message,
icon: "none",
duration: 2000,
});
}
}
return response;
},
(error) => error
);
export { storeHttp };
export default storeHttp;

View File

@@ -106,9 +106,77 @@
],
"subPackages": [
{
"root": "pages-sub/merchant",
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "商家工作台",
"navigationBarBackgroundColor": "#111827",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/login/index",
"style": {
"navigationBarTitleText": "商家登录",
"navigationBarBackgroundColor": "#111827",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/order/list",
"style": {
"navigationBarTitleText": "订单核销管理",
"enablePullDownRefresh": true,
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/order/detail",
"style": {
"navigationBarTitleText": "订单详情",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/goods/list",
"style": {
"navigationBarTitleText": "商品管理",
"enablePullDownRefresh": true,
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/goods/detail",
"style": {
"navigationBarTitleText": "商品详情",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/evaluation/list",
"style": {
"navigationBarTitleText": "评价管理",
"enablePullDownRefresh": true,
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
}
]
},
{
"root": "pages/mine",
"pages": [
{"path":"memberBenefits","style":{"navigationBarTitleText":"会员权益","enablePullDownRefresh":true,"navigationBarBackgroundColor":"#f5f6f9","navigationBarTextStyle":"black"}},
{"path":"paidMember","style":{"navigationBarTitleText":"付费会员","enablePullDownRefresh":true,"navigationBarBackgroundColor":"#f5f6f9","navigationBarTextStyle":"black"}},
{"path":"paidMemberRecords","style":{"navigationBarTitleText":"会员购买记录","enablePullDownRefresh":true,"navigationBarBackgroundColor":"#f5f6f9","navigationBarTextStyle":"black"}},
{"path":"experience","style":{"navigationBarTitleText":"我的经验","enablePullDownRefresh":true,"navigationBarBackgroundColor":"#f5f6f9","navigationBarTextStyle":"black"}},
{
"path": "signIn",
@@ -447,6 +515,13 @@
"navigationBarTitleText": "设置"
}
},
{
"path": "set/messageNotice",
"style": {
"navigationBarTitleText": "消息通知",
"navigationStyle": "custom"
}
},
{
"path": "set/personMsg",
"style": {
@@ -471,7 +546,8 @@
}, {
"path": "point/myPoint",
"style": {
"navigationBarTitleText": "我的积分"
"navigationBarTitleText": "我的积分",
"navigationStyle": "custom"
}
},
{
@@ -772,7 +848,11 @@
}
},
{
"path": "gift-card/myGiftCards",
"path": "gift-card/pickupCard",
"style": { "navigationBarTitleText": "提货卡兑换" }
},
{
"path": "gift-card/myGiftCards",
"style": {
"navigationBarTitleText": "我的礼品卡"
}
@@ -926,6 +1006,7 @@
{
"path": "afterSales/applySuccess",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "提交成功",
"app-plus": {
"bounce": "none",
@@ -957,13 +1038,6 @@
"navigationBarTitleText": "申请售后"
}
},
{
"path": "afterSales/afterSales",
"style": {
"navigationBarTitleText": "售后管理",
"enablePullDownRefresh": true
}
},
{
"path": "fillorder",
"style": {

View File

@@ -11,7 +11,7 @@
<view class="coupon-card-right">
<view class="coupon-info">
<text class="coupon-card-name">{{ item.storeName == 'platform' ? '全平台' : item.storeName + '店铺' }}使用</text>
<text class="coupon-card-desc">满{{unitPrice(item.consumeThreshold) }}元可用</text>
<text class="coupon-card-desc">{{ item.consumeThreshold > 0 ? '满' + unitPrice(item.consumeThreshold) + '元可用' : '无门槛使用' }}</text>
<text class="coupon-card-time" v-if="item.endTime">有效期至:{{ item.endTime.split(" ")[0] }}</text>
</view>
<view class="coupon-claim-btn" @click="receive(item)">

View File

@@ -5,7 +5,7 @@
<view class="title">{{ coupon.title }}</view>
<view class="price" v-if="coupon.couponType == 'PRICE'"><text>¥</text>{{ unitPrice(coupon.price) }}</view>
<view class="price" v-if="coupon.couponType == 'DISCOUNT'">{{ coupon.discount }}折</view>
<view class="text">满{{ coupon.consumeThreshold }}元可用</view>
<view class="text">{{ coupon.consumeThreshold > 0 ? '满' + coupon.consumeThreshold + '元可用' : '无门槛使用' }}</view>
<view class="bg-quan">券</view>
<view class="jiao-1" :class="{ 'used-color': coupon.used_status != 0 }">
<text class="text-1">{{ coupon.used_status == 0 ? '新到' : coupon.used_status_text }}</text>

View File

@@ -41,7 +41,7 @@
</template>
使用
</text>
<text class="coupon-card-desc">满{{ unitPrice(item.consumeThreshold) }}元可用</text>
<text class="coupon-card-desc">{{ item.consumeThreshold > 0 ? '满' + unitPrice(item.consumeThreshold) + '元可用' : '无门槛使用' }}</text>
<text class="coupon-card-time" v-if="item.reason">{{ item.reason }}</text>
<text class="coupon-card-time" v-if="item.endTime">有效期至:{{ item.endTime }}</text>
</view>

View File

@@ -47,6 +47,7 @@
<view class="coupon-card-right">
<view class="coupon-info">
<text class="coupon-card-name">{{ coupon.title }}</text>
<text class="coupon-card-desc">{{ coupon.consumeThreshold > 0 ? '满' + unitPrice(coupon.consumeThreshold) + '元可用' : '无门槛使用' }}</text>
<text class="coupon-card-desc">使用范围:{{
coupon.scopeType == "ALL" && coupon.storeId == "0"
? "全平台"

View File

@@ -52,17 +52,18 @@
<view class="gcc-header-inner">
<view class="gcc-header-left">
<view class="gcc-title">{{ item.giftCardName || '礼品卡' }}</view>
<view class="gcc-face">面值{{ item.faceText }}元</view>
<view class="gcc-face">{{ item.cardType === 'PICKUP' ? '兑换 ' + item.pickupCount + ' 种商品' : '面值' + item.faceText + '元' }}</view>
<view class="gcc-cardno">{{ item.cardNo }}</view>
</view>
<view class="gcc-header-right">
<view class="gcc-type">现金卡</view>
<view class="gcc-type">{{ item.cardType === 'PICKUP' ? '提货卡' : '现金卡' }}</view>
<view class="gcc-expire">{{ item.expireText }}</view>
</view>
</view>
</view>
<view class="gcc-body">
<view class="gcc-row">
<view v-if="item.cardType === 'PICKUP'" class="gcc-row">每种一件 · 一次兑完 · 免邮</view>
<view v-else class="gcc-row">
当前余额:
<text class="gcc-strong" v-if="item.balance != null">¥{{ unitPrice(item.balance) }}</text>
<text class="gcc-strong" v-else>—</text>
@@ -236,7 +237,8 @@ function onCardPrimaryAction(item: any) {
return
}
if (statusTab.value === GIFT_CARD_STATUS.AVAILABLE) {
uni.switchTab({ url: '/pages/tabbar/home/index' })
if (item.cardType === 'PICKUP') uni.navigateTo({ url: '/pages/cart/gift-card/pickupCard?id=' + encodeURIComponent(resolveMemberCardId(item)) })
else uni.switchTab({ url: '/pages/tabbar/home/index' })
}
}
</script>

View File

@@ -0,0 +1,118 @@
<!--
提货卡选品及确认兑换,兼容 H5、App 和微信小程序。
@author Bulbasaur
@since 2026-11-05
-->
<template>
<view class="pickup-page">
<view class="notice">每张卡一次兑完,每种一件,免邮。兑换成功后取消不退权益,不支持退款或退货。</view>
<view v-if="loading" class="state">加载中...</view>
<view v-else-if="error" class="state" @click="load">{{ error }},点击重试</view>
<template v-else-if="detail.credentialId">
<view class="title">{{ detail.giftCardName }}</view>
<view v-if="completed || detail.tradeSn" class="state">该提货卡已兑换<button @click="viewOrders">查看订单</button></view>
<template v-else>
<view class="rule">{{ detail.pickupMode === 'ALL' ? '兑换以下全部商品' : '任选 ' + detail.pickupCount + ' 种商品' }} · 已选 {{ selected.length }} 种</view>
<view v-for="goods in detail.goods" :key="goods.id" class="goods" :class="{ selected: selected.includes(goods.id), disabled: !goods.available }" @click="toggle(goods)">
<image :src="goods.thumbnail" mode="aspectFill" />
<view class="goods-info"><view>{{ goods.goodsName }}</view><view class="muted">{{ specsText(goods.specs) }}</view><view class="muted">{{ goods.storeName }} · 1 件</view><text v-if="!goods.available">暂不可兑换</text></view>
<text>{{ selected.includes(goods.id) ? '●' : '○' }}</text>
</view>
<view class="address">
<view class="title">收货地址</view>
<picker :range="addresses" :range-key="'label'" :value="addressIndex" :disabled="busy" @change="chooseAddress"><view>{{ addresses[addressIndex]?.label || '请选择收货地址' }} ›</view></picker>
<view class="links"><text @click="manageAddress">管理收货地址</text><text @click="loadAddresses">刷新地址</text></view>
</view>
<button class="submit" :loading="busy" :disabled="!canSubmit || busy" @click="submit">确认兑换 · 实付 ¥0</button>
</template>
</template>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import { getAddressList } from '@/api/address.js'
import { getPickupCard, previewPickupCard, redeemPickupCard } from '@/api/pickupCard.js'
import storage from '@/utils/storage.js'
const detail = ref({}), selected = ref([]), addresses = ref([]), addressIndex = ref(0)
const loading = ref(false), busy = ref(false), error = ref(''), completed = ref(false)
let cardId = '', active = true, version = 0, owner = ''
const canSubmit = computed(() => selected.value.length === detail.value.pickupCount && !!addresses.value[addressIndex.value] && !detail.value.goods?.some(g => selected.value.includes(g.id) && !g.available))
/** 隔离账号切换和离页后的异步响应。 */
function sameOwner() { return active && owner === String(storage.getUserInfo()?.id || '') }
/** 将既有 SKU 规格转成可读文字。 */
function specsText(value) { try { const specs = JSON.parse(value || '[]'); return Array.isArray(specs) ? specs.filter(s => s.specName !== 'images').map(s => s.specValue).join(' / ') : '' } catch { return value || '' } }
/** 非全选模式可增减选项,数量仍由后端再次校验。 */
function toggle(goods) {
if (busy.value || !goods.available || detail.value.pickupMode === 'ALL') return
if (selected.value.includes(goods.id)) selected.value = selected.value.filter(id => id !== goods.id)
else if (detail.value.pickupCount === 1) selected.value = [goods.id]
else if (selected.value.length < detail.value.pickupCount) selected.value = [...selected.value, goods.id]
else uni.showToast({ title: '已达到可兑换种数', icon: 'none' })
}
/** 读取当前会员地址并保留有效选项。 */
async function loadAddresses() {
const previous = addresses.value[addressIndex.value]?.id
const response = await getAddressList(1, 100)
if (!sameOwner()) return
if (!response.data.success) throw new Error(response.data.message || '地址加载失败')
addresses.value = (response.data.result?.records || []).map(a => ({ ...a, label: `${a.name} ${a.mobile} ${a.consigneeAddressPath || ''} ${a.detail || ''}` }))
const index = addresses.value.findIndex(a => a.id === previous)
addressIndex.value = index >= 0 ? index : Math.max(0, addresses.value.findIndex(a => a.isDefault))
}
/** 刷新卡可用性;返回页面重新校验已选商品。 */
async function load() {
const request = ++version
loading.value = true; error.value = ''
try {
const response = await getPickupCard(cardId)
if (!sameOwner() || request !== version) return
if (!response.data.success) throw new Error(response.data.message || '卡片加载失败')
detail.value = response.data.result
selected.value = detail.value.pickupMode === 'ALL' ? detail.value.goods.map(g => g.id) : selected.value.filter(id => detail.value.goods.some(g => g.id === id && g.available))
await loadAddresses()
} catch (e) { if (sameOwner() && request === version) error.value = e.message || '加载失败' }
finally { if (sameOwner() && request === version) loading.value = false }
}
/** 提交前服务端试算;确认后核销,网络错误允许幂等重试。 */
async function submit() {
if (busy.value || !canSubmit.value || !sameOwner()) return
busy.value = true
const payload = { credentialId: cardId, skuIds: [...selected.value], addressId: addresses.value[addressIndex.value].id }
try {
const preview = await previewPickupCard(payload)
if (!sameOwner()) return
if (!preview.data.success) throw new Error(preview.data.message || '兑换校验失败')
const confirmed = await new Promise(resolve => uni.showModal({ title: '确认兑换', content: '每种一件,免邮且实付为零。兑换后取消不退权益,不支持退款或退货。', confirmText: '确认兑换', success: result => resolve(result.confirm), fail: () => resolve(false) }))
if (!confirmed || !sameOwner()) return
const response = await redeemPickupCard(payload)
if (!sameOwner()) return
if (!response.data.success) throw new Error(response.data.message || '兑换失败')
completed.value = true
} catch (e) {
if (sameOwner()) {
try { const status = await getPickupCard(cardId); if (sameOwner() && status.data.success && status.data.result?.tradeSn) { detail.value = status.data.result; completed.value = true; return } } catch { /* 保留选项以便重试 */ }
uni.showToast({ title: e.message || '结果待确认,请重试或查看订单', icon: 'none' })
}
}
finally { if (sameOwner()) busy.value = false }
}
/** 地址选择仅改变本页状态,不修改地址本身。 */
function chooseAddress(event) { addressIndex.value = Number(event.detail.value) }
/** 复用现有地址管理,返回时刷新。 */
function manageAddress() { if (!busy.value) uni.navigateTo({ url: '/pages/mine/address/addressManage' }) }
/** 兑换订单沿用普通订单列表与物流详情。 */
function viewOrders() { uni.navigateTo({ url: '/pages/order/myOrder?status=0' }) }
onLoad(options => { cardId = options.id || ''; owner = String(storage.getUserInfo()?.id || '') })
onShow(() => {
if (owner !== String(storage.getUserInfo()?.id || '')) { detail.value = {}; selected.value = []; addresses.value = []; error.value = '账号已变更,请返回我的礼品卡'; return }
load()
})
onUnload(() => { active = false; version++ })
</script>
<style scoped>
.pickup-page{min-height:100vh;background:#f6f7f9;padding:24rpx;box-sizing:border-box;padding-bottom:calc(40rpx + env(safe-area-inset-bottom))}.notice{background:#fff5e8;color:#965b24;padding:24rpx;border-radius:16rpx;font-size:26rpx;line-height:1.7}.title{font-weight:600;font-size:32rpx;margin:28rpx 0 20rpx}.rule,.muted{color:#777;font-size:24rpx;margin:12rpx 0}.goods{display:flex;align-items:center;gap:20rpx;background:#fff;border:2rpx solid transparent;border-radius:16rpx;padding:24rpx;margin:16rpx 0}.goods.selected{border-color:#f56c36}.goods.disabled{opacity:.5}.goods image{width:130rpx;height:130rpx;border-radius:12rpx;flex-shrink:0}.goods-info{flex:1;font-size:28rpx}.address{background:#fff;padding:4rpx 24rpx 24rpx;border-radius:16rpx;font-size:26rpx}.links{display:flex;justify-content:space-between;color:#e75d31;margin-top:24rpx}.submit{background:#e75d31;color:#fff;margin-top:32rpx;border-radius:48rpx;font-size:30rpx}.state{text-align:center;padding:60rpx 20rpx}
</style>

View File

@@ -68,14 +68,14 @@
</template>
<script setup lang="ts">
import { ref, getCurrentInstance, onMounted, computed } from 'vue'
import { onLoad, onBackPress } from '@dcloudio/uni-app'
import { onLoad, onBackPress, onUnload } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import * as API_Trade from '@/api/trade'
import { payCallback } from '@/api/members'
import { unitPrice } from '@/utils/filters.js'
import { unitPrice } from '@/utils/filters.js'
import { getThemeStyle } from '@/utils/theme'
const store = useStore()
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const { proxy } = getCurrentInstance()!
@@ -88,6 +88,8 @@ const paymentType = ref('')
const paymentClient = ref('')
const walletValue = ref(0)
const autoCancelTime = ref(0)
let h5PollTimer: ReturnType<typeof setInterval> | undefined
onUnload(() => { if (h5PollTimer) clearInterval(h5PollTimer) })
onLoad((val) => {
routerVal.value = val || {}
@@ -107,7 +109,9 @@ onLoad((val) => {
onBackPress((e) => {
if (e.from == 'backbutton') {
if (routerVal.value.recharge_sn) {
if (routerVal.value.paid_member_sn) {
uni.redirectTo({ url: '/pages/mine/paidMemberRecords' })
} else if (routerVal.value.recharge_sn) {
uni.switchTab({ url: '/pages/tabbar/user/my' })
} else {
uni.navigateTo({ url: '/pages/order/myOrder?status=0' })
@@ -126,21 +130,33 @@ function hideLoadingIfNeeded() {
}
function callback(paymentMethod: string) {
uni.redirectTo({
url:
'/pages/cart/payment/success?paymentMethod=' +
paymentMethod +
'&payPrice=' +
cashierParams.value.price +
'&orderType=' +
orderType.value,
})
if (orderType.value === 'PAID_MEMBER') {
uni.redirectTo({ url: '/pages/mine/paidMember?payment_sn=' + encodeURIComponent(sn.value) })
return
}
const goSuccess = () => {
uni.redirectTo({
url:
'/pages/cart/payment/success?paymentMethod=' +
paymentMethod +
'&payPrice=' +
cashierParams.value.price +
'&orderType=' +
orderType.value +
'&orderPromotionType=' +
(cashierParams.value.orderPromotionType || ''),
})
}
goSuccess()
}
function cashierData() {
const parms: Record<string, string> = {}
if (routerVal.value.recharge_sn) {
if (routerVal.value.paid_member_sn) {
sn.value = routerVal.value.paid_member_sn
orderType.value = 'PAID_MEMBER'
} else if (routerVal.value.recharge_sn) {
sn.value = routerVal.value.recharge_sn
orderType.value = 'RECHARGE'
} else if (routerVal.value.trade_sn) {
@@ -207,6 +223,11 @@ function onPayTimeout() {
}
function goPayError(message = '支付失败,如果您已支付,请勿反复支付') {
if (orderType.value === 'PAID_MEMBER') {
uni.showToast({ title: '支付未完成,请在购买记录中查看结果', icon: 'none' })
uni.redirectTo({ url: '/pages/mine/paidMemberRecords' })
return
}
const query = [`orderSn=${encodeURIComponent(sn.value)}`, `message=${encodeURIComponent(message)}`]
uni.navigateTo({ url: `/pages/cart/payment/error?${query.join('&')}` })
}
@@ -271,15 +292,24 @@ async function pay(payment: string) {
})
hideLoadingIfNeeded()
} else {
window.location.href = JSON.parse(response.result).h5_url
const paymentUrl = typeof response.result === 'string' && response.result.startsWith('http')
? response.result : JSON.parse(response.result).h5_url
const returnUrl = orderType.value === 'PAID_MEMBER'
? window.location.href.split('#')[0] + '#/pages/mine/paidMember?payment_sn=' + encodeURIComponent(sn.value)
: window.location.href
window.location.href = paymentUrl + (paymentUrl.includes('?') ? '&' : '?') + 'redirect_url=' + encodeURIComponent(returnUrl)
const searchParams = { ...params, price: cashierParams.value }
const timer = setInterval(() => {
let attempts = 0
if (h5PollTimer) clearInterval(h5PollTimer)
h5PollTimer = setInterval(() => {
if (++attempts > 100) { clearInterval(h5PollTimer); return }
payCallback(searchParams).then((cbRes) => {
if (cbRes.data.result) {
clearInterval(timer)
uni.navigateTo({ url: '/pages/order/myOrder' })
clearInterval(h5PollTimer)
if (orderType.value === 'PAID_MEMBER') callback(paymentMethod)
else uni.navigateTo({ url: '/pages/order/myOrder' })
}
})
}).catch(() => {})
}, 3000)
hideLoadingIfNeeded()
}

View File

@@ -1,154 +1,188 @@
<template>
<div :style="themeStyle" class="wrapper">
<div class="pay-wrapper">
<div class="pay-money">
¥{{unitPrice(Number(payPrice)) }}
</div>
<div class="pay-btns">
<div v-show="!from" @click="checkOrder">查看{{ orderType == "RECHARGE" ? '余额' : '订单' }}</div>
<div @click="navigateTo('/pages/tabbar/home/index', 'switch')">回到首页</div>
</div>
</div>
<div class="pay-box">
<div class="pay-tag-box">
<h2>订单支付成功!</h2>
<div class="pay-item">
<div>
支付方式:
</div>
<div>{{ paymentTypeFilter(paymentMethod) }}</div>
</div>
</div>
</div>
<goodsRecommend />
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import goodsRecommend from '@/components/m-goods-recommend'
<template>
<div :style="themeStyle" class="wrapper">
<div class="pay-wrapper">
<div class="pay-money">
¥{{ unitPrice(Number(payPrice)) }}
</div>
<div class="pay-btns">
<div v-show="!from" @click="checkOrder">查看{{ orderType == "RECHARGE" ? '余额' : '订单' }}</div>
<div @click="navigateTo('/pages/tabbar/home/index', 'switch')">回到首页</div>
</div>
</div>
<div class="pay-box">
<div class="pay-tag-box">
<h2>订单支付成功!</h2>
<div class="pay-item">
<div>
支付方式:
</div>
<div>{{ paymentTypeFilter(paymentMethod) }}</div>
</div>
</div>
</div>
<goodsRecommend />
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import goodsRecommend from '@/components/m-goods-recommend'
import { unitPrice } from '@/utils/filters.js'
import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
import {
prefetchMpMessageTemplates,
subscribePayMessageThen,
} from '@/js_sdk/lili-pay/wx-pay.js'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const paymentMethod = ref('')
const from = ref('')
const payPrice = ref<number | string>(0)
const orderType = ref('')
onLoad((options) => {
paymentMethod.value = options.paymentMethod || ''
from.value = options.from || ''
payPrice.value = options.payPrice || 0
orderType.value = options.orderType || ''
})
function paymentTypeFilter(val: string) {
switch (val) {
case 'WECHAT':
return '微信'
case 'ALIPAY':
return '支付宝'
case 'WALLET':
return '余额支付'
default:
return ''
}
}
function checkOrder() {
if (orderType.value == 'RECHARGE') {
uni.reLaunch({
url: '/pages/mine/deposit/operation',
})
return
}
uni.reLaunch({
url: '/pages/order/myOrder?status=0',
})
}
function navigateTo(url: string, type?: string) {
if (type === 'switch') {
uni.switchTab({ url })
} else {
uni.redirectTo({ url })
}
}
</script><style scoped lang="scss">
.subscribe {
justify-content: space-between;
align-items: center;
margin: 0 auto 40rpx auto;
padding: 0 20rpx 20rpx;
width: 80%;
}
.pay-btns {
display: flex;
width: 50%;
justify-content: space-between;
margin: 0 auto;
color: #fff;
>div {
padding: 6px 12px;
border: 1px solid #fff;
border-radius: 100px;
}
}
.pay-money {
line-height: 1;
font-size: 50rpx;
color: #fff;
margin-bottom: 100rpx;
}
.pay-item {
font-weight: bold;
margin: 32rpx 0;
display: flex;
justify-content: space-between;
font-size: 24rpx;
color: var(--theme-primary-70, rgba(255, 60, 42, 0.7));
}
.pay-box {
overflow: hidden;
}
.pay-tag-box {
width: 80%;
margin: 80rpx auto 40rpx auto;
padding: 20rpx;
border-radius: 20rpx;
background: var(--theme-primary-20, rgba(255, 60, 42, 0.2));
>h2 {
margin-top: 20rpx;
font-size: 40rpx;
color: $main-color;
}
}
.pay-wrapper {
background-image: linear-gradient(90deg, $main-color, $light-color, $aider-light-color);
height: 480rpx;
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.pay-box {
transform: translateY(-100rpx);
width: 100%;
background: #fff;
border-top-right-radius: 100rpx;
}
</style>
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const paymentMethod = ref('')
const from = ref('')
const payPrice = ref<number | string>(0)
const orderType = ref('')
const orderPromotionType = ref('')
onLoad((options) => {
paymentMethod.value = options.paymentMethod || ''
from.value = options.from || ''
payPrice.value = options.payPrice || 0
orderType.value = options.orderType || ''
orderPromotionType.value = options.orderPromotionType || ''
// #ifdef MP-WEIXIN
prefetchMpMessageTemplates()
// #endif
})
function paymentTypeFilter(val: string) {
switch (val) {
case 'WECHAT':
return '微信'
case 'ALIPAY':
return '支付宝'
case 'WALLET':
return '余额支付'
default:
return ''
}
}
function checkOrder() {
if (orderType.value == 'RECHARGE') {
uni.reLaunch({
url: '/pages/mine/deposit/operation',
})
return
}
const doGoOrder = () => {
uni.reLaunch({
url: '/pages/order/myOrder?status=0',
})
}
// #ifdef MP-WEIXIN
subscribePayMessageThen(doGoOrder, {
orderPromotionType: orderPromotionType.value,
})
// #endif
// #ifndef MP-WEIXIN
doGoOrder()
// #endif
}
function navigateTo(url: string, type?: string) {
const doNavigate = () => {
if (type === 'switch') {
uni.switchTab({ url })
} else {
uni.redirectTo({ url })
}
}
if (orderType.value == 'RECHARGE') {
doNavigate()
return
}
// #ifdef MP-WEIXIN
subscribePayMessageThen(doNavigate, {
orderPromotionType: orderPromotionType.value,
})
// #endif
// #ifndef MP-WEIXIN
doNavigate()
// #endif
}
</script>
<style scoped lang="scss">
.subscribe {
justify-content: space-between;
align-items: center;
margin: 0 auto 40rpx auto;
padding: 0 20rpx 20rpx;
width: 80%;
}
.pay-btns {
display: flex;
width: 50%;
justify-content: space-between;
margin: 0 auto;
color: #fff;
>div {
padding: 6px 12px;
border: 1px solid #fff;
border-radius: 100px;
}
}
.pay-money {
line-height: 1;
font-size: 50rpx;
color: #fff;
margin-bottom: 100rpx;
}
.pay-item {
font-weight: bold;
margin: 32rpx 0;
display: flex;
justify-content: space-between;
font-size: 24rpx;
color: var(--theme-primary-70, rgba(255, 60, 42, 0.7));
}
.pay-box {
overflow: hidden;
}
.pay-tag-box {
width: 80%;
margin: 80rpx auto 40rpx auto;
padding: 20rpx;
border-radius: 20rpx;
background: var(--theme-primary-20, rgba(255, 60, 42, 0.2));
>h2 {
margin-top: 20rpx;
font-size: 40rpx;
color: $main-color;
}
}
.pay-wrapper {
background-image: linear-gradient(90deg, $main-color, $light-color, $aider-light-color);
height: 480rpx;
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.pay-box {
transform: translateY(-100rpx);
width: 100%;
background: #fff;
border-top-right-radius: 100rpx;
}
</style>

View File

@@ -1,5 +1,19 @@
<template>
<view :style="themeStyle" class="add-address">
<view :style="themeStyle" class="address-page add-address">
<!-- #ifdef MP-WEIXIN -->
<view class="wechat-import">
<button class="wechat-import-button" :disabled="importing" @click="importWechatAddress">
<view class="wechat-icon"><u-icon name="weixin-fill" color="#07b75b" size="26" /></view>
<view class="wechat-copy">
<text class="wechat-title">{{ importing ? '正在导入微信地址…' : '导入微信收货地址' }}</text>
</view>
<u-icon name="arrow-right" color="#77a38a" size="14" />
</button>
</view>
<!-- #endif -->
<view v-if="importNotice" class="import-notice">{{ importNotice }}</view>
<view class="section-heading">收货信息</view>
<view class="address-card form-card">
<up-form
:model="form"
ref="uForm"
@@ -7,30 +21,31 @@
:rules="rules"
label-position="left"
label-width="180rpx"
:label-style="{ color: '#5b606b', fontSize: '28rpx' }"
>
<up-form-item label="收货人" label-width="180rpx" prop="name" :border-bottom="true">
<up-form-item label="收货人" label-width="160rpx" prop="name" :border-bottom="true" :custom-style="fieldStyle">
<u-input
v-model="form.name"
border="none"
input-align="right"
input-align="left"
clearable
placeholder="请输入收货人姓名"
placeholder="姓名"
/>
</up-form-item>
<up-form-item label="手机号码" label-width="180rpx" prop="mobile" :border-bottom="true">
<up-form-item label="手机号码" label-width="160rpx" prop="mobile" :border-bottom="true" :custom-style="fieldStyle">
<u-input
v-model="form.mobile"
type="number"
maxlength="11"
border="none"
input-align="right"
placeholder="请输入收货人手机号码"
input-align="left"
placeholder="收货人的手机号码"
/>
</up-form-item>
<up-form-item label="所在区域" label-width="180rpx" prop="___path" :border-bottom="true">
<view class="form-value" @click="showPicker">
<up-form-item label="所在地区" label-width="160rpx" prop="___path" :border-bottom="true" :custom-style="fieldStyle" @click="showPicker">
<view class="form-value" :class="{ 'is-placeholder': !form.___path }">
{{ form.___path || '请选择所在地区' }}
</view>
<template #right>
@@ -38,40 +53,38 @@
</template>
</up-form-item>
<up-form-item class="detailAddress" label="详细地址" label-width="180rpx" prop="detail" :border-bottom="true">
<u-input
type="textarea"
<up-form-item label="详细地址" label-width="160rpx" prop="detail" :border-bottom="true" :custom-style="{ ...fieldStyle, alignItems: 'flex-start' }">
<textarea
class="address-textarea"
v-model="form.detail"
maxlength="100"
height="150"
border="none"
input-align="right"
placeholder="街道楼牌号等"
:auto-height="true"
placeholder="街道、门牌号等详细信息"
placeholder-style="color: #b1b5bd; font-size: 28rpx;"
/>
</up-form-item>
<up-form-item label="地址别名" label-width="180rpx" :border-bottom="false">
<up-form-item label="地址标签" label-width="160rpx" :border-bottom="false" :custom-style="fieldStyle">
<u-input
v-model="form.alias"
border="none"
input-align="right"
placeholder="请输入地址别名"
input-align="left"
placeholder="如:家、公司(选填)"
/>
</up-form-item>
<view class="default-row">
<u-checkbox
usedAlone
shape="circle"
size="30"
:active-color="lightColor"
v-model:checked="form.isDefault"
label="设为默认地址"
></u-checkbox>
</view>
<view class="saveBtn" @click="save">保存</view>
</up-form>
</view>
<view class="address-card default-row">
<view>
<text class="default-title">设为默认地址</text>
<text class="default-hint">下单时优先使用这个地址</text>
</view>
<switch :checked="!!form.isDefault" :color="mainColor" @change="form.isDefault = $event.detail.value" />
</view>
<view class="address-footer">
<button class="address-primary-button" :disabled="importing" @click="save">保存收货地址</button>
</view>
<m-city
:provinceData="list"
@@ -93,16 +106,114 @@ import MCity from '@/components/m-city/m-city.vue'
import uniMap from '@/components/uniMap'
import permision from '@/js_sdk/wa-permission/permission.js'
import { getThemeStyle } from '@/utils/theme'
import { getRegionsById } from '@/api/common.js'
import { resolveWechatRegion, getWechatAddressErrorType } from '@/utils/wechatAddress.js'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const { proxy } = getCurrentInstance()!
const lightColor = computed(() => store.getters.lightColor)
const mainColor = computed(() => store.getters.mainColor)
const fieldStyle = { padding: '28rpx 0', minHeight: '100rpx', boxSizing: 'border-box', alignItems: 'center' }
const mapFlag = ref(false)
const routerVal = ref<Record<string, string>>({})
const uForm = ref<any>(null)
const cityPicker = ref<any>(null)
const importing = ref(false)
const importNotice = ref('')
// #ifdef MP-WEIXIN
function importWechatAddress() {
if (importing.value) return
importing.value = true
uni.chooseAddress({
success: async (address: any) => {
uni.showLoading({ title: '正在匹配地区', mask: true })
// Clear the old region and coordinates, including when editing an existing address.
Object.assign(form, {
name: address.userName || '',
mobile: address.telNumber || '',
detail: address.detailInfoNew || address.detailInfo || '',
consigneeAddressIdPath: [],
consigneeAddressPath: [],
___path: '',
lat: '',
lon: '',
})
importNotice.value = ''
try {
const regions = await resolveWechatRegion(address, getRegionsById)
if (regions) {
if (regions.length < 4 && address.streetName) {
form.detail = address.detailInfo || [address.streetName, address.detailInfoNew].filter(Boolean).join('')
}
const names = regions.map((item: any) => item.name || item.localName)
form.consigneeAddressIdPath = regions.map((item: any) => item.id)
form.consigneeAddressPath = names
form.___path = names.join('')
const center = String(regions[regions.length - 1].center || '').split(',')
if (center.length === 2) [form.lon, form.lat] = center
importNotice.value = '已导入微信地址,请核对后保存'
} else {
showImportedRegionHint(address)
}
} catch {
showImportedRegionHint(address)
} finally {
importing.value = false
uni.hideLoading()
uForm.value?.clearValidate()
}
},
fail: (error: any) => {
importing.value = false
const type = getWechatAddressErrorType(error)
if (type === 'cancel') return
let platform = ''
try { platform = (globalThis as any).wx?.getDeviceInfo?.()?.platform || '' } catch {}
// Log diagnostics only, never the selected address or other personal information.
console.warn('[微信地址导入失败]', {
errMsg: String(error?.errMsg || error?.message || ''),
errCode: error?.errCode ?? error?.errno ?? '',
type,
platform,
})
if (type === 'auth') {
uni.showModal({
title: '开启收货地址授权',
content: '需要允许使用微信收货地址才能导入,也可以继续手动填写。',
confirmText: '去设置',
success: (result) => { if (result.confirm) uni.openSetting({}) },
})
return
}
if (type === 'privacy') {
console.warn('[微信地址导入] 请核对小程序后台的用户隐私保护指引是否声明收货地址,以及用户隐私授权状态。')
}
if (type === 'configuration') {
console.warn('[微信地址导入] manifest.json 的 mp-weixin.requiredPrivateInfos 必须包含 chooseAddress,修改后需重新编译小程序。')
}
const content = type === 'configuration'
? '收货地址导入暂未配置完成,请稍后再试,也可以手动填写。'
: type === 'privacy'
? '暂时无法获取微信收货地址,请稍后重试,也可以手动填写。'
: platform === 'devtools'
? '当前开发工具中未能打开微信地址簿,请使用手机微信扫码预览测试,也可以手动填写。'
: type === 'unsupported'
? '当前微信环境不支持导入收货地址,请更新微信后重试,也可以手动填写。'
: '暂时无法打开微信地址簿,请稍后重试,也可以手动填写。'
uni.showModal({ title: '微信地址未导入', content, showCancel: false })
},
})
}
function showImportedRegionHint(address: any) {
const region = [address.provinceName, address.cityName, address.countyName, address.streetName].filter(Boolean).join('')
// Preserve the street if the user has to select it manually and the new detail field omits it.
form.detail = address.detailInfo || [address.streetName, address.detailInfoNew].filter(Boolean).join('')
importNotice.value = `已导入姓名、电话和详细地址,请补选所在地区${region ? '(微信地址:' + region + ')' : ''}`
}
// #endif
const form = reactive<Record<string, any>>({
detail: '',
@@ -160,18 +271,24 @@ const rules = {
onShow(() => {})
onLoad((option) => {
uni.showLoading({ title: '加载中' })
onLoad(async (option) => {
routerVal.value = option || {}
if (option.id) {
getAddressDetail(option.id).then((res) => {
const params = res.data.result
params.___path = params.consigneeAddressPath
Object.assign(form, params)
if (store.state.isShowToast) uni.hideLoading()
})
uni.setNavigationBarTitle({ title: option?.id ? '编辑收货地址' : '新增收货地址' })
if (!option?.id) return
uni.showLoading({ title: '加载中' })
try {
const res = await getAddressDetail(option.id)
const params = res.data?.result
if (!res.data?.success || !params) {
uni.showToast({ title: '地址加载失败,请重试', icon: 'none' })
return
}
Object.assign(form, params, { ___path: params.consigneeAddressPath || '' })
} catch {
uni.showToast({ title: '地址加载失败,请重试', icon: 'none' })
} finally {
uni.hideLoading()
}
uni.hideLoading()
})
onReady(() => {
@@ -233,6 +350,7 @@ function callBackAddress(val: any) {
}
function save() {
if (importing.value) return
uForm.value?.validate().then(() => {
const params = { ...form }
delete params.___path
@@ -278,11 +396,12 @@ function getpickerParentValue(e: any[]) {
form.___path = name
}
if (index == e.length - 1) {
const _town = item.children.filter((_child: any) => _child.id == item.id)
form.lat = _town[0].center.split(',')[1]
form.lon = _town[0].center.split(',')[0]
const center = String(item.center || item.children?.find((_child: any) => _child.id == item.id)?.center || '').split(',')
form.lat = center.length === 2 ? center[1] : ''
form.lon = center.length === 2 ? center[0] : ''
}
})
importNotice.value = ''
}
function showPicker() {
@@ -290,99 +409,25 @@ function showPicker() {
}
</script>
<style scoped lang="scss">
page {
background: #f5f5f5;
@import "./address-page.scss";
.wechat-import { margin-bottom: 28rpx; }
.wechat-import-button {
display: flex; align-items: center; padding: 28rpx; margin: 0; width: 100%; text-align: left;
background: #eef9f2; border-radius: 24rpx; line-height: 1.5;
&::after { border: 1rpx solid #dcefe3; border-radius: 24rpx; }
&[disabled] { background: #eef9f2; opacity: .65; }
}
.add-address {
min-height: 100vh;
background: #fff;
padding-bottom: 40rpx;
}
:deep(.u-form-item) {
padding: 0 30rpx;
min-height: 100rpx;
}
:deep(.u-form-item__body) {
min-height: 100rpx;
align-items: center;
}
:deep(.u-form-item__body__left__content__label) {
font-size: 30rpx;
color: #333;
}
:deep(.u-form-item__body__right__content__slot) {
flex: 1;
display: flex;
justify-content: flex-end;
align-items: center;
}
:deep(.u-input) {
padding: 0 !important;
}
:deep(.u-input__content__field-wrapper__field) {
font-size: 28rpx !important;
color: #666 !important;
}
.form-value {
width: 100%;
text-align: right;
font-size: 28rpx;
color: #666;
}
.detailAddress {
:deep(.u-form-item__body) {
align-items: flex-start;
padding-top: 24rpx;
padding-bottom: 24rpx;
min-height: auto;
}
:deep(.u-form-item__body__left) {
padding-top: 8rpx;
}
:deep(.u-form-item__body__right__content__slot) {
justify-content: flex-end;
align-items: flex-start;
}
:deep(.u-textarea__field),
:deep(.u-input__content__field-wrapper__field) {
text-align: right !important;
}
:deep(.uni-textarea-placeholder),
:deep(.input-placeholder) {
text-align: right;
}
}
.default-row {
padding: 24rpx 30rpx;
}
:deep(.default-row .u-checkbox__label) {
font-size: 28rpx;
color: $font-color-light;
}
.saveBtn {
margin: 40rpx 30rpx 0;
height: 80rpx;
line-height: 80rpx;
text-align: center;
font-size: 30rpx;
background: $light-color;
color: #fff;
border-radius: 40rpx;
}
</style>
.wechat-icon { width: 76rpx; height: 76rpx; border-radius: 20rpx; background: #fff; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-right: 22rpx; }
.wechat-copy { flex: 1; min-width: 0; }
.wechat-title { display: block; color: #253e30; font-size: 30rpx; font-weight: 600; }
.import-notice { margin-bottom: 24rpx; padding: 20rpx 24rpx; border-radius: 16rpx; background: #fff5e5; color: #a47a3b; font-size: 24rpx; line-height: 1.6; }
.section-heading { padding: 0 8rpx 20rpx; font-size: 26rpx; color: #858b96; }
.form-card { padding: 0 28rpx; }
.form-value { width: 100%; line-height: 1.6; font-size: 28rpx; color: #303744; overflow-wrap: anywhere; }
.is-placeholder { color: #b1b5bd; }
.address-textarea { width: 100%; min-height: 88rpx; font-size: 28rpx; color: #303744; line-height: 1.6; padding: 0; }
.default-row { display: flex; justify-content: space-between; align-items: center; margin-top: 24rpx; padding: 28rpx; }
.default-title { display: block; font-size: 28rpx; color: #353b47; }
.default-hint { display: block; font-size: 23rpx; color: #a0a5af; margin-top: 8rpx; }
.default-row switch { transform: scale(.8); transform-origin: right center; }
</style>

View File

@@ -0,0 +1,43 @@
.address-page {
min-height: 100vh;
box-sizing: border-box;
background: #f6f7f9;
padding: 28rpx 24rpx calc(180rpx + env(safe-area-inset-bottom));
}
.address-card {
background: #fff;
border-radius: 24rpx;
overflow: hidden;
}
.address-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 90;
box-sizing: border-box;
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
background: #fff;
border-top: 1rpx solid #f0f1f4;
}
.address-primary-button {
display: flex;
align-items: center;
justify-content: center;
gap: 14rpx;
width: 100%;
height: 88rpx;
margin: 0;
padding: 0 24rpx;
border-radius: 44rpx;
background: var(--theme-primary);
color: #fff;
font-size: 30rpx;
font-weight: 500;
line-height: 1;
&::after { border: none; }
&[disabled] { opacity: .5; background: var(--theme-primary); color: #fff; }
}

View File

@@ -1,55 +1,64 @@
<template>
<view class="address">
<u-empty class="empty" v-if="addressList.length === 0" text="暂无收货地址" mode="address"></u-empty>
<view class="list" >
<view class="item c-content" v-for="(item, index) in addressList" :key="index">
<view class="basic">
<text>{{ item.name }}</text>
<text>{{ item.mobile }}</text>
<text class="default" v-show="item.isDefault">默认</text>
<view>
<div class="region">
<span v-if="item.consigneeAddressPath[0]">{{item.consigneeAddressPath[0]}}</span>
<span v-if="item.consigneeAddressPath[1]">{{item.consigneeAddressPath[1]}}</span>
<span v-if="item.consigneeAddressPath[2]">{{item.consigneeAddressPath[2]}}</span>
<span v-if="item.consigneeAddressPath[3]">{{item.consigneeAddressPath[3]}}</span>
<span>{{ item.detail }}</span>
</div>
</view>
<view class="address-page address-manage" :style="themeStyle">
<view v-if="!loading && addressList.length === 0" class="empty-state">
<view class="empty-icon"><u-icon name="map" size="42" color="#c4c8d1" /></view>
<text class="empty-title">还没有收货地址</text>
<text class="empty-description">添加常用地址,下次购物更方便</text>
</view>
<view v-if="addressList.length" class="list-heading">
<text>我的收货地址</text>
<text class="address-count">共 {{ addressList.length }} 个</text>
</view>
<view class="address-list">
<view class="address-card address-item" v-for="item in addressList" :key="item.id">
<view class="recipient-line">
<text class="recipient-name">{{ item.name }}</text>
<text class="recipient-mobile">{{ item.mobile }}</text>
</view>
<view class="edit">
<view class="relative" @click="setDefault(item)">
<view v-if="item.isDefault" class="alifont icon-xuanzhong"></view>
<text v-else class="unchecked"></text>
<text>{{ item.isDefault ? "默认地址" : "设为默认" }}</text>
<view class="address-region">{{ item.consigneeAddressPath.join(' ') }}</view>
<text class="address-detail">{{ item.detail }}</text>
<view class="address-tags" v-if="item.isDefault || item.alias">
<text v-if="item.isDefault" class="address-tag default-tag">默认地址</text>
<text v-if="item.alias" class="address-tag">{{ item.alias }}</text>
</view>
<view class="card-actions">
<view class="default-action" @click="setDefault(item)">
<view class="default-check" :class="{ 'is-checked': item.isDefault }">
<u-icon v-if="item.isDefault" name="checkmark" size="11" color="#fff" />
</view>
<text>{{ item.isDefault ? '已设为默认' : '设为默认' }}</text>
</view>
<view class="relative">
<view class="alifont icon-bianji-copy"></view>
<text class="mr-40" @click="addAddress(item.id)">编辑</text>
<view class="alifont icon-lajitong" @click.stop="removeAddress(item.id)"></view>
<text @click.stop="removeAddress(item.id)">删除</text>
<view class="edit-actions">
<view class="text-action" @click="addAddress(item.id)">
<u-icon name="edit-pen" size="16" color="#6c7280" /><text>编辑</text>
</view>
<view class="action-divider"></view>
<view class="text-action" @click="removeAddress(item.id)">
<u-icon name="trash" size="16" color="#9297a1" /><text>删除</text>
</view>
</view>
</view>
</view>
</view>
<view class="btn-bar">
<view class="btn" @click="addAddress('')">
<u-icon name="plus-circle" color="#ffffff" size="18"></u-icon>
<text class="btn-text">添加新收货人</text>
</view>
<view class="address-footer">
<button class="address-primary-button" @click="addAddress('')">
<u-icon name="plus" color="#fff" size="17" /><text>新增收货地址</text>
</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { onLoad, onShow, onPullDownRefresh, onBackPress } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { tipsToLogin } from '@/utils/filters.js'
import * as API_Address from '@/api/address.js'
import { getThemeStyle } from '@/utils/theme'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const loading = ref(true)
const addressList = ref<any[]>([])
const routerVal = ref<Record<string, string>>({})
@@ -65,7 +74,6 @@ onLoad((val) => {
})
onPullDownRefresh(() => {
addressList.value = []
getAddressList()
})
@@ -75,19 +83,23 @@ onShow(() => {
}
})
function hideLoadingIfNeeded() {
if (store.state.isShowToast) uni.hideLoading()
}
function getAddressList() {
uni.showLoading()
API_Address.getAddressList(params.pageNumber, params.pageSize).then((res) => {
res.data.result.records.forEach((item: any) => {
item.consigneeAddressPath = item.consigneeAddressPath.split(',')
})
addressList.value = res.data.result.records
hideLoadingIfNeeded()
})
async function getAddressList() {
loading.value = true
try {
const res = await API_Address.getAddressList(params.pageNumber, params.pageSize)
if (!res.data?.success || !Array.isArray(res.data.result?.records)) throw new Error('地址加载失败')
addressList.value = res.data.result.records.map((item: any) => ({
...item,
consigneeAddressPath: Array.isArray(item.consigneeAddressPath)
? item.consigneeAddressPath
: String(item.consigneeAddressPath || '').split(',').filter(Boolean),
}))
} catch {
uni.showToast({ title: '地址加载失败,请下拉重试', icon: 'none' })
} finally {
loading.value = false
uni.stopPullDownRefresh()
}
}
function removeAddress(id: string) {
@@ -97,7 +109,7 @@ function removeAddress(id: string) {
success: (res) => {
if (!res.confirm) return
API_Address.deleteAddress(id).then((result) => {
if (result.statusCode == 200) {
if (result.data?.success) {
uni.showToast({ icon: 'none', title: '删除成功' })
getAddressList()
} else {
@@ -115,17 +127,45 @@ function addAddress(id?: string) {
}
function setDefault(item: any) {
delete item.updateBy
delete item.updateTime
delete item.deleteFlag
if (!item.isDefault) item.isDefault = true
API_Address.editAddress(item).then(() => {
uni.showToast({ title: '设置默认地址成功', icon: 'none' })
getAddressList()
})
if (item.isDefault) return
const data = { ...item, isDefault: true, consigneeAddressPath: item.consigneeAddressPath.join(',') }
delete data.updateBy
delete data.updateTime
delete data.deleteFlag
API_Address.editAddress(data).then((res) => {
if (res.data?.success) {
uni.showToast({ title: '已设为默认地址', icon: 'none' })
getAddressList()
} else {
uni.showToast({ title: res.data?.message || '设置失败,请重试', icon: 'none' })
}
}).catch(() => uni.showToast({ title: '设置失败,请重试', icon: 'none' }))
}
</script>
<style lang="scss" scoped>
@import "./address.scss";
@import "./address-page.scss";
.list-heading { display: flex; justify-content: space-between; padding: 8rpx 8rpx 24rpx; font-size: 26rpx; color: #6c7280; }
.address-count { color: #a0a5af; font-size: 24rpx; }
.address-item { padding: 32rpx 32rpx 0; margin-bottom: 24rpx; }
.recipient-line { display: flex; align-items: baseline; flex-wrap: wrap; gap: 20rpx; margin-bottom: 18rpx; }
.recipient-name { font-size: 34rpx; font-weight: 600; color: #222732; overflow-wrap: anywhere; }
.recipient-mobile { color: #636a77; font-size: 28rpx; }
.address-region { color: #9399a4; font-size: 25rpx; line-height: 1.7; }
.address-detail { display: block; color: #343b48; font-size: 29rpx; line-height: 1.7; overflow-wrap: anywhere; }
.address-tags { display: flex; flex-wrap: wrap; gap: 12rpx; padding-top: 20rpx; }
.address-tag { max-width: 100%; overflow-wrap: anywhere; padding: 4rpx 14rpx; background: #f3f4f6; border-radius: 8rpx; color: #7f8591; font-size: 22rpx; }
.default-tag { color: var(--theme-primary); background: var(--theme-primary-10); }
.card-actions { margin-top: 26rpx; border-top: 1rpx solid #f0f1f4; min-height: 92rpx; display: flex; justify-content: space-between; align-items: center; }
.default-action, .text-action, .edit-actions { display: flex; align-items: center; }
.default-action { gap: 12rpx; min-height: 80rpx; color: #858b96; font-size: 24rpx; }
.default-check { width: 30rpx; height: 30rpx; box-sizing: border-box; border: 2rpx solid #dce0e6; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.is-checked { background: var(--theme-primary); border-color: var(--theme-primary); }
.text-action { gap: 8rpx; min-height: 80rpx; color: #6c7280; font-size: 24rpx; padding: 0 8rpx; }
.action-divider { width: 1rpx; height: 22rpx; background: #e9ebef; margin: 0 16rpx; }
.empty-state { display: flex; flex-direction: column; align-items: center; padding: 160rpx 0 80rpx; }
.empty-icon { width: 148rpx; height: 148rpx; display: flex; align-items: center; justify-content: center; background: #eaecef; border-radius: 50%; margin-bottom: 36rpx; }
.empty-title { color: #454c59; font-size: 32rpx; font-weight: 500; }
.empty-description { color: #a0a5af; font-size: 26rpx; margin-top: 16rpx; }
</style>

View File

@@ -0,0 +1,37 @@
<template>
<view class="member-card" :style="{ color: cardColor }">
<image v-if="grade.gradeBackground && !backgroundFailed" :key="grade.gradeBackground" class="card-background" :src="grade.gradeBackground"
mode="aspectFill" @load="backgroundLoaded = true" @error="backgroundFailed = true" />
<view class="member-card-content">
<view class="card-status">{{ isCurrent ? '当前等级' : '等级预览' }}</view>
<view class="grade-heading">
<image v-if="grade.gradeImage && !iconFailed" :key="grade.gradeImage" class="grade-icon" :src="grade.gradeImage" mode="aspectFit" @error="iconFailed = true" />
<u-icon v-else name="level" size="30" :color="cardColor" /><text class="grade-title">{{ grade.gradeName }}</text>
</view>
<text class="card-subtitle">{{ benefitCount }} 项专属权益 · {{ thresholdText }}</text>
</view>
</view>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({ grade: { type: Object, required: true }, benefitCount: { type: Number, default: 0 }, isCurrent: { type: Boolean, default: false } })
const backgroundLoaded = ref(false), backgroundFailed = ref(false), iconFailed = ref(false)
const cardColor = computed(() => {
if (!backgroundLoaded.value || backgroundFailed.value) return '#fff5df'
const color = props.grade.gradeFontColor
return /^#[0-9a-f]{6}$/i.test(color || '') ? color : '#333333'
})
const thresholdText = computed(() => props.grade.isDefault ? '默认等级' : `${Math.max(0, Number(props.grade.requiredExperience) || 0).toLocaleString('en-US')} 经验值可达`)
watch(() => props.grade.gradeBackground, () => { backgroundLoaded.value = false; backgroundFailed.value = false }, { flush: 'sync' })
watch(() => props.grade.gradeImage, () => { iconFailed.value = false }, { flush: 'sync' })
</script>
<style scoped lang="scss">
.member-card { position: relative; overflow: hidden; width: 100%; height: 100%; border-radius: 24rpx; background: linear-gradient(115deg, #51443b, #8e7351); }
.card-background { position: absolute; width: 100%; height: 100%; top: 0; left: 0; }
.member-card-content { position: relative; height: 100%; padding: 30rpx 32rpx; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; }
.card-status { border: 1rpx solid currentColor; opacity: .8; font-size: 20rpx; padding: 4rpx 14rpx; border-radius: 20rpx; }
.grade-heading { display: flex; align-items: center; gap: 14rpx; margin: 18rpx 0 12rpx; max-width: 100%; }
.grade-icon { width: 68rpx; height: 68rpx; flex-shrink: 0; }
.grade-title { font-size: 38rpx; line-height: 1.4; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-subtitle { font-size: 23rpx; opacity: .85; }
</style>

View File

@@ -1,239 +1,484 @@
<template>
<view :style="themeStyle" class="wap">
<u-navbar
title="预存款列表"
:fixed="true"
:placeholder="true"
:border="false"
:auto-back="true"
></u-navbar>
<view class="wrapper-show-money">
<view class="money-view">
<text class="money-label">预存款金额</text>
<text class="money">¥{{ unitPrice(walletNum) }}</text>
</view>
</view>
<view class="wrapper-tabs">
<swiper class="swiper-box" :current="swiperCurrent">
<swiper-item class="swiper-item" v-for="index in list.length" :key="index">
<scroll-view
class="scroll-v view-wrapper"
enable-back-to-top
scroll-with-animation
scroll-y
@scrolltolower="loadMore"
>
<view v-if="depositData.length != 0" class="list-card">
<view
class="view-item"
v-for="(logItem, logIndex) in depositData"
:key="logIndex"
>
<view class="view-item-detail">
<text class="item-title">{{ logItem.detail }}</text>
</view>
<view class="view-item-change">
<text
class="item-money"
:class="getMoneyClass(logItem.serviceType)"
>{{ formatLogMoney(logItem) }}</text>
<text class="item-time">{{ logItem.createTime }}</text>
</view>
</view>
</view>
<u-empty v-if="depositData.length == 0" mode="history" text="暂无记录" />
</scroll-view>
</swiper-item>
</swiper>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { getWalletLog, getUserWallet } from '@/api/members'
<template>
<view :style="themeStyle" class="deposit-log-page">
<u-navbar
title="预存款明细"
:fixed="true"
:placeholder="true"
:border="false"
:auto-back="true"
></u-navbar>
<!-- 顶部余额概览卡片 -->
<view class="header-wrap">
<view class="balance-hero">
<view class="hero-glow hero-glow-1"></view>
<view class="hero-glow hero-glow-2"></view>
<view class="hero-top">
<text class="money-label">当前预存款余额 (元)</text>
</view>
<view class="money-row">
<text class="money-symbol">¥</text>
<text class="money-value">{{ unitPrice(walletNum) }}</text>
</view>
</view>
<!-- 收支筛选栏 -->
<view class="filter-bar">
<view
v-for="(tab, idx) in filterTabs"
:key="tab.key"
class="filter-pill"
:class="{ active: activeFilter === idx }"
@click="activeFilter = idx"
>
{{ tab.name }}
</view>
</view>
</view>
<!-- 明细列表区 -->
<view class="list-container">
<view v-if="filteredData.length > 0" class="list-card">
<view
class="log-item"
v-for="(item, logIndex) in filteredData"
:key="logIndex"
>
<!-- 第一行:类型标签 + 标题 | 变动金额 -->
<view class="log-row-main">
<view class="log-title-wrap">
<text class="type-tag" :class="item.parsed.tagClass">{{ item.parsed.tagText }}</text>
<text class="log-title">{{ item.parsed.title }}</text>
</view>
<text class="log-amount" :class="item.parsed.moneyClass">
{{ item.parsed.formattedMoney }}
</text>
</view>
<!-- 第二行:提取出的单号或备注说明 -->
<view v-if="item.parsed.metaList.length > 0" class="log-meta-box">
<view
class="log-meta-line"
v-for="(meta, mIdx) in item.parsed.metaList"
:key="mIdx"
>
<text class="meta-label">{{ meta.label }}:</text>
<text class="meta-val">{{ meta.value }}</text>
</view>
</view>
<!-- 第三行:变动时间 -->
<view class="log-row-foot">
<text class="log-time">{{ item.createTime }}</text>
</view>
</view>
</view>
<view v-else-if="loaded" class="empty-box">
<u-empty mode="history" text="暂无预存款变动记录" />
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { onReachBottom } from '@dcloudio/uni-app'
import { getWalletLog, getUserWallet } from '@/api/members'
import { unitPrice } from '@/utils/filters.js'
import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const walletNum = ref(0)
const swiperCurrent = ref(0)
const params = ref({
pageNumber: 1,
pageSize: 10,
order: 'desc',
})
const depositData = ref<any[]>([])
const list = [{ name: '预存款变动明细' }]
onMounted(async () => {
getWallet()
const result = await getUserWallet()
walletNum.value = result.data.result.memberWallet
})
function isOutgoing(serviceType: string) {
return serviceType === 'WALLET_PAY' || serviceType === 'WALLET_WITHDRAWAL'
}
function isIncoming(serviceType: string) {
return (
serviceType === 'WALLET_REFUND' ||
serviceType === 'WALLET_RECHARGE' ||
serviceType === 'WALLET_COMMISSION'
)
}
function getMoneyClass(serviceType: string) {
if (isOutgoing(serviceType)) return 'out'
if (isIncoming(serviceType)) return 'in'
return ''
}
function formatLogMoney(logItem: any) {
const amount = unitPrice(logItem.money)
if (isOutgoing(logItem.serviceType)) return `-${amount}`
if (isIncoming(logItem.serviceType)) return `+${amount}`
return amount
}
function getWallet() {
getWalletLog(params.value).then((res) => {
if (res.data.success && res.data.result.records.length) {
depositData.value.push(...res.data.result.records)
}
})
}
function loadMore() {
params.value.pageNumber++
getWallet()
}
</script>
<style lang="scss" scoped>
.wap {
min-height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.wrapper-show-money {
flex-shrink: 0;
height: 200rpx;
}
.money-view {
height: 100%;
padding: 32rpx 40rpx;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
color: #fff;
background-image: linear-gradient(
25deg,
$main-color,
$light-color,
$aider-light-color
);
}
.money-label {
font-size: 28rpx;
opacity: 0.95;
}
.money {
margin-top: 12rpx;
max-width: 100%;
font-size: 56rpx;
font-weight: 600;
line-height: 1.2;
word-break: break-all;
}
.wrapper-tabs {
flex: 1;
min-height: 0;
padding: 24rpx;
box-sizing: border-box;
}
.swiper-box,
.swiper-item,
.scroll-v {
height: 100%;
}
.list-card {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
}
.view-item {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
padding: 28rpx 32rpx;
border-bottom: 1px solid #f5f5f5;
&:last-child {
border-bottom: none;
}
}
.view-item-detail {
flex: 1;
min-width: 0;
padding-right: 24rpx;
}
.item-title {
font-size: 28rpx;
color: #333;
line-height: 1.5;
word-break: break-all;
}
.view-item-change {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
align-self: center;
text-align: right;
}
.item-money {
font-size: 34rpx;
font-weight: 600;
line-height: 1.3;
white-space: nowrap;
&.in {
color: $aider-color-green;
}
&.out {
color: #ff5a5f;
}
}
.item-time {
margin-top: 8rpx;
font-size: 22rpx;
color: #999;
line-height: 1.3;
white-space: nowrap;
}
</style>
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const walletNum = ref(0)
const loaded = ref(false)
const hasMore = ref(true)
const activeFilter = ref(0)
const filterTabs = [
{ key: 'ALL', name: '全部明细' },
{ key: 'IN', name: '收入 / 退款' },
{ key: 'OUT', name: '支出 / 提现' },
]
const params = ref({
pageNumber: 1,
pageSize: 10,
order: 'desc',
})
const depositData = ref<any[]>([])
onMounted(async () => {
getWallet()
const result = await getUserWallet()
walletNum.value = result.data?.result?.memberWallet ?? 0
})
onReachBottom(() => {
if (!hasMore.value) return
loadMore()
})
function isOutgoing(logItem: any) {
const money = Number(logItem?.money) || 0
if (money < 0) return true
const serviceType = logItem?.serviceType
return serviceType === 'WALLET_PAY' || serviceType === 'WALLET_WITHDRAWAL'
}
function isIncoming(logItem: any) {
const money = Number(logItem?.money) || 0
if (money > 0) return true
const serviceType = logItem?.serviceType
return (
serviceType === 'WALLET_REFUND' ||
serviceType === 'WALLET_RECHARGE' ||
serviceType === 'WALLET_COMMISSION'
)
}
function parseLogDetail(logItem: any) {
const raw = String(logItem?.detail || '').trim()
const serviceType = logItem?.serviceType || ''
const out = isOutgoing(logItem)
const inc = isIncoming(logItem)
const absAmount = unitPrice(Math.abs(Number(logItem?.money) || 0))
const formattedMoney = out ? `-${absAmount}` : inc ? `+${absAmount}` : absAmount
const moneyClass = out ? 'out' : inc ? 'in' : ''
let title = '余额变动'
let tagText = '变动'
let tagClass = 'tag-default'
const orderMatch = raw.match(/订单\[([^\]]+)\]/)
const afterSaleMatch = raw.match(/售后单\[([^\]]+)\]/)
const rechargeMatch = raw.match(/充值单号为[::]\s*([A-Za-z0-9_-]+)/)
if (afterSaleMatch && afterSaleMatch[1] === 'ORDER_CANCEL') {
title = '订单取消退回'
tagText = '退款'
tagClass = 'tag-in'
} else if (serviceType === 'WALLET_REFUND' || raw.includes('退还') || raw.includes('返还')) {
title = '售后退款入账'
tagText = '退款'
tagClass = 'tag-in'
} else if (serviceType === 'WALLET_PAY' || raw.includes('支付金额')) {
title = '订单余额支付'
tagText = '消费'
tagClass = 'tag-out'
} else if (serviceType === 'WALLET_RECHARGE') {
title = raw.includes('后台') ? '平台余额充值' : '预存款充值'
tagText = '充值'
tagClass = 'tag-in'
} else if (serviceType === 'WALLET_WITHDRAWAL') {
title = out ? '余额提现申请' : '提现退回解冻'
tagText = '提现'
tagClass = out ? 'tag-out' : 'tag-in'
} else if (serviceType === 'WALLET_COMMISSION') {
title = '分销佣金入账'
tagText = '佣金'
tagClass = 'tag-in'
}
const metaList: Array<{ label: string; value: string }> = []
if (orderMatch && orderMatch[1]) {
metaList.push({ label: '订单编号', value: orderMatch[1] })
}
if (afterSaleMatch && afterSaleMatch[1] && afterSaleMatch[1] !== 'ORDER_CANCEL') {
metaList.push({ label: '售后单号', value: afterSaleMatch[1] })
}
if (rechargeMatch && rechargeMatch[1]) {
metaList.push({ label: '充值单号', value: rechargeMatch[1] })
}
if (metaList.length === 0 && raw && !raw.includes('???')) {
const cleaned = raw
.replace(/[,,]?\s*(支付金额|退还金额|余额返还)\[[^\]]+\]/g, '')
.replace(/:\s*\d+(\.\d+)?$/, '')
.trim()
if (cleaned && cleaned !== title) {
metaList.push({ label: '变动说明', value: cleaned })
}
}
return {
title,
tagText,
tagClass,
formattedMoney,
moneyClass,
isOut: out,
isIn: inc,
metaList,
}
}
const decoratedList = computed(() =>
depositData.value.map((item) => ({
...item,
parsed: parseLogDetail(item),
}))
)
const filteredData = computed(() => {
if (activeFilter.value === 1) {
return decoratedList.value.filter((item) => item.parsed.isIn)
}
if (activeFilter.value === 2) {
return decoratedList.value.filter((item) => item.parsed.isOut)
}
return decoratedList.value
})
function getWallet() {
getWalletLog(params.value).then((res) => {
loaded.value = true
const records = (res.data?.success && res.data?.result?.records) || []
if (records.length) {
depositData.value.push(...records)
}
if (records.length < params.value.pageSize) {
hasMore.value = false
}
})
}
function loadMore() {
params.value.pageNumber++
getWallet()
}
</script>
<style lang="scss" scoped>
.deposit-log-page {
min-height: 100vh;
background: #f5f6fa;
padding-bottom: 48rpx;
box-sizing: border-box;
}
.header-wrap {
padding: 24rpx 24rpx 8rpx;
}
.balance-hero {
position: relative;
overflow: hidden;
border-radius: 24rpx;
padding: 36rpx 36rpx 34rpx;
color: #ffffff;
background: linear-gradient(135deg, $main-color 0%, $light-color 60%, $aider-light-color 100%);
box-shadow: 0 12rpx 28rpx rgba(229, 50, 56, 0.18);
}
.hero-glow {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
pointer-events: none;
}
.hero-glow-1 {
width: 220rpx;
height: 220rpx;
right: -60rpx;
top: -70rpx;
}
.hero-glow-2 {
width: 140rpx;
height: 140rpx;
right: 90rpx;
bottom: -60rpx;
background: rgba(255, 255, 255, 0.08);
}
.hero-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.money-label {
font-size: 25rpx;
color: rgba(255, 255, 255, 0.88);
letter-spacing: 0.5rpx;
}
.money-row {
margin-top: 14rpx;
display: flex;
align-items: baseline;
}
.money-symbol {
font-size: 34rpx;
font-weight: 600;
margin-right: 8rpx;
}
.money-value {
font-size: 58rpx;
font-weight: 700;
line-height: 1.15;
letter-spacing: 1rpx;
font-variant-numeric: tabular-nums;
}
.filter-bar {
display: flex;
align-items: center;
gap: 16rpx;
margin-top: 24rpx;
}
.filter-pill {
padding: 12rpx 28rpx;
border-radius: 999rpx;
font-size: 25rpx;
color: #666666;
background: #ffffff;
font-weight: 500;
transition: all 0.2s;
&.active {
background: $main-color;
color: #ffffff;
font-weight: 600;
box-shadow: 0 4rpx 12rpx rgba(229, 50, 56, 0.2);
}
}
.list-container {
padding: 16rpx 24rpx 0;
}
.list-card {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.log-item {
background: #ffffff;
border-radius: 20rpx;
padding: 28rpx 30rpx;
box-shadow: 0 2rpx 12rpx rgba(17, 30, 79, 0.03);
}
.log-row-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.log-title-wrap {
display: flex;
align-items: center;
gap: 12rpx;
min-width: 0;
flex: 1;
}
.type-tag {
flex-shrink: 0;
font-size: 20rpx;
font-weight: 600;
padding: 4rpx 12rpx;
border-radius: 8rpx;
line-height: 1.3;
&.tag-in {
color: #00a870;
background: rgba(0, 168, 112, 0.1);
}
&.tag-out {
color: #ff4d4f;
background: rgba(255, 77, 79, 0.1);
}
&.tag-default {
color: #666666;
background: #f0f2f5;
}
}
.log-title {
font-size: 30rpx;
font-weight: 600;
color: #1d2129;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-amount {
flex-shrink: 0;
font-size: 34rpx;
font-weight: 700;
line-height: 1.2;
font-variant-numeric: tabular-nums;
color: #1d2129;
&.in {
color: #00a870;
}
&.out {
color: #ff4d4f;
}
}
.log-meta-box {
margin-top: 16rpx;
padding: 14rpx 18rpx;
background: #f8f9fb;
border-radius: 12rpx;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.log-meta-line {
display: flex;
align-items: center;
font-size: 23rpx;
line-height: 1.45;
color: #4e5969;
}
.meta-label {
flex-shrink: 0;
color: #86909c;
}
.meta-val {
flex: 1;
min-width: 0;
color: #4e5969;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-all;
}
.log-row-foot {
margin-top: 16rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.log-time {
font-size: 22rpx;
color: #999999;
line-height: 1.2;
}
.empty-box {
padding-top: 120rpx;
}
</style>

View File

@@ -45,6 +45,11 @@ import { getUserWallet } from '@/api/members'
import { isLogin, navigateToLogin, unitPrice } from '@/utils/filters.js'
import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
import {
prefetchMpMessageTemplates,
subscribeMpMessageByScenes,
} from '@/js_sdk/lili-pay/wx-pay.js'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
@@ -52,6 +57,9 @@ const walletNum = ref(0)
onShow(async () => {
if (isLogin('auth')) {
// #ifdef MP-WEIXIN
prefetchMpMessageTemplates()
// #endif
const result = await getUserWallet()
walletNum.value = result.data.result.memberWallet
} else {
@@ -60,7 +68,15 @@ onShow(async () => {
})
function back() {
uni.switchTab({ url: '/pages/tabbar/user/my' })
const doBack = () => {
uni.switchTab({ url: '/pages/tabbar/user/my' })
}
// #ifdef MP-WEIXIN
subscribeMpMessageByScenes(['WALLET_CHANGE'], doBack)
// #endif
// #ifndef MP-WEIXIN
doBack()
// #endif
}
function navigateTo(url: string) {

View File

@@ -1,41 +1,114 @@
<template>
<view :style="themeStyle" class="wap">
<view :style="themeStyle" class="withdraw-log-page">
<u-navbar
title="提现记录"
:fixed="true"
:placeholder="true"
:border="false"
:auto-back="true"
:auto-back="false"
@leftClick="back"
></u-navbar>
<scroll-view class="scroll-v view-wrapper" enableBackToTop="true" scroll-with-animation scroll-y @scrolltolower="loadMore">
<view v-if="records.length != 0" class="view-item" v-for="(item, index) in records" :key="index">
<view class="view-item-detail">
<view class="-title">{{ withdrawStatusText(item.applyStatus) }}</view>
<view class="-number" v-if="item.inspectRemark || item.errorMessage">{{ item.inspectRemark || item.errorMessage }}</view>
</view>
<view class="view-item-change">
<view class="-money">-{{unitPrice(item.applyMoney) }}</view>
<view class="-time">{{ item.createTime || item.inspectTime || "" }}</view>
<view v-if="item.applyStatus === 'WAIT_USER_CONFIRM'" class="confirm-btn" @click.stop="confirmWechatReceive(item)">确认收款</view>
<!-- 状态筛选胶囊栏 -->
<view class="filter-bar">
<view
v-for="(tab, idx) in filterTabs"
:key="tab.key"
class="filter-pill"
:class="{ active: activeFilter === idx }"
@click="activeFilter = idx"
>
{{ tab.name }}
</view>
</view>
<!-- 提现记录列表 -->
<view class="list-container">
<view v-if="filteredRecords.length > 0" class="list-card">
<view
class="record-card"
v-for="(item, index) in filteredRecords"
:key="item.id || index"
>
<!-- 第一行:状态标签 + 标题 | 提现金额 -->
<view class="card-row-main">
<view class="title-wrap">
<text class="status-tag" :class="item.parsed.statusClass">
{{ item.parsed.statusText }}
</text>
<text class="card-title">余额提现</text>
</view>
<view class="amount-wrap">
<text class="amount-symbol">¥</text>
<text class="amount-val">{{ unitPrice(item.applyMoney) }}</text>
</view>
</view>
<!-- 第二行:单号、审核备注或失败原因 -->
<view
v-if="item.sn || item.inspectRemark || item.errorMessage || item.inspectTime"
class="meta-box"
>
<view v-if="item.sn" class="meta-line">
<text class="meta-label">提现单号:</text>
<text class="meta-val mono">{{ item.sn }}</text>
</view>
<view v-if="item.inspectTime" class="meta-line">
<text class="meta-label">审核时间:</text>
<text class="meta-val">{{ item.inspectTime }}</text>
</view>
<view v-if="item.inspectRemark || item.errorMessage" class="meta-line">
<text class="meta-label">备注说明:</text>
<text class="meta-val remark-text">{{ item.inspectRemark || item.errorMessage }}</text>
</view>
</view>
<!-- 第三行:申请时间 + 确认收款按钮 -->
<view class="card-row-foot">
<text class="apply-time">申请时间:{{ item.createTime || item.inspectTime || '-' }}</text>
<view
v-if="item.applyStatus === 'WAIT_USER_CONFIRM'"
class="confirm-btn"
@click.stop="confirmWechatReceive(item)"
>
确认收款
</view>
</view>
</view>
</view>
<u-empty v-if="loaded && records.length == 0" mode="history" text="暂无记录" />
</scroll-view>
<view v-else-if="loaded" class="empty-box">
<u-empty mode="history" text="暂无提现记录" />
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import { getWithdrawApplyPage, getWithdrawApplyWechatTransferInfo } from '@/api/members'
import { unitPrice } from '@/utils/filters.js'
import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
import {
prefetchMpMessageTemplates,
subscribeMpMessageByScenes,
} from '@/js_sdk/lili-pay/wx-pay.js'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const loaded = ref(false)
const hasMore = ref(true)
const activeFilter = ref(0)
const filterTabs = [
{ key: 'ALL', name: '全部' },
{ key: 'PENDING', name: '处理中' },
{ key: 'SUCCESS', name: '已到账' },
{ key: 'FAIL', name: '未通过/失败' },
]
const params = ref({
pageNumber: 1,
pageSize: 10,
@@ -44,40 +117,88 @@ const params = ref({
const records = ref<any[]>([])
onShow(() => {
// #ifdef MP-WEIXIN
// 预拉取订阅模板,保证点击左上角返回时可同步拉起微信订阅弹窗
prefetchMpMessageTemplates()
// #endif
reloadData()
})
// 左上角返回:提示订阅「提现结果通知」后再返回
function back() {
const doBack = () => {
if (getCurrentPages().length > 1) {
uni.navigateBack({ delta: 1 })
} else {
uni.redirectTo({ url: '/pages/mine/deposit/operation' })
}
}
// #ifdef MP-WEIXIN
subscribeMpMessageByScenes(['WALLET_WITHDRAWAL_AUDIT_ERROR'], doBack)
// #endif
// #ifndef MP-WEIXIN
doBack()
// #endif
}
onReachBottom(() => {
if (!hasMore.value) return
loadMore()
})
function reloadData() {
params.value.pageNumber = 1
hasMore.value = true
records.value = []
loaded.value = false
getData()
})
}
function withdrawStatusText(applyStatus: string) {
function parseWithdrawStatus(applyStatus: string) {
switch (applyStatus) {
case 'APPLY':
return '申请中'
return { statusText: '申请中', statusClass: 'tag-pending', group: 'PENDING' }
case 'VIA_AUDITING':
return '审核通过'
return { statusText: '审核通过', statusClass: 'tag-pending', group: 'PENDING' }
case 'D_VIA_AUDITING':
return '分销提现审核通过'
case 'FAIL_AUDITING':
return '审核未通过'
case 'D_FAIL_AUDITING':
return '分销提现审核未通过'
return { statusText: '分销审核通过', statusClass: 'tag-pending', group: 'PENDING' }
case 'WAIT_USER_CONFIRM':
return '等待用户确认'
return { statusText: '待确认收款', statusClass: 'tag-action', group: 'PENDING' }
case 'SUCCESS':
return '提现成功'
return { statusText: '提现成功', statusClass: 'tag-success', group: 'SUCCESS' }
case 'FAIL_AUDITING':
return { statusText: '审核未通过', statusClass: 'tag-fail', group: 'FAIL' }
case 'D_FAIL_AUDITING':
return { statusText: '分销审核未通过', statusClass: 'tag-fail', group: 'FAIL' }
case 'ERROR':
return '提现失败'
return { statusText: '提现失败', statusClass: 'tag-fail', group: 'FAIL' }
default:
return applyStatus || ''
return { statusText: applyStatus || '处理中', statusClass: 'tag-pending', group: 'PENDING' }
}
}
const decoratedRecords = computed(() =>
records.value.map((item) => ({
...item,
parsed: parseWithdrawStatus(item.applyStatus),
}))
)
const filteredRecords = computed(() => {
const targetKey = filterTabs[activeFilter.value]?.key || 'ALL'
if (targetKey === 'ALL') return decoratedRecords.value
return decoratedRecords.value.filter((item) => item.parsed.group === targetKey)
})
function getData() {
getWithdrawApplyPage(params.value).then((res) => {
loaded.value = true
if (res.data.success && res.data.result.records.length != 0) {
records.value.push(...res.data.result.records)
const list = (res.data?.success && res.data?.result?.records) || []
if (list.length > 0) {
records.value.push(...list)
}
if (list.length < params.value.pageSize) {
hasMore.value = false
}
})
}
@@ -119,7 +240,8 @@ function confirmWechatReceive(item: any) {
const openResultToast = (errMsg?: string) => {
if (errMsg === 'requestMerchantTransfer:ok') {
uni.showToast({ title: '已唤起确认页面', duration: 2000, icon: 'none' })
uni.showToast({ title: '已确认收款', duration: 2000, icon: 'none' })
setTimeout(() => reloadData(), 800)
return
}
if (errMsg === 'requestMerchantTransfer:cancel') {
@@ -175,55 +297,191 @@ function confirmWechatReceive(item: any) {
</script>
<style lang="scss" scoped>
.view-item {
padding: 32rpx;
.withdraw-log-page {
min-height: 100vh;
background: #f5f6fa;
padding-bottom: 48rpx;
box-sizing: border-box;
}
.filter-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16rpx;
padding: 24rpx 24rpx 8rpx;
overflow-x: auto;
}
.view-item-change {
text-align: right;
> .-money {
font-size: 36rpx;
color: $main-color;
font-weight: bold;
}
> .-time {
font-size: 22rpx;
color: #999;
}
> .confirm-btn {
display: inline-block;
margin-top: 12rpx;
padding: 12rpx 20rpx;
font-size: 24rpx;
border-radius: 8rpx;
.filter-pill {
flex-shrink: 0;
padding: 12rpx 28rpx;
border-radius: 999rpx;
font-size: 25rpx;
color: #666666;
background: #ffffff;
font-weight: 500;
transition: all 0.2s;
&.active {
background: $main-color;
color: #fff;
color: #ffffff;
font-weight: 600;
box-shadow: 0 4rpx 12rpx rgba(229, 50, 56, 0.2);
}
}
.view-item-detail {
line-height: 1.75;
.list-container {
padding: 16rpx 24rpx 0;
}
.list-card {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.record-card {
background: #ffffff;
border-radius: 20rpx;
padding: 28rpx 30rpx;
box-shadow: 0 2rpx 12rpx rgba(17, 30, 79, 0.03);
}
.card-row-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.title-wrap {
display: flex;
align-items: center;
gap: 12rpx;
min-width: 0;
flex: 1;
overflow: hidden;
> .-title {
font-size: 28rpx;
}
.status-tag {
flex-shrink: 0;
font-size: 21rpx;
font-weight: 600;
padding: 4rpx 14rpx;
border-radius: 8rpx;
line-height: 1.35;
&.tag-pending {
color: #ff7d00;
background: rgba(255, 125, 0, 0.1);
}
> .-number {
font-size: 22rpx;
color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.tag-action {
color: $main-color;
background: rgba(229, 50, 56, 0.1);
}
&.tag-success {
color: #00a870;
background: rgba(0, 168, 112, 0.1);
}
&.tag-fail {
color: #f53f3f;
background: rgba(245, 63, 63, 0.1);
}
}
.wap {
width: 100%;
height: calc(100vh - 44px);
.card-title {
font-size: 30rpx;
font-weight: 600;
color: #1d2129;
line-height: 1.35;
}
.scroll-v {
height: calc(100vh - 88rpx);
.amount-wrap {
flex-shrink: 0;
display: flex;
align-items: baseline;
color: #1d2129;
}
.amount-symbol {
font-size: 24rpx;
font-weight: 600;
margin-right: 4rpx;
}
.amount-val {
font-size: 36rpx;
font-weight: 700;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.meta-box {
margin-top: 16rpx;
padding: 14rpx 18rpx;
background: #f8f9fb;
border-radius: 12rpx;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.meta-line {
display: flex;
align-items: flex-start;
font-size: 23rpx;
line-height: 1.45;
color: #4e5969;
}
.meta-label {
flex-shrink: 0;
color: #86909c;
}
.meta-val {
flex: 1;
min-width: 0;
color: #4e5969;
word-break: break-all;
&.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
&.remark-text {
color: #f53f3f;
}
}
.card-row-foot {
margin-top: 18rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.apply-time {
font-size: 22rpx;
color: #999999;
line-height: 1.2;
}
.confirm-btn {
flex-shrink: 0;
padding: 10rpx 28rpx;
font-size: 24rpx;
font-weight: 600;
border-radius: 999rpx;
background: linear-gradient(135deg, $main-color 0%, $light-color 100%);
color: #ffffff;
box-shadow: 0 4rpx 12rpx rgba(229, 50, 56, 0.22);
}
.empty-box {
padding-top: 140rpx;
}
</style>

View File

@@ -118,12 +118,23 @@ onMounted(async () => {
withdrawRule.value = res.data.result.withdrawRule || ''
})
let submitting = false
function cashd() {
if (submitting) return
const amount = Number(price.value)
if (!proxy.$u.test.amount(parseInt(String(amount)))) {
if (!(amount > 0) || !proxy.$u.test.amount(parseInt(String(amount)))) {
uni.showToast({ title: '请输入正确金额', duration: 2000, icon: 'none' })
return
}
if (minPrice.value && amount < Number(minPrice.value)) {
uni.showToast({ title: `最低提现金额为 ${minPrice.value} 元`, duration: 2000, icon: 'none' })
return
}
if (amount > Number(walletNum.value || 0)) {
uni.showToast({ title: '提现金额不能大于可提现金额', duration: 2000, icon: 'none' })
return
}
const params: Record<string, any> = { price: amount }
if (type.value === 'ALI') {
@@ -139,14 +150,23 @@ function cashd() {
params.realName = realName.value
}
withdrawalApply(params).then((res) => {
if (res.data.success) {
uni.showToast({ title: '提现成功!', duration: 2000, icon: 'none' })
setTimeout(() => {
uni.navigateBack({ delta: 1 })
}, 1000)
}
})
const doSubmit = () => {
submitting = true
withdrawalApply(params)
.then((res) => {
if (res.data.success) {
uni.showToast({ title: '提现申请已提交', duration: 2000, icon: 'none' })
setTimeout(() => {
uni.navigateBack({ delta: 1 })
}, 1000)
}
})
.finally(() => {
submitting = false
})
}
doSubmit()
}
function handleAll() {

164
pages/mine/experience.vue Normal file
View File

@@ -0,0 +1,164 @@
<template>
<view class="experience-page">
<view class="summary-card">
<text class="summary-label">当前经验值</text><text class="summary-number">{{ summaryLoaded ? formatNumber(experience) : '—' }}</text>
<text class="summary-grade">{{ gradeName || '经验积累,解锁更多等级权益' }}</text>
<text v-if="logError && !summaryLoaded" class="summary-retry" @click="loadLogs(true)">经验值加载失败,点击重试</text>
</view>
<view class="tabs"><view class="tab" :class="{ active: activeTab === 'tasks' }" @click="activeTab = 'tasks'">经验值任务</view><view class="tab" :class="{ active: activeTab === 'logs' }" @click="activeTab = 'logs'">经验值明细</view></view>
<view v-if="activeTab === 'tasks'">
<view class="section-heading"><text>做任务,积累经验</text></view>
<view v-if="taskLoading && !tasksLoaded" class="state">正在加载任务…</view>
<view v-else-if="taskError" class="state"><text>任务加载失败</text><button class="retry" @click="loadTasks">点击重试</button></view>
<template v-else>
<view v-for="item in tasks" :key="item.ruleKey" class="task-card">
<view class="task-main"><view class="task-title-row"><text class="task-title">{{ taskName(item) }}</text><text class="reward">{{ rewardText(item) }}</text></view>
<text class="task-description">{{ taskDescription(item) }}</text>
<text class="task-progress">累计获得 {{ formatNumber(item.gainedExperience) }} 经验<text v-if="hasLimit(item)"> · 上限 {{ formatNumber(item.maxValue) }}</text></text>
</view>
<view class="task-footer"><text class="task-status">{{ statusText(item) }}</text><button class="task-button" :class="{ finished: isFinished(item) }" :disabled="isFinished(item)" @click="doTask(item)">{{ actionText(item) }}</button></view>
</view>
<view v-if="tasksLoaded && !tasks.length" class="state"><u-icon name="list" size="36" color="#bec4ce" /><text>暂无已开启的经验值任务</text></view>
<view v-if="description" class="rules-note"><text class="note-title">经验值说明</text><text>{{ description }}</text></view>
<text class="page-note">完成相应行为并满足规则后,由系统自动发放经验值,无需手动领取。</text>
</template>
</view>
<view v-else>
<view class="section-heading"><text>经验值变动记录</text><text class="section-caption">按时间倒序展示</text></view>
<view v-if="logsLoading && !records.length" class="state">正在加载明细…</view>
<view v-else-if="!records.length && logError" class="state"><text>明细加载失败</text><button class="retry" @click="retryLogs">点击重试</button></view>
<view v-else-if="!records.length" class="state"><u-icon name="list" size="36" color="#bec4ce" /><text>暂无经验值明细</text><text class="state-hint">完成已开启的任务后,可在这里查看</text></view>
<view v-else class="record-list">
<view v-for="item in records" :key="item.id" class="record-row"><view class="record-content"><text class="record-title">{{ item.content || ruleNames[item.ruleKey] || '经验值变动' }}</text><text class="record-time">{{ item.createTime || '—' }}</text><text class="record-balance">变动后经验:{{ formatNumber(item.experience) }}</text></view><text class="record-change" :class="{ negative: Number(item.variableExperience) < 0 }">{{ changeText(item.variableExperience) }}</text></view>
</view>
<view v-if="records.length" class="load-more" @click="retryLogs">{{ logsLoading ? '加载中…' : logError ? '加载失败,点击重试' : hasMore ? '点击或上拉加载更多' : '已显示全部明细' }}</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onShow, onReachBottom, onPullDownRefresh, onUnload } from '@dcloudio/uni-app'
import { getExperienceTasks, getExperienceRecords } from '@/api/members'
import { isLogin, navigateToLogin } from '@/utils/filters'
const activeTab = ref('tasks'), experience = ref(0), gradeName = ref(''), summaryLoaded = ref(false)
const tasks = ref([]), description = ref(''), taskLoading = ref(false), tasksLoaded = ref(false), taskError = ref(false)
const records = ref([]), logsLoading = ref(false), logError = ref(false), hasMore = ref(true)
let ownerId = '', taskRequest = 0, logRequest = 0, nextPage = 1, retryReset = true
const pageSize = 20
const ruleNames = { CONSUME: '购物消费', REGISTER: '注册会员', SIGN_IN: '每日签到', COMMENT: '商品评价', SHARE: '分享商城', PROFILE: '完善个人信息', FOLLOW_STORE: '关注店铺', BIND_WECHAT: '绑定微信', ADD_ADDRESS: '添加收货地址', SHARE_REGISTER: '邀请注册', SHARE_BUY: '邀请购买' }
const onceRules = new Set(['REGISTER', 'PROFILE', 'BIND_WECHAT', 'ADD_ADDRESS'])
const taskDescriptions = { CONSUME: '订单完成后,按实际消费金额向下取整计算经验。', REGISTER: '注册成功后获得一次经验奖励。', SIGN_IN: '每天签到可获得一次经验奖励。', COMMENT: '对已购买商品提交超过30字的评价,满足条件后获得经验。', SHARE: '通过商城的分享入口分享页面,满足条件后获得经验。', PROFILE: '完善个人信息,满足条件后获得一次经验奖励。', FOLLOW_STORE: '关注店铺可获得经验,同一店铺不重复发放。', BIND_WECHAT: '成功绑定微信,满足条件后获得一次经验奖励。', ADD_ADDRESS: '添加收货地址后获得一次经验奖励。', SHARE_REGISTER: '通过分享邀请新用户注册成功后获得经验。', SHARE_BUY: '通过分享邀请用户购买并满足奖励条件后获得经验。' }
function formatNumber(value) { return Math.max(0, Number(value) || 0).toLocaleString('en-US') }
function changeText(value) { const n = Number(value) || 0; return `${n > 0 ? '+' : n < 0 ? '-' : ''}${Math.abs(n).toLocaleString('en-US')}` }
function taskName(item) { return item.ruleName || ruleNames[item.ruleKey] || '经验值任务' }
function taskDescription(item) { return taskDescriptions[item.ruleKey] || '按商城配置的规则完成相应行为后获得经验。' }
function rewardText(item) { return item.ruleKey === 'CONSUME' ? `每1元 +${formatNumber(item.value)}` : `+${formatNumber(item.value)} 经验` }
function hasLimit(item) { return item.ruleKey !== 'CONSUME' && item.maxValue != null && Number(item.maxValue) > 0 }
function reachedLimit(item) { return hasLimit(item) && Number(item.gainedExperience) >= Number(item.maxValue) }
function isFinished(item) { return reachedLimit(item) || (onceRules.has(item.ruleKey) && Boolean(item.completed)) }
function statusText(item) { return reachedLimit(item) ? '已达经验上限' : isFinished(item) ? '已完成' : item.ruleKey === 'SIGN_IN' ? '每日可参与' : onceRules.has(item.ruleKey) ? '仅限一次' : '满足条件可获得' }
function actionText(item) { return isFinished(item) ? '已完成' : item.ruleKey === 'SIGN_IN' ? '去签到' : item.ruleKey === 'PROFILE' ? '去完善' : item.ruleKey === 'ADD_ADDRESS' ? '去添加' : item.ruleKey === 'COMMENT' ? '去评价' : item.ruleKey === 'CONSUME' || item.ruleKey === 'FOLLOW_STORE' || item.ruleKey.startsWith('SHARE') ? '去逛逛' : '查看规则' }
function doTask(item) {
if (isFinished(item)) return
const routes = { SIGN_IN: '/pages/mine/signIn', PROFILE: '/pages/mine/set/personMsg', ADD_ADDRESS: '/pages/mine/address/addressManage', COMMENT: '/pages/order/myOrder?status=4' }
if (routes[item.ruleKey]) uni.navigateTo({ url: routes[item.ruleKey] })
else if (item.ruleKey === 'CONSUME' || item.ruleKey === 'FOLLOW_STORE' || item.ruleKey.startsWith('SHARE')) uni.switchTab({ url: '/pages/tabbar/home/index' })
else uni.showModal({ title: taskName(item), content: taskDescription(item), showCancel: false })
}
function stillOwner(memberId) { return ownerId === memberId && String(isLogin()?.id || '') === memberId }
function reset() {
taskRequest++; logRequest++; ownerId = ''
tasks.value = []; records.value = []; description.value = ''; experience.value = 0; gradeName.value = ''
summaryLoaded.value = false; tasksLoaded.value = false; taskLoading.value = false; logsLoading.value = false
taskError.value = false; logError.value = false; hasMore.value = true; nextPage = 1; retryReset = true
}
async function loadTasks() {
if (!ownerId) return
const memberId = ownerId, id = ++taskRequest
taskLoading.value = true; taskError.value = false
try {
const response = await getExperienceTasks()
if (id !== taskRequest || !stillOwner(memberId)) return
if (!response.data?.success) throw new Error('tasks')
const data = response.data.result || {}
// 服务端 /rules 仅返回开启的任务;兼容接口未来增加 enabled 字段。
tasks.value = (data.items || []).filter(item => item && item.ruleKey && item.enabled !== false)
description.value = data.description || ''; tasksLoaded.value = true
} catch { if (id === taskRequest && stillOwner(memberId)) taskError.value = true }
finally { if (id === taskRequest) taskLoading.value = false }
}
async function loadLogs(refresh = false) {
if (!ownerId || (!refresh && (logsLoading.value || !hasMore.value))) return
const memberId = ownerId, id = ++logRequest, page = refresh ? 1 : nextPage
retryReset = refresh; logsLoading.value = true; logError.value = false
try {
const response = await getExperienceRecords({ pageNumber: page, pageSize })
if (id !== logRequest || !stillOwner(memberId)) return
if (!response.data?.success) throw new Error('logs')
const data = response.data.result || {}, pageData = data.experienceLogs || {}, rows = pageData.records || []
experience.value = data.experience || 0; gradeName.value = data.grade?.gradeName || ''; summaryLoaded.value = true
const previous = refresh ? [] : records.value
const ids = new Set(previous.map(item => String(item.id)))
records.value = previous.concat(rows.filter(item => !ids.has(String(item.id))))
hasMore.value = pageData.total != null ? page * pageSize < Number(pageData.total) : rows.length === pageSize
nextPage = page + 1; retryReset = false
} catch { if (id === logRequest && stillOwner(memberId)) logError.value = true }
finally { if (id === logRequest) logsLoading.value = false }
}
function retryLogs() { if (!logsLoading.value) loadLogs(retryReset) }
async function refresh() {
const memberId = String(isLogin()?.id || '')
if (!memberId) { reset(); uni.stopPullDownRefresh(); navigateToLogin(); return }
if (ownerId !== memberId) { reset(); ownerId = memberId }
await Promise.all([loadTasks(), loadLogs(true)])
uni.stopPullDownRefresh()
}
onLoad(options => { activeTab.value = options?.tab === 'logs' ? 'logs' : 'tasks' })
onShow(refresh)
onPullDownRefresh(refresh)
onReachBottom(() => { if (activeTab.value === 'logs' && !logError.value) loadLogs(false) })
onUnload(() => { taskRequest++; logRequest++ })
</script>
<style scoped lang="scss">
.experience-page { min-height: 100vh; box-sizing: border-box; padding: 24rpx 28rpx calc(36rpx + env(safe-area-inset-bottom)); background: #f5f6f9; color: #292f39; }
.summary-card { background: linear-gradient(115deg, #51443b, #8e7351); border-radius: 24rpx; padding: 32rpx; color: #fff1d8; display: flex; flex-direction: column; align-items: flex-start; }
.summary-label { font-size: 24rpx; opacity: .8; }
.summary-number { font-size: 58rpx; font-weight: 600; line-height: 1.5; }
.summary-grade { margin-top: 4rpx; font-size: 23rpx; opacity: .8; }
.summary-retry { margin-top: 16rpx; font-size: 23rpx; text-decoration: underline; }
.tabs { display: flex; margin: 24rpx 0 32rpx; padding: 8rpx; background: #eceef2; border-radius: 18rpx; }
.tab { flex: 1; padding: 18rpx 0; text-align: center; font-size: 27rpx; color: #858c97; border-radius: 14rpx; }
.tab.active { color: #896337; background: #fff; font-weight: 600; }
.section-heading { display: flex; justify-content: space-between; align-items: center; font-size: 28rpx; font-weight: 600; margin-bottom: 20rpx; }
.section-caption { color: #9ba2ad; font-size: 21rpx; font-weight: 400; }
.task-card { padding: 26rpx; margin-bottom: 20rpx; background: #fff; border-radius: 20rpx; }
.task-title-row { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 10rpx; }
.task-title { font-size: 29rpx; font-weight: 600; }
.reward { font-size: 25rpx; color: #aa7a3c; }
.task-description { display: block; font-size: 24rpx; color: #89919d; margin-top: 14rpx; line-height: 1.7; }
.task-progress { display: block; font-size: 22rpx; color: #a1a8b2; margin-top: 12rpx; }
.task-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 20rpx; padding-top: 18rpx; border-top: 1rpx solid #f2f3f6; }
.task-status { color: #929aa5; font-size: 22rpx; }
.task-button { padding: 0 28rpx; margin: 0; line-height: 58rpx; font-size: 24rpx; border-radius: 32rpx; color: #886135; background: #f5ead8; }
.task-button.finished { background: #f2f3f5; color: #a2a8b0; }
button::after { border: none; }
.state { padding: 80rpx 20rpx; display: flex; flex-direction: column; align-items: center; gap: 20rpx; text-align: center; font-size: 26rpx; color: #949ca8; }
.state-hint { font-size: 23rpx; }
.retry { margin: 0; padding: 0 30rpx; line-height: 64rpx; background: #f4e8d5; color: #927043; font-size: 25rpx; border-radius: 32rpx; }
.rules-note { padding: 24rpx; background: #fff; border-radius: 18rpx; font-size: 24rpx; color: #8b939f; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; }
.rules-note text { display: block; }
.note-title { color: #737d8b; font-weight: 500; margin-bottom: 8rpx; }
.page-note { display: block; margin-top: 24rpx; color: #a0a7b1; font-size: 22rpx; line-height: 1.8; }
.record-list { background: #fff; padding: 0 26rpx; border-radius: 20rpx; }
.record-row { display: flex; align-items: center; gap: 24rpx; padding: 26rpx 0; border-bottom: 1rpx solid #f0f2f5; }
.record-row:last-child { border-bottom: none; }
.record-content { flex: 1; min-width: 0; }
.record-title { display: block; font-size: 27rpx; line-height: 1.6; overflow-wrap: anywhere; }
.record-time, .record-balance { display: block; margin-top: 8rpx; color: #a0a7b1; font-size: 22rpx; }
.record-change { flex-shrink: 0; color: #b28449; font-size: 34rpx; font-weight: 600; }
.record-change.negative { color: #798694; }
.load-more { padding: 30rpx 0; text-align: center; color: #a0a7b1; font-size: 23rpx; }
</style>

View File

@@ -0,0 +1,230 @@
<template>
<view class="benefits-page">
<view v-if="loading && !loaded" class="page-skeleton" aria-label="正在加载会员权益">
<view class="skeleton skeleton-card" /><view class="skeleton skeleton-growth" />
<view class="skeleton-grid"><view v-for="n in 4" :key="n" class="skeleton skeleton-benefit" /></view>
</view>
<view v-else-if="error && !loaded" class="page-state">
<u-icon name="info-circle" size="40" color="#b4bac4" /><text class="state-title">权益加载失败</text>
<text class="state-description">请检查网络后重试</text><button class="retry-button" @click="load">重新加载</button>
</view>
<template v-else-if="loaded">
<view v-if="error" class="refresh-error" @click="load">刷新失败,点击重试</view>
<view v-if="grades.length">
<swiper class="grade-swiper" :current="selectedIndex" :duration="250" :disable-touch="grades.length < 2" @change="onGradeSwipe">
<swiper-item v-for="item in grades" :key="item.grade.id">
<MemberGradeCard :grade="item.grade" :benefit-count="item.benefits?.length || 0" :is-current="sameId(current.gradeId, item.grade.id)" />
</swiper-item>
</swiper>
<view v-if="grades.length > 1" class="swipe-hint"><text>左右滑动查看等级</text><text>{{ selectedIndex + 1 }} / {{ grades.length }}</text></view>
<view class="growth-card">
<view class="section-heading"><text class="section-title">我的成长</text><text class="section-caption">{{ current.grade?.gradeName || '暂无等级' }}</text></view>
<view class="experience-row"><text class="experience-number">{{ number(current.experience) }}</text><text class="experience-label">当前经验值</text></view>
<template v-if="current.nextGrade">
<view class="growth-track"><view class="growth-fill" :style="{ width: progress + '%' }" /></view>
<view class="growth-labels"><text>{{ number(growthBaseline) }}</text><text>{{ number(current.nextGrade.requiredExperience) }}</text></view>
<text v-if="Number(current.remainingExperience) > 0" class="growth-description">再获得 <text class="accent">{{ number(current.remainingExperience) }}</text> 经验值,即可升级为{{ current.nextGrade.gradeName }}</text>
<text v-else class="growth-description">已达到{{ current.nextGrade.gradeName }}的经验门槛,等级以系统更新结果为准</text>
</template>
<view v-else-if="current.highestGrade" class="highest-grade"><u-icon name="checkmark-circle" size="16" color="#a47a3b" /><text>已达到最高等级,尽享专属权益</text></view>
<text v-else class="growth-description">暂无升级信息</text>
<view class="growth-actions">
<view class="growth-action" @click="openExperience('tasks')"><u-icon name="level" size="17" color="#9a7545" /><text>经验值任务</text><u-icon name="arrow-right" size="11" color="#b4bac4" /></view>
<view class="growth-action" @click="openExperience('logs')"><u-icon name="list" size="17" color="#9a7545" /><text>经验值明细</text><u-icon name="arrow-right" size="11" color="#b4bac4" /></view>
</view>
</view>
<view class="benefits-section">
<view class="section-heading"><text class="section-title">专属权益 <text class="benefit-count">{{ selected.benefits?.length || 0 }}项</text></text><text class="section-caption">点击查看详情</text></view>
<view v-if="selected.benefits?.length" class="benefit-grid">
<view v-for="benefit in selected.benefits" :key="benefit.id" class="benefit-card" @click="detail = benefit">
<view class="benefit-topline"><view class="benefit-icon-wrap">
<image v-if="benefit.benefitLogo && !failedBenefitIcons[benefit.id]" class="benefit-icon" :src="benefit.benefitLogo" mode="aspectFit" @error="failedBenefitIcons[benefit.id] = true" />
<u-icon v-else name="gift" size="28" color="#ac844e" />
</view><u-icon name="arrow-right" size="12" color="#c4c8ce" /></view>
<text class="benefit-name">{{ benefit.benefitName }}</text><text class="benefit-summary">{{ summary(benefit) }}</text>
<text class="benefit-state" :class="{ owned: owned(benefit) }">{{ benefitState(benefit) }}</text>
</view>
</view>
<view v-else class="empty-benefits"><u-icon name="gift" size="36" color="#c1c6ce" /><text>该等级暂未配置权益</text><text class="state-description">可以切换其他等级查看</text></view>
</view>
<view class="page-notice"><text>权益说明</text><text>各等级权益独立配置,以当前等级实际享有的权益为准。具体适用条件与发放规则请查看权益详情。</text></view>
</view>
<view v-else class="page-state"><u-icon name="gift" size="40" color="#b4bac4" /><text class="state-title">暂无会员等级</text><text class="state-description">等级权益配置后将在这里展示</text></view>
</template>
<view v-if="detail" class="detail-overlay">
<view class="detail-backdrop" @click="detail = null" @touchmove.stop.prevent />
<view class="detail-sheet">
<view class="sheet-handle" /><view class="detail-header"><text>权益详情</text><view class="close-icon" aria-label="关闭权益详情" @click="detail = null"><u-icon name="close" size="20" color="#707783" /></view></view>
<scroll-view scroll-y class="detail-scroll">
<view class="detail-content">
<view class="detail-identity">
<image v-if="detail.benefitLogo && !failedBenefitIcons[detail.id]" class="detail-icon" :src="detail.benefitLogo" mode="aspectFit" @error="failedBenefitIcons[detail.id] = true" />
<u-icon v-else name="gift" size="36" color="#ac844e" /><text class="detail-title">{{ detail.benefitName }}</text>
<text class="benefit-state" :class="{ owned: owned(detail) }">{{ benefitState(detail) }}</text>
</view>
<view class="detail-rule"><text class="rule-label">权益规则</text><text>{{ rule(detail) }}</text></view>
<text class="description-title">使用说明</text><rich-text v-if="detail.benefitDesc" class="rich-description" :nodes="detailDescription" />
<text v-else class="state-description">暂无详细说明</text>
</view>
</scroll-view>
<view class="sheet-footer"><button class="confirm-button" @click="detail = null">我知道了</button></view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onShow, onPullDownRefresh, onUnload } from '@dcloudio/uni-app'
import { getCurrentMemberBenefits, getMemberGradeBenefits } from '@/api/members'
import { isLogin, navigateToLogin } from '@/utils/filters'
import MemberGradeCard from './components/MemberGradeCard.vue'
const loading = ref(false), loaded = ref(false), error = ref(false)
const current = ref({}), grades = ref([]), selectedId = ref(''), detail = ref(null)
const failedBenefitIcons = ref({})
let requestId = 0, ownerId = ''
const sameId = (a, b) => a != null && b != null && String(a) === String(b)
const selected = computed(() => grades.value.find(item => sameId(item.grade.id, selectedId.value)) || {})
const selectedIndex = computed(() => Math.max(0, grades.value.findIndex(item => sameId(item.grade.id, selectedId.value))))
const progress = computed(() => Math.max(0, Math.min(100, Number(current.value.upgradeProgress) || 0)))
const growthBaseline = computed(() => current.value.grade?.isDefault ? 0 : current.value.grade?.requiredExperience || 0)
const owned = benefit => (current.value.benefits || []).some(item => sameId(item.id, benefit.id))
function number(value) { return Math.max(0, Number(value) || 0).toLocaleString('en-US') }
function config(benefit) {
try { const value = typeof benefit.benefitConfig === 'string' ? JSON.parse(benefit.benefitConfig) : benefit.benefitConfig; return value && typeof value === 'object' ? value : {} } catch { return {} }
}
function summary(benefit) {
const value = config(benefit)
switch (benefit.benefitType) {
case 'POINT_MULTIPLIER': return `购物积分按 ${value.multiplier || 1} 倍计算`
case 'GIFT_POINT': return `首次达到等级赠送 ${number(value.giftPoint)} 积分`
case 'COUPON_PACKAGE': return value.grantRule === 'MONTHLY' ? `每月 ${value.grantDay || 1} 号发放优惠券` : value.grantRule === 'PERIOD' ? `每 ${value.periodMonths || 1} 个月发放优惠券` : '首次达到等级赠送优惠券'
case 'GRADE_BADGE': return '展示你的专属等级身份'
case 'EXCLUSIVE_CUSTOMER_SERVICE': return '享受会员专属客服服务'
default: return '查看权益介绍与适用说明'
}
}
function rule(benefit) {
const text = summary(benefit)
switch (benefit.benefitType) {
case 'POINT_MULTIPLIER': return `${text},同类权益取最高倍率,不叠加。`
case 'GIFT_POINT': return `${text},同一等级的该权益仅赠送一次。`
case 'COUPON_PACKAGE': return `${text}。每月不存在指定日期时取当月最后一天;按周期发放时首期立即发放,暂停期间未生成的历史周期不追发。具体券种和数量以实际发放为准。`
case 'CUSTOM': return '适用条件、参与方式及服务内容以以下权益介绍为准。'
default: return `${text},具体服务内容请查看以下说明。`
}
}
function benefitState(benefit) {
if (owned(benefit)) return '已享有'
return Number(selected.value.grade?.requiredExperience) > Number(current.value.grade?.requiredExperience || 0) ? '达到该等级可享' : '当前等级未包含'
}
const detailDescription = computed(() => String(detail.value?.benefitDesc || '').replace(/<img\b[^>]*>/gi, tag =>
tag.replace(/\sstyle\s*=\s*(?:"[^"]*"|'[^']*')/gi, '').replace(/<img\b/i, '<img style="max-width:100%;height:auto;display:block;"')))
function selectGrade(id) { selectedId.value = id; detail.value = null }
function openExperience(tab) { uni.navigateTo({ url: `/pages/mine/experience?tab=${tab}` }) }
function onGradeSwipe(event) {
// 初始化和刷新引发的程序切换无需回写,避免旧事件覆盖最新选择。
if (event.detail?.source !== 'touch') return
const item = grades.value[event.detail.current]
if (item) selectGrade(item.grade.id)
}
function resetMembership() {
requestId++; ownerId = ''; loaded.value = false; loading.value = false; error.value = false
current.value = {}; grades.value = []; selectedId.value = ''; detail.value = null; failedBenefitIcons.value = {}
}
async function load() {
const memberId = String(isLogin()?.id || '')
if (!memberId) { resetMembership(); navigateToLogin(); uni.stopPullDownRefresh(); return }
if (ownerId !== memberId) {
ownerId = memberId; loaded.value = false
current.value = {}; grades.value = []; selectedId.value = ''; detail.value = null; failedBenefitIcons.value = {}
}
const id = ++requestId
const stillCurrent = () => id === requestId && String(isLogin()?.id || '') === memberId
loading.value = true; error.value = false
try {
const [a, b] = await Promise.all([getCurrentMemberBenefits(), getMemberGradeBenefits()])
if (!stillCurrent()) return
if (!a.data?.success || !b.data?.success) throw new Error('load')
current.value = a.data.result || {}
grades.value = (b.data.result || []).filter(item => item.grade?.gradeState === 'OPEN')
const requested = selectedId.value || current.value.gradeId
selectedId.value = grades.value.find(item => sameId(item.grade.id, requested))?.grade.id || grades.value[0]?.grade.id || ''
detail.value = null; failedBenefitIcons.value = {}; loaded.value = true
} catch { if (stillCurrent()) error.value = true }
finally { if (id === requestId) { loading.value = false; uni.stopPullDownRefresh() } }
}
onShow(load)
onPullDownRefresh(load)
onUnload(() => { requestId++ })
</script>
<style scoped lang="scss">
.benefits-page { min-height: 100vh; box-sizing: border-box; background: #f5f6f9; padding: 24rpx 28rpx calc(36rpx + env(safe-area-inset-bottom)); color: #252b35; }
.grade-swiper { width: 100%; height: 299rpx; border-radius: 24rpx; overflow: hidden; }
.swipe-hint { display: flex; justify-content: space-between; margin-top: 14rpx; color: #a0a5af; font-size: 21rpx; }
.growth-card { margin-top: 24rpx; padding: 28rpx; background: #fff; border-radius: 20rpx; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
.section-title { font-size: 30rpx; font-weight: 600; }
.section-caption { font-size: 22rpx; color: #959ba5; }
.experience-row { display: flex; align-items: baseline; gap: 12rpx; margin-top: 20rpx; }
.experience-number { font-size: 42rpx; font-weight: 600; }
.experience-label { font-size: 22rpx; color: #959ba5; }
.growth-track { margin-top: 20rpx; height: 10rpx; overflow: hidden; background: #f2eee8; border-radius: 8rpx; }
.growth-fill { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #dfc59b, #ae8245); transition: width .25s; }
.growth-labels { display: flex; justify-content: space-between; font-size: 20rpx; color: #a0a5af; margin-top: 8rpx; }
.growth-description { display: block; margin-top: 16rpx; color: #777e88; font-size: 24rpx; line-height: 1.7; }
.accent { color: #a47a3b; font-weight: 600; }
.highest-grade { display: flex; align-items: center; gap: 10rpx; margin-top: 18rpx; color: #987343; font-size: 24rpx; }
.growth-actions { display: flex; margin-top: 24rpx; padding-top: 22rpx; border-top: 1rpx solid #f0f1f4; }
.growth-action { flex: 1; display: flex; justify-content: center; align-items: center; gap: 10rpx; padding: 8rpx 0; font-size: 24rpx; color: #777e88; }
.growth-action + .growth-action { border-left: 1rpx solid #eceef2; }
.benefits-section { margin-top: 36rpx; }
.benefit-count { color: #9a7b50; font-size: 24rpx; font-weight: 400; margin-left: 10rpx; }
.benefit-grid { margin-top: 20rpx; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20rpx; }
.benefit-card { background: #fff; padding: 24rpx; border-radius: 20rpx; display: flex; flex-direction: column; align-items: flex-start; }
.benefit-card:active { background: #fcfaf6; }
.benefit-topline { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.benefit-icon-wrap { width: 84rpx; height: 84rpx; background: #faf6ee; border-radius: 22rpx; display: flex; align-items: center; justify-content: center; }
.benefit-icon { width: 64rpx; height: 64rpx; }
.benefit-name { font-size: 27rpx; font-weight: 600; line-height: 1.5; margin-top: 18rpx; overflow-wrap: anywhere; }
.benefit-summary { margin: 8rpx 0 20rpx; color: #9298a2; font-size: 22rpx; line-height: 1.6; flex: 1; }
.benefit-state { display: inline-block; border-radius: 8rpx; padding: 5rpx 10rpx; background: #f4f5f7; color: #9298a2; font-size: 20rpx; line-height: 1.5; }
.benefit-state.owned { background: #f6eedf; color: #97713c; }
.empty-benefits { margin-top: 20rpx; padding: 48rpx 20rpx; background: #fff; border-radius: 20rpx; display: flex; align-items: center; flex-direction: column; gap: 16rpx; font-size: 26rpx; color: #777e88; }
.page-notice { margin-top: 32rpx; color: #a0a5ae; font-size: 22rpx; line-height: 1.8; }
.page-notice text { display: block; }
.page-notice text:first-child { margin-bottom: 8rpx; color: #858c97; font-weight: 500; }
.page-state { min-height: 65vh; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 22rpx; }
.state-title { font-size: 30rpx; color: #727985; }
.state-description { font-size: 24rpx; color: #a0a6af; }
.retry-button { font-size: 26rpx; color: #8d6836; background: #f1e6d4; padding: 0 36rpx; margin-top: 10rpx; border-radius: 40rpx; }
button::after { border: none; }
.refresh-error { padding: 16rpx; margin-bottom: 20rpx; background: #fff3df; color: #a27a42; font-size: 24rpx; border-radius: 12rpx; text-align: center; }
.skeleton { background: #e8ebef; border-radius: 20rpx; }
.skeleton-card { height: 299rpx; }
.skeleton-growth { height: 160rpx; margin: 24rpx 0; }
.skeleton-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20rpx; }
.skeleton-benefit { height: 270rpx; }
.detail-overlay { position: fixed; inset: 0; z-index: 100; display: flex; align-items: flex-end; }
.detail-backdrop { position: absolute; inset: 0; background: rgba(20, 25, 34, .45); }
.detail-sheet { position: relative; width: 100%; background: #fff; border-radius: 32rpx 32rpx 0 0; overflow: hidden; }
.sheet-handle { width: 64rpx; height: 7rpx; margin: 16rpx auto 8rpx; background: #e1e4e9; border-radius: 8rpx; }
.detail-header { display: flex; justify-content: space-between; align-items: center; padding: 8rpx 20rpx 16rpx 32rpx; font-size: 28rpx; font-weight: 500; }
.close-icon { padding: 16rpx; }
.detail-scroll { height: 48vh; }
.detail-content { padding: 8rpx 32rpx 32rpx; }
.detail-identity { display: flex; flex-direction: column; align-items: center; gap: 16rpx; padding: 12rpx 0 28rpx; }
.detail-icon { width: 96rpx; height: 96rpx; }
.detail-title { font-size: 34rpx; font-weight: 600; text-align: center; }
.detail-rule { background: #faf6ef; border-radius: 16rpx; padding: 24rpx; font-size: 25rpx; color: #987445; line-height: 1.8; }
.detail-rule text { display: block; }
.rule-label { font-weight: 600; margin-bottom: 8rpx; }
.description-title { display: block; font-size: 28rpx; font-weight: 600; margin: 28rpx 0 20rpx; }
.rich-description { display: block; font-size: 26rpx; line-height: 1.8; color: #737a85; overflow-wrap: anywhere; }
.sheet-footer { padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid #f2f3f5; }
.confirm-button { background: #f2e5cf; color: #805d2d; font-size: 28rpx; border-radius: 40rpx; line-height: 80rpx; }
</style>

79
pages/mine/paidMember.vue Normal file
View File

@@ -0,0 +1,79 @@
<template>
<view class="paid-page">
<view v-if="loading && !data.config" class="empty">加载中…</view>
<view v-else-if="error && !data.config" class="empty" @click="load">加载失败,点击重试</view>
<template v-else-if="data.config">
<view v-if="error" class="poll-message" @click="load">刷新失败,点击重试</view>
<view class="member-card" :style="cardStyle">
<image v-if="data.config.backgroundType === 'IMAGE' && data.config.backgroundImage" class="card-bg" :src="data.config.backgroundImage" mode="aspectFill" />
<view class="card-content"><view class="card-title"><image v-if="data.config.icon" class="member-icon" :src="data.config.icon" mode="aspectFit" /><text>{{ data.config.name }}</text></view><text class="member-status">{{ data.active ? '会员有效期至 ' + data.endTime : data.status === 'EXPIRED' ? '会员已到期' : '开通会员,享专属权益' }}</text><text v-if="data.status !== 'UNOPENED'" class="saved">累计已优惠 ¥{{ money(data.savedAmount) }}</text></view>
</view>
<view v-if="pollMessage" class="poll-message" @click="checkPayment">{{ pollMessage }}</view>
<view class="purchase-card"><view><text class="price">¥{{ money(data.config.price) }}</text><text class="period"> / {{ data.config.months }}个月</text></view><text class="hint">{{ data.active ? '续费将从原到期时间顺延,续费权益在新周期开始后生效' : '支付成功后开通,按自然月计算有效期' }}</text></view>
<view class="section"><view class="section-heading"><text>专属权益</text><text class="record-link" @click="goRecords">购买记录 ›</text></view>
<view class="rights-grid"><view v-for="b in displayedBenefits" :key="b.id" class="right-item" @click="detail = b"><image v-if="b.benefitLogo" :src="b.benefitLogo" mode="aspectFit" /><u-icon v-else name="gift" size="28" color="#ae8245" /><text>{{ b.benefitName }}</text></view></view><text v-if="!displayedBenefits.length" class="hint">{{ data.active ? '当前暂无关联权益' : '开通后按会员配置享有权益' }}</text>
<view class="discount-note"><text>会员专享商品折扣</text><text>优惠由平台提供,限适用商家及商品。实际优惠以结算页为准。</text></view>
</view>
<view v-if="data.recommendedGoods?.length" class="section"><view class="section-heading">为你推荐</view><view class="goods-grid"><view v-for="g in data.recommendedGoods" :key="g.id" class="goods-card" @click="goGoods(g)"><image class="goods-cover" :src="g.thumbnail" mode="aspectFit" /><text class="goods-name">{{ g.goodsName }}</text><text class="goods-price">¥{{ money(g.price) }}</text><view v-if="quotes[g.id]" class="member-price"><image v-if="quotes[g.id].label" class="member-price-label" :src="quotes[g.id].label" mode="aspectFit" /><text>会员价 ¥{{ money(quotes[g.id].price) }}</text></view></view></view></view>
<view v-if="data.config.description" class="section"><view class="section-heading">购买说明</view><rich-text :nodes="data.config.description" /></view>
<view class="purchase-footer"><view class="agreement-row" @click="agreed = !agreed"><u-icon :name="agreed ? 'checkmark-circle-fill' : 'checkmark-circle'" size="17" color="#a78149" /><text>我已阅读并同意</text><text class="agreement-link" @click.stop="showAgreement = true">《付费会员协议》</text></view><button :disabled="!data.config.enabled || buying || !agreed" :loading="buying" class="buy-button" @click="buy">{{ !data.config.enabled ? '暂未开放购买和续费' : data.active ? '立即续费' : '立即开通' }}</button></view>
</template>
<view v-if="detail || showAgreement" class="overlay"><view class="mask" @click="closeDetail" @touchmove.stop.prevent /><view class="sheet"><text class="sheet-title">{{ detail?.benefitName || '付费会员协议' }}</text><scroll-view scroll-y class="sheet-scroll"><rich-text :nodes="detail ? detail.benefitDesc || '暂无说明' : data.config?.agreement || '请联系平台查看会员协议'" /></scroll-view><button class="close-button" @click="closeDetail">关闭</button></view></view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad, onShow, onHide, onUnload, onPullDownRefresh } from '@dcloudio/uni-app'
import { getPaidMember, createPaidMemberOrder, getPaidMemberOrder, getPaidMemberQuotes } from '@/api/paid-member'
import { isLogin, navigateToLogin } from '@/utils/filters'
const data=ref({}), quotes=ref({}), loading=ref(false), error=ref(false), agreed=ref(false), buying=ref(false), detail=ref(null), showAgreement=ref(false), pollMessage=ref('')
let request=0, owner='', purchaseRequest='', paymentSn='', timer=null, pollId=0, pollCount=0
const money=value=>(Number(value)||0).toFixed(2)
const cardStyle=computed(()=>({backgroundColor:data.value.config?.backgroundColor || '#51443b',color:data.value.config?.fontColor || '#fff1d8'}))
const displayedBenefits=computed(()=>data.value.active ? data.value.benefits || [] : data.value.offeredBenefits || [])
function closeDetail(){detail.value=null;showAgreement.value=false}
function goRecords(){uni.navigateTo({url:'/pages/mine/paidMemberRecords'})}
function goGoods(g){uni.navigateTo({url:`/pages/product/goods?id=${g.id}&goodsId=${g.goodsId}`})}
async function load(){
const memberId=String(isLogin()?.id || '')
if(!memberId){request++;stopPolling();owner='';data.value={};quotes.value={};loading.value=false;agreed.value=false;purchaseRequest='';paymentSn='';pollMessage.value='';navigateToLogin();uni.stopPullDownRefresh();return}
if(owner!==memberId){if(owner){stopPolling();paymentSn='';pollMessage.value=''}owner=memberId;data.value={};quotes.value={};agreed.value=false;purchaseRequest='';detail.value=null}
const id=++request;loading.value=true;error.value=false
try{
const res=await getPaidMember()
if(id!==request || String(isLogin()?.id||'')!==memberId)return
if(!res.data?.success)throw new Error('load')
if(data.value.configVersion !== res.data.result?.configVersion) agreed.value=false
data.value=res.data.result||{};quotes.value={}
const ids=(data.value.recommendedGoods||[]).map(g=>g.id)
const results=await Promise.all(Array.from({length:Math.ceil(ids.length/100)},(_,i)=>getPaidMemberQuotes(ids.slice(i*100,i*100+100))))
if(id===request && String(isLogin()?.id||'')===memberId) quotes.value=Object.assign({},...results.filter(q=>q.data?.success).map(q=>q.data.result||{}))
}catch{if(id===request)error.value=true}finally{if(id===request)loading.value=false;uni.stopPullDownRefresh()}
}
async function buy(){
if(buying.value || !agreed.value || !data.value.config?.enabled)return
buying.value=true
const memberId=owner
purchaseRequest=purchaseRequest || 'pm_'+Date.now().toString(36)+'_'+Math.random().toString(36).slice(2)
try{
const res=await createPaidMemberOrder({requestId:purchaseRequest,configVersion:data.value.configVersion,agreed:true})
if(String(isLogin()?.id||'')!==memberId)return
if(res.data?.success){const o=res.data.result;purchaseRequest='';if(o.status==='PAID')uni.redirectTo({url:'/pages/mine/paidMember?payment_sn='+encodeURIComponent(o.sn)});else uni.navigateTo({url:`/pages/cart/payment/payOrder?paid_member_sn=${o.sn}`})}
else{purchaseRequest='';agreed.value=false;await load()}
}catch{uni.showToast({title:'购买结果暂未确认,请重试或查看购买记录',icon:'none'})}finally{buying.value=false}
}
function stopPolling(){pollId++;if(timer)clearTimeout(timer);timer=null}
async function checkPayment(){
if(!paymentSn)return
const id=++pollId;pollMessage.value='正在确认支付结果…'
try{const res=await getPaidMemberOrder(paymentSn);if(id!==pollId)return;if(res.data?.success && res.data.result.status==='PAID'){paymentSn='';pollMessage.value='支付已确认,会员资格已更新';await load();return}pollCount++;if(pollCount<10){timer=setTimeout(checkPayment,1500)}else pollMessage.value='支付结果待确认,点击刷新或查看购买记录'}catch{if(id===pollId)pollMessage.value='支付结果待确认,点击重试'}
}
onLoad(options=>{paymentSn=options?.payment_sn||''})
onShow(async()=>{await load();if(paymentSn){pollCount=0;checkPayment()}})
onPullDownRefresh(load)
onHide(stopPolling)
onUnload(()=>{request++;stopPolling()})
</script>
<style scoped lang="scss">
.paid-page{min-height:100vh;box-sizing:border-box;background:#f5f6f9;padding:28rpx 28rpx 240rpx;color:#292f39}.member-card{position:relative;overflow:hidden;border-radius:24rpx;min-height:240rpx}.card-bg{position:absolute;inset:0;width:100%;height:100%}.card-content{position:relative;padding:36rpx}.card-title{display:flex;align-items:center;gap:16rpx;font-size:38rpx;font-weight:600}.member-icon{width:70rpx;height:70rpx}.member-status,.saved{display:block;margin-top:18rpx;font-size:24rpx}.saved{opacity:.8}.purchase-card,.section{margin-top:24rpx;background:#fff;padding:28rpx;border-radius:20rpx}.price{font-size:48rpx;font-weight:600;color:#a67b3d}.period{font-size:25rpx;color:#929aa6}.hint{display:block;margin-top:14rpx;font-size:23rpx;line-height:1.7;color:#929aa6}.section-heading{display:flex;align-items:center;justify-content:space-between;font-size:30rpx;font-weight:600;margin-bottom:24rpx}.record-link{font-size:23rpx;font-weight:400;color:#a78149}.rights-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:28rpx 16rpx}.right-item{display:flex;flex-direction:column;align-items:center;gap:12rpx;text-align:center;font-size:24rpx}.right-item image{width:72rpx;height:72rpx}.discount-note{margin-top:24rpx;padding:20rpx;background:#fbf6ed;border-radius:14rpx;font-size:23rpx;color:#a78149;line-height:1.8}.discount-note text{display:block}.goods-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24rpx}.goods-cover{width:100%;height:230rpx}.goods-name{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-size:24rpx;line-height:1.6}.goods-price,.member-price{display:block;margin-top:8rpx;font-size:25rpx}.member-price{display:flex;align-items:center;gap:6rpx;color:#9c7139;font-size:22rpx}.member-price-label{width:28rpx;height:28rpx;flex-shrink:0}.purchase-footer{position:fixed;left:0;right:0;bottom:0;background:#fff;padding:22rpx 28rpx calc(22rpx + env(safe-area-inset-bottom));border-top:1rpx solid #eee;z-index:8}.agreement-row{display:flex;justify-content:center;align-items:center;gap:8rpx;font-size:22rpx;color:#929aa6;margin-bottom:18rpx}.agreement-link{color:#a78149}.buy-button,.close-button{background:#ead5b1;color:#705125;border-radius:44rpx;font-size:28rpx;line-height:84rpx}button::after{border:none}.empty{padding:160rpx 20rpx;text-align:center;color:#969da8;font-size:27rpx}.poll-message{padding:20rpx;background:#fff2d9;border-radius:12rpx;margin-top:20rpx;color:#9d753e;font-size:24rpx}.overlay{position:fixed;inset:0;z-index:20;display:flex;align-items:flex-end}.mask{position:absolute;inset:0;background:rgba(0,0,0,.4)}.sheet{position:relative;width:100%;background:#fff;border-radius:28rpx 28rpx 0 0;padding:30rpx;box-sizing:border-box;padding-bottom:calc(30rpx + env(safe-area-inset-bottom))}.sheet-title{display:block;text-align:center;font-size:32rpx;font-weight:600;margin-bottom:24rpx}.sheet-scroll{height:50vh;margin-bottom:24rpx;font-size:26rpx;line-height:1.8}
</style>

View File

@@ -0,0 +1,36 @@
<template>
<view class="records-page">
<view v-for="order in rows" :key="order.id" class="record-card">
<view class="heading"><text>{{ memberName(order) }}</text><text class="status">{{ status(order) }}</text></view>
<text class="amount">¥{{ Number(order.amount).toFixed(2) }} <text class="months">/ {{ order.months }}个月</text></text>
<text class="line">订单号:{{ order.sn }}</text><text class="line">创建时间:{{ order.createTime }}</text>
<template v-if="order.status === 'PAID'"><text class="line">支付时间:{{ order.payTime }}</text><text class="line">有效期:{{ order.startTime }} 至 {{ order.endTime }}</text></template>
<view v-if="canPay(order)" class="actions"><button class="cancel" @click="cancel(order)">取消订单</button><button class="pay" @click="pay(order)">继续支付</button></view>
</view>
<view v-if="!rows.length" class="empty">{{ loading ? '加载中…' : error ? '加载失败,点击重试' : '暂无购买记录' }}</view>
<view v-if="error" class="load-more" @click="load(retryReset)">点击重新加载</view><view v-else-if="rows.length" class="load-more" @click="load(false)">{{ loading ? '加载中…' : more ? '上拉或点击加载更多' : '已显示全部记录' }}</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onShow, onReachBottom, onPullDownRefresh, onUnload } from '@dcloudio/uni-app'
import { getPaidMemberOrders, cancelPaidMemberOrder } from '@/api/paid-member'
import { isLogin, navigateToLogin } from '@/utils/filters'
const rows=ref([]),loading=ref(false),error=ref(false),more=ref(true)
let page=1,owner='',request=0,retryReset=true
const time=value=>new Date(String(value||'').replace(/-/g,'/')).getTime()
function memberName(order){try{return JSON.parse(order.configSnapshot).name}catch{return '付费会员'}}
function canPay(order){return order.status==='UNPAID' && time(order.payDeadline)>Date.now()}
function status(order){return order.status==='PAID' ? time(order.startTime)>Date.now() ? '待生效' : time(order.endTime)<=Date.now() ? '已到期' : '会员有效' : order.status==='CANCELLED' ? '已取消' : canPay(order) ? '待支付' : '已超时'}
function pay(order){uni.navigateTo({url:`/pages/cart/payment/payOrder?paid_member_sn=${order.sn}`})}
function cancel(order){uni.showModal({title:'取消会员订单',content:'确定取消这笔未支付订单?',success:async result=>{if(result.confirm){const res=await cancelPaidMemberOrder(order.sn);if(res.data?.success)load(true)}}})}
async function load(reset=false){
const memberId=String(isLogin()?.id||'');if(!memberId){request++;rows.value=[];owner='';navigateToLogin();uni.stopPullDownRefresh();return}
if(!reset && (loading.value || !more.value))return
if(owner!==memberId){owner=memberId;rows.value=[];reset=true}
const id=++request,next=reset?1:page;loading.value=true;error.value=false;retryReset=reset
try{const res=await getPaidMemberOrders({pageNumber:next,pageSize:20});if(id!==request||String(isLogin()?.id||'')!==memberId)return;if(!res.data?.success)throw new Error('load');const data=res.data.result||{};rows.value=reset ? data.records||[] : [...rows.value,...(data.records||[]).filter(row=>!rows.value.some(old=>old.id===row.id))];page=next+1;more.value=next*20<Number(data.total||0);retryReset=false}catch{if(id===request)error.value=true}finally{if(id===request)loading.value=false;uni.stopPullDownRefresh()}
}
onShow(()=>load(true));onPullDownRefresh(()=>load(true));onReachBottom(()=>{if(!error.value)load(false)});onUnload(()=>request++)
</script>
<style scoped>.records-page{min-height:100vh;box-sizing:border-box;padding:24rpx 28rpx calc(30rpx + env(safe-area-inset-bottom));background:#f5f6f9}.record-card{padding:28rpx;background:#fff;border-radius:20rpx;margin-bottom:20rpx}.heading{display:flex;align-items:center;justify-content:space-between;font-size:29rpx;font-weight:600}.status{font-size:23rpx;color:#a78149;font-weight:400}.amount{display:block;font-size:40rpx;margin:20rpx 0;color:#9d753e}.months{font-size:23rpx;color:#989faa}.line{display:block;font-size:22rpx;line-height:1.9;color:#929aa6;overflow-wrap:anywhere}.actions{display:flex;justify-content:flex-end;gap:16rpx;margin-top:22rpx}.actions button{margin:0;padding:0 26rpx;font-size:23rpx;line-height:58rpx;border-radius:30rpx}.actions button::after{border:none}.pay{background:#f0dfc2;color:#8d6836}.cancel{background:#f2f3f5;color:#929aa6}.empty,.load-more{padding:40rpx 0;text-align:center;font-size:25rpx;color:#a0a7b1}</style>

View File

@@ -1,5 +1,13 @@
<template>
<view :style="themeStyle" class="content">
<u-navbar
title="我的积分"
:fixed="true"
:placeholder="true"
:border="false"
:auto-back="false"
@leftClick="handleBack"
></u-navbar>
<view class="portrait-box">
<image src="/pages/mine/static/point_bg_1.png" mode=""></image>
<image class="point-img" src="/pages/mine/static/tradehall.png" />
@@ -41,6 +49,10 @@ import { onLoad, onReachBottom } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { getPointsData, getMemberPointSum } from '@/api/members.js'
import { getThemeStyle } from '@/utils/theme'
import {
prefetchMpMessageTemplates,
subscribeMpMessageByScenes,
} from '@/js_sdk/lili-pay/wx-pay.js'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
@@ -51,10 +63,29 @@ const params = ref({ pageNumber: 1, pageSize: 10 })
const pointSummary = ref<Record<string, any>>({})
onLoad(() => {
// #ifdef MP-WEIXIN
prefetchMpMessageTemplates()
// #endif
fetchPointSummary()
fetchPointLogList()
})
function handleBack() {
const doBack = () => {
uni.navigateBack({
fail: () => {
uni.switchTab({ url: '/pages/tabbar/user/my' })
},
})
}
// #ifdef MP-WEIXIN
subscribeMpMessageByScenes(['POINT_CHANGE'], doBack)
// #endif
// #ifndef MP-WEIXIN
doBack()
// #endif
}
onReachBottom(() => {
params.value.pageNumber++
fetchPointLogList()

View File

@@ -0,0 +1,320 @@
<template>
<view :style="themeStyle" class="notice-page">
<u-navbar title="消息通知" :auto-back="true" :placeholder="true" :border="false"></u-navbar>
<view class="content">
<view class="tip-card">
<u-icon name="volume" color="#ff7a00" size="18"></u-icon>
<text class="tip-text">开启通知后,可第一时间接收订单发货、售后审核及资金变动提醒。</text>
</view>
<!-- 微信公众号通知设置 -->
<view v-if="oaSetting.enabled" class="card">
<view class="card-head">
<view class="card-title-wrap">
<view class="icon-badge oa-badge">
<u-icon name="weixin-fill" color="#fff" size="20"></u-icon>
</view>
<view class="title-box">
<view class="title-row">
<text class="card-title">{{ oaSetting.accountName || '微信公众号通知' }}</text>
<text class="status-tag" :class="oaSetting.bound ? 'status-open' : 'status-close'">
{{ oaSetting.bound ? '已开启' : '未开启' }}
</text>
</view>
<text class="card-desc">关注官方公众号,长期接收服务与订单通知</text>
</view>
</view>
</view>
<view class="card-body">
<view class="notice-points">
<view class="point-item">
<text class="dot">•</text>
<text class="point-text">订单发货、完成及拼团进度实时推送</text>
</view>
<view class="point-item">
<text class="dot">•</text>
<text class="point-text">售后申请审核结果、商家退款提醒</text>
</view>
<view class="point-item">
<text class="dot">•</text>
<text class="point-text">余额变动、提现审核与到账结果通知</text>
</view>
</view>
</view>
<view class="card-foot">
<view
class="action-btn"
:class="oaSetting.bound ? 'btn-bound' : 'btn-primary'"
@click="handleOaAction"
>
{{ oaSetting.bound ? '查看公众号文章' : '关注公众号开启通知' }}
</view>
</view>
</view>
<!-- 微信小程序订阅消息说明 -->
<view class="card">
<view class="card-head">
<view class="card-title-wrap">
<view class="icon-badge mp-badge">
<u-icon name="chat-fill" color="#fff" size="20"></u-icon>
</view>
<view class="title-box">
<view class="title-row">
<text class="card-title">小程序订阅消息</text>
<text class="status-tag status-open">已支持</text>
</view>
<text class="card-desc">在支付成功、售后提交、积分与提现等页面主动提示</text>
</view>
</view>
</view>
<view class="card-body">
<text class="mp-intro">
小程序订阅消息支持:订单发货提醒、订单已完成提醒、拼团成功通知、售后申请审核通知、积分变更提醒、账户余额变动通知、提现结果通知。您在相关操作时点击授权即可生效。
</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getWechatOfficialAccountSetting } from '@/api/message.js'
import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const oaSetting = ref({
enabled: false,
accountName: '',
articleUrl: '',
bound: false,
})
onShow(() => {
fetchSetting()
})
function fetchSetting() {
getWechatOfficialAccountSetting()
.then((res: any) => {
if (res && res.data && res.data.success && res.data.result) {
oaSetting.value = res.data.result
}
})
.catch(() => {})
}
function handleOaAction() {
const url = (oaSetting.value.articleUrl || '').trim()
if (!url) {
uni.showToast({ title: '暂未配置公众号文章', icon: 'none' })
return
}
// #ifdef MP-WEIXIN
if (wx.openOfficialAccountArticle) {
wx.openOfficialAccountArticle({
url,
fail: () => {
fallbackOpen(url)
},
})
return
}
// #endif
fallbackOpen(url)
}
function fallbackOpen(url: string) {
uni.setClipboardData({
data: url,
success: () => {
uni.showModal({
title: '提示',
content: '公众号引导文章链接已复制到剪贴板,请在微信中粘贴发送并打开关注公众号',
showCancel: false,
})
},
})
}
</script>
<style lang="scss" scoped>
.notice-page {
min-height: 100vh;
background: #f7f8fa;
padding-bottom: 40rpx;
}
.content {
padding: 24rpx;
}
.tip-card {
display: flex;
align-items: center;
background: #fff7eb;
border-radius: 12rpx;
padding: 20rpx 24rpx;
margin-bottom: 24rpx;
}
.tip-text {
flex: 1;
font-size: 24rpx;
color: #ff7a00;
line-height: 1.5;
margin-left: 12rpx;
}
.card {
background: #fff;
border-radius: 20rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.03);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 24rpx;
border-bottom: 1px solid #f2f3f5;
}
.card-title-wrap {
display: flex;
align-items: center;
flex: 1;
}
.icon-badge {
width: 72rpx;
height: 72rpx;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 20rpx;
}
.oa-badge {
background: linear-gradient(135deg, #07c160, #0ab155);
}
.mp-badge {
background: linear-gradient(135deg, #2b85e4, #007aff);
}
.title-box {
flex: 1;
min-width: 0;
}
.title-row {
display: flex;
align-items: center;
}
.card-title {
font-size: 30rpx;
font-weight: 600;
color: #1d2129;
}
.status-tag {
font-size: 20rpx;
padding: 4rpx 14rpx;
border-radius: 20rpx;
margin-left: 14rpx;
font-weight: 500;
}
.status-open {
background: #e8f8f0;
color: #07c160;
}
.status-close {
background: #f2f3f5;
color: #86909c;
}
.card-desc {
display: block;
font-size: 24rpx;
color: #86909c;
margin-top: 6rpx;
}
.card-body {
padding: 24rpx 0 12rpx;
}
.notice-points {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.point-item {
display: flex;
align-items: flex-start;
font-size: 26rpx;
color: #4e5969;
line-height: 1.6;
}
.dot {
color: #07c160;
font-size: 32rpx;
line-height: 1;
margin-right: 12rpx;
}
.point-text {
flex: 1;
}
.card-foot {
margin-top: 24rpx;
}
.action-btn {
height: 80rpx;
line-height: 80rpx;
text-align: center;
border-radius: 40rpx;
font-size: 28rpx;
font-weight: 500;
}
.btn-primary {
background: linear-gradient(135deg, #07c160, #05a04e);
color: #fff;
box-shadow: 0 6rpx 18rpx rgba(7, 193, 96, 0.25);
}
.btn-bound {
background: #f2f3f5;
color: #4e5969;
}
.mp-intro {
font-size: 26rpx;
color: #4e5969;
line-height: 1.8;
}
</style>

View File

@@ -1,9 +1,17 @@
<template>
<view :style="themeStyle" class="person-msg">
<!-- #ifdef MP-WEIXIN -->
<button class="head avatar-button" open-type="chooseAvatar" :disabled="avatarUploading || saving" @chooseavatar="onChooseAvatar">
<image :src="form.face || '/static/missing-face.png'" mode="aspectFill"></image>
<view>{{ avatarUploading ? '头像上传中…' : '点击选择微信头像' }}</view>
</button>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<view class="head" @click="changeFace">
<image :src="form.face || '/static/missing-face.png'" mode="aspectFill"></image>
<view>点击修改头像</view>
<view>{{ avatarUploading ? '头像上传中…' : '点击修改头像' }}</view>
</view>
<!-- #endif -->
<up-form
:model="form"
@@ -13,12 +21,26 @@
label-width="180rpx"
>
<up-form-item label="昵称" label-width="180rpx" :border-bottom="true">
<!-- #ifdef MP-WEIXIN -->
<input
class="nickname-input"
type="nickname"
:value="form.nickName"
:disabled="saving"
placeholder="点击填写微信昵称"
@input="onNicknameChange"
@blur="onNicknameChange"
@nicknamereview="onNicknameReview"
/>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<u-input
v-model="form.nickName"
border="none"
input-align="right"
placeholder="请输入昵称"
/>
<!-- #endif -->
</up-form-item>
<up-form-item label="性别" label-width="180rpx" :border-bottom="true">
@@ -55,7 +77,9 @@
</up-form>
<view class="bottom">
<view class="submit" @click="submit">保存</view>
<button class="submit" :disabled="avatarUploading || saving" :loading="saving" @click="submit">
{{ avatarUploading ? '头像上传中…' : saving ? '正在保存…' : '保存' }}
</button>
</view>
<u-datetime-picker
@@ -80,7 +104,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { ref, reactive, computed, nextTick } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { saveUserInfo } from '@/api/members.js'
@@ -126,6 +150,24 @@ const maxBirthdayDate = Date.now()
const region = ref([{ id: '', localName: '请选择', children: [] }])
const showBirthday = ref(false)
const cityPicker = ref<any>(null)
const avatarUploading = ref(false)
const saving = ref(false)
function onNicknameChange(e: any) {
if (typeof e?.detail?.value === 'string') form.nickName = e.detail.value
}
function onNicknameReview(e: any) {
if (e?.detail?.pass === false) {
form.nickName = ''
uni.showToast({ title: '昵称未通过审核,请重新填写', icon: 'none' })
}
}
function onChooseAvatar(e: any) {
const filePath = e?.detail?.avatarUrl
if (typeof filePath === 'string' && filePath) uploadAvatar(filePath)
}
function getPickerParentValue(selected: any[]) {
form.region = []
@@ -145,34 +187,69 @@ function clickRegion() {
cityPicker.value?.show()
}
function submit() {
async function submit() {
if (avatarUploading.value || saving.value) return
// Nickname autofill can update the value in the blur event before the save tap.
await nextTick()
if (avatarUploading.value || saving.value) return
const params = JSON.parse(JSON.stringify(form))
params.nickName = String(params.nickName || '').trim()
if (!params.nickName) {
uni.showToast({ title: '请填写昵称', icon: 'none' })
return
}
delete params.regionPath
saveUserInfo(params).then((res) => {
if (res.statusCode === 200) {
saving.value = true
try {
const res = await saveUserInfo(params)
if (res.data?.success && res.data.result) {
storage.setUserInfo(res.data.result)
uni.navigateBack()
} else {
uni.showToast({ title: res.data?.message || '保存失败,请重试', icon: 'none' })
}
})
} catch {
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
} finally {
saving.value = false
}
}
function changeFace() {
if (avatarUploading.value || saving.value) return
uni.chooseImage({
count: 1,
success: (chooseImageRes) => {
uni.uploadFile({
url: upload,
filePath: chooseImageRes.tempFilePaths[0],
name: 'file',
header: { accessToken: storage.getAccessToken() },
success: (uploadFileRes) => {
const data = JSON.parse(uploadFileRes.data)
form.face = data.result
},
})
const filePath = chooseImageRes.tempFilePaths?.[0]
if (filePath) uploadAvatar(filePath)
},
})
}
function uploadAvatar(filePath: string) {
if (avatarUploading.value || saving.value) return
avatarUploading.value = true
uni.uploadFile({
url: upload,
filePath,
name: 'file',
header: { accessToken: storage.getAccessToken() },
success: (res) => {
try {
const data = JSON.parse(res.data)
if (res.statusCode !== 200 || !data.success || typeof data.result !== 'string' || !data.result) {
throw new Error('上传失败')
}
form.face = data.result
} catch {
uni.showToast({ title: '头像上传失败,请重新选择', icon: 'none' })
}
},
fail: () => uni.showToast({ title: '头像上传失败,请重新选择', icon: 'none' }),
complete: () => { avatarUploading.value = false },
})
}
function selectTime(e: any) {
const timestamp = typeof e?.value === 'number' ? e.value : birthdayValue.value
const formatted = formatBirthday(timestamp)
@@ -192,22 +269,11 @@ function navigateToBindMobile(username: string) {
})
}
function syncUserInfo() {
onShow(() => {
const latest = storage.getUserInfo() || {}
form.nickName = latest.nickName || ''
form.birthday = latest.birthday || ''
form.face = latest.face || '/static/missing-face.png'
form.region = latest.region || []
form.sex = latest.sex != null ? String(latest.sex) : '1'
form.regionPath = latest.region
// Refresh a newly bound phone without overwriting unsaved avatar/nickname edits.
form.mobile = latest.mobile
form.username = latest.username
birthday.value = latest.birthday || ''
birthdayValue.value = parseBirthdayTimestamp(latest.birthday)
}
onShow(() => {
syncUserInfo()
})
</script>
@@ -248,6 +314,23 @@ page {
}
}
.avatar-button {
width: 100%;
border-radius: 0;
line-height: normal;
&::after { border: none; }
&[disabled] { background: #fff; color: #999; }
}
.nickname-input {
width: 100%;
min-width: 0;
height: 64rpx;
text-align: right;
font-size: 28rpx;
color: #333;
}
.form {
background: #fff;
}
@@ -341,5 +424,9 @@ page {
font-size: 30rpx;
background: $light-color;
color: #fff;
margin: 0;
padding: 0;
&::after { border: none; }
&[disabled] { opacity: .5; background: $light-color; color: #fff; }
}
</style>

View File

@@ -26,6 +26,11 @@
></u-cell>
<!-- #endif -->
<u-cell is-link title="用户注销" v-if="userInfo.id" @click="logoff"></u-cell>
<u-cell
is-link
title="消息通知"
@click="navigateTo('/pages/mine/set/messageNotice')"
></u-cell>
<u-cell is-link title="意见反馈" @click="navigateTo('/pages/mine/set/feedBack')"></u-cell>
<u-cell
is-link
@@ -42,10 +47,10 @@
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import config from '@/config/config'
import { isLogin, tipsToLogin, quiteLoginOut, logoff } from '@/utils/filters.js'
import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
const store = useStore()
import { isLogin, tipsToLogin, quiteLoginOut, logoff } from '@/utils/filters.js'
import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const userImage = config.defaultUserPhoto

View File

@@ -1,585 +0,0 @@
<template>
<view :style="themeStyle" class="content">
<view class="u-tabs-box">
<u-tabs
:list="list"
:scrollable="false"
v-model:current="current"
@change="change"
:lineColor="lightColor"
:activeStyle="{ color: lightColor }"
></u-tabs>
</view>
<div class="u-tabs-search">
<u-search
placeholder="请输入订单编号/商品名称/售后单号"
@search="submitSearchOrderList(current)"
@clear="clear(current)"
@custom="submitSearchOrderList(current)"
v-model="keywords"
>
</u-search>
</div>
<scroll-view class="body-view" scroll-y @scrolltolower="renderDate">
<view
class="seller-view"
v-for="(order, orderIndex) in orderList"
:key="orderIndex"
>
<!-- 店铺名称 -->
<view class="seller-info" v-if="current == 0">
<view class="seller-name wes">{{ order.storeName }}</view>
<view class="order-sn wes">订单编号:{{ order.sn }}</view>
</view>
<!-- 申请记录 选项卡 -->
<view class="seller-info" v-if="current != 0">
<view class="order-sn wes">售后单号:{{ order.service_sn || order.sn }}</view>
<view class="order-sn service-type">{{ order.serviceType_text }}</view>
</view>
<view v-for="(sku, goodsIndex) in order.orderItems" :key="goodsIndex">
<view class="goods-item-view" @click="onDetail(order, sku)">
<view class="goods-img">
<u-image
border-radius="6"
width="131rpx"
height="131rpx"
mode="aspectFill"
:src="getGoodsImage(sku, order, goodsIndex)"
></u-image>
</view>
<view class="goods-info">
<view class="goods-title">{{ getGoodsName(sku) }}</view>
<!-- 如果商品多个则不显示每个商品价格-->
<view class="goods-price" v-if="order.orderItems.length <= 1">
¥{{ unitPrice(order.flowPrice) }}
</view>
</view>
<view class="goods-num">
<view>x{{ sku.num }}</view>
</view>
</view>
<view class="description">
<!-- 售后申请 -->
<view v-if="current === 0 && sku.afterSaleStatus">
<view
v-if="sku.afterSaleStatus.includes('ALREADY_APPLIED')"
class="cannot_apply not_center"
>
<u-icon class="icon" name="info-circle-fill"></u-icon>
该商品已申请售后服务
</view>
</view>
<view v-if="current === 0 && sku.afterSaleStatus">
<view
v-if="sku.afterSaleStatus.includes('EXPIRED')"
class="cannot_apply not_center"
@click="tipsShow = true"
>
<u-icon class="icon" name="info-circle-fill"></u-icon>
该商品无法申请售后
</view>
</view>
<div v-if="current === 1 || current === 2">
<!-- 申请中 -->
<view
class="cannot_apply not_center"
v-if="order.serviceType == 'RETURN_GOODS'"
>
退货处理-{{serviceStatusList(order.serviceStatus) }}</view
>
<view
class="cannot_apply not_center"
v-if="order.serviceType == 'SUPPLY_AGAIN_GOODS'"
>
补发商品-{{serviceStatusList(order.serviceStatus) }}</view
>
<view
class="cannot_apply not_center"
v-if="order.serviceType == 'RETURN_MONEY'"
>
退款-{{serviceStatusList(order.serviceStatus) }}</view
>
<view
class="cannot_apply not_center"
v-if="order.serviceType == 'EXCHANGE_GOODS'"
>
换货-{{serviceStatusList(order.serviceStatus) }}</view
>
<view
class="cannot_apply not_center"
v-if="order.serviceType == 'CANCEL'"
>
取消订单-{{serviceStatusList(order.serviceStatus) }}</view
>
</div>
<!-- 申请记录 -->
</view>
<view class="btn-view">
<!-- 售后申请 -->
<div class="sale" v-if="current === 0 && sku.afterSaleStatus && !isECouponOrder(order.orderType)">
<div
v-if="
order.flowPrice != 0 &&
sku.afterSaleStatus.includes('NOT_APPLIED') ||
sku.afterSaleStatus.includes('PART_AFTER_SALE')
"
@click="applyService(sku.sn, order, sku)"
>
<view class="default-btn border"> 申请售后 </view>
</div>
</div>
<view class="after-line">
<!-- 申请中 -->
<view
class="default-btn border"
v-if="
current === 2 &&
order.serviceStatus &&
order.serviceStatus == 'PASS' &&
order.serviceType != 'RETURN_MONEY'
"
@click="onExpress(order, sku)"
>
提交物流
</view>
<view
@click="close(order, sku)"
v-if="current === 1"
class="default-btn close"
>
取消售后
</view>
<view
@click="afterDetails(order, sku)"
v-if="current === 1 || current === 2"
class="default-btn border"
>
售后详情
</view>
</view>
</view>
</view>
<view
v-if="
current === 0 &&
order.groupAfterSaleStatus &&
order.groupAfterSaleStatus != 'ALREADY_APPLIED' &&
order.orderItems.length >= 1
"
class="btn-view order-total"
>
<!-- 多个商品显示订单总价格 -->
<view class="cannot_apply">
订单总金额:<span class="countMoney"
>¥{{unitPrice(order.flowPrice) }}</span
>
</view>
</view>
</view>
<u-loadmore bg-color="#f8f8f8" :status="status" />
</scroll-view>
<u-modal
show-cancel-button
@confirm="closeService"
v-model:show="cancelShow"
content="确认取消售后"
></u-modal>
<u-modal
v-model:show="tipsShow"
content="当订单未确认收货|已过售后服务有效期|已申请售后服务时,不能申请售后"
></u-modal>
</view>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { unitPrice, serviceStatusList, parseGoodsImageUrl } from '@/utils/filters.js'
// E_COUPON 订单列表不展示「申请售后」入口(FR-B-04)
import { isECouponOrder } from '@/utils/goodsType.js'
import { getAfterSaleList, cancelAfterSale } from '@/api/after-sale.js'
import { getOrderList } from '@/api/order.js'
import storage from '@/utils/storage'
import { getThemeStyle } from '@/utils/theme'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const lightColor = computed(() => store.getters.lightColor)
const list = [
{ name: '售后申请' },
{ name: '申请中' },
{ name: '申请记录' },
]
const current = ref(0)
const tipsShow = ref(false)
const cancelShow = ref(false)
const selectedOrder = ref<any>(null)
const orderList = ref<any[]>([])
const params = reactive<Record<string, any>>({
pageNumber: 1,
pageSize: 10,
sort: 'createTime',
flowPrice: 0,
order: 'desc',
})
const logParams = reactive<Record<string, any>>({
pageNumber: 1,
pageSize: 10,
})
const status = ref('loadmore')
const keywords = ref('')
onLoad((options) => {
orderList.value = []
params.pageNumber = 1
if (options?.orderSn) params.keywords = options.orderSn
searchOrderList(current.value)
})
onPullDownRefresh(() => {
change(current.value)
})
function hideLoadingIfNeeded() {
if (store.state.isShowToast) uni.hideLoading()
}
function getGoodsName(sku: any) {
return sku.goodsName || sku.name || ''
}
function getGoodsImage(goods: any, order: any, index: number) {
let image = goods.image || goods.goodsImage || goods.thumbnail
if (!image && order.groupImages) {
const images = String(order.groupImages).split(',')
image = images[index] || images[0]
}
return parseGoodsImageUrl(image)
}
function submitSearchOrderList(tabIndex: number) {
params.pageNumber = 1
logParams.pageNumber = 1
orderList.value = []
searchOrderList(tabIndex)
}
function clear(tabIndex: number) {
params.pageNumber = 1
logParams.pageNumber = 1
params.keywords = ''
orderList.value = []
searchOrderList(tabIndex)
}
function change(e: number | { index: number }) {
const index = typeof e === 'object' && e != null ? e.index : e
current.value = index
Object.assign(params, { pageNumber: 1, pageSize: 10 })
orderList.value = []
searchOrderList(index)
uni.stopPullDownRefresh()
}
function searchOrderList(index: number) {
if (index == 0) {
if (keywords.value) params.keywords = keywords.value
fetchOrderList()
} else {
Object.assign(logParams, {
pageNumber: 1,
pageSize: 10,
sort: 'createTime',
order: 'desc',
})
if (index === 1) {
logParams.serviceStatus = 'APPLY'
}
if (keywords.value) logParams.keywords = keywords.value
orderList.value = []
fetchAfterSaleLogList()
}
}
function fetchOrderList() {
uni.showLoading({ title: '加载中', mask: true })
getOrderList(params).then((res) => {
hideLoadingIfNeeded()
const records = res.data.result.records
if (records.length > 0) {
orderList.value = orderList.value.concat(records)
params.pageNumber += 1
}
status.value = records.length < 10 ? 'nomore' : 'loading'
})
}
function close(order: any, _sku: any) {
selectedOrder.value = order
cancelShow.value = true
}
async function closeService() {
uni.showLoading({ title: '加载中' })
const res = await cancelAfterSale(selectedOrder.value.sn)
if (res.data.success) {
uni.showToast({ title: '取消成功!', duration: 2000, icon: 'none' })
}
orderList.value = []
searchOrderList(current.value)
hideLoadingIfNeeded()
}
function afterDetails(order: any, _sku?: any) {
uni.navigateTo({ url: './applyDetail?sn=' + order.sn })
}
function fetchAfterSaleLogList() {
getAfterSaleList(logParams).then((res) => {
const afterSaleLogList = res.data.result.records
afterSaleLogList.forEach((item: any) => {
item.orderItems = [
{
image: item.goodsImage,
skuId: item.skuId,
name: item.goodsName,
num: item.num,
price: item.flowPrice,
},
]
})
orderList.value = orderList.value.concat(afterSaleLogList)
status.value = afterSaleLogList.length < 10 ? 'nomore' : 'loading'
})
}
function applyService(sn: string, order: any, sku: any) {
storage.setAfterSaleData({ ...order, ...sku })
uni.navigateTo({ url: `/pages/order/afterSales/afterSalesSelect?sn=${sn}` })
}
function onExpress(order: any, sku: any) {
sku.storeName = order.storeName
storage.setAfterSaleData({ ...order, ...sku })
uni.navigateTo({ url: `./afterSalesDetailExpress?serviceSn=${order.sn}` })
}
function onDetail(goods: any, sku: any) {
if (current.value == 0) {
uni.navigateTo({
url: `/pages/product/goods?id=${sku.skuId}&goodsId=${sku.goodsId || sku.goodsId}`,
})
} else {
uni.navigateTo({
url: `/pages/product/goods?id=${goods.skuId}&goodsId=${goods.goodsId || goods.goodsId}`,
})
}
}
function renderDate() {
if (current.value === 0) {
params.pageNumber += 1
fetchOrderList()
} else {
logParams.pageNumber += 1
fetchAfterSaleLogList()
}
}
</script>
<style lang="scss">
page,
.content {
background: $page-color-base;
height: 100%;
}
.body-view {
overflow-y: auto;
height: calc(100vh - 44px - 80rpx - 104rpx);
padding: 0 20rpx;
box-sizing: border-box;
}
.u-tabs-search {
padding: 20rpx;
background: #fff;
}
.countMoney {
margin-left: 7rpx;
color: $main-color;
font-size: 28rpx;
}
.seller-view {
background-color: #fff;
margin: 20rpx 0;
padding: 0 20rpx 24rpx;
border-radius: 20rpx;
.seller-info {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
min-height: 70rpx;
padding: 20rpx 0 16rpx;
gap: 20rpx;
.seller-name {
flex-shrink: 0;
max-width: 40%;
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.order-sn {
flex: 1;
min-width: 0;
font-size: 22rpx;
color: #909399;
text-align: right;
}
.service-type {
flex-shrink: 0;
text-align: right;
}
}
.goods-item-view {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 16rpx 0;
.goods-img {
flex-shrink: 0;
width: 131rpx;
height: 131rpx;
border-radius: 6rpx;
overflow: hidden;
}
.goods-info {
flex: 1;
min-width: 0;
padding: 0 20rpx;
.goods-title {
margin-bottom: 10rpx;
color: #333333;
font-size: 26rpx;
line-height: 1.4;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
word-break: break-all;
}
.goods-specs {
font-size: 24rpx;
margin-bottom: 10rpx;
color: #cccccc;
}
.goods-price {
font-size: 28rpx;
color: #ff5a10;
}
}
.goods-num {
flex-shrink: 0;
min-width: 60rpx;
text-align: right;
color: $main-color;
font-size: 26rpx;
line-height: 1.4;
}
}
.btn-view {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
padding: 16rpx 0 0;
.after-line {
display: flex;
flex-direction: row;
align-items: center;
gap: 15rpx;
}
}
.order-total {
justify-content: flex-start;
padding-top: 8rpx;
padding-bottom: 4rpx;
.cannot_apply {
text-align: left;
height: auto;
line-height: 1.5;
}
}
}
.description {
color: #909399;
size: 25rpx;
}
.cannot_apply {
text-align: center;
font-size: 22rpx;
color: #999999;
height: 70rpx;
line-height: 70rpx;
}
.not_center {
text-align: left;
}
.icon {
margin-right: 10rpx;
}
.sale {
width: 100%;
display: flex;
justify-content: flex-end;
}
.default-btn {
background-color: #ffffff;
margin-left: 15rpx;
height: 60rpx;
line-height: 60rpx;
text-align: center;
font-size: 24rpx;
padding: 0 24rpx;
border-radius: 200px;
}
.close {
color: $light-color;
}
.border {
border: 2rpx solid $light-color;
color: $light-color;
}
</style>

View File

@@ -158,7 +158,7 @@ function onSubmit() {
title: '提交成功',
type: 'success',
back: true,
url: '/pages/order/afterSales/afterSales',
url: '/pages/order/myOrder?status=5',
})
}
})

View File

@@ -1,80 +1,275 @@
<template>
<view :style="themeStyle">
<view class="header">
<u-icon name='checkmark' size="150" color="#ff6b35"></u-icon>
<view class="success-text">售后申请提交成功</view>
<view class="btn-view">
<view class="btn-item">
<u-button ripple class="btn1" shape="circle" @click="navigateToAfterSales()">查看记录</u-button>
<view :style="themeStyle" class="apply-success-page">
<u-navbar
title="提交成功"
:border="false"
:fixed="true"
:placeholder="true"
:auto-back="false"
@leftClick="handleBack"
></u-navbar>
<view class="page-body">
<!-- 核心状态卡片 -->
<view class="result-card">
<view class="icon-halo">
<view class="icon-circle">
<u-icon name="checkmark" size="52rpx" color="#ffffff"></u-icon>
</view>
</view>
<view class="btn-item">
<u-button ripple class="btn2" shape="circle" @click="navigateToHome()">回到主页</u-button>
<view class="success-title">售后申请提交成功</view>
<view class="success-sub">商家将尽快为您审核处理,请留意消息通知</view>
<view class="btn-group">
<view class="action-btn btn-outline" @click="navigateToHome()">
回到主页
</view>
<view class="action-btn btn-primary" @click="navigateToAfterSales()">
查看记录
</view>
</view>
</view>
<!-- 温馨提示卡片 -->
<view class="tips-card">
<view class="tips-header">
<view class="tips-bar"></view>
<text class="tips-title">温馨提示</text>
</view>
<view class="tips-list">
<view class="tip-item">
<view class="tip-dot"></view>
<text class="tip-text">商品寄回地址将在商家审核通过后,在「我的订单 - 售后」详情中展示。</text>
</view>
<view class="tip-item">
<view class="tip-dot"></view>
<text class="tip-text">提交服务单后,售后专员可能与您电话沟通,请保持手机畅通。</text>
</view>
<view class="tip-item">
<view class="tip-dot"></view>
<text class="tip-text">售后处理完成后,退款金额将原路退回至您的原支付账户中。</text>
</view>
</view>
</view>
</view>
<view class="text-view">
<view style="display: flex; margin-bottom: 20rpx; align-items: center">温馨提示:</view>
<view>• 商品寄回地址将在审核通过后在申请记录中查询。</view>
<view>• 提交服务单后,售后专员可能与您电话沟通,请保持手机畅通。</view>
<view>• 退货处理成功后退款金额将原路返回到您的支持账户中;</view>
</view>
</view>
</template>
<script setup lang="ts">import { computed } from 'vue'
<script setup lang="ts">
import { computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
import {
prefetchMpMessageTemplates,
subscribeMpMessageByScenes,
} from '@/js_sdk/lili-pay/wx-pay.js'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
onLoad(() => {
// #ifdef MP-WEIXIN
prefetchMpMessageTemplates()
// #endif
})
function handleBack() {
const doBack = () => {
uni.navigateBack({
fail: () => {
uni.redirectTo({
url: '/pages/order/myOrder?status=5',
})
},
})
}
// #ifdef MP-WEIXIN
subscribeMpMessageByScenes(['AFTER_SALE_AUDIT'], doBack)
// #endif
// #ifndef MP-WEIXIN
doBack()
// #endif
}
function navigateToAfterSales() {
uni.redirectTo({
url: '/pages/order/afterSales/afterSales',
})
const goAfterSales = () => {
uni.redirectTo({
url: '/pages/order/myOrder?status=5',
})
}
// #ifdef MP-WEIXIN
subscribeMpMessageByScenes(['AFTER_SALE_AUDIT'], goAfterSales)
// #endif
// #ifndef MP-WEIXIN
goAfterSales()
// #endif
}
function navigateToHome() {
uni.switchTab({
url: '/pages/tabbar/home/index',
})
const goHome = () => {
uni.switchTab({
url: '/pages/tabbar/home/index',
})
}
// #ifdef MP-WEIXIN
subscribeMpMessageByScenes(['AFTER_SALE_AUDIT'], goHome)
// #endif
// #ifndef MP-WEIXIN
goHome()
// #endif
}
</script>
<style lang="scss" scoped>
page {
background: $page-color-base;
padding-bottom: 100rpx;
.apply-success-page {
min-height: 100vh;
background: #f5f6fa;
box-sizing: border-box;
}
.btn1 {
background: $light-color;
color: #fff;
.page-body {
padding: 28rpx 24rpx 60rpx;
}
.btn2 {
color: $light-color;
}
.header {
background-color: #fff;
height: 500rpx;
.result-card {
background: #ffffff;
border-radius: 24rpx;
padding: 64rpx 40rpx 52rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 4rpx 20rpx rgba(17, 30, 79, 0.04);
}
.icon-halo {
width: 140rpx;
height: 140rpx;
border-radius: 50%;
background: rgba(255, 107, 53, 0.12);
display: flex;
align-items: center;
justify-content: center;
.success-text {
font-size: 40rpx;
font-weight: 400;
margin: 20rpx 0;
}
.btn-view {
display: flex;
flex-direction: row;
margin: 40rpx 0rpx;
.btn-item {
margin: 0rpx 20rpx;
}
}
.icon-circle {
width: 104rpx;
height: 104rpx;
border-radius: 50%;
background: linear-gradient(135deg, $main-color 0%, $light-color 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 20rpx rgba(229, 50, 56, 0.25);
}
.success-title {
margin-top: 32rpx;
font-size: 36rpx;
font-weight: 700;
color: #1d2129;
letter-spacing: 0.5rpx;
}
.success-sub {
margin-top: 12rpx;
font-size: 25rpx;
color: #86909c;
text-align: center;
}
.btn-group {
margin-top: 48rpx;
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 24rpx;
}
.action-btn {
flex: 1;
height: 82rpx;
line-height: 82rpx;
border-radius: 999rpx;
font-size: 28rpx;
font-weight: 600;
text-align: center;
box-sizing: border-box;
transition: opacity 0.15s;
&:active {
opacity: 0.85;
}
}
.text-view {
margin: 40rpx;
color: #909399;
.btn-primary {
background: linear-gradient(135deg, $main-color 0%, $light-color 100%);
color: #ffffff;
box-shadow: 0 6rpx 16rpx rgba(229, 50, 56, 0.22);
}
.btn-outline {
background: #fff6f3;
color: $main-color;
border: 2rpx solid rgba(229, 50, 56, 0.28);
}
.tips-card {
margin-top: 24rpx;
background: #ffffff;
border-radius: 24rpx;
padding: 32rpx 32rpx 36rpx;
box-shadow: 0 4rpx 20rpx rgba(17, 30, 79, 0.03);
}
.tips-header {
display: flex;
align-items: center;
margin-bottom: 20rpx;
}
.tips-bar {
width: 6rpx;
height: 26rpx;
border-radius: 4rpx;
background: linear-gradient(180deg, $main-color 0%, $light-color 100%);
margin-right: 14rpx;
}
.tips-title {
font-size: 28rpx;
font-weight: 600;
color: #1d2129;
}
.tips-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.tip-item {
display: flex;
align-items: flex-start;
}
.tip-dot {
flex-shrink: 0;
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #c9cdd4;
margin-top: 14rpx;
margin-right: 16rpx;
}
.tip-text {
flex: 1;
font-size: 25rpx;
line-height: 1.65;
color: #4e5969;
}
</style>

View File

@@ -346,7 +346,7 @@
</div>
<div>
<u-row>
<u-col :span="6">活动优惠</u-col>
<u-col :span="6">活动优惠(含会员优惠)</u-col>
<u-col :span="6" class="tr tipsColor" textAlign="right">
<span v-if="orderMessage.priceDetailDTO.discountPrice"
>-¥{{unitPrice(orderMessage.priceDetailDTO.discountPrice)

File diff suppressed because it is too large Load Diff

View File

@@ -257,7 +257,7 @@
<u-popup class="cancel-popup" v-model:show="cancelShow" mode="bottom" length="60%">
<view class="header">取消订单</view>
<view class="body">
<view class="title">取消订单后,本单享有的优惠可能会一并取消,是否继续?</view>
<view class="title">{{ order.pickupCardId ? '此订单使用提货卡兑换,取消不退还提货权益,无法再次兑换。是否继续?' : '取消订单后,本单享有的优惠可能会一并取消,是否继续?' }}</view>
<view>
<u-radio-group v-model="reason">
<view class="value">

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@
class="showBox"
v-if="
promotion.__key == 'SECKILL' ||
promotion.__key == 'FLASH_DISCOUNT' ||
promotion.__key == 'GROUPBUY' ||
promotion.__key == 'PINTUAN'
"
@@ -37,29 +36,6 @@
</view>
</view>
<!-- 限时直降 -->
<view class="u-group-flex-left" v-if="promotion.__key == 'FLASH_DISCOUNT'">
<span
class="u-group-flex-left-span"
v-if="detail.promotionPrice != undefined"
>
¥
<span class="flex-price">
{{ goodsFormatPrice(detail.promotionPrice)[0] }}.{{
goodsFormatPrice(detail.promotionPrice)[1]
}}</span
>
</span>
<view class="u-group-flex" v-if="detail.price != undefined">
<span class="old-price"
>¥{{ goodsFormatPrice(detail.price)[0] }}.{{
goodsFormatPrice(detail.price)[1]
}}</span
>
<view class="promotion">限时直降</view>
</view>
</view>
<!-- 团购 -->
<view class="u-group-flex-left" v-if="promotion.__key == 'GROUPBUY'">
<span class="u-group-flex-left-span">

View File

@@ -15,7 +15,7 @@
<text v-else-if="item.scopeType == 'PORTION_GOODS'">部分商品</text>
<text v-else>{{ item.storeName == 'platform' ? '全平台' : item.storeName + '店铺' }}使用</text>
</view>
<text>满{{ unitPrice(item.consumeThreshold) }}可用</text>
<text>{{ item.consumeThreshold > 0 ? '满' + unitPrice(item.consumeThreshold) + '可用' : '无门槛使用' }}</text>
</view>
<view class="lingqu-btn" @tap="getCoupon(item, index)">
<view class="lingqu-text" :class="{ cur: yhqFlag[index] }">

View File

@@ -1,154 +1,136 @@
<template>
<view class="wrapper" v-if="res">
<view v-for="prom in promotionKeys" :key="prom">
<view v-if="getPromType(prom) == 'FULL_DISCOUNT'">
<view class="res_prom_item" v-if="res[prom].fullMinus">
<view class="deg_tag">满减</view>
<text class="pro-text">
满{{ res[prom].fullMoney }}元 立减现金
<text class="price">{{ res[prom].fullMinus }}元</text>
<text v-if="res[prom].couponFlag"> 赠送优惠券</text>
<text v-if="res[prom].pointFlag"> 赠送{{ res[prom].point }}积分</text>
<text v-if="res[prom].giftFlag"> 赠送商品</text>
<text v-if="res[prom].freeFreightFlag"> 赠送包邮服务</text>
</text>
</view>
<view class="res_prom_item" v-if="res[prom].fullRate && res[prom].fullRateFlag">
<view class="deg_tag">打折</view>
<text class="pro-text">
满{{ res[prom].fullMoney }}元,立享
<text class="price">{{ res[prom].fullRate }}折</text>
优惠
</text>
</view>
</view>
<view v-if="getPromType(prom) == 'PINTUAN'">
<view class="res_prom_item" v-if="res[prom].requiredNum">
<view class="deg_tag">拼团</view>
<text class="pro-text">
{{ res[prom].requiredNum }}人拼团 限购
<text class="price">{{ res[prom].limitNum }}件</text>
</text>
</view>
</view>
<view v-if="getPromType(prom) == 'SECKILL'">
<view class="res_prom_item">
<view class="deg_tag">限时抢购</view>
<text class="pro-text">限时抢购</text>
</view>
</view>
<view v-if="getPromType(prom) == 'FLASH_DISCOUNT'">
<view class="res_prom_item">
<view class="deg_tag">限时直降</view>
<text class="pro-text">{{ props.res[prom].promotionName || props.res[prom].title || '限时直降活动' }}</text>
</view>
</view>
<view v-if="getPromType(prom) == 'NTH_ITEM_DISCOUNT'">
<view class="res_prom_item">
<view class="deg_tag">第N件优惠</view>
<text class="pro-text">
第{{ props.res[prom].nthNum || 'N' }}件
<text v-if="props.res[prom].discountType === 'FREE'">免费</text>
<text v-else-if="props.res[prom].discountType === 'HALF'">半价</text>
<text v-else-if="props.res[prom].discountValue">享{{ props.res[prom].discountValue }}折</text>
<text v-else>优惠</text>
</text>
</view>
</view>
<view v-if="getPromType(prom) == 'POINTS_GOODS'">
<view class="res_prom_item">
<view class="deg_tag">积分活动</view>
<text class="pro-text">
当前商品参与积分活动。
<text @tap="handClickToJoinPromotion(prom)" class="href">点击此处参与活动</text>
</text>
</view>
</view>
<view v-if="getPromType(prom) == 'KANJIA'">
<view class="res_prom_item">
<view class="deg_tag">砍价活动</view>
<text class="pro-text">
当前商品参与砍价活动。
<text @tap="handClickToJoinPromotion(prom)" class="href">点击此处参与活动</text>
</text>
</view>
</view>
</view>
<view v-if="!promotionKeys.length">暂无促销活动</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
res?: any
}>()
const promotionKeys = computed(() => {
if (!props.res || typeof props.res !== 'object') return []
return Object.keys(props.res).filter((key) => getPromType(key) !== 'COUPON')
})
function getPromType(key: string) {
return key.split('-')[0]
}
function handClickToJoinPromotion(val: string) {
const type = getPromType(val)
const promotionMap: Record<string, string> = {
POINTS_GOODS: `/pages/promotion/point/detail?id=${props.res[val].id}`,
KANJIA: `/pages/promotion/bargain/detail?id=${props.res[val].id}`,
}
const url = promotionMap[type]
if (url) {
uni.navigateTo({ url })
}
}
</script>
<style lang="scss" scoped>
.deg_tag {
flex-shrink: 0;
color: $price-color;
padding: 4rpx 12rpx;
border: 2rpx solid $price-color;
border-radius: 6rpx;
font-size: 22rpx;
line-height: 1.4;
white-space: nowrap;
}
.pro-text {
flex: 1;
min-width: 0;
font-size: 26rpx;
line-height: 1.6;
color: #333333;
}
.wrapper {
display: block;
}
.res_prom_item {
display: flex;
align-items: flex-start;
gap: 16rpx;
margin: 20rpx 0;
}
.price {
color: $price-color;
}
.href {
color: $main-color;
}
</style>
<template>
<view class="wrapper" v-if="res">
<view v-for="prom in promotionKeys" :key="prom">
<view v-if="getPromType(prom) == 'FULL_DISCOUNT'">
<view class="res_prom_item" v-if="res[prom].fullMinus">
<view class="deg_tag">满减</view>
<text class="pro-text">
满{{ res[prom].fullMoney }}元 立减现金
<text class="price">{{ res[prom].fullMinus }}元</text>
<text v-if="res[prom].couponFlag"> 赠送优惠券</text>
<text v-if="res[prom].pointFlag"> 赠送{{ res[prom].point }}积分</text>
<text v-if="res[prom].giftFlag"> 赠送商品</text>
<text v-if="res[prom].freeFreightFlag"> 赠送包邮服务</text>
</text>
</view>
<view class="res_prom_item" v-if="res[prom].fullRate && res[prom].fullRateFlag">
<view class="deg_tag">打折</view>
<text class="pro-text">
满{{ res[prom].fullMoney }}元,立享
<text class="price">{{ res[prom].fullRate }}折</text>
优惠
</text>
</view>
</view>
<view v-if="getPromType(prom) == 'PINTUAN'">
<view class="res_prom_item" v-if="res[prom].requiredNum">
<view class="deg_tag">拼团</view>
<text class="pro-text">
{{ res[prom].requiredNum }}人拼团 限购
<text class="price">{{ res[prom].limitNum }}件</text>
</text>
</view>
</view>
<view v-if="getPromType(prom) == 'SECKILL'">
<view class="res_prom_item">
<view class="deg_tag">限时抢购</view>
<text class="pro-text">限时抢购</text>
</view>
</view>
<view v-if="getPromType(prom) == 'POINTS_GOODS'">
<view class="res_prom_item">
<view class="deg_tag">积分活动</view>
<text class="pro-text">
当前商品参与积分活动。
<text @tap="handClickToJoinPromotion(prom)" class="href">点击此处参与活动</text>
</text>
</view>
</view>
<view v-if="getPromType(prom) == 'KANJIA'">
<view class="res_prom_item">
<view class="deg_tag">砍价活动</view>
<text class="pro-text">
当前商品参与砍价活动。
<text @tap="handClickToJoinPromotion(prom)" class="href">点击此处参与活动</text>
</text>
</view>
</view>
</view>
<view v-if="!promotionKeys.length">暂无促销活动</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
res?: any
}>()
const promotionKeys = computed(() => {
if (!props.res || typeof props.res !== 'object') return []
return Object.keys(props.res).filter((key) =>
['FULL_DISCOUNT', 'PINTUAN', 'SECKILL', 'POINTS_GOODS', 'KANJIA'].includes(getPromType(key))
)
})
function getPromType(key: string) {
return key.split('-')[0]
}
function handClickToJoinPromotion(val: string) {
const type = getPromType(val)
const promotionMap: Record<string, string> = {
POINTS_GOODS: `/pages/promotion/point/detail?id=${props.res[val].id}`,
KANJIA: `/pages/promotion/bargain/detail?id=${props.res[val].id}`,
}
const url = promotionMap[type]
if (url) {
uni.navigateTo({ url })
}
}
</script>
<style lang="scss" scoped>
.deg_tag {
flex-shrink: 0;
color: $price-color;
padding: 4rpx 12rpx;
border: 2rpx solid $price-color;
border-radius: 6rpx;
font-size: 22rpx;
line-height: 1.4;
white-space: nowrap;
}
.pro-text {
flex: 1;
min-width: 0;
font-size: 26rpx;
line-height: 1.6;
color: #333333;
}
.wrapper {
display: block;
}
.res_prom_item {
display: flex;
align-items: flex-start;
gap: 16rpx;
margin: 20rpx 0;
}
.price {
color: $price-color;
}
.href {
color: $main-color;
}
</style>

View File

@@ -2,18 +2,6 @@
<view>
<view v-for="(promotionItem, promotionIndex) in promotion" :key="promotionIndex" class="promotion_row" @click="shutMask(1)">
<view v-if="res != null" v-for="(item, index) in Object.keys(res)" :key="index">
<div class="promotion_col" v-if="item.split('-')[0] == promotionItem.value && item.split('-')[0] == 'FLASH_DISCOUNT'">
<div class="flex">
<view class="deg_tag">{{ promotionItem.title }}</view>
<div class="text proText">{{ res[item].promotionName || res[item].title || '限时直降' }}</div>
</div>
</div>
<div class="promotion_col" v-if="item.split('-')[0] == promotionItem.value && item.split('-')[0] == 'NTH_ITEM_DISCOUNT'">
<div class="flex">
<view class="deg_tag">{{ promotionItem.title }}</view>
<div class="text proText">{{ res[item].promotionName || res[item].title || '第N件优惠' }}</div>
</div>
</div>
<div class="promotion_col" v-if="item.split('-')[0] == promotionItem.value && item.split('-')[0] == 'FULL_DISCOUNT'">
<!-- 满减,折扣 -->
<div class="flex">

View File

@@ -7,14 +7,6 @@ const promotion = [
title: "砍价活动",
value: "KANJIA",
},
{
title: "限时直降",
value: "FLASH_DISCOUNT",
},
{
title: "第N件优惠",
value: "NTH_ITEM_DISCOUNT",
},
{
title: "团购",
value: "GROUPBUY",

View File

@@ -96,7 +96,7 @@
<view class="xian"></view>
<view class="text">
<text>{{ "领取优惠券" }}</text>
<text>满{{unitPrice(item.consumeThreshold) }}元可用</text>
<text>{{ item.consumeThreshold > 0 ? '满' + unitPrice(item.consumeThreshold) + '元可用' : '无门槛使用' }}</text>
</view>
</view>
</view>

View File

@@ -36,11 +36,12 @@
}}</span
>元</view
>
<view
<view v-if="item.consumeThreshold > 0"
>满<span style="color: red; font-size: 32rpx"
>{{unitPrice(item.consumeThreshold) }}元</span
>可用</view
>
<view v-else>无门槛使用</view>
<view v-if="item.scopeType == 'ALL' && item.storeId == '0'"
>全平台</view
>

View File

@@ -21,7 +21,7 @@
<text class="coupon-value">{{ unitPrice(item.price) }}</text>
</template>
</view>
<view class="coupon-threshold">满{{ unitPrice(item.consumeThreshold) }}元可用</view>
<view class="coupon-threshold">{{ item.consumeThreshold > 0 ? '满' + unitPrice(item.consumeThreshold) + '元可用' : '无门槛使用' }}</view>
</view>
<view class="coupon-right">
<view class="coupon-name">

View File

@@ -1,14 +1,21 @@
<template>
<view class="user-page" :style="themeStyle">
<!-- 顶部沉浸式头部区域 -->
<view class="hero-header" :style="{ paddingTop: headerPaddingTop + 'px' }">
<!-- 用户信息区(沉降至胶囊安全区下方,舒展大气) -->
<view class="hero-header" :style="heroHeaderStyle">
<image
v-if="gradeBackgroundUrl && !gradeBackgroundFailed"
:key="gradeBackgroundUrl"
class="grade-background"
:class="{ 'is-loaded': gradeBackgroundLoaded }"
:src="gradeBackgroundUrl"
mode="aspectFill"
@load="gradeBackgroundLoaded = true"
@error="onGradeBackgroundError"
/>
<!-- 用户信息区保持在微信胶囊安全区下方 -->
<view class="user-profile" @click="userDetail">
<view class="avatar-container">
<image class="avatar-img" :src="userInfo.face || userImage" mode="aspectFill"></image>
<view class="vip-tag-icon" v-if="userInfo.id">
<u-icon name="level" size="10" color="#ffffff"></u-icon>
</view>
</view>
<view class="user-detail-info">
@@ -16,17 +23,19 @@
<text class="user-nickname">{{ userInfo.id ? userInfo.nickName : '登录 / 注册' }}</text>
</view>
<view class="user-sub-row">
<view class="badge-tag" v-if="userInfo.id">
<text>普通会员</text>
<view class="badge-tag" v-if="userInfo.id" @click.stop="navigateTo('/pages/mine/memberBenefits')">
<image v-if="gradeIconUrl && !gradeIconFailed" :key="gradeIconUrl" class="grade-icon" :src="gradeIconUrl" mode="aspectFit" @error="gradeIconFailed = true" />
<u-icon v-else name="level" size="16" :color="memberHeaderColor"></u-icon>
<text class="grade-name">{{ memberGradeName }}</text>
</view>
<text class="user-sub-text" v-else>
登录享受更多会员特权与折扣
登录享受更多会员权益
</text>
</view>
</view>
<view class="profile-arrow">
<u-icon name="arrow-right" size="14" color="rgba(255, 255, 255, 0.75)"></u-icon>
<u-icon name="arrow-right" size="14" :color="memberHeaderColor"></u-icon>
</view>
</view>
@@ -60,6 +69,13 @@
<!-- 下方主内容卡片区域 -->
<view class="main-container">
<navigator v-if="showPaidMemberEntry" class="paid-entry" :style="{ color: paidEntryColor }" url="/pages/mine/paidMember" hover-class="none">
<image v-if="paidEntryBackground" class="entry-bg" :src="paidEntryBackground" mode="aspectFill" />
<view class="entry-content">
<view><text class="entry-title">{{ paidEntryTitle }}</text><text class="entry-subtitle">{{ paidEntrySubtitle }}</text></view>
<u-icon name="arrow-right" size="14" :color="paidEntryColor" />
</view>
</navigator>
<!-- 我的订单卡片 -->
<view class="card-box order-box">
<view class="card-title-bar">
@@ -109,13 +125,13 @@
</view>
<!-- 服务工具卡片(分组化重构) -->
<tool />
<tool :user-info="userInfo" />
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import {
onLoad,
onShow,
@@ -123,6 +139,7 @@ import {
onNavigationBarButtonTap,
} from '@dcloudio/uni-app'
import tool from '@/pages/tabbar/user/utils/tool.vue'
import { getPaidMember } from '@/api/paid-member'
import { getCouponsNum, getFootprintNum } from '@/api/members.js'
import { getUserWallet } from '@/api/members'
import configs from '@/config/config'
@@ -130,6 +147,13 @@ import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
import { isLogin, navigateToLogin, unitPrice } from '@/utils/filters.js'
import { getCurrentMemberBenefits } from '@/api/members'
const memberGrade = ref<any>({})
const gradeOwnerId = ref('')
const gradeBackgroundLoaded = ref(false)
const gradeBackgroundFailed = ref(false)
const gradeIconFailed = ref(false)
let gradeRequestId = 0
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const lightColor = computed(() => store.getters.lightColor)
@@ -138,12 +162,116 @@ const aiderLightColor = computed(() => store.getters.aiderLightColor)
const userImage = configs.defaultUserPhoto
const userInfo = ref<any>({})
const paidMemberInfo = ref<any>(null)
let paidMemberRequestId = 0
const showPaidMemberEntry = computed(() => !!paidMemberInfo.value?.config
&& !!(paidMemberInfo.value.config.enabled || paidMemberInfo.value.active || paidMemberInfo.value.status === 'EXPIRED'))
const paidEntryTitle = computed(() => paidMemberInfo.value?.active
? paidMemberInfo.value.config?.openedText || '我的付费会员'
: paidMemberInfo.value?.config?.unopenedText || '开通付费会员,享专属权益')
const paidEntryColor = computed(() => (paidMemberInfo.value?.active
? paidMemberInfo.value.config?.openedFontColor
: paidMemberInfo.value?.config?.unopenedFontColor) || '#f4dfb9')
const paidEntryBackground = computed(() => paidMemberInfo.value?.active
? paidMemberInfo.value.config?.openedBackground
: paidMemberInfo.value?.config?.unopenedBackground)
const paidEntrySubtitle = computed(() => paidMemberInfo.value?.active
? '有效期至 ' + paidMemberInfo.value.endTime
: paidMemberInfo.value?.status === 'EXPIRED' ? '会员已到期,续费继续享权益' : '开通享商品优惠与专属权益')
const couponNum = ref<string | number>('')
const footNum = ref<string | number>('')
const walletNum = ref<string | number>('')
const currentGrade = computed(() =>
userInfo.value.id && gradeOwnerId.value === String(userInfo.value.id) ? memberGrade.value : {}
)
const memberGradeName = computed(() => currentGrade.value.gradeName || '普通会员')
const gradeBackgroundUrl = computed(() => currentGrade.value.gradeBackground || '')
const gradeIconUrl = computed(() => currentGrade.value.gradeImage || '')
const memberHeaderColor = computed(() => {
if (!gradeBackgroundUrl.value || !gradeBackgroundLoaded.value || gradeBackgroundFailed.value) return '#ffffff'
const color = currentGrade.value.gradeFontColor || ''
return /^#[0-9a-f]{6}$/i.test(color) ? color : '#333333'
})
const heroHeaderStyle = computed(() => ({
paddingTop: headerPaddingTop.value + 'px',
'--member-header-color': memberHeaderColor.value,
}))
watch(gradeBackgroundUrl, () => {
gradeBackgroundLoaded.value = false
gradeBackgroundFailed.value = false
}, { flush: 'sync' })
watch(gradeIconUrl, () => { gradeIconFailed.value = false }, { flush: 'sync' })
function resetMemberGrade() {
gradeRequestId++
gradeOwnerId.value = ''
memberGrade.value = {}
gradeBackgroundLoaded.value = false
gradeBackgroundFailed.value = false
gradeIconFailed.value = false
}
/** 个人中心显示时读取会员配置及资格;退出或切换账号后丢弃旧请求结果。 */
async function loadPaidMemberInfo() {
const requestId = ++paidMemberRequestId
const memberId = String(userInfo.value.id || '')
paidMemberInfo.value = null
if (!memberId) return
try {
const res = await getPaidMember()
if (requestId !== paidMemberRequestId
|| String(userInfo.value.id || '') !== memberId
|| String((isLogin() || {}).id || '') !== memberId) return
if (!res.data?.success) throw new Error(res.data?.message || '会员服务返回异常')
paidMemberInfo.value = res.data.result || null
} catch (error: any) {
if (requestId === paidMemberRequestId) {
console.warn('[付费会员] 入口加载失败', error?.message || '请求失败')
}
}
}
function onGradeBackgroundError() {
gradeBackgroundFailed.value = true
gradeBackgroundLoaded.value = false
}
async function loadMemberGrade() {
const memberId = String(userInfo.value.id || '')
if (!memberId) { resetMemberGrade(); return }
if (gradeOwnerId.value !== memberId) resetMemberGrade()
const requestId = ++gradeRequestId
const stillCurrent = () => requestId === gradeRequestId
&& String((isLogin() || {}).id || '') === memberId
&& String(userInfo.value.id || '') === memberId
try {
const res = await getCurrentMemberBenefits()
if (!stillCurrent()) return
gradeOwnerId.value = memberId
memberGrade.value = res.data?.success ? (res.data.result?.grade || {}) : {}
} catch {
if (stillCurrent()) resetMemberGrade()
}
}
// 保留页在退出登录/切换账号时立即清除上一账号的等级展示。
watch(() => [store.state.userInfo?.id, store.state.hasLogin], () => {
const account = store.state.hasLogin === false ? {} : (isLogin() || {})
if (String(account.id || '') !== String(userInfo.value.id || '')) {
userInfo.value = account
resetMemberGrade()
paidMemberRequestId++
paidMemberInfo.value = null
walletNum.value = 0
couponNum.value = 0
footNum.value = 0
}
})
// 动态计算顶部安全留白,避免顶到灵动岛/状态栏
const headerPaddingTop = ref<number>(88)
const headerPaddingTop = ref<number>(80)
function initSafeTop() {
try {
@@ -152,17 +280,17 @@ function initSafeTop() {
// #ifdef MP-WEIXIN
const menu = uni.getMenuButtonBoundingClientRect()
if (menu && menu.bottom) {
// 头像整行落到微信胶囊下方,预留 16px 呼吸空隙,彻底消除压迫感
headerPaddingTop.value = menu.bottom + 16
// 胶囊下方保留 8px,减少空白且不遮挡用户信息
headerPaddingTop.value = menu.bottom + 8
} else {
headerPaddingTop.value = statusBar + 54
headerPaddingTop.value = statusBar + 44
}
// #endif
// #ifndef MP-WEIXIN
headerPaddingTop.value = statusBar + 32
headerPaddingTop.value = statusBar + 20
// #endif
} catch (e) {
headerPaddingTop.value = 88
headerPaddingTop.value = 80
}
}
@@ -170,21 +298,26 @@ onLoad(() => {
initSafeTop()
})
onShow(() => {
async function refreshUserPage() {
userInfo.value = isLogin() || {}
if (isLogin('auth')) {
getUserOrderNum()
} else {
walletNum.value = 0
couponNum.value = 0
footNum.value = 0
try {
if (isLogin('auth')) {
await Promise.allSettled([loadMemberGrade(), loadPaidMemberInfo(), getUserOrderNum()])
} else {
resetMemberGrade()
paidMemberRequestId++
paidMemberInfo.value = null
walletNum.value = 0
couponNum.value = 0
footNum.value = 0
}
} finally {
uni.stopPullDownRefresh()
}
})
}
onPullDownRefresh(() => {
getUserOrderNum()
userInfo.value = isLogin()
})
onShow(refreshUserPage)
onPullDownRefresh(refreshUserPage)
// #ifndef MP
onNavigationBarButtonTap((e) => {
@@ -205,21 +338,34 @@ function userDetail() {
}
async function getUserOrderNum() {
uni.stopPullDownRefresh()
Promise.all([
getCouponsNum(),
getFootprintNum(),
getUserWallet(),
]).then((res: any[]) => {
couponNum.value = res[0].data.result
footNum.value = res[1].data.result
walletNum.value = res[2].data.result.memberWallet
})
const memberId = String(userInfo.value.id || '')
try {
// Each request can fail independently; a missing wallet must not break the other counts.
const results = await Promise.allSettled([
getCouponsNum(),
getFootprintNum(),
getUserWallet(),
])
// The user may have logged out while these requests were running.
if (String((isLogin() || {}).id || '') !== memberId || String(userInfo.value.id || '') !== memberId) return
const targets = [couponNum, footNum, walletNum]
results.forEach((result, index) => {
if (result.status !== 'fulfilled' || !result.value?.data?.success) return
const data = result.value.data.result
targets[index].value = index === 2 ? (data?.memberWallet ?? 0) : (data ?? 0)
})
} catch {
// 等级与资产请求独立,接口异常不影响页面其它内容。
}
}
</script>
<style lang="scss" scoped>
.paid-entry { position: relative; overflow: hidden; border-radius: 20rpx; background: linear-gradient(110deg, #423c36, #756149); margin-bottom: 20rpx; }
.entry-bg { position: absolute; inset: 0; width: 100%; height: 100%; }
.entry-content { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 20rpx; padding: 25rpx 28rpx; }
.entry-title { font-size: 27rpx; font-weight: 600; display: block; }
.entry-subtitle { font-size: 21rpx; opacity: .75; display: block; margin-top: 8rpx; }
.user-page {
min-height: 100vh;
background-color: #f5f6f9;
@@ -234,19 +380,35 @@ async function getUserOrderNum() {
background: linear-gradient(175deg, #e53238 0%, #eb4448 45%, #ff5e62 100%);
padding-left: 28rpx;
padding-right: 28rpx;
padding-bottom: 64rpx;
padding-bottom: 44rpx;
border-bottom-left-radius: 36rpx;
border-bottom-right-radius: 36rpx;
/* CSS 静态兜底:状态栏高度 + 充足的沉降留白 */
padding-top: calc(var(--status-bar-height, 44px) + 88rpx);
/* 静态兜底保留状态栏与导航操作区域 */
padding-top: calc(var(--status-bar-height, 44px) + 72rpx);
box-sizing: border-box;
overflow: hidden;
}
/* 用户信息行(移到胶囊下方,拥有充裕横纵向空间) */
.grade-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
pointer-events: none;
}
.grade-background.is-loaded {
opacity: 1;
}
/* 紧凑用户信息行 */
.user-profile {
position: relative;
display: flex;
align-items: center;
padding: 8rpx 6rpx;
padding: 4rpx 6rpx;
transition: opacity 0.2s;
&:active {
@@ -258,8 +420,8 @@ async function getUserOrderNum() {
flex-shrink: 0;
.avatar-img {
width: 124rpx;
height: 124rpx;
width: 104rpx;
height: 104rpx;
border-radius: 50%;
border: 4rpx solid rgba(255, 255, 255, 0.92);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.16);
@@ -267,19 +429,6 @@ async function getUserOrderNum() {
display: block;
}
.vip-tag-icon {
position: absolute;
right: -2rpx;
bottom: 2rpx;
width: 34rpx;
height: 34rpx;
background: linear-gradient(135deg, #f59e0b, #d97706);
border: 2rpx solid #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
}
.user-detail-info {
@@ -292,9 +441,9 @@ async function getUserOrderNum() {
align-items: center;
.user-nickname {
font-size: 38rpx;
font-size: 34rpx;
font-weight: 700;
color: #ffffff;
color: var(--member-header-color, #ffffff);
letter-spacing: 0.5rpx;
overflow: hidden;
text-overflow: ellipsis;
@@ -303,25 +452,46 @@ async function getUserOrderNum() {
}
.user-sub-row {
margin-top: 12rpx;
margin-top: 8rpx;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12rpx;
.badge-tag {
display: inline-flex;
align-items: center;
gap: 6rpx;
max-width: 100%;
min-height: 40rpx;
box-sizing: border-box;
font-size: 20rpx;
color: #ffffff;
color: var(--member-header-color, #ffffff);
background: rgba(255, 255, 255, 0.22);
padding: 3rpx 14rpx;
padding: 2rpx 12rpx 2rpx 6rpx;
border-radius: 20rpx;
border: 1rpx solid rgba(255, 255, 255, 0.35);
font-weight: 500;
.grade-icon {
width: 36rpx;
height: 36rpx;
flex-shrink: 0;
display: block;
}
.grade-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.user-sub-text {
font-size: 23rpx;
color: rgba(255, 255, 255, 0.88);
color: var(--member-header-color, #ffffff);
opacity: 0.88;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -336,12 +506,13 @@ async function getUserOrderNum() {
}
}
/* 核心资产栏(沉浸在红色背景中) */
/* 核心资产栏 */
.hero-assets {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 44rpx;
margin-top: 24rpx;
padding: 0 12rpx;
.asset-col {
@@ -360,7 +531,7 @@ async function getUserOrderNum() {
.asset-number-row {
display: flex;
align-items: baseline;
color: #ffffff;
color: var(--member-header-color, #ffffff);
.symbol {
font-size: 22rpx;
@@ -370,7 +541,7 @@ async function getUserOrderNum() {
}
.num {
font-size: 40rpx;
font-size: 36rpx;
font-weight: 700;
letter-spacing: 0.5rpx;
}
@@ -390,9 +561,10 @@ async function getUserOrderNum() {
}
.asset-title {
margin-top: 10rpx;
margin-top: 8rpx;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.88);
color: var(--member-header-color, #ffffff);
opacity: 0.88;
line-height: 1;
}
}
@@ -400,7 +572,8 @@ async function getUserOrderNum() {
.asset-sep {
width: 2rpx;
height: 38rpx;
background-color: rgba(255, 255, 255, 0.22);
background-color: var(--member-header-color, #ffffff);
opacity: 0.22;
}
}
@@ -486,4 +659,4 @@ async function getUserOrderNum() {
}
}
}
</style>
</style>

View File

@@ -76,6 +76,11 @@
<text class="grid-label">店铺入驻</text>
</view>
<view v-if="hasMerchantPermission" class="service-grid-item" @click="toMerchantWorkbench">
<image class="grid-icon" src="/static/mine/setting.png" mode="aspectFit"></image>
<text class="grid-label">商家工作台</text>
</view>
<view class="service-grid-item" @click="linkMsgDetail()">
<image class="grid-icon" src="/static/mine/mycommit.png" mode="aspectFit"></image>
<text class="grid-label">我的消息</text>
@@ -115,7 +120,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { distribution as fetchDistribution } from '@/api/distribution'
import configs from '@/config/config'
import storage from '@/utils/storage'
@@ -128,9 +133,47 @@ import {
DISTRIBUTION_STATUS,
} from '@/utils/distributionStatus'
const props = withDefaults(
defineProps<{
userInfo?: any
}>(),
{
userInfo: () => ({}),
}
)
const sharingShow = ref(false)
const sharingLink = ref('')
// 只有商家权限(店主 haveStore / 绑有 storeId 的店员 / 本地已有有效商家 token)的用户才展示入口
const hasMerchantPermission = computed(() => {
if (storage.getStoreAccessToken()) {
return true
}
const u = (props.userInfo && props.userInfo.id) ? props.userInfo : (storage.getUserInfo() || {})
return Boolean(u.haveStore === true || u.haveStore === 'true' || u.storeId)
})
function toMerchantWorkbench() {
if (!hasMerchantPermission.value) {
uni.showToast({
title: '暂无商家权限',
icon: 'none',
})
return
}
const storeToken = storage.getStoreAccessToken()
if (storeToken) {
uni.navigateTo({
url: '/pages-sub/merchant/pages/index/index',
})
} else {
uni.navigateTo({
url: '/pages-sub/merchant/pages/login/index',
})
}
}
function handleNavigate(url: string) {
uni.navigateTo({ url })
}

View File

@@ -1,152 +1,193 @@
let isDev = process.env.NODE_ENV === "development";
const UUID = isDev ? "uuid_key_dev" : "uuid_key";
const HAS_LOGIN = isDev ? "has_login_key_dev" : "has_login_key";
const ACCESS_TOKEN = isDev ? "access_token_key_dev" : "access_token_key";
const REFRESH_TOKEN = isDev ? "refresh_token_key_dev" : "refresh_token_key";
const USER_INFO = isDev ? "user_info_obj_dev" : "user_info_obj";
const FACE_LOGIN = isDev ? "face_login_dev" : "face_login";
const FINGER_LOGIN = isDev ? "finger_login_dev" : "finger_login";
const CART_BACKBTN = isDev ? "cart_backbtn_dev" : "cart_backbtn";
const AFTERSALE_DATA = isDev ? "aftersale_data_dev" : "aftersale_data";
export default {
setInviter(val){
uni.setStorageSync('inviter', val)
},
getInviter(){
return uni.getStorageSync('inviter');
},
//写入自动发券
setAutoCp(val){
uni.setStorageSync('autoCp', val)
},
//获取自动发券时间
getAutoCp(){
return uni.getStorageSync('autoCp');
},
// 写入热门搜索时间戳
setHotWords(val) {
uni.setStorageSync("hotWords", val);
},
// 获取热门搜索时间戳
getHotWords() {
return uni.getStorageSync(`hotWords`);
},
//写入 展示还是不展示
setShow(val) {
uni.setStorageSync("show", val);
},
getShow() {
if (uni.getStorageSync(`show`) === "" || uni.getStorageSync(`show`) === undefined) {
return true;
}
return uni.getStorageSync(`show`);
},
// 获取face id登录
getFaceLogin() {
return uni.getStorageSync(FACE_LOGIN);
},
// 写入face id
setFaceLogin(val) {
uni.setStorageSync(FACE_LOGIN, val);
},
// 获取指纹登录
getFingerLogin() {
return uni.getStorageSync(FINGER_LOGIN);
},
// 写入指纹登录
setFingerLogin(val) {
uni.setStorageSync(FINGER_LOGIN, val);
},
// 写入用户信息
setUserInfo(val) {
uni.setStorageSync(USER_INFO, val);
},
// 获取用户信息
getUserInfo() {
return uni.getStorageSync(USER_INFO);
},
setTalkToUser(val){
uni.setStorageSync("TALK_TO_USER", val);
},
getTalkToUser(){
return uni.getStorageSync("TALK_TO_USER");
},
// 写入uuid
setUuid(val) {
uni.setStorageSync(UUID, val);
},
// 获取uuid
getUuid() {
return uni.getStorageSync(UUID);
},
// 写入登录
setHasLogin(val) {
uni.setStorageSync(HAS_LOGIN, val);
if (val) {
try {
const { flushPendingBind } = require("@/utils/distributionBind.js");
flushPendingBind();
} catch (e) {
// ignore
}
}
},
// 获取是否登录
getHasLogin() {
return uni.getStorageSync(HAS_LOGIN);
},
// 删除uuid
removeUuid() {
uni.removeStorageSync(UUID);
},
// 写入accessToken
setAccessToken(val) {
uni.setStorageSync(ACCESS_TOKEN, val);
},
// 获取accessToken
getAccessToken() {
return uni.getStorageSync(ACCESS_TOKEN);
},
// 后退购物车
setCartBackbtn(val) {
uni.setStorageSync(CART_BACKBTN, val);
},
// 删除token
removeAccessToken() {
uni.removeStorageSync(ACCESS_TOKEN);
},
// 写入刷新token
setRefreshToken(val) {
uni.setStorageSync(REFRESH_TOKEN, val);
},
// 获取刷新token
getRefreshToken() {
return uni.getStorageSync(REFRESH_TOKEN);
},
// 删除token
removeRefreshToken() {
uni.removeStorageSync(REFRESH_TOKEN);
},
setAfterSaleData(val) {
uni.setStorageSync(AFTERSALE_DATA, val);
},
getAfterSaleData() {
return uni.getStorageSync(AFTERSALE_DATA);
},
// 删除token
removeAfterSaleData() {
uni.removeStorageSync(AFTERSALE_DATA);
},
// 是否发送商品连接记录
setImGoodsLink(val) {
uni.setStorageSync('imGoodId', val);
},
getImGoodsLink() {
return uni.getStorageSync('imGoodId');
},
};
let isDev = process.env.NODE_ENV === "development";
const UUID = isDev ? "uuid_key_dev" : "uuid_key";
const HAS_LOGIN = isDev ? "has_login_key_dev" : "has_login_key";
const ACCESS_TOKEN = isDev ? "access_token_key_dev" : "access_token_key";
const REFRESH_TOKEN = isDev ? "refresh_token_key_dev" : "refresh_token_key";
const USER_INFO = isDev ? "user_info_obj_dev" : "user_info_obj";
const FACE_LOGIN = isDev ? "face_login_dev" : "face_login";
const FINGER_LOGIN = isDev ? "finger_login_dev" : "finger_login";
const CART_BACKBTN = isDev ? "cart_backbtn_dev" : "cart_backbtn";
const AFTERSALE_DATA = isDev ? "aftersale_data_dev" : "aftersale_data";
// 商家端专属 Key(与买家隔离)
const STORE_ACCESS_TOKEN = isDev ? "store_access_token_dev" : "store_access_token";
const STORE_REFRESH_TOKEN = isDev ? "store_refresh_token_dev" : "store_refresh_token";
const STORE_INFO = isDev ? "store_info_obj_dev" : "store_info_obj";
export default {
setInviter(val){
uni.setStorageSync('inviter', val)
},
getInviter(){
return uni.getStorageSync('inviter');
},
//写入自动发券
setAutoCp(val){
uni.setStorageSync('autoCp', val)
},
//获取自动发券时间
getAutoCp(){
return uni.getStorageSync('autoCp');
},
// 写入热门搜索时间戳
setHotWords(val) {
uni.setStorageSync("hotWords", val);
},
// 获取热门搜索时间戳
getHotWords() {
return uni.getStorageSync(`hotWords`);
},
//写入 展示还是不展示
setShow(val) {
uni.setStorageSync("show", val);
},
getShow() {
if (uni.getStorageSync(`show`) === "" || uni.getStorageSync(`show`) === undefined) {
return true;
}
return uni.getStorageSync(`show`);
},
// 获取face id登录
getFaceLogin() {
return uni.getStorageSync(FACE_LOGIN);
},
// 写入face id
setFaceLogin(val) {
uni.setStorageSync(FACE_LOGIN, val);
},
// 获取指纹登录
getFingerLogin() {
return uni.getStorageSync(FINGER_LOGIN);
},
// 写入指纹登录
setFingerLogin(val) {
uni.setStorageSync(FINGER_LOGIN, val);
},
// 写入用户信息
setUserInfo(val) {
uni.setStorageSync(USER_INFO, val);
},
// 获取用户信息
getUserInfo() {
return uni.getStorageSync(USER_INFO);
},
setTalkToUser(val){
uni.setStorageSync("TALK_TO_USER", val);
},
getTalkToUser(){
return uni.getStorageSync("TALK_TO_USER");
},
// 写入uuid
setUuid(val) {
uni.setStorageSync(UUID, val);
},
// 获取uuid
getUuid() {
return uni.getStorageSync(UUID);
},
// 写入登录
setHasLogin(val) {
uni.setStorageSync(HAS_LOGIN, val);
if (val) {
try {
const { flushPendingBind } = require("@/utils/distributionBind.js");
flushPendingBind();
} catch (e) {
// ignore
}
}
},
// 获取是否登录
getHasLogin() {
return uni.getStorageSync(HAS_LOGIN);
},
// 删除uuid
removeUuid() {
uni.removeStorageSync(UUID);
},
// 写入accessToken
setAccessToken(val) {
uni.setStorageSync(ACCESS_TOKEN, val);
},
// 获取accessToken
getAccessToken() {
return uni.getStorageSync(ACCESS_TOKEN);
},
// 后退购物车
setCartBackbtn(val) {
uni.setStorageSync(CART_BACKBTN, val);
},
// 删除token
removeAccessToken() {
uni.removeStorageSync(ACCESS_TOKEN);
},
// 写入刷新token
setRefreshToken(val) {
uni.setStorageSync(REFRESH_TOKEN, val);
},
// 获取刷新token
getRefreshToken() {
return uni.getStorageSync(REFRESH_TOKEN);
},
// 删除token
removeRefreshToken() {
uni.removeStorageSync(REFRESH_TOKEN);
},
setAfterSaleData(val) {
uni.setStorageSync(AFTERSALE_DATA, val);
},
getAfterSaleData() {
return uni.getStorageSync(AFTERSALE_DATA);
},
// 删除token
removeAfterSaleData() {
uni.removeStorageSync(AFTERSALE_DATA);
},
// 是否发送商品连接记录
setImGoodsLink(val) {
uni.setStorageSync('imGoodId', val);
},
getImGoodsLink() {
return uni.getStorageSync('imGoodId');
},
// ============== 商家专属 Token 与鉴权方法 ==============
setStoreAccessToken(val) {
uni.setStorageSync(STORE_ACCESS_TOKEN, val);
},
getStoreAccessToken() {
return uni.getStorageSync(STORE_ACCESS_TOKEN);
},
removeStoreAccessToken() {
uni.removeStorageSync(STORE_ACCESS_TOKEN);
},
setStoreRefreshToken(val) {
uni.setStorageSync(STORE_REFRESH_TOKEN, val);
},
getStoreRefreshToken() {
return uni.getStorageSync(STORE_REFRESH_TOKEN);
},
removeStoreRefreshToken() {
uni.removeStorageSync(STORE_REFRESH_TOKEN);
},
setStoreInfo(val) {
uni.setStorageSync(STORE_INFO, val);
},
getStoreInfo() {
return uni.getStorageSync(STORE_INFO);
},
removeStoreInfo() {
uni.removeStorageSync(STORE_INFO);
},
/** 清理商家登录状态,保留买家会话 */
clearStoreAuth() {
uni.removeStorageSync(STORE_ACCESS_TOKEN);
uni.removeStorageSync(STORE_REFRESH_TOKEN);
uni.removeStorageSync(STORE_INFO);
},
};

31
utils/wechatAddress.js Normal file
View File

@@ -0,0 +1,31 @@
export function getWechatAddressErrorType(error) {
const message = String(error?.errMsg || error?.message || '')
if (/cancel/i.test(message)) return 'cancel'
if (/requiredPrivateInfos/i.test(message)) return 'configuration'
if (/privacy|隐私/i.test(message)) return 'privacy'
if (/auth deny|auth denied|authorize no response|auth reject/i.test(message)) return 'auth'
if (/not support|not supported|not available|not a function/i.test(message)) return 'unsupported'
return 'unknown'
}
// Match each name under its parent; never guess an ID from a similarly named region.
export async function resolveWechatRegion(address, fetchRegions) {
const names = [address.provinceName, address.cityName, address.countyName, address.streetName]
const selected = []
let parentId = 0
for (let level = 0; level < 4; level++) {
const response = await fetchRegions(parentId)
if (!response?.data?.success || !Array.isArray(response.data.result)) {
throw new Error('地区加载失败')
}
const children = response.data.result
if (!children.length) return selected.length >= 3 ? selected : null
const name = String(names[level] || '').trim()
if (!name) return null
const matches = children.filter(item => String(item.name || item.localName || '').trim() === name)
if (matches.length !== 1 || !matches[0].id) return null
selected.push(matches[0])
parentId = matches[0].id
}
return selected
}