17 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
pikachu1995@126.com
1411f15679 refactor(goods): 移除商品详情页配送地址相关功能
- 删除了"送至"地址展示卡片区域
- 移除了配送地址弹窗组件(popupAddress)的引用和导入
- 删除了 delivery 和 addressFlag 响应式变量
- 删除了 deliveryFun 和 closePopupAddress 函数
- 移除了 popupGoods 组件中的 addr 属性传参
- 删除了 shutMask 中地址弹窗的 case 3 逻辑
- 移除了 address.vue 中未登录跳转登录页的 else 分支
2026-10-29 17:17:35 +08:00
pikachu1995@126.com
af7bd78614 feat(withdrawal): 添加提现规则说明以满足审核要求
- 新增提现规则说明卡片组件展示在提现页面底部
- 添加默认规则文本,涵盖提现额度、频次、时间、到账及手续费说明
- 支持从后端接口获取自定义规则,优先展示接口返回内容
- 根据提现渠道动态调整到账账户描述(支付宝/微信零钱)
- 补充规则卡片相关样式,包括标题装饰条及正文排版
2026-10-29 10:55:47 +08:00
pikachu1995@126.com
0170c92f9a style(user): 优化个人中心页面布局与视觉样式
- 顶部沉浸式头部与微信胶囊安全距离适配
- 核心资产栏沉浸式融入主色调,规范金融数字展示
- 订单模块优化图标视觉层级与按压交互
- 服务工具重构为资产与常用、更多服务双卡片分组
2026-10-22 16:41:24 +08:00
pikachu1995@126.com
5e59f2cd3c refactor(category): 重构分类页面UI
- 重构分类页面导航栏,将标题+搜索框改为仅搜索框样式
- 优化分类页面左侧列表与右侧内容区域的布局及视觉样式
- 重写搜索栏宽度计算逻辑
- 优化分类滚动组件、侧边列表、排序栏及子标签栏的交互样式
- 增加分类头部图片条件渲染及商品列表嵌入模式样式
- 关闭商品列表划线价默认显示配置
2026-10-22 15:59:47 +08:00
pikachu1995@126.com
6b59ae3d6e fix: 分类页设置缓存读出后同样过 sanitize 清洗,防篡改/旧结构 2026-10-22 09:51:18 +08:00
pikachu1995@126.com
b589c551bf feat: 分类页支持后台配置的四种导航模板 2026-10-22 09:27:33 +08:00
pikachu1995@126.com
b8a60a9369 feat: m-goods-list 支持商品卡片字段开关 2026-10-22 09:24:00 +08:00
pikachu1995@126.com
1032b6e34d feat: uniapp 读取分类页设置配置(缓存+默认值兜底) 2026-10-22 09:17:58 +08:00
87 changed files with 12384 additions and 4137 deletions

24
App.vue
View File

@@ -3,13 +3,18 @@ import config from '@/config/config'
import { getClipboardData } from '@/js_sdk/h5-copy/h5-copy.js' import { getClipboardData } from '@/js_sdk/h5-copy/h5-copy.js'
import APPUpdate from '@/plugins/APPUpdate' import APPUpdate from '@/plugins/APPUpdate'
import storage from '@/utils/storage' import storage from '@/utils/storage'
import { getThemeSetting } from '@/api/common.js' import { getThemeSetting, getCategoryPageSetting } from '@/api/common.js'
import { import {
applyTabBarStyle, applyTabBarStyle,
cacheTheme, cacheTheme,
loadCachedTheme, loadCachedTheme,
normalizeTheme, normalizeTheme,
} from '@/utils/theme' } from '@/utils/theme'
import {
cacheCategoryPageSetting,
loadCachedCategoryPageSetting,
normalizeCategoryPageSetting,
} from '@/utils/categoryPageSetting'
import { onLaunch, onShow } from '@dcloudio/uni-app' import { onLaunch, onShow } from '@dcloudio/uni-app'
import { useStore } from '@/store' import { useStore } from '@/store'
import { handleAppLaunchScene } from '@/utils/distributionBind.js' import { handleAppLaunchScene } from '@/utils/distributionBind.js'
@@ -24,6 +29,7 @@ wx.onAppRoute((res) => {
onLaunch((val) => { onLaunch((val) => {
initTheme() initTheme()
initCategoryPageSetting()
handleAppLaunchScene(val) handleAppLaunchScene(val)
if (val?.query?.inviter) { if (val?.query?.inviter) {
storage.setInviter(val.query.inviter) storage.setInviter(val.query.inviter)
@@ -81,6 +87,22 @@ function initTheme() {
.catch(() => {}) .catch(() => {})
} }
function initCategoryPageSetting() {
const cached = loadCachedCategoryPageSetting()
if (cached) {
store.commit('SET_CATEGORY_PAGE_SETTING', cached)
}
getCategoryPageSetting()
.then((res) => {
if (res.data?.success) {
const setting = normalizeCategoryPageSetting(res.data.result)
store.commit('SET_CATEGORY_PAGE_SETTING', setting)
cacheCategoryPageSetting(setting)
}
})
.catch(() => {})
}
function applyUpdateWeChat() { function applyUpdateWeChat() {
const updateManager = uni.getUpdateManager() const updateManager = uni.getUpdateManager()

216
LICENSE
View File

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

View File

@@ -5,7 +5,7 @@
<p> <p>
<a href="https://github.com/lilishop/lilishop"><img src="https://img.shields.io/github/stars/lilishop/lilishop.svg?style=social&logo=github" alt="GitHub Stars" /></a> <a href="https://github.com/lilishop/lilishop"><img src="https://img.shields.io/github/stars/lilishop/lilishop.svg?style=social&logo=github" alt="GitHub Stars" /></a>
<a href="https://gitee.com/beijing_hongye_huicheng/lilishop"><img src="https://gitee.com/beijing_hongye_huicheng/lilishop/badge/star.svg?theme=dark" alt="Gitee Stars" /></a> <a href="https://gitee.com/beijing_hongye_huicheng/lilishop"><img src="https://gitee.com/beijing_hongye_huicheng/lilishop/badge/star.svg?theme=dark" alt="Gitee Stars" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a> <a href="./LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License" /></a>
<a href="https://vuejs.org/"><img src="https://img.shields.io/badge/Vue-3-brightgreen.svg" alt="Vue 3" /></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> <a href="https://uniapp.dcloud.net.cn/"><img src="https://img.shields.io/badge/uni--app-Vue3-blue.svg" alt="uni-app" /></a>
</p> </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. **LILISHOP** is an open-source e-commerce system built with Spring Boot, Vue 3, and uni-app. It supports B2B2C multi-vendor marketplaces, WeChat Mini Programs, H5, Android/iOS Apps, live-streaming e-commerce, affiliate distribution, and flash sales.
### ✨ Why Choose Lilishop ### ✨ Why Choose Lilishop
- 🟢 **True MIT License**: Free for commercial use and private secondary development without licensing concerns. - 🟢 **True Apache-2.0 License**: Free for commercial use and private secondary development without licensing concerns.
- 🚀 **Modern Vue 3 Stack**: Built on `uni-app + Vue 3 + uview-plus 3.x`, leaving legacy Vue 2 templates behind. - 🚀 **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. - 🧩 **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 ## 6. License
Lilishop is licensed under the [MIT](./LICENSE) License. Copyright © 2020-2026 **Beijing Hongye Huicheng Technology Co., Ltd.** Lilishop is licensed under the [Apache-2.0](./LICENSE) License. Copyright © 2020-2026 **Beijing Hongye Huicheng Technology Co., Ltd.**

View File

@@ -5,7 +5,7 @@
<p> <p>
<a href="https://github.com/lilishop/lilishop"><img src="https://img.shields.io/github/stars/lilishop/lilishop.svg?style=social&logo=github" alt="GitHub Stars" /></a> <a href="https://github.com/lilishop/lilishop"><img src="https://img.shields.io/github/stars/lilishop/lilishop.svg?style=social&logo=github" alt="GitHub Stars" /></a>
<a href="https://gitee.com/beijing_hongye_huicheng/lilishop"><img src="https://gitee.com/beijing_hongye_huicheng/lilishop/badge/star.svg?theme=dark" alt="Gitee Stars" /></a> <a href="https://gitee.com/beijing_hongye_huicheng/lilishop"><img src="https://gitee.com/beijing_hongye_huicheng/lilishop/badge/star.svg?theme=dark" alt="Gitee Stars" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a> <a href="./LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License" /></a>
<a href="https://vuejs.org/"><img src="https://img.shields.io/badge/Vue-3-brightgreen.svg" alt="Vue 3" /></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> <a href="https://uniapp.dcloud.net.cn/"><img src="https://img.shields.io/badge/uni--app-Vue3-blue.svg" alt="uni-app" /></a>
</p> </p>
@@ -27,7 +27,7 @@
**LILISHOP** 是基于 Spring Boot / Vue 3 / uni-app 开发的开源商城系统,支持 B2B2C 多商户商城、微信小程序商城、H5 商城、APP 商城、直播电商、分销返佣、秒杀活动。 **LILISHOP** 是基于 Spring Boot / Vue 3 / uni-app 开发的开源商城系统,支持 B2B2C 多商户商城、微信小程序商城、H5 商城、APP 商城、直播电商、分销返佣、秒杀活动。
### ✨ 为什么选择 Lilishop ### ✨ 为什么选择 Lilishop
- 🟢 **真 MIT 协议**:支持免费商用与私有化闭源二开,无版权后顾之忧。 - 🟢 **真 Apache-2.0 协议**:支持免费商用与私有化闭源二开,无版权后顾之忧。
- 🚀 **Vue 3 全新栈**:基于 `uni-app + Vue 3 + uview-plus 3.x`,告别老旧 Vue 2 模板。 - 🚀 **Vue 3 全新栈**:基于 `uni-app + Vue 3 + uview-plus 3.x`,告别老旧 Vue 2 模板。
- 🧩 **生产级完整度**:非静态切图模板,完整覆盖 B2B2C 多商户、可视化装修、IM 客服、秒杀拼团、分销直播全闭环。 - 🧩 **生产级完整度**:非静态切图模板,完整覆盖 B2B2C 多商户、可视化装修、IM 客服、秒杀拼团、分销直播全闭环。
@@ -144,7 +144,7 @@ lilishop-uniapp
## 6. 开源与授权 ## 6. 开源与授权
本项目遵循 [MIT](./LICENSE) 开源协议,版权归 **北京宏业汇成科技有限公司** 所有。允许个人及企业免费用于学习、研究、修改和商业用途(需保留原始版权和许可声明)。 本项目遵循 [Apache-2.0](./LICENSE) 开源协议,版权归 **北京宏业汇成科技有限公司** 所有。允许个人及企业免费用于学习、研究、修改和商业用途(需保留原始版权和许可声明)。
--- ---
@@ -160,20 +160,47 @@ lilishop-uniapp
## 8. 升级计划 ## 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

@@ -41,3 +41,14 @@ export function getThemeSetting() {
message: false, message: false,
}); });
} }
/**
* 获取分类页配置
*/
export function getCategoryPageSetting() {
return http.request({
url: `${api.buyer}/common/site/category-page`,
method: Method.GET,
message: false,
});
}

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

@@ -0,0 +1,133 @@
<template>
<scroll-view scroll-x scroll-with-animation class="cate-scroller" :scroll-into-view="intoView">
<view
v-for="item in list"
:key="item.id"
:id="'cs-' + item.id"
class="cs-item"
:class="{ active: item.id === currentId, 'cs-item--text': mode === 'text' }"
@click="$emit('change', item)"
>
<template v-if="mode === 'icon'">
<view class="cs-icon-box">
<view class="cs-icon">
<u-image width="80rpx" height="80rpx" shape="circle" :src="item.image" mode="aspectFill">
<template #loading><u-loading-icon></u-loading-icon></template>
</u-image>
</view>
</view>
<text class="cs-name">{{ item.name }}</text>
</template>
<template v-else>
<text class="cs-text-name">{{ item.name }}</text>
<view class="cs-text-line" v-if="item.id === currentId"></view>
</template>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
list?: any[]
currentId?: string | number | null
mode?: 'icon' | 'text'
}>(), {
list: () => [],
currentId: null,
mode: 'icon'
})
defineEmits(['change'])
const intoView = computed(() =>
props.currentId != null ? 'cs-' + props.currentId : ''
)
</script>
<style lang="scss" scoped>
.cate-scroller {
white-space: nowrap;
width: 100%;
background: #ffffff;
border-bottom: 1rpx solid #f2f3f5;
box-sizing: border-box;
flex-shrink: 0;
}
.cs-item {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 130rpx;
padding: 16rpx 6rpx 14rpx;
vertical-align: top;
box-sizing: border-box;
transition: all 0.2s ease;
}
.cs-icon-box {
width: 92rpx;
height: 92rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 3rpx solid transparent;
box-sizing: border-box;
background: #ffffff;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.cs-item.active .cs-icon-box {
border-color: $light-color;
box-shadow: 0 4rpx 12rpx rgba(255, 102, 0, 0.15);
}
.cs-icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
overflow: hidden;
background: #f6f7f9;
display: flex;
align-items: center;
justify-content: center;
}
.cs-name {
margin-top: 10rpx;
font-size: 24rpx;
color: #555;
max-width: 120rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
.cs-item.active .cs-name {
color: $light-color;
font-weight: 600;
}
.cs-item--text {
width: auto;
padding: 18rpx 28rpx;
position: relative;
}
.cs-text-name {
font-size: 26rpx;
color: #666;
transition: color 0.2s ease;
}
.cs-item--text.active .cs-text-name {
color: $light-color;
font-weight: 600;
}
.cs-text-line {
position: absolute;
bottom: 6rpx;
left: 50%;
transform: translateX(-50%);
width: 32rpx;
height: 4rpx;
border-radius: 2rpx;
background: $light-color;
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<scroll-view scroll-y scroll-with-animation class="side-list">
<view
v-for="item in list"
:key="item.id"
class="side-item"
:class="{ active: item.id === currentId }"
@click="$emit('change', item)"
>
<view class="active-indicator" v-if="item.id === currentId"></view>
<text class="side-item-text">{{ item.name }}</text>
</view>
</scroll-view>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
list?: any[]
currentId?: string | number | null
}>(), {
list: () => [],
currentId: null
})
defineEmits(['change'])
</script>
<style lang="scss" scoped>
.side-list {
width: 176rpx;
flex-shrink: 0;
height: 100%;
background-color: #f6f7f9;
border-right: 1rpx solid #ebebeb;
box-sizing: border-box;
}
.side-item {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 96rpx;
padding: 16rpx 14rpx;
box-sizing: border-box;
font-size: 26rpx;
color: #666;
text-align: center;
position: relative;
transition: all 0.2s ease;
&.active {
font-weight: 600;
color: $light-color;
background: #ffffff;
}
}
.side-item-text {
line-height: 1.35;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
}
.active-indicator {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6rpx;
height: 36rpx;
border-radius: 0 4rpx 4rpx 0;
background: $light-color;
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<view class="sort-bar">
<view
class="sort-item"
:class="{ active: sort === 'releaseTime' }"
@click="onClick('releaseTime')"
>
<text class="sort-label">综合</text>
</view>
<view
class="sort-item"
:class="{ active: sort === 'buyCount' }"
@click="onClick('buyCount')"
>
<text class="sort-label">销量</text>
</view>
<view
class="sort-item"
:class="{ active: sort === 'price' }"
@click="onClick('price')"
>
<text class="sort-label">价格</text>
<view class="arrows">
<u-icon
name="arrow-up-fill"
size="7"
:color="sort === 'price' && order === 'asc' ? '#ff6700' : '#bbbbbb'"
></u-icon>
<u-icon
name="arrow-down-fill"
size="7"
:color="sort === 'price' && order === 'desc' ? '#ff6700' : '#bbbbbb'"
></u-icon>
</view>
</view>
</view>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
sort?: string
order?: string
}>(), {
sort: 'releaseTime',
order: 'desc'
})
const emit = defineEmits(['change'])
function onClick(type: string) {
let nextOrder = 'desc'
if (type === 'price') {
nextOrder = props.sort === 'price' && props.order === 'desc' ? 'asc' : 'desc'
}
emit('change', { sort: type, order: nextOrder })
}
</script>
<style lang="scss" scoped>
.sort-bar {
display: flex;
align-items: center;
height: 68rpx;
background: #ffffff;
padding: 0 20rpx;
border-bottom: 1rpx solid #f6f6f6;
box-sizing: border-box;
flex-shrink: 0;
}
.sort-item {
display: flex;
align-items: center;
margin-right: 44rpx;
font-size: 26rpx;
color: #666;
height: 100%;
cursor: pointer;
transition: color 0.2s ease;
&.active {
color: $light-color;
font-weight: 600;
}
}
.sort-label {
font-size: 26rpx;
}
.arrows {
display: flex;
flex-direction: column;
margin-left: 8rpx;
gap: 2rpx;
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<scroll-view scroll-x scroll-with-animation class="tag-bar" :scroll-into-view="intoView">
<view
v-for="item in list"
:key="item.id"
:id="'tb-' + item.id"
class="tag-item"
:class="{ active: item.id === currentId }"
@click="$emit('change', item)"
>
{{ item.name }}
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
list?: any[]
currentId?: string | number | null
}>(), {
list: () => [],
currentId: null
})
defineEmits(['change'])
const intoView = computed(() =>
props.currentId != null ? 'tb-' + props.currentId : ''
)
</script>
<style lang="scss" scoped>
.tag-bar {
white-space: nowrap;
width: 100%;
background: #ffffff;
padding: 12rpx 16rpx 10rpx;
border-bottom: 1rpx solid #f6f6f6;
box-sizing: border-box;
flex-shrink: 0;
}
.tag-item {
display: inline-block;
padding: 6rpx 22rpx;
margin-right: 12rpx;
font-size: 24rpx;
color: #666;
background: #f5f6f8;
border-radius: 24rpx;
line-height: 1.35;
transition: all 0.2s ease;
&.active {
color: #ffffff;
background: $light-color;
font-weight: 500;
}
}
</style>

View File

@@ -0,0 +1,297 @@
<template>
<view class="tpl-goods">
<!-- 模板2/3:顶部一级分类图标横滑 -->
<category-scroller
v-if="template !== 4"
:list="categoryList"
:current-id="currentL1 && currentL1.id"
mode="icon"
@change="onL1Change"
/>
<view class="tpl-main">
<!-- 左侧:模板2/3 为二级分类,模板4 为一级分类 -->
<category-side-list
:list="sideList"
:current-id="sideCurrentId"
@change="onSideChange"
/>
<view class="tpl-right">
<!-- 模板2:三级分类标签横滑 (置于右栏顶部) -->
<sub-tag-bar
v-if="template === 2 && l3List.length"
:list="l3List"
:current-id="currentL3 && currentL3.id"
@change="onL3Change"
/>
<!-- 模板4:右上二级分类文字横滑(起点为当前一级名称,作为"全部"入口) -->
<category-scroller
v-if="template === 4"
:list="l2Items"
:current-id="topCurrentId"
mode="text"
@change="onL2Change"
/>
<sort-bar :sort="sort" :order="order" @change="onSortChange" />
<scroll-view scroll-y class="goods-scroll" @scrolltolower="loadMore">
<m-goods-list
v-if="goodsList.length"
:res="goodsList"
type="oneColumns"
:show-fields="setting.showFields"
:tab-bar-gap="false"
:store-name="false"
@add-cart="onAddCart"
/>
<view v-else-if="!loading" class="empty-state">
<u-empty
:text="failed ? '加载失败,请重试' : '暂无相关商品'"
mode="data"
icon="http://cdn.uviewui.com/uview/empty/data.png"
margin-top="100"
>
<view v-if="failed" class="retry" @click="loadGoods(true)">重试</view>
</u-empty>
</view>
<uni-load-more
v-if="showLoadMore"
:status="loading ? 'loading' : hasMore ? 'more' : 'noMore'"
:color="'#bfbfbf'"
:content-text="loadMoreText"
></uni-load-more>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { getGoodsList } from '@/api/goods.js'
import CategoryScroller from './category-scroller.vue'
import CategorySideList from './category-side-list.vue'
import SubTagBar from './sub-tag-bar.vue'
import SortBar from './sort-bar.vue'
import MGoodsList from '@/components/m-goods-list/list.vue'
defineOptions({
options: {
virtualHost: true,
addGlobalClass: true,
},
})
const props = withDefaults(defineProps<{
categoryList?: any[]
setting?: any
template?: number
}>(), {
categoryList: () => [],
setting: () => ({ template: 1, showFields: {} }),
template: 2
})
const currentL1 = ref<any>(null)
const currentL2 = ref<any>(null)
const currentL3 = ref<any>(null)
const sort = ref('releaseTime')
const order = ref('desc')
const goodsList = ref<any[]>([])
const pageNumber = ref(1)
const hasMore = ref(true)
const loading = ref(false)
const failed = ref(false)
const loadMoreText = {
contentdown: '上拉加载更多',
contentrefresh: '正在加载...',
contentnomore: '—— 到底啦 ——',
}
// 仅在正在加载、或者有下一页、或者商品总数达到 6 件以上触底时才展示底线,避免少量商品(如1~2件)时在空白区域突兀展示
const showLoadMore = computed(() => {
if (!goodsList.value.length) return false
if (loading.value) return true
if (hasMore.value) return true
return goodsList.value.length >= 6
})
// 模板2/3:左侧为当前一级的二级分类;模板4:左侧为一级分类
const sideList = computed(() =>
props.template === 4
? props.categoryList
: (currentL1.value && currentL1.value.children) || []
)
const sideCurrentId = computed(() =>
props.template === 4
? currentL1.value && currentL1.value.id
: currentL2.value && currentL2.value.id
)
// 模板2 的三级标签
const l3List = computed(() =>
(currentL2.value && currentL2.value.children) || []
)
// 模板4 顶部:当前一级名称(作为"全部") + 其二级分类
const l2Items = computed(() => {
if (!currentL1.value) return []
return [currentL1.value, ...((currentL1.value.children) || [])]
})
const topCurrentId = computed(() =>
(currentL2.value && currentL2.value.id) || (currentL1.value && currentL1.value.id)
)
const queryCategoryId = computed(() =>
(currentL3.value && currentL3.value.id) ||
(currentL2.value && currentL2.value.id) ||
(currentL1.value && currentL1.value.id)
)
watch(
() => props.categoryList,
(list) => {
if (list && list.length && !currentL1.value) {
currentL1.value = list[0]
currentL2.value = props.template === 4 ? null : (list[0].children && list[0].children[0]) || null
currentL3.value = (currentL2.value && currentL2.value.children && currentL2.value.children[0]) || null
loadGoods(true)
}
},
{ immediate: true }
)
function onL1Change(item: any) {
if (currentL1.value && item.id === currentL1.value.id) return
currentL1.value = item
currentL2.value = props.template === 4 ? null : (item.children && item.children[0]) || null
currentL3.value = (currentL2.value && currentL2.value.children && currentL2.value.children[0]) || null
resetSort()
loadGoods(true)
}
function onSideChange(item: any) {
if (props.template === 4) {
onL1Change(item)
return
}
if (currentL2.value && item.id === currentL2.value.id) return
currentL2.value = item
currentL3.value = (item.children && item.children[0]) || null
loadGoods(true)
}
function onL3Change(item: any) {
if (currentL3.value && item.id === currentL3.value.id) return
currentL3.value = item
loadGoods(true)
}
function onL2Change(item: any) {
// 选中一级名称项 = 全部(L2 置空)
if (currentL1.value && item.id === currentL1.value.id) {
currentL2.value = null
} else {
currentL2.value = item
}
loadGoods(true)
}
function onSortChange(payload: { sort: string; order: string }) {
sort.value = payload.sort
order.value = payload.order
loadGoods(true)
}
function resetSort() {
sort.value = 'releaseTime'
order.value = 'desc'
}
async function loadGoods(reset = false) {
if (loading.value || !queryCategoryId.value) return
if (reset) {
pageNumber.value = 1
goodsList.value = []
hasMore.value = true
}
loading.value = true
failed.value = false
try {
const res = await getGoodsList({
categoryId: queryCategoryId.value,
sort: sort.value,
order: order.value,
pageNumber: pageNumber.value,
pageSize: 10,
})
const records = (res.data && res.data.result && res.data.result.records) || []
goodsList.value.push(...records)
hasMore.value = records.length >= 10
} catch (e) {
failed.value = true
uni.showToast({ title: '加载失败,请重试', icon: 'none' })
} finally {
loading.value = false
}
}
function loadMore() {
if (!hasMore.value || loading.value) return
pageNumber.value += 1
loadGoods(false)
}
function onAddCart(item: any) {
uni.navigateTo({
url: '/pages/product/goods?id=' + item.id + '&goodsId=' + item.goodsId,
})
}
</script>
<style lang="scss" scoped>
.tpl-goods {
flex: 1;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
background: #ffffff;
overflow: hidden;
}
.tpl-main {
flex: 1;
min-height: 0;
height: 0;
display: flex;
overflow: hidden;
}
.tpl-right {
flex: 1;
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: #ffffff;
}
.goods-scroll {
flex: 1;
min-height: 0;
height: 0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx 0 100rpx;
}
.retry {
margin-top: 24rpx;
padding: 12rpx 50rpx;
border: 1rpx solid $light-color;
color: $light-color;
border-radius: 36rpx;
font-size: 24rpx;
font-weight: 500;
}
</style>

View File

@@ -27,18 +27,7 @@
</view> </view>
<!-- 正常商品的价格 --> <!-- 正常商品的价格 -->
<view v-else> <view v-else>
<!-- 批发阶梯价(E_COUPON 仅零售价) --> <div class="goods-price">
<div class="price-row flex" v-if="goodsDetail.salesModel === 'WHOLESALE' && !isECoupon(goodsDetail.goodsType)">
<div class="goods-price" v-for="(item, index) in wholesaleList" :key="index">
<span>
¥
<span class="goods-price-bigshow">{{ goodsFormatPrice(item.price)[0] }}</span>
.{{ goodsFormatPrice(item.price)[1] }}
</span>
<span class="wholesale-item">{{ item.num }}{{ goodsDetail.goodsUnit }}</span>
</div>
</div>
<div class="goods-price" v-else>
<span> <span>
¥ ¥
<span class="goods-price-bigshow">{{ goodsFormatPrice(goodsDetail.price)[0] }}</span> <span class="goods-price-bigshow">{{ goodsFormatPrice(goodsDetail.price)[0] }}</span>
@@ -135,7 +124,6 @@ import {
} from '@/utils/goodsType.js' } from '@/utils/goodsType.js'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
wholesaleList?: any[] | boolean
buyMask?: boolean buyMask?: boolean
isGroup?: boolean isGroup?: boolean
goodsDetail?: any goodsDetail?: any
@@ -144,7 +132,6 @@ const props = withDefaults(defineProps<{
addr?: any | null addr?: any | null
pointDetail?: any | null pointDetail?: any | null
}>(), { }>(), {
wholesaleList: false,
buyMask: false, buyMask: false,
isGroup: false, isGroup: false,
goodsDetail: () => ({}), goodsDetail: () => ({}),
@@ -156,7 +143,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits(['closeBuy', 'changed', 'handleClickSku', 'queryCart']) const emit = defineEmits(['closeBuy', 'changed', 'handleClickSku', 'queryCart'])
const num = ref(props.wholesaleList && Array.isArray(props.wholesaleList) && props.wholesaleList.length > 0 ? props.wholesaleList[0].num : 1) const num = ref(1)
const selectName = ref('') const selectName = ref('')
const selectSkuList = ref<any>('') const selectSkuList = ref<any>('')
@@ -231,18 +218,7 @@ watch(
} }
) )
const numCheck = (val: number) => {
if (Array.isArray(props.wholesaleList) && props.wholesaleList.length > 0) {
if (num.value <= props.wholesaleList[0].num) {
uni.showToast({
title: '批发商品购买数量不能小于起批数量!',
duration: 2000,
icon: 'none'
})
num.value = props.wholesaleList[0].num
}
}
}
const closeMask = () => { const closeMask = () => {
emit('closeBuy', false) emit('closeBuy', false)
@@ -541,13 +517,6 @@ defineExpose({
} }
} }
.wholesale-item {
color: #999 !important;
font-size: 24rpx;
margin: 0 20rpx;
display: flex;
justify-content: center;
}
.goods-header { .goods-header {
height: 200rpx; height: 200rpx;

View File

@@ -8,4 +8,11 @@
| `type` | 商品展示类型 oneColumns twoColumns ,默认展示一行两列商品 | String | 否 | | `type` | 商品展示类型 oneColumns twoColumns ,默认展示一行两列商品 | String | 否 |
| `storeName` | 是否展示店铺名称,默认展示 | Boolean | 否 | | `storeName` | 是否展示店铺名称,默认展示 | Boolean | 否 |
| `keywords` | 高亮展示搜索内容 | String | 否 | | `keywords` | 高亮展示搜索内容 | String | 否 |
| `showFields` | 商品卡片字段开关(Object,null=现状渲染全量字段)。键:`subTitle` 副标题、`spec` 规格摘要(取 attrList 前 2 项)、`tags` 促销标签、`selfTag` 自营、`price` 价格、`linePrice` 划线价(仅 marketPrice>price 时显示)、`sales` 已售/评论、`cart` 购物车按钮。新增字段(副标题/规格/划线价/购物车)仅在 showFields 提供时才会渲染;目前仅 oneColumns 布局支持开关 | Object | 否 |
### EVENTS 事件说明
| 事件名 | 说明 | 回调参数 |
| ------ | ---- | -------- |
| `addCart` | 点击购物车按钮(showFields.cart 开启时渲染) | 当前商品对象 |

View File

@@ -82,9 +82,6 @@
</div> </div>
</view> </view>
<div class="promotion" @click="navigateToDetailPage(item)"> <div class="promotion" @click="navigateToDetailPage(item)">
<div v-if="item.salesModel == 'WHOLESALE'">
<span>批</span>
</div>
<div <div
v-for="(promotionItem, promotionIndex) in getPromotion(item)" v-for="(promotionItem, promotionIndex) in getPromotion(item)"
:key="promotionIndex" :key="promotionIndex"

View File

@@ -20,10 +20,8 @@
}} }}
</div> </div>
</view> </view>
<PaidMemberPrice :quote="paidQuotes[item.id]" />
<div class="promotion" @click="navigateToDetailPage(item)"> <div class="promotion" @click="navigateToDetailPage(item)">
<div v-if="item.salesModel == 'WHOLESALE'">
<span class="promotion-tag">批</span>
</div>
<div v-for="(promotionItem,promotionIndex) in getPromotion(item)" :key="promotionIndex"> <div v-for="(promotionItem,promotionIndex) in getPromotion(item)" :key="promotionIndex">
<span class="promotion-tag" v-if="promotionItem.indexOf('COUPON') != -1">劵</span> <span class="promotion-tag" v-if="promotionItem.indexOf('COUPON') != -1">劵</span>
<span class="promotion-tag" v-if="promotionItem.indexOf('FULL_DISCOUNT') != -1">满减</span> <span class="promotion-tag" v-if="promotionItem.indexOf('FULL_DISCOUNT') != -1">满减</span>
@@ -46,40 +44,47 @@
</view> </view>
</view> </view>
<!-- 一行一列商品展示 --> <!-- 一行一列商品展示 -->
<div v-if="type == 'oneColumns'" class="goods-one-row"> <div v-if="type == 'oneColumns'" class="goods-one-row" :class="{ 'goods-one-row--embedded': !tabBarGap }">
<div v-for="(item, index) in res" :key="index" class="goods-row"> <div v-for="(item, index) in res" :key="index" class="goods-row">
<div class="flex goods-col"> <div class="flex goods-col">
<div class="goods-img" @click="navigateToDetailPage(item)"> <div class="goods-img" @click="navigateToDetailPage(item)">
<u-image width="230rpx" mode="aspectFit" border-radius='16' height="230rpx" :src="item.thumbnail"> <u-image :width="!tabBarGap ? '170rpx' : '230rpx'" mode="aspectFit" :border-radius="!tabBarGap ? '12' : '16'" :height="!tabBarGap ? '170rpx' : '230rpx'" :src="item.thumbnail">
<template #loading><u-loading-icon></u-loading-icon></template> <template #loading><u-loading-icon></u-loading-icon></template>
</u-image> </u-image>
</div> </div>
<div class="goods-detail"> <div class="goods-detail">
<div class="title clamp3" @click="navigateToDetailPage(item)">{{ item.goodsName }}</div> <div class="title clamp2" @click="navigateToDetailPage(item)">
<view class="price-box" @click="navigateToDetailPage(item)"> <text class="self-tag" v-if="(!storeName || flag('selfTag')) && item.selfOperated">自营</text>
<div class="price" v-if="item.price!=undefined"> {{ item.goodsName }}
¥<span class="price-int">{{ goodsFormatPrice(item.price )[0] }} </span>.{{ </div>
goodsFormatPrice(item.price )[1] <div class="selling-point" v-if="extra('subTitle') && item.sellingPoint">{{ item.sellingPoint }}</div>
}} <div class="spec-summary" v-if="extra('spec') && specSummary(item)">{{ specSummary(item) }}</div>
</div> <div class="promotion" v-if="flag('tags')" @click="navigateToDetailPage(item)">
</view> <div v-for="(promotionItem,promotionIndex) in getPromotion(item)" :key="promotionIndex">
<div class="promotion" @click="navigateToDetailPage(item)">
<div v-if="item.salesModel == 'WHOLESALE'">
<span class="promotion-tag">批</span>
</div>
<div v-for="(promotionItem,promotionIndex) in getPromotion(item)" :key="promotionIndex">
<span class="promotion-tag" v-if="promotionItem.indexOf('COUPON') != -1">劵</span> <span class="promotion-tag" v-if="promotionItem.indexOf('COUPON') != -1">劵</span>
<span class="promotion-tag" v-if="promotionItem.indexOf('FULL_DISCOUNT') != -1">满减</span> <span class="promotion-tag" v-if="promotionItem.indexOf('FULL_DISCOUNT') != -1">满减</span>
<span class="promotion-tag" v-if="promotionItem.indexOf('SECKILL') != -1">秒杀</span> <span class="promotion-tag" v-if="promotionItem.indexOf('SECKILL') != -1">秒杀</span>
</div> </div>
</div> </div>
<div style="overflow: hidden" @click="navigateToDetailPage(item)" class="count-config"> <div style="overflow: hidden" v-if="flag('sales')" @click="navigateToDetailPage(item)" class="count-config">
<span style="float: left; font-size: 22rpx">已售 {{ item.buyCount || '0' }}</span> <span style="float: left; font-size: 22rpx">已售 {{ item.buyCount || '0' }}</span>
<span style="float: right; font-size: 22rpx">{{ item.commentNum || '0' }}条评论</span> <span style="float: right; font-size: 22rpx" v-if="item.commentNum">{{ item.commentNum }}条评论</span>
</div> </div>
<div class="store-seller-name" @click="navigateToStoreDetailPage(item)"> <view class="price-box" @click="navigateToDetailPage(item)">
<view class="store-name-row" v-if="storeName"> <div class="price" v-if="flag('price') && item.price!=undefined">
<text class="self-tag" v-if="item.selfOperated">自营</text> ¥<span class="price-int">{{ goodsFormatPrice(item.price )[0] }} </span>.{{
goodsFormatPrice(item.price )[1]
}}
</div>
<div class="market-price" v-if="extra('linePrice') && hasMarketPrice(item)">¥{{ item.marketPrice }}</div>
<div class="cart-btn" v-if="extra('cart')" @click.stop="emit('addCart', item)">
<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>
<text class="store-name-text">{{ item.storeName }}</text> <text class="store-name-text">{{ item.storeName }}</text>
<text class="to-store">进店</text> <text class="to-store">进店</text>
</view> </view>
@@ -87,18 +92,24 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { 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 { useGoodsListCommon } from './common'
import { goodsFormatPrice } from '@/utils/filters.js' import { goodsFormatPrice } from '@/utils/filters.js'
const store = useStore()
const mainColor = computed(() => (store && store.getters && store.getters.mainColor) || '#ff3c2a')
const { lightSearchStr, getPromotion, navigateToDetailPage, navigateToStoreDetailPage } = useGoodsListCommon() const { lightSearchStr, getPromotion, navigateToDetailPage, navigateToStoreDetailPage } = useGoodsListCommon()
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
@@ -107,14 +118,44 @@ const props = withDefaults(defineProps<{
storeName?: boolean storeName?: boolean
tabBarGap?: boolean tabBarGap?: boolean
keyword?: string | null keyword?: string | null
showFields?: Record<string, boolean> | null
}>(), { }>(), {
res: () => [], res: () => [],
type: 'twoColumns', type: 'twoColumns',
storeName: true, storeName: true,
tabBarGap: true, tabBarGap: true,
keyword: '' keyword: '',
showFields: null
}) })
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
// 新增字段(副标题/规格/划线价/购物车按钮):仅 showFields 提供时可渲染
const extra = (key: string) => props.showFields != null && props.showFields[key] !== false
const hasMarketPrice = (item: any) =>
item.marketPrice != null && item.price != null && item.marketPrice > item.price
const specSummary = (item: any) =>
Array.isArray(item.attrList)
? item.attrList.slice(0, 2).map((a: any) => `${a.name}: ${a.value}`).join(' · ')
: ''
watch(() => props.keyword, (val) => { watch(() => props.keyword, (val) => {
if (val) { if (val) {
lightSearchStr(val as string, '') lightSearchStr(val as string, '')
@@ -217,6 +258,128 @@ watch(() => props.keyword, (val) => {
} }
} }
.goods-one-row--embedded {
.goods-row {
padding: 20rpx 16rpx;
border-bottom: 1rpx solid #f6f6f6;
background: #ffffff;
>.goods-col {
display: flex;
align-items: stretch;
>.goods-img {
flex: 0 0 170rpx;
width: 170rpx;
height: 170rpx;
border-radius: 12rpx;
overflow: hidden;
background: #f8f8f8;
}
>.goods-detail {
flex: 1;
min-width: 0;
margin: 0 0 0 16rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
>.title {
font-size: 26rpx;
color: #222;
line-height: 1.4;
max-height: 74rpx;
padding: 0;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
.self-tag {
display: inline-block;
margin-right: 8rpx;
padding: 1rpx 8rpx;
border-radius: 6rpx;
background: $main-color;
color: #fff;
font-size: 20rpx;
line-height: 1.4;
vertical-align: middle;
}
}
.selling-point {
font-size: 22rpx;
color: #999;
line-height: 1.3;
margin-top: 4rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.spec-summary {
font-size: 22rpx;
color: #999;
line-height: 1.3;
margin-top: 2rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.count-config {
font-size: 22rpx;
color: #999;
margin-top: 4rpx;
}
.price-box {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-top: auto;
padding-top: 8rpx;
.price {
font-size: 22rpx;
color: $main-color;
font-weight: 500;
line-height: 1;
.price-int {
font-size: 34rpx;
font-weight: bold;
}
}
.market-price {
font-size: 22rpx;
color: #bbb;
text-decoration: line-through;
margin-left: 8rpx;
}
.cart-btn {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: #ffffff;
border: 2rpx solid $main-color;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
box-shadow: 0 2rpx 8rpx rgba(255, 60, 42, 0.12);
}
}
}
}
}
}
.goods-row { .goods-row {
background: #fff; background: #fff;
padding: 16rpx; padding: 16rpx;
@@ -250,7 +413,48 @@ watch(() => props.keyword, (val) => {
overflow: hidden; overflow: hidden;
} }
.promotion { .selling-point {
font-size: 22rpx;
color: #999;
line-height: 1.4;
margin-top: 4rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.spec-summary {
font-size: 22rpx;
color: #999;
line-height: 1.4;
margin-top: 4rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.market-price {
font-size: 22rpx;
color: #999;
text-decoration: line-through;
margin-left: 8rpx;
}
.cart-btn {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: #ffffff;
border: 2rpx solid $main-color;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-sizing: border-box;
box-shadow: 0 2rpx 8rpx rgba(255, 60, 42, 0.12);
}
.promotion {
margin-top: 4rpx; margin-top: 4rpx;
display: flex; display: flex;

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

View File

@@ -1,15 +1,18 @@
/** /**
* buyer : 买家 API(baseURL 为 buyer + /buyer) * buyer : 买家 API(baseURL 为 buyer + /buyer)
* store : 商家 API(baseURL 为 store + /store)
*/ */
// 开发环境 // 开发环境
const dev = { const dev = {
buyer: "https://buyer-api.pickmall.cn", buyer: "https://buyer-api.pickmall.cn",
store: "https://admin-api.pickmall.cn",
im: "https://im-api.pickmall.cn", im: "https://im-api.pickmall.cn",
mqtt: "wss://lilishop-mqtt-pull.dllll.xyz/mqtt", mqtt: "wss://lilishop-mqtt-pull.dllll.xyz/mqtt",
}; };
// 生产环境 // 生产环境
const prod = { const prod = {
buyer: "https://buyer-api.pickmall.cn", buyer: "https://buyer-api.pickmall.cn",
store: "https://admin-api.pickmall.cn",
im: "https://im-api.pickmall.cn", im: "https://im-api.pickmall.cn",
mqtt: "wss://lilishop-mqtt-pull.dllll.xyz/mqtt", mqtt: "wss://lilishop-mqtt-pull.dllll.xyz/mqtt",
}; };
@@ -26,10 +29,13 @@ api = prod;
// #endif // #endif
const buyerHost = api.buyer; const buyerHost = api.buyer;
const storeHost = api.store;
api.buyer = buyerHost + "/buyer"; api.buyer = buyerHost + "/buyer";
api.store = storeHost + "/store";
api.im = api.im + "/im"; api.im = api.im + "/im";
export default { export default {
...api, ...api,
buyerHost, buyerHost,
storeHost,
}; };

View File

@@ -2,18 +2,105 @@
* 微信小程序支付 * 微信小程序支付
* 此处针对于微信小程序开发的支付插件 * 此处针对于微信小程序开发的支付插件
* 第一次支付成功后会跳出订阅的消息 如果用户拒绝或同意都会跳转到支付成功页面 * 第一次支付成功后会跳出订阅的消息 如果用户拒绝或同意都会跳转到支付成功页面
* 如果点击订阅 会将状态写进缓存 之后不再提醒。
* *
* @param {sn,price} * @param {sn,price,orderPromotionType}
*/ */
import { getWeChatMpMessage } from "@/api/message.js"; import { getWeChatMpMessage } from "@/api/message.js";
import { initiatePay } from "@/api/trade"; 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 { class LiLiWXPay {
constructor(...payList) { constructor(...payList) {
this.data = payList[0]; this.data = payList[0];
console.log(payList);
// 调用支付
this.pay = () => { this.pay = () => {
uni.showLoading({ uni.showLoading({
title: "加载中", title: "加载中",
@@ -26,7 +113,6 @@ class LiLiWXPay {
}; };
const paymentMethod = "WECHAT"; const paymentMethod = "WECHAT";
const paymentClient = "MP"; const paymentClient = "MP";
// 调用支付
initiatePay(paymentMethod, paymentClient, submitData).then((res) => { initiatePay(paymentMethod, paymentClient, submitData).then((res) => {
let response = res.data.result; let response = res.data.result;
uni.hideLoading(); uni.hideLoading();
@@ -38,16 +124,25 @@ class LiLiWXPay {
package: response.package, package: response.package,
signType: response.signType, signType: response.signType,
paySign: response.paySign, paySign: response.paySign,
success: (e) => { success: () => {
uni.showToast({ uni.showToast({
icon: "none", icon: "none",
title: "支付成功!", 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) => { fail: (e) => {
this.exception = e; this.exception = e;
// 支付异常或支付失败之后跳转到订单页面
uni.showModal({ uni.showModal({
content: "支付失败,如果您已支付,请勿反复支付", content: "支付失败,如果您已支付,请勿反复支付",
showCancel: false, 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; export default LiLiWXPay;

View File

@@ -39,6 +39,7 @@ export function createApp() {
setConfig({ setConfig({
config: { config: {
loadFontOnce: true, loadFontOnce: true,
iconUrl: 'https://lilishop-uniapp.oss-cn-beijing.aliyuncs.com/font_2225171_8kdcwk4po24.ttf',
}, },
props: { props: {
form: { form: {

View File

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

2
package-lock.json generated
View File

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

View File

@@ -11,7 +11,7 @@
"type": "git", "type": "git",
"url": "https://gitee.com/beijing_hongye_huicheng/lilishop-uniapp" "url": "https://gitee.com/beijing_hongye_huicheng/lilishop-uniapp"
}, },
"license": "MIT", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@tencentcloud/chat": "^3.5.5", "@tencentcloud/chat": "^3.5.5",
"hls.js": "^1.6.16", "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": [ "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", "root": "pages/mine",
"pages": [ "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", "path": "signIn",
@@ -447,6 +515,13 @@
"navigationBarTitleText": "设置" "navigationBarTitleText": "设置"
} }
}, },
{
"path": "set/messageNotice",
"style": {
"navigationBarTitleText": "消息通知",
"navigationStyle": "custom"
}
},
{ {
"path": "set/personMsg", "path": "set/personMsg",
"style": { "style": {
@@ -471,7 +546,8 @@
}, { }, {
"path": "point/myPoint", "path": "point/myPoint",
"style": { "style": {
"navigationBarTitleText": "我的积分" "navigationBarTitleText": "我的积分",
"navigationStyle": "custom"
} }
}, },
{ {
@@ -772,7 +848,11 @@
} }
}, },
{ {
"path": "gift-card/myGiftCards", "path": "gift-card/pickupCard",
"style": { "navigationBarTitleText": "提货卡兑换" }
},
{
"path": "gift-card/myGiftCards",
"style": { "style": {
"navigationBarTitleText": "我的礼品卡" "navigationBarTitleText": "我的礼品卡"
} }
@@ -926,6 +1006,7 @@
{ {
"path": "afterSales/applySuccess", "path": "afterSales/applySuccess",
"style": { "style": {
"navigationStyle": "custom",
"navigationBarTitleText": "提交成功", "navigationBarTitleText": "提交成功",
"app-plus": { "app-plus": {
"bounce": "none", "bounce": "none",
@@ -957,13 +1038,6 @@
"navigationBarTitleText": "申请售后" "navigationBarTitleText": "申请售后"
} }
}, },
{
"path": "afterSales/afterSales",
"style": {
"navigationBarTitleText": "售后管理",
"enablePullDownRefresh": true
}
},
{ {
"path": "fillorder", "path": "fillorder",
"style": { "style": {

View File

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

View File

@@ -5,7 +5,7 @@
<view class="title">{{ coupon.title }}</view> <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 == 'PRICE'"><text>¥</text>{{ unitPrice(coupon.price) }}</view>
<view class="price" v-if="coupon.couponType == 'DISCOUNT'">{{ coupon.discount }}折</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="bg-quan">券</view>
<view class="jiao-1" :class="{ 'used-color': coupon.used_status != 0 }"> <view class="jiao-1" :class="{ 'used-color': coupon.used_status != 0 }">
<text class="text-1">{{ coupon.used_status == 0 ? '新到' : coupon.used_status_text }}</text> <text class="text-1">{{ coupon.used_status == 0 ? '新到' : coupon.used_status_text }}</text>

View File

@@ -41,7 +41,7 @@
</template> </template>
使用 使用
</text> </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.reason">{{ item.reason }}</text>
<text class="coupon-card-time" v-if="item.endTime">有效期至:{{ item.endTime }}</text> <text class="coupon-card-time" v-if="item.endTime">有效期至:{{ item.endTime }}</text>
</view> </view>

View File

@@ -47,6 +47,7 @@
<view class="coupon-card-right"> <view class="coupon-card-right">
<view class="coupon-info"> <view class="coupon-info">
<text class="coupon-card-name">{{ coupon.title }}</text> <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">使用范围:{{ <text class="coupon-card-desc">使用范围:{{
coupon.scopeType == "ALL" && coupon.storeId == "0" coupon.scopeType == "ALL" && coupon.storeId == "0"
? "全平台" ? "全平台"

View File

@@ -52,17 +52,18 @@
<view class="gcc-header-inner"> <view class="gcc-header-inner">
<view class="gcc-header-left"> <view class="gcc-header-left">
<view class="gcc-title">{{ item.giftCardName || '礼品卡' }}</view> <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 class="gcc-cardno">{{ item.cardNo }}</view>
</view> </view>
<view class="gcc-header-right"> <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 class="gcc-expire">{{ item.expireText }}</view>
</view> </view>
</view> </view>
</view> </view>
<view class="gcc-body"> <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-if="item.balance != null">¥{{ unitPrice(item.balance) }}</text>
<text class="gcc-strong" v-else>—</text> <text class="gcc-strong" v-else>—</text>
@@ -236,7 +237,8 @@ function onCardPrimaryAction(item: any) {
return return
} }
if (statusTab.value === GIFT_CARD_STATUS.AVAILABLE) { 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> </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,7 +68,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, getCurrentInstance, onMounted, computed } from 'vue' 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 { useStore } from '@/store'
import * as API_Trade from '@/api/trade' import * as API_Trade from '@/api/trade'
import { payCallback } from '@/api/members' import { payCallback } from '@/api/members'
@@ -88,6 +88,8 @@ const paymentType = ref('')
const paymentClient = ref('') const paymentClient = ref('')
const walletValue = ref(0) const walletValue = ref(0)
const autoCancelTime = ref(0) const autoCancelTime = ref(0)
let h5PollTimer: ReturnType<typeof setInterval> | undefined
onUnload(() => { if (h5PollTimer) clearInterval(h5PollTimer) })
onLoad((val) => { onLoad((val) => {
routerVal.value = val || {} routerVal.value = val || {}
@@ -107,7 +109,9 @@ onLoad((val) => {
onBackPress((e) => { onBackPress((e) => {
if (e.from == 'backbutton') { 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' }) uni.switchTab({ url: '/pages/tabbar/user/my' })
} else { } else {
uni.navigateTo({ url: '/pages/order/myOrder?status=0' }) uni.navigateTo({ url: '/pages/order/myOrder?status=0' })
@@ -126,21 +130,33 @@ function hideLoadingIfNeeded() {
} }
function callback(paymentMethod: string) { function callback(paymentMethod: string) {
uni.redirectTo({ if (orderType.value === 'PAID_MEMBER') {
url: uni.redirectTo({ url: '/pages/mine/paidMember?payment_sn=' + encodeURIComponent(sn.value) })
'/pages/cart/payment/success?paymentMethod=' + return
paymentMethod + }
'&payPrice=' + const goSuccess = () => {
cashierParams.value.price + uni.redirectTo({
'&orderType=' + url:
orderType.value, '/pages/cart/payment/success?paymentMethod=' +
}) paymentMethod +
'&payPrice=' +
cashierParams.value.price +
'&orderType=' +
orderType.value +
'&orderPromotionType=' +
(cashierParams.value.orderPromotionType || ''),
})
}
goSuccess()
} }
function cashierData() { function cashierData() {
const parms: Record<string, string> = {} 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 sn.value = routerVal.value.recharge_sn
orderType.value = 'RECHARGE' orderType.value = 'RECHARGE'
} else if (routerVal.value.trade_sn) { } else if (routerVal.value.trade_sn) {
@@ -207,6 +223,11 @@ function onPayTimeout() {
} }
function goPayError(message = '支付失败,如果您已支付,请勿反复支付') { 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)}`] const query = [`orderSn=${encodeURIComponent(sn.value)}`, `message=${encodeURIComponent(message)}`]
uni.navigateTo({ url: `/pages/cart/payment/error?${query.join('&')}` }) uni.navigateTo({ url: `/pages/cart/payment/error?${query.join('&')}` })
} }
@@ -271,15 +292,24 @@ async function pay(payment: string) {
}) })
hideLoadingIfNeeded() hideLoadingIfNeeded()
} else { } 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 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) => { payCallback(searchParams).then((cbRes) => {
if (cbRes.data.result) { if (cbRes.data.result) {
clearInterval(timer) clearInterval(h5PollTimer)
uni.navigateTo({ url: '/pages/order/myOrder' }) if (orderType.value === 'PAID_MEMBER') callback(paymentMethod)
else uni.navigateTo({ url: '/pages/order/myOrder' })
} }
}) }).catch(() => {})
}, 3000) }, 3000)
hideLoadingIfNeeded() hideLoadingIfNeeded()
} }

View File

@@ -2,7 +2,7 @@
<div :style="themeStyle" class="wrapper"> <div :style="themeStyle" class="wrapper">
<div class="pay-wrapper"> <div class="pay-wrapper">
<div class="pay-money"> <div class="pay-money">
¥{{unitPrice(Number(payPrice)) }} ¥{{ unitPrice(Number(payPrice)) }}
</div> </div>
<div class="pay-btns"> <div class="pay-btns">
<div v-show="!from" @click="checkOrder">查看{{ orderType == "RECHARGE" ? '余额' : '订单' }}</div> <div v-show="!from" @click="checkOrder">查看{{ orderType == "RECHARGE" ? '余额' : '订单' }}</div>
@@ -22,7 +22,6 @@
</div> </div>
<goodsRecommend /> <goodsRecommend />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
@@ -31,6 +30,11 @@ import goodsRecommend from '@/components/m-goods-recommend'
import { unitPrice } from '@/utils/filters.js' import { unitPrice } from '@/utils/filters.js'
import { useStore } from '@/store' import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme' import { getThemeStyle } from '@/utils/theme'
import {
prefetchMpMessageTemplates,
subscribePayMessageThen,
} from '@/js_sdk/lili-pay/wx-pay.js'
const store = useStore() const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme)) const themeStyle = computed(() => getThemeStyle(store.state.theme))
@@ -38,12 +42,17 @@ const paymentMethod = ref('')
const from = ref('') const from = ref('')
const payPrice = ref<number | string>(0) const payPrice = ref<number | string>(0)
const orderType = ref('') const orderType = ref('')
const orderPromotionType = ref('')
onLoad((options) => { onLoad((options) => {
paymentMethod.value = options.paymentMethod || '' paymentMethod.value = options.paymentMethod || ''
from.value = options.from || '' from.value = options.from || ''
payPrice.value = options.payPrice || 0 payPrice.value = options.payPrice || 0
orderType.value = options.orderType || '' orderType.value = options.orderType || ''
orderPromotionType.value = options.orderPromotionType || ''
// #ifdef MP-WEIXIN
prefetchMpMessageTemplates()
// #endif
}) })
function paymentTypeFilter(val: string) { function paymentTypeFilter(val: string) {
@@ -66,19 +75,44 @@ function checkOrder() {
}) })
return return
} }
uni.reLaunch({ const doGoOrder = () => {
url: '/pages/order/myOrder?status=0', 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) { function navigateTo(url: string, type?: string) {
if (type === 'switch') { const doNavigate = () => {
uni.switchTab({ url }) if (type === 'switch') {
} else { uni.switchTab({ url })
uni.redirectTo({ 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"> </script>
<style scoped lang="scss">
.subscribe { .subscribe {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;

View File

@@ -1,5 +1,19 @@
<template> <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 <up-form
:model="form" :model="form"
ref="uForm" ref="uForm"
@@ -7,30 +21,31 @@
:rules="rules" :rules="rules"
label-position="left" label-position="left"
label-width="180rpx" 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 <u-input
v-model="form.name" v-model="form.name"
border="none" border="none"
input-align="right" input-align="left"
clearable clearable
placeholder="请输入收货人姓名" placeholder="姓名"
/> />
</up-form-item> </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 <u-input
v-model="form.mobile" v-model="form.mobile"
type="number" type="number"
maxlength="11" maxlength="11"
border="none" border="none"
input-align="right" input-align="left"
placeholder="请输入收货人手机号码" placeholder="收货人的手机号码"
/> />
</up-form-item> </up-form-item>
<up-form-item label="所在区域" label-width="180rpx" prop="___path" :border-bottom="true"> <up-form-item label="所在地区" label-width="160rpx" prop="___path" :border-bottom="true" :custom-style="fieldStyle" @click="showPicker">
<view class="form-value" @click="showPicker"> <view class="form-value" :class="{ 'is-placeholder': !form.___path }">
{{ form.___path || '请选择所在地区' }} {{ form.___path || '请选择所在地区' }}
</view> </view>
<template #right> <template #right>
@@ -38,40 +53,38 @@
</template> </template>
</up-form-item> </up-form-item>
<up-form-item class="detailAddress" label="详细地址" label-width="180rpx" prop="detail" :border-bottom="true"> <up-form-item label="详细地址" label-width="160rpx" prop="detail" :border-bottom="true" :custom-style="{ ...fieldStyle, alignItems: 'flex-start' }">
<u-input <textarea
type="textarea" class="address-textarea"
v-model="form.detail" v-model="form.detail"
maxlength="100" maxlength="100"
height="150" :auto-height="true"
border="none" placeholder="街道、门牌号等详细信息"
input-align="right" placeholder-style="color: #b1b5bd; font-size: 28rpx;"
placeholder="街道楼牌号等"
/> />
</up-form-item> </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 <u-input
v-model="form.alias" v-model="form.alias"
border="none" border="none"
input-align="right" input-align="left"
placeholder="请输入地址别名" placeholder="如:家、公司(选填)"
/> />
</up-form-item> </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> </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 <m-city
:provinceData="list" :provinceData="list"
@@ -93,16 +106,114 @@ import MCity from '@/components/m-city/m-city.vue'
import uniMap from '@/components/uniMap' import uniMap from '@/components/uniMap'
import permision from '@/js_sdk/wa-permission/permission.js' import permision from '@/js_sdk/wa-permission/permission.js'
import { getThemeStyle } from '@/utils/theme' import { getThemeStyle } from '@/utils/theme'
import { getRegionsById } from '@/api/common.js'
import { resolveWechatRegion, getWechatAddressErrorType } from '@/utils/wechatAddress.js'
const store = useStore() const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme)) const themeStyle = computed(() => getThemeStyle(store.state.theme))
const { proxy } = getCurrentInstance()! 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 mapFlag = ref(false)
const routerVal = ref<Record<string, string>>({}) const routerVal = ref<Record<string, string>>({})
const uForm = ref<any>(null) const uForm = ref<any>(null)
const cityPicker = 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>>({ const form = reactive<Record<string, any>>({
detail: '', detail: '',
@@ -160,18 +271,24 @@ const rules = {
onShow(() => {}) onShow(() => {})
onLoad((option) => { onLoad(async (option) => {
uni.showLoading({ title: '加载中' })
routerVal.value = option || {} routerVal.value = option || {}
if (option.id) { uni.setNavigationBarTitle({ title: option?.id ? '编辑收货地址' : '新增收货地址' })
getAddressDetail(option.id).then((res) => { if (!option?.id) return
const params = res.data.result uni.showLoading({ title: '加载中' })
params.___path = params.consigneeAddressPath try {
Object.assign(form, params) const res = await getAddressDetail(option.id)
if (store.state.isShowToast) uni.hideLoading() 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(() => { onReady(() => {
@@ -233,6 +350,7 @@ function callBackAddress(val: any) {
} }
function save() { function save() {
if (importing.value) return
uForm.value?.validate().then(() => { uForm.value?.validate().then(() => {
const params = { ...form } const params = { ...form }
delete params.___path delete params.___path
@@ -278,11 +396,12 @@ function getpickerParentValue(e: any[]) {
form.___path = name form.___path = name
} }
if (index == e.length - 1) { if (index == e.length - 1) {
const _town = item.children.filter((_child: any) => _child.id == item.id) const center = String(item.center || item.children?.find((_child: any) => _child.id == item.id)?.center || '').split(',')
form.lat = _town[0].center.split(',')[1] form.lat = center.length === 2 ? center[1] : ''
form.lon = _town[0].center.split(',')[0] form.lon = center.length === 2 ? center[0] : ''
} }
}) })
importNotice.value = ''
} }
function showPicker() { function showPicker() {
@@ -290,99 +409,25 @@ function showPicker() {
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
page { @import "./address-page.scss";
background: #f5f5f5; .wechat-import { margin-bottom: 28rpx; }
} .wechat-import-button {
display: flex; align-items: center; padding: 28rpx; margin: 0; width: 100%; text-align: left;
.add-address { background: #eef9f2; border-radius: 24rpx; line-height: 1.5;
min-height: 100vh; &::after { border: 1rpx solid #dcefe3; border-radius: 24rpx; }
background: #fff; &[disabled] { background: #eef9f2; opacity: .65; }
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;
} }
.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> </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> <template>
<view class="address"> <view class="address-page address-manage" :style="themeStyle">
<u-empty class="empty" v-if="addressList.length === 0" text="暂无收货地址" mode="address"></u-empty> <view v-if="!loading && addressList.length === 0" class="empty-state">
<view class="list" > <view class="empty-icon"><u-icon name="map" size="42" color="#c4c8d1" /></view>
<view class="item c-content" v-for="(item, index) in addressList" :key="index"> <text class="empty-title">还没有收货地址</text>
<view class="basic"> <text class="empty-description">添加常用地址,下次购物更方便</text>
<text>{{ item.name }}</text> </view>
<text>{{ item.mobile }}</text> <view v-if="addressList.length" class="list-heading">
<text class="default" v-show="item.isDefault">默认</text> <text>我的收货地址</text>
<view> <text class="address-count">共 {{ addressList.length }} 个</text>
<div class="region"> </view>
<span v-if="item.consigneeAddressPath[0]">{{item.consigneeAddressPath[0]}}</span> <view class="address-list">
<span v-if="item.consigneeAddressPath[1]">{{item.consigneeAddressPath[1]}}</span> <view class="address-card address-item" v-for="item in addressList" :key="item.id">
<span v-if="item.consigneeAddressPath[2]">{{item.consigneeAddressPath[2]}}</span> <view class="recipient-line">
<span v-if="item.consigneeAddressPath[3]">{{item.consigneeAddressPath[3]}}</span> <text class="recipient-name">{{ item.name }}</text>
<span>{{ item.detail }}</span> <text class="recipient-mobile">{{ item.mobile }}</text>
</div>
</view>
</view> </view>
<view class="edit"> <view class="address-region">{{ item.consigneeAddressPath.join(' ') }}</view>
<view class="relative" @click="setDefault(item)"> <text class="address-detail">{{ item.detail }}</text>
<view v-if="item.isDefault" class="alifont icon-xuanzhong"></view> <view class="address-tags" v-if="item.isDefault || item.alias">
<text v-else class="unchecked"></text> <text v-if="item.isDefault" class="address-tag default-tag">默认地址</text>
<text>{{ item.isDefault ? "默认地址" : "设为默认" }}</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>
<view class="relative"> <view class="edit-actions">
<view class="alifont icon-bianji-copy"></view> <view class="text-action" @click="addAddress(item.id)">
<text class="mr-40" @click="addAddress(item.id)">编辑</text> <u-icon name="edit-pen" size="16" color="#6c7280" /><text>编辑</text>
<view class="alifont icon-lajitong" @click.stop="removeAddress(item.id)"></view> </view>
<text @click.stop="removeAddress(item.id)">删除</text> <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> </view>
</view> </view>
<view class="address-footer">
<view class="btn-bar"> <button class="address-primary-button" @click="addAddress('')">
<view class="btn" @click="addAddress('')"> <u-icon name="plus" color="#fff" size="17" /><text>新增收货地址</text>
<u-icon name="plus-circle" color="#ffffff" size="18"></u-icon> </button>
<text class="btn-text">添加新收货人</text>
</view>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed } from 'vue'
import { onLoad, onShow, onPullDownRefresh, onBackPress } from '@dcloudio/uni-app' import { onLoad, onShow, onPullDownRefresh, onBackPress } from '@dcloudio/uni-app'
import { useStore } from '@/store' import { useStore } from '@/store'
import { tipsToLogin } from '@/utils/filters.js' import { tipsToLogin } from '@/utils/filters.js'
import * as API_Address from '@/api/address.js' import * as API_Address from '@/api/address.js'
import { getThemeStyle } from '@/utils/theme'
const store = useStore() const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const loading = ref(true)
const addressList = ref<any[]>([]) const addressList = ref<any[]>([])
const routerVal = ref<Record<string, string>>({}) const routerVal = ref<Record<string, string>>({})
@@ -65,7 +74,6 @@ onLoad((val) => {
}) })
onPullDownRefresh(() => { onPullDownRefresh(() => {
addressList.value = []
getAddressList() getAddressList()
}) })
@@ -75,19 +83,23 @@ onShow(() => {
} }
}) })
function hideLoadingIfNeeded() { async function getAddressList() {
if (store.state.isShowToast) uni.hideLoading() loading.value = true
} try {
const res = await API_Address.getAddressList(params.pageNumber, params.pageSize)
function getAddressList() { if (!res.data?.success || !Array.isArray(res.data.result?.records)) throw new Error('地址加载失败')
uni.showLoading() addressList.value = res.data.result.records.map((item: any) => ({
API_Address.getAddressList(params.pageNumber, params.pageSize).then((res) => { ...item,
res.data.result.records.forEach((item: any) => { consigneeAddressPath: Array.isArray(item.consigneeAddressPath)
item.consigneeAddressPath = item.consigneeAddressPath.split(',') ? item.consigneeAddressPath
}) : String(item.consigneeAddressPath || '').split(',').filter(Boolean),
addressList.value = res.data.result.records }))
hideLoadingIfNeeded() } catch {
}) uni.showToast({ title: '地址加载失败,请下拉重试', icon: 'none' })
} finally {
loading.value = false
uni.stopPullDownRefresh()
}
} }
function removeAddress(id: string) { function removeAddress(id: string) {
@@ -97,7 +109,7 @@ function removeAddress(id: string) {
success: (res) => { success: (res) => {
if (!res.confirm) return if (!res.confirm) return
API_Address.deleteAddress(id).then((result) => { API_Address.deleteAddress(id).then((result) => {
if (result.statusCode == 200) { if (result.data?.success) {
uni.showToast({ icon: 'none', title: '删除成功' }) uni.showToast({ icon: 'none', title: '删除成功' })
getAddressList() getAddressList()
} else { } else {
@@ -115,17 +127,45 @@ function addAddress(id?: string) {
} }
function setDefault(item: any) { function setDefault(item: any) {
delete item.updateBy if (item.isDefault) return
delete item.updateTime const data = { ...item, isDefault: true, consigneeAddressPath: item.consigneeAddressPath.join(',') }
delete item.deleteFlag delete data.updateBy
if (!item.isDefault) item.isDefault = true delete data.updateTime
API_Address.editAddress(item).then(() => { delete data.deleteFlag
uni.showToast({ title: '设置默认地址成功', icon: 'none' }) API_Address.editAddress(data).then((res) => {
getAddressList() 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> </script>
<style lang="scss" scoped> <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> </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,87 +1,136 @@
<template> <template>
<view :style="themeStyle" class="wap"> <view :style="themeStyle" class="deposit-log-page">
<u-navbar <u-navbar
title="预存款列表" title="预存款明细"
:fixed="true" :fixed="true"
:placeholder="true" :placeholder="true"
:border="false" :border="false"
:auto-back="true" :auto-back="true"
></u-navbar> ></u-navbar>
<view class="wrapper-show-money"> <!-- 顶部余额概览卡片 -->
<view class="money-view"> <view class="header-wrap">
<text class="money-label">预存款金额</text> <view class="balance-hero">
<text class="money">¥{{ unitPrice(walletNum) }}</text> <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> </view>
<view class="wrapper-tabs"> <!-- 明细列表区 -->
<swiper class="swiper-box" :current="swiperCurrent"> <view class="list-container">
<swiper-item class="swiper-item" v-for="index in list.length" :key="index"> <view v-if="filteredData.length > 0" class="list-card">
<scroll-view <view
class="scroll-v view-wrapper" class="log-item"
enable-back-to-top v-for="(item, logIndex) in filteredData"
scroll-with-animation :key="logIndex"
scroll-y >
@scrolltolower="loadMore" <!-- 第一行:类型标签 + 标题 | 变动金额 -->
> <view class="log-row-main">
<view v-if="depositData.length != 0" class="list-card"> <view class="log-title-wrap">
<view <text class="type-tag" :class="item.parsed.tagClass">{{ item.parsed.tagText }}</text>
class="view-item" <text class="log-title">{{ item.parsed.title }}</text>
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> </view>
<text class="log-amount" :class="item.parsed.moneyClass">
{{ item.parsed.formattedMoney }}
</text>
</view>
<u-empty v-if="depositData.length == 0" mode="history" text="暂无记录" /> <!-- 第二行:提取出的单号或备注说明 -->
</scroll-view> <view v-if="item.parsed.metaList.length > 0" class="log-meta-box">
</swiper-item> <view
</swiper> 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>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { onReachBottom } from '@dcloudio/uni-app'
import { getWalletLog, getUserWallet } from '@/api/members' import { getWalletLog, getUserWallet } from '@/api/members'
import { unitPrice } from '@/utils/filters.js' import { unitPrice } from '@/utils/filters.js'
import { useStore } from '@/store' import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme' import { getThemeStyle } from '@/utils/theme'
const store = useStore() const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme)) const themeStyle = computed(() => getThemeStyle(store.state.theme))
const walletNum = ref(0) const walletNum = ref(0)
const swiperCurrent = 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({ const params = ref({
pageNumber: 1, pageNumber: 1,
pageSize: 10, pageSize: 10,
order: 'desc', order: 'desc',
}) })
const depositData = ref<any[]>([]) const depositData = ref<any[]>([])
const list = [{ name: '预存款变动明细' }]
onMounted(async () => { onMounted(async () => {
getWallet() getWallet()
const result = await getUserWallet() const result = await getUserWallet()
walletNum.value = result.data.result.memberWallet walletNum.value = result.data?.result?.memberWallet ?? 0
}) })
function isOutgoing(serviceType: string) { 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' return serviceType === 'WALLET_PAY' || serviceType === 'WALLET_WITHDRAWAL'
} }
function isIncoming(serviceType: string) { function isIncoming(logItem: any) {
const money = Number(logItem?.money) || 0
if (money > 0) return true
const serviceType = logItem?.serviceType
return ( return (
serviceType === 'WALLET_REFUND' || serviceType === 'WALLET_REFUND' ||
serviceType === 'WALLET_RECHARGE' || serviceType === 'WALLET_RECHARGE' ||
@@ -89,23 +138,109 @@ function isIncoming(serviceType: string) {
) )
} }
function getMoneyClass(serviceType: string) { function parseLogDetail(logItem: any) {
if (isOutgoing(serviceType)) return 'out' const raw = String(logItem?.detail || '').trim()
if (isIncoming(serviceType)) return 'in' const serviceType = logItem?.serviceType || ''
return '' 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,
}
} }
function formatLogMoney(logItem: any) { const decoratedList = computed(() =>
const amount = unitPrice(logItem.money) depositData.value.map((item) => ({
if (isOutgoing(logItem.serviceType)) return `-${amount}` ...item,
if (isIncoming(logItem.serviceType)) return `+${amount}` parsed: parseLogDetail(item),
return amount }))
} )
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() { function getWallet() {
getWalletLog(params.value).then((res) => { getWalletLog(params.value).then((res) => {
if (res.data.success && res.data.result.records.length) { loaded.value = true
depositData.value.push(...res.data.result.records) 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
} }
}) })
} }
@@ -117,123 +252,233 @@ function loadMore() {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.wap { .deposit-log-page {
min-height: 100vh; min-height: 100vh;
display: flex; background: #f5f6fa;
flex-direction: column; padding-bottom: 48rpx;
background: #f5f5f5;
}
.wrapper-show-money {
flex-shrink: 0;
height: 200rpx;
}
.money-view {
height: 100%;
padding: 32rpx 40rpx;
box-sizing: border-box; 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; display: flex;
flex-direction: column; align-items: center;
justify-content: center; justify-content: space-between;
align-items: flex-start;
color: #fff;
background-image: linear-gradient(
25deg,
$main-color,
$light-color,
$aider-light-color
);
} }
.money-label { .money-label {
font-size: 28rpx; font-size: 25rpx;
opacity: 0.95; color: rgba(255, 255, 255, 0.88);
letter-spacing: 0.5rpx;
} }
.money { .money-row {
margin-top: 12rpx; margin-top: 14rpx;
max-width: 100%; display: flex;
font-size: 56rpx; align-items: baseline;
}
.money-symbol {
font-size: 34rpx;
font-weight: 600; font-weight: 600;
line-height: 1.2; margin-right: 8rpx;
word-break: break-all;
} }
.wrapper-tabs { .money-value {
flex: 1; font-size: 58rpx;
min-height: 0; font-weight: 700;
padding: 24rpx; line-height: 1.15;
box-sizing: border-box; letter-spacing: 1rpx;
font-variant-numeric: tabular-nums;
} }
.swiper-box, .filter-bar {
.swiper-item, display: flex;
.scroll-v { align-items: center;
height: 100%; 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 { .list-card {
background: #fff; display: flex;
border-radius: 16rpx; flex-direction: column;
overflow: hidden; gap: 18rpx;
} }
.view-item { .log-item {
display: flex; background: #ffffff;
flex-direction: row; border-radius: 20rpx;
align-items: flex-start; padding: 28rpx 30rpx;
justify-content: space-between; box-shadow: 0 2rpx 12rpx rgba(17, 30, 79, 0.03);
padding: 28rpx 32rpx; }
border-bottom: 1px solid #f5f5f5;
&:last-child { .log-row-main {
border-bottom: none; 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;
} }
} }
.view-item-detail { .log-title {
flex: 1; font-size: 30rpx;
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; font-weight: 600;
line-height: 1.3; color: #1d2129;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; 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 { &.in {
color: $aider-color-green; color: #00a870;
} }
&.out { &.out {
color: #ff5a5f; color: #ff4d4f;
} }
} }
.item-time { .log-meta-box {
margin-top: 8rpx; 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; font-size: 22rpx;
color: #999; color: #999999;
line-height: 1.3; line-height: 1.2;
white-space: nowrap; }
.empty-box {
padding-top: 120rpx;
} }
</style> </style>

View File

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

View File

@@ -1,41 +1,114 @@
<template> <template>
<view :style="themeStyle" class="wap"> <view :style="themeStyle" class="withdraw-log-page">
<u-navbar <u-navbar
title="提现记录" title="提现记录"
:fixed="true" :fixed="true"
:placeholder="true" :placeholder="true"
:border="false" :border="false"
:auto-back="true" :auto-back="false"
@leftClick="back"
></u-navbar> ></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="filter-bar">
<view class="-title">{{ withdrawStatusText(item.applyStatus) }}</view> <view
<view class="-number" v-if="item.inspectRemark || item.errorMessage">{{ item.inspectRemark || item.errorMessage }}</view> v-for="(tab, idx) in filterTabs"
</view> :key="tab.key"
<view class="view-item-change"> class="filter-pill"
<view class="-money">-{{unitPrice(item.applyMoney) }}</view> :class="{ active: activeFilter === idx }"
<view class="-time">{{ item.createTime || item.inspectTime || "" }}</view> @click="activeFilter = idx"
<view v-if="item.applyStatus === 'WAIT_USER_CONFIRM'" class="confirm-btn" @click.stop="confirmWechatReceive(item)">确认收款</view> >
{{ 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>
</view> </view>
<u-empty v-if="loaded && records.length == 0" mode="history" text="暂无记录" /> <view v-else-if="loaded" class="empty-box">
</scroll-view> <u-empty mode="history" text="暂无提现记录" />
</view>
</view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' 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 { getWithdrawApplyPage, getWithdrawApplyWechatTransferInfo } from '@/api/members'
import { unitPrice } from '@/utils/filters.js' import { unitPrice } from '@/utils/filters.js'
import { useStore } from '@/store' import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme' import { getThemeStyle } from '@/utils/theme'
import {
prefetchMpMessageTemplates,
subscribeMpMessageByScenes,
} from '@/js_sdk/lili-pay/wx-pay.js'
const store = useStore() const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme)) const themeStyle = computed(() => getThemeStyle(store.state.theme))
const loaded = ref(false) 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({ const params = ref({
pageNumber: 1, pageNumber: 1,
pageSize: 10, pageSize: 10,
@@ -44,40 +117,88 @@ const params = ref({
const records = ref<any[]>([]) const records = ref<any[]>([])
onShow(() => { 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 params.value.pageNumber = 1
hasMore.value = true
records.value = [] records.value = []
loaded.value = false loaded.value = false
getData() getData()
}) }
function withdrawStatusText(applyStatus: string) { function parseWithdrawStatus(applyStatus: string) {
switch (applyStatus) { switch (applyStatus) {
case 'APPLY': case 'APPLY':
return '申请中' return { statusText: '申请中', statusClass: 'tag-pending', group: 'PENDING' }
case 'VIA_AUDITING': case 'VIA_AUDITING':
return '审核通过' return { statusText: '审核通过', statusClass: 'tag-pending', group: 'PENDING' }
case 'D_VIA_AUDITING': case 'D_VIA_AUDITING':
return '分销提现审核通过' return { statusText: '分销审核通过', statusClass: 'tag-pending', group: 'PENDING' }
case 'FAIL_AUDITING':
return '审核未通过'
case 'D_FAIL_AUDITING':
return '分销提现审核未通过'
case 'WAIT_USER_CONFIRM': case 'WAIT_USER_CONFIRM':
return '等待用户确认' return { statusText: '待确认收款', statusClass: 'tag-action', group: 'PENDING' }
case 'SUCCESS': 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': case 'ERROR':
return '提现失败' return { statusText: '提现失败', statusClass: 'tag-fail', group: 'FAIL' }
default: 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() { function getData() {
getWithdrawApplyPage(params.value).then((res) => { getWithdrawApplyPage(params.value).then((res) => {
loaded.value = true loaded.value = true
if (res.data.success && res.data.result.records.length != 0) { const list = (res.data?.success && res.data?.result?.records) || []
records.value.push(...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) => { const openResultToast = (errMsg?: string) => {
if (errMsg === 'requestMerchantTransfer:ok') { if (errMsg === 'requestMerchantTransfer:ok') {
uni.showToast({ title: '已唤起确认页面', duration: 2000, icon: 'none' }) uni.showToast({ title: '已确认收款', duration: 2000, icon: 'none' })
setTimeout(() => reloadData(), 800)
return return
} }
if (errMsg === 'requestMerchantTransfer:cancel') { if (errMsg === 'requestMerchantTransfer:cancel') {
@@ -175,55 +297,191 @@ function confirmWechatReceive(item: any) {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.view-item { .withdraw-log-page {
padding: 32rpx; min-height: 100vh;
background: #f5f6fa;
padding-bottom: 48rpx;
box-sizing: border-box;
}
.filter-bar {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
gap: 16rpx;
padding: 24rpx 24rpx 8rpx;
overflow-x: auto;
} }
.view-item-change {
text-align: right; .filter-pill {
> .-money { flex-shrink: 0;
font-size: 36rpx; padding: 12rpx 28rpx;
color: $main-color; border-radius: 999rpx;
font-weight: bold; font-size: 25rpx;
} color: #666666;
> .-time { background: #ffffff;
font-size: 22rpx; font-weight: 500;
color: #999; transition: all 0.2s;
}
> .confirm-btn { &.active {
display: inline-block;
margin-top: 12rpx;
padding: 12rpx 20rpx;
font-size: 24rpx;
border-radius: 8rpx;
background: $main-color; 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; 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; &.tag-action {
color: #999; color: $main-color;
overflow: hidden; background: rgba(229, 50, 56, 0.1);
text-overflow: ellipsis; }
white-space: nowrap;
&.tag-success {
color: #00a870;
background: rgba(0, 168, 112, 0.1);
}
&.tag-fail {
color: #f53f3f;
background: rgba(245, 63, 63, 0.1);
} }
} }
.wap { .card-title {
width: 100%; font-size: 30rpx;
height: calc(100vh - 44px); font-weight: 600;
color: #1d2129;
line-height: 1.35;
} }
.scroll-v { .amount-wrap {
height: calc(100vh - 88rpx); 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> </style>

View File

@@ -55,6 +55,17 @@
</template> </template>
<view class="submit" @click="cashd">提现</view> <view class="submit" @click="cashd">提现</view>
<!-- 提现规则说明(满足微信小程序审核要求) -->
<view class="rule-card">
<view class="rule-title">
<text class="rule-icon"></text>
<text>提现规则说明</text>
</view>
<view class="rule-content">
<text class="rule-text">{{ displayRule }}</text>
</view>
</view>
</view> </view>
</template> </template>
@@ -75,6 +86,7 @@ const minPrice = ref(0)
const type = ref('') const type = ref('')
const connectNumber = ref('') const connectNumber = ref('')
const realName = ref('') const realName = ref('')
const withdrawRule = ref('')
const typeLabel = computed(() => { const typeLabel = computed(() => {
if (type.value === 'ALI') return '支付宝' if (type.value === 'ALI') return '支付宝'
@@ -82,20 +94,47 @@ const typeLabel = computed(() => {
return '--' return '--'
}) })
const defaultRule = computed(() => {
const minText = minPrice.value ? `单笔最低提现金额为 ${minPrice.value} 元` : '单笔最低提现金额以页面提示为准'
const channelText = typeLabel.value === '支付宝' ? '支付宝账户' : '微信零钱账户'
return `1. 提现额度:${minText},单日累计最高提现 5000 元;
2. 提现频次:每个用户每日最多可申请提现 1 次;
3. 提现时间:支持全天 24 小时随时发起提现申请;
4. 到账时间:提交申请后预计 1~3 个工作日内审核并转账至您的${channelText};
5. 手续费说明:提现免收手续费;
6. 如有其他疑问或遇到提现未到账,请联系在线客服协助处理。`
})
const displayRule = computed(() => {
return withdrawRule.value ? withdrawRule.value : defaultRule.value
})
onMounted(async () => { onMounted(async () => {
const result = await getUserWallet() const result = await getUserWallet()
const res = await withdrawalSettingVO() const res = await withdrawalSettingVO()
walletNum.value = result.data.result.memberWallet walletNum.value = result.data.result.memberWallet
minPrice.value = res.data.result.minPrice minPrice.value = res.data.result.minPrice
type.value = res.data.result.type type.value = res.data.result.type
withdrawRule.value = res.data.result.withdrawRule || ''
}) })
let submitting = false
function cashd() { function cashd() {
if (submitting) return
const amount = Number(price.value) 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' }) uni.showToast({ title: '请输入正确金额', duration: 2000, icon: 'none' })
return 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 } const params: Record<string, any> = { price: amount }
if (type.value === 'ALI') { if (type.value === 'ALI') {
@@ -111,14 +150,23 @@ function cashd() {
params.realName = realName.value params.realName = realName.value
} }
withdrawalApply(params).then((res) => { const doSubmit = () => {
if (res.data.success) { submitting = true
uni.showToast({ title: '提现成功!', duration: 2000, icon: 'none' }) withdrawalApply(params)
setTimeout(() => { .then((res) => {
uni.navigateBack({ delta: 1 }) if (res.data.success) {
}, 1000) uni.showToast({ title: '提现申请已提交', duration: 2000, icon: 'none' })
} setTimeout(() => {
}) uni.navigateBack({ delta: 1 })
}, 1000)
}
})
.finally(() => {
submitting = false
})
}
doSubmit()
} }
function handleAll() { function handleAll() {
@@ -265,4 +313,37 @@ function handleAll() {
text-align: center; text-align: center;
font-size: 32rpx; font-size: 32rpx;
} }
.rule-card {
margin-top: 48rpx;
padding: 12rpx;
}
.rule-title {
display: flex;
flex-direction: row;
align-items: center;
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
.rule-icon {
width: 6rpx;
height: 26rpx;
background: $main-color;
border-radius: 4rpx;
margin-right: 12rpx;
}
}
.rule-content {
.rule-text {
font-size: 24rpx;
color: #888;
line-height: 1.8;
white-space: pre-wrap;
word-break: break-all;
}
}
</style> </style>

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> <template>
<view :style="themeStyle" class="content"> <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"> <view class="portrait-box">
<image src="/pages/mine/static/point_bg_1.png" mode=""></image> <image src="/pages/mine/static/point_bg_1.png" mode=""></image>
<image class="point-img" src="/pages/mine/static/tradehall.png" /> <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 { useStore } from '@/store'
import { getPointsData, getMemberPointSum } from '@/api/members.js' import { getPointsData, getMemberPointSum } from '@/api/members.js'
import { getThemeStyle } from '@/utils/theme' import { getThemeStyle } from '@/utils/theme'
import {
prefetchMpMessageTemplates,
subscribeMpMessageByScenes,
} from '@/js_sdk/lili-pay/wx-pay.js'
const store = useStore() const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme)) const themeStyle = computed(() => getThemeStyle(store.state.theme))
@@ -51,10 +63,29 @@ const params = ref({ pageNumber: 1, pageSize: 10 })
const pointSummary = ref<Record<string, any>>({}) const pointSummary = ref<Record<string, any>>({})
onLoad(() => { onLoad(() => {
// #ifdef MP-WEIXIN
prefetchMpMessageTemplates()
// #endif
fetchPointSummary() fetchPointSummary()
fetchPointLogList() 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(() => { onReachBottom(() => {
params.value.pageNumber++ params.value.pageNumber++
fetchPointLogList() 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> <template>
<view :style="themeStyle" class="person-msg"> <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"> <view class="head" @click="changeFace">
<image :src="form.face || '/static/missing-face.png'" mode="aspectFill"></image> <image :src="form.face || '/static/missing-face.png'" mode="aspectFill"></image>
<view>点击修改头像</view> <view>{{ avatarUploading ? '头像上传中…' : '点击修改头像' }}</view>
</view> </view>
<!-- #endif -->
<up-form <up-form
:model="form" :model="form"
@@ -13,12 +21,26 @@
label-width="180rpx" label-width="180rpx"
> >
<up-form-item label="昵称" label-width="180rpx" :border-bottom="true"> <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 <u-input
v-model="form.nickName" v-model="form.nickName"
border="none" border="none"
input-align="right" input-align="right"
placeholder="请输入昵称" placeholder="请输入昵称"
/> />
<!-- #endif -->
</up-form-item> </up-form-item>
<up-form-item label="性别" label-width="180rpx" :border-bottom="true"> <up-form-item label="性别" label-width="180rpx" :border-bottom="true">
@@ -55,7 +77,9 @@
</up-form> </up-form>
<view class="bottom"> <view class="bottom">
<view class="submit" @click="submit">保存</view> <button class="submit" :disabled="avatarUploading || saving" :loading="saving" @click="submit">
{{ avatarUploading ? '头像上传中…' : saving ? '正在保存…' : '保存' }}
</button>
</view> </view>
<u-datetime-picker <u-datetime-picker
@@ -80,7 +104,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed } from 'vue' import { ref, reactive, computed, nextTick } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import { useStore } from '@/store' import { useStore } from '@/store'
import { saveUserInfo } from '@/api/members.js' import { saveUserInfo } from '@/api/members.js'
@@ -126,6 +150,24 @@ const maxBirthdayDate = Date.now()
const region = ref([{ id: '', localName: '请选择', children: [] }]) const region = ref([{ id: '', localName: '请选择', children: [] }])
const showBirthday = ref(false) const showBirthday = ref(false)
const cityPicker = ref<any>(null) 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[]) { function getPickerParentValue(selected: any[]) {
form.region = [] form.region = []
@@ -145,34 +187,69 @@ function clickRegion() {
cityPicker.value?.show() 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)) 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 delete params.regionPath
saveUserInfo(params).then((res) => { saving.value = true
if (res.statusCode === 200) { try {
const res = await saveUserInfo(params)
if (res.data?.success && res.data.result) {
storage.setUserInfo(res.data.result) storage.setUserInfo(res.data.result)
uni.navigateBack() uni.navigateBack()
} else {
uni.showToast({ title: res.data?.message || '保存失败,请重试', icon: 'none' })
} }
}) } catch {
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
} finally {
saving.value = false
}
} }
function changeFace() { function changeFace() {
if (avatarUploading.value || saving.value) return
uni.chooseImage({ uni.chooseImage({
count: 1,
success: (chooseImageRes) => { success: (chooseImageRes) => {
uni.uploadFile({ const filePath = chooseImageRes.tempFilePaths?.[0]
url: upload, if (filePath) uploadAvatar(filePath)
filePath: chooseImageRes.tempFilePaths[0],
name: 'file',
header: { accessToken: storage.getAccessToken() },
success: (uploadFileRes) => {
const data = JSON.parse(uploadFileRes.data)
form.face = data.result
},
})
}, },
}) })
} }
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) { function selectTime(e: any) {
const timestamp = typeof e?.value === 'number' ? e.value : birthdayValue.value const timestamp = typeof e?.value === 'number' ? e.value : birthdayValue.value
const formatted = formatBirthday(timestamp) const formatted = formatBirthday(timestamp)
@@ -192,22 +269,11 @@ function navigateToBindMobile(username: string) {
}) })
} }
function syncUserInfo() { onShow(() => {
const latest = storage.getUserInfo() || {} const latest = storage.getUserInfo() || {}
form.nickName = latest.nickName || '' // Refresh a newly bound phone without overwriting unsaved avatar/nickname edits.
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
form.mobile = latest.mobile form.mobile = latest.mobile
form.username = latest.username form.username = latest.username
birthday.value = latest.birthday || ''
birthdayValue.value = parseBirthdayTimestamp(latest.birthday)
}
onShow(() => {
syncUserInfo()
}) })
</script> </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 { .form {
background: #fff; background: #fff;
} }
@@ -341,5 +424,9 @@ page {
font-size: 30rpx; font-size: 30rpx;
background: $light-color; background: $light-color;
color: #fff; color: #fff;
margin: 0;
padding: 0;
&::after { border: none; }
&[disabled] { opacity: .5; background: $light-color; color: #fff; }
} }
</style> </style>

View File

@@ -26,6 +26,11 @@
></u-cell> ></u-cell>
<!-- #endif --> <!-- #endif -->
<u-cell is-link title="用户注销" v-if="userInfo.id" @click="logoff"></u-cell> <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 title="意见反馈" @click="navigateTo('/pages/mine/set/feedBack')"></u-cell>
<u-cell <u-cell
is-link is-link

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: '提交成功', title: '提交成功',
type: 'success', type: 'success',
back: true, back: true,
url: '/pages/order/afterSales/afterSales', url: '/pages/order/myOrder?status=5',
}) })
} }
}) })

View File

@@ -1,80 +1,275 @@
<template> <template>
<view :style="themeStyle"> <view :style="themeStyle" class="apply-success-page">
<view class="header"> <u-navbar
<u-icon name='checkmark' size="150" color="#ff6b35"></u-icon> title="提交成功"
<view class="success-text">售后申请提交成功</view> :border="false"
<view class="btn-view"> :fixed="true"
<view class="btn-item"> :placeholder="true"
<u-button ripple class="btn1" shape="circle" @click="navigateToAfterSales()">查看记录</u-button> :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>
<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>
</view>
<view class="text-view">
<view style="display: flex; margin-bottom: 20rpx; align-items: center">温馨提示:</view>
<view>• 商品寄回地址将在审核通过后在申请记录中查询。</view>
<view>• 提交服务单后,售后专员可能与您电话沟通,请保持手机畅通。</view>
<view>• 退货处理成功后退款金额将原路返回到您的支持账户中;</view>
</view> </view>
</view> </view>
</template> </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 { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme' import { getThemeStyle } from '@/utils/theme'
import {
prefetchMpMessageTemplates,
subscribeMpMessageByScenes,
} from '@/js_sdk/lili-pay/wx-pay.js'
const store = useStore() const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme)) 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() { function navigateToAfterSales() {
uni.redirectTo({ const goAfterSales = () => {
url: '/pages/order/afterSales/afterSales', uni.redirectTo({
}) url: '/pages/order/myOrder?status=5',
})
}
// #ifdef MP-WEIXIN
subscribeMpMessageByScenes(['AFTER_SALE_AUDIT'], goAfterSales)
// #endif
// #ifndef MP-WEIXIN
goAfterSales()
// #endif
} }
function navigateToHome() { function navigateToHome() {
uni.switchTab({ const goHome = () => {
url: '/pages/tabbar/home/index', uni.switchTab({
}) url: '/pages/tabbar/home/index',
})
}
// #ifdef MP-WEIXIN
subscribeMpMessageByScenes(['AFTER_SALE_AUDIT'], goHome)
// #endif
// #ifndef MP-WEIXIN
goHome()
// #endif
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
page { .apply-success-page {
background: $page-color-base; min-height: 100vh;
padding-bottom: 100rpx; background: #f5f6fa;
box-sizing: border-box;
} }
.btn1 {
background: $light-color; .page-body {
color: #fff; padding: 28rpx 24rpx 60rpx;
} }
.btn2 {
color: $light-color; .result-card {
} background: #ffffff;
.header { border-radius: 24rpx;
background-color: #fff; padding: 64rpx 40rpx 52rpx;
height: 500rpx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; 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; justify-content: center;
.success-text { }
font-size: 40rpx;
font-weight: 400; .icon-circle {
margin: 20rpx 0; width: 104rpx;
} height: 104rpx;
.btn-view { border-radius: 50%;
display: flex; background: linear-gradient(135deg, $main-color 0%, $light-color 100%);
flex-direction: row; display: flex;
margin: 40rpx 0rpx; align-items: center;
.btn-item { justify-content: center;
margin: 0rpx 20rpx; 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; .btn-primary {
color: #909399; 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> </style>

View File

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

View File

@@ -38,6 +38,8 @@
<view class="btn-view u-flex u-row-between"> <view class="btn-view u-flex u-row-between">
<view class="description">{{ afterSaleTypeText(order.serviceType) }}</view> <view class="description">{{ afterSaleTypeText(order.serviceType) }}</view>
<view class="goods-btn flex flex-a-c"> <view class="goods-btn flex flex-a-c">
<view class="cancel-btn" v-if="order.serviceStatus === 'APPLY'" @click="onCancelAfterSale(order)">取消售后</view>
<view class="pay-btn" v-if="order.serviceStatus === 'PASS' && order.serviceType !== 'RETURN_MONEY'" @click="onAfterSaleExpress(order)">提交物流</view>
<view class="pay-btn" @click="openOrder(order)">售后详情</view> <view class="pay-btn" @click="openOrder(order)">售后详情</view>
</view> </view>
</view> </view>
@@ -112,7 +114,7 @@
<u-popup class="cancel-popup" v-model:show="cancelShow" mode="bottom" length="60%"> <u-popup class="cancel-popup" v-model:show="cancelShow" mode="bottom" length="60%">
<view class="header">取消订单</view> <view class="header">取消订单</view>
<view class="body"> <view class="body">
<view class="title">取消订单后,本单享有的优惠可能会一并取消,是否继续?</view> <view class="title">{{ cancelIsPickup ? '此订单使用提货卡兑换,取消不退还提货权益,无法再次兑换。是否继续?' : '取消订单后,本单享有的优惠可能会一并取消,是否继续?' }}</view>
<view> <view>
<u-radio-group v-model="reason"> <u-radio-group v-model="reason">
<view class="value"> <view class="value">
@@ -131,6 +133,8 @@
<u-toast ref="uToast" /> <u-toast ref="uToast" />
<u-modal :confirm-color="lightColor" v-model:show="rogShow" :show-cancel-button="true" :content="'是否确认收货?'" <u-modal :confirm-color="lightColor" v-model:show="rogShow" :show-cancel-button="true" :content="'是否确认收货?'"
@confirm="confirmRog"></u-modal> @confirm="confirmRog"></u-modal>
<u-modal :confirm-color="lightColor" v-model:show="cancelAfterSaleShow" :show-cancel-button="true" content="确认取消售后?"
@confirm="confirmCancelAfterSale"></u-modal>
</view> </view>
</template> </template>
@@ -141,7 +145,8 @@ import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme' import { getThemeStyle } from '@/utils/theme'
import uniLoadMore from '@/components/uni-load-more/uni-load-more.vue' import uniLoadMore from '@/components/uni-load-more/uni-load-more.vue'
import { getOrderList, cancelOrder, confirmReceipt } from '@/api/order.js' import { getOrderList, cancelOrder, confirmReceipt } from '@/api/order.js'
import { getClearReason, getAfterSaleList } from '@/api/after-sale.js' import { getClearReason, getAfterSaleList, cancelAfterSale } from '@/api/after-sale.js'
import storage from '@/utils/storage'
import LiLiWXPay from '@/js_sdk/lili-pay/wx-pay.js' import LiLiWXPay from '@/js_sdk/lili-pay/wx-pay.js'
import { import {
unitPrice, unitPrice,
@@ -200,6 +205,8 @@ const orderSn = ref('')
const reason = ref('') const reason = ref('')
const cancelList = ref<any[]>([]) const cancelList = ref<any[]>([])
const rogShow = ref(false) const rogShow = ref(false)
const cancelAfterSaleShow = ref(false)
const pendingAfterSaleSn = ref('')
function hideLoadingIfNeeded() { function hideLoadingIfNeeded() {
if (store.state.isShowToast) uni.hideLoading() if (store.state.isShowToast) uni.hideLoading()
@@ -292,7 +299,10 @@ function getOrderItemImage(goods: any, order: any, index: number) {
return parseGoodsImageUrl(image) return parseGoodsImageUrl(image)
} }
const cancelIsPickup = ref(false)
function onCancel(sn: string) { function onCancel(sn: string) {
cancelIsPickup.value = !!navList.flatMap(tab => tab.orderList).find(order => order.sn === sn)?.pickupCardId
orderSn.value = sn orderSn.value = sn
cancelShow.value = true cancelShow.value = true
uni.showLoading({ title: '加载中' }) uni.showLoading({ title: '加载中' })
@@ -418,6 +428,30 @@ function openOrder(order: any) {
navigateToOrderDetail(order.sn) navigateToOrderDetail(order.sn)
} }
function onCancelAfterSale(order: any) {
pendingAfterSaleSn.value = order?.serviceSn || order?.sn || ''
cancelAfterSaleShow.value = true
}
async function confirmCancelAfterSale() {
if (!pendingAfterSaleSn.value) return
uni.showLoading({ title: '加载中' })
const res = await cancelAfterSale(pendingAfterSaleSn.value)
hideLoadingIfNeeded()
if (res?.data?.success) {
uni.showToast({ title: '取消成功!', duration: 2000, icon: 'none' })
initData(tabCurrentIndex.value)
}
}
function onAfterSaleExpress(order: any) {
const sku = (order?.orderItems && order.orderItems[0]) || {}
storage.setAfterSaleData({ ...order, ...sku, storeName: order.storeName })
uni.navigateTo({
url: `./afterSales/afterSalesDetailExpress?serviceSn=${order.serviceSn || order.sn}`,
})
}
function changeTab(e: any) { function changeTab(e: any) {
// H5 的 swiper change 把下标放在 detail.current。 // H5 的 swiper change 把下标放在 detail.current。
// 读 target.current 会得到 undefined,选中态被清掉,列表就停在「全部」。 // 读 target.current 会得到 undefined,选中态被清掉,列表就停在「全部」。

View File

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

View File

@@ -70,7 +70,7 @@
<GoodsSwiper id="main1" :res="imgList" :video="goodsDetail.goodsVideo" /> <GoodsSwiper id="main1" :res="imgList" :video="goodsDetail.goodsVideo" />
<!-- 促销活动条(秒杀/限时直降/拼团等,M-02 含 E_COUPON) --> <!-- 促销活动条(秒杀/拼团等,M-02 含 E_COUPON) -->
<PromotionAssembleLayout v-if="PromotionList" :detail="goodsDetail" :res="PromotionList" /> <PromotionAssembleLayout v-if="PromotionList" :detail="goodsDetail" :res="PromotionList" />
<view class="card-box top-radius-0" id="main2"> <view class="card-box top-radius-0" id="main2">
@@ -101,20 +101,7 @@
<view class="-goods-price" v-if="goodsDetail.price != undefined"> <view class="-goods-price" v-if="goodsDetail.price != undefined">
<span> <span>
<span v-if="wholesaleList.length"> <span>
<span>¥</span><span class="price">{{
goodsFormatPrice(wholesaleList[wholesaleList.length - 1].price)[0]
}}</span>.{{
goodsFormatPrice(wholesaleList[wholesaleList.length - 1].price)[1]
}}
~
<span>¥</span><span class="price">{{
goodsFormatPrice(wholesaleList[0].price)[0]
}}</span>.{{
goodsFormatPrice(wholesaleList[0].price)[1]
}}
</span>
<span v-else>
<span>¥</span><span class="price">{{ <span>¥</span><span class="price">{{
goodsFormatPrice(goodsDetail.price)[0] goodsFormatPrice(goodsDetail.price)[0]
}}</span>.{{ goodsFormatPrice(goodsDetail.price)[1] }} }}</span>.{{ goodsFormatPrice(goodsDetail.price)[1] }}
@@ -152,6 +139,7 @@
</view> </view>
</view> </view>
<view v-if="paidQuote && !isSeckill && !isGroup" class="card-box"><PaidMemberPrice :quote="paidQuote" /></view>
<!-- 促销/领券入口 --> <!-- 促销/领券入口 -->
<view class="card-box"> <view class="card-box">
<view class="card-flex" @click="shutMask(1)"> <view class="card-flex" @click="shutMask(1)">
@@ -183,17 +171,6 @@
<u-icon name="more-dot-fill"></u-icon> <u-icon name="more-dot-fill"></u-icon>
</view> </view>
</view> </view>
<view class="card-flex" @click="shutMask(3)">
<view class="card-title"> 送至</view>
<view class="card-content">
<span v-if="delivery">{{clearStrComma(delivery.consigneeAddressPath)
}}</span>
<span v-else>暂无地址信息</span>
</view>
<view class="card-bottom">
<u-icon name="more-dot-fill"></u-icon>
</view>
</view>
</view> </view>
<!-- 评价 --> <!-- 评价 -->
@@ -284,14 +261,10 @@
</view> </view>
</u-popup> </u-popup>
<!-- 配送地址弹窗 -->
<popupAddress @closeAddress="closePopupAddress" @deliveryData="deliveryFun" v-if="goodsDetail.id"
:goodsId="goodsDetail.id" :addressFlag="addressFlag" />
<!-- 商品规格 商品详情,以及默认参与活动的id--> <!-- 商品规格 商品详情,以及默认参与活动的id-->
<popupGoods :addr="delivery" ref="popupGoodsRef" @changed="changedGoods" @closeBuy="closePopupBuy" <popupGoods ref="popupGoodsRef" @changed="changedGoods" @closeBuy="closePopupBuy"
@queryCart="cartCount()" :goodsDetail="goodsDetail" :goodsSpec="goodsSpec" :isGroup="isGroup" :id="productId" @queryCart="cartCount()" :goodsDetail="goodsDetail" :goodsSpec="goodsSpec" :isGroup="isGroup" :id="productId"
v-if="goodsDetail.id" :pointDetail="pointDetail" :wholesaleList="wholesaleList" @handleClickSku="selectSku" v-if="goodsDetail.id" :pointDetail="pointDetail" @handleClickSku="selectSku"
:buyMask="buyMask" /> :buyMask="buyMask" />
<!-- 下架框 --> <!-- 下架框 -->
@@ -326,12 +299,13 @@ import PromotionAssembleLayout from './product/promotion/-promotion-assemble-pro
import PromotionAssembleListLayout from './product/promotion/-promotion-assemble-list' import PromotionAssembleListLayout from './product/promotion/-promotion-assemble-list'
import PromotionCoupon from './product/promotion/-promotion-coupon' import PromotionCoupon from './product/promotion/-promotion-coupon'
import GoodsIntro from './product/goods/-goods-intro' import GoodsIntro from './product/goods/-goods-intro'
import PaidMemberPrice from '@/components/paid-member-price/index.vue'
import { getPaidMemberQuotes } from '@/api/paid-member'
import GoodsRecommend from './product/goods/-goods-recommend' import GoodsRecommend from './product/goods/-goods-recommend'
import storeLayout from './product/shop/-shop' import storeLayout from './product/shop/-shop'
import Evaluation from './product/evaluation/-evaluation' import Evaluation from './product/evaluation/-evaluation'
import GoodsSwiper from './product/goods/-goods-swiper' import GoodsSwiper from './product/goods/-goods-swiper'
import popupGoods from '@/components/m-buy/goods' import popupGoods from '@/components/m-buy/goods'
import popupAddress from './product/popup/address'
import shares from '@/components/m-share/index' import shares from '@/components/m-share/index'
import takeDownFormSaleGoods from '@/components/m-take-down-sale-goods/index' import takeDownFormSaleGoods from '@/components/m-take-down-sale-goods/index'
import setup from './product/popup/popup' import setup from './product/popup/popup'
@@ -367,11 +341,23 @@ const scrollId = ref("1")
const scrollFlag = ref(true) const scrollFlag = ref(true)
const current = ref("1") const current = ref("1")
const goodsDetail = ref<any>({}) const goodsDetail = ref<any>({})
const paidQuote = ref<any>(null)
let paidQuoteRequest = 0
async function loadPaidQuote() {
const id = ++paidQuoteRequest, memberId = String(isLogin()?.id || ''), skuId = goodsDetail.value?.id
paidQuote.value = null
if (!memberId || !skuId) return
try {
const res = await getPaidMemberQuotes([skuId])
if (id === paidQuoteRequest && String(isLogin()?.id || '') === memberId && res.data?.success) paidQuote.value = res.data.result?.[skuId] || null
} catch {}
}
watch(() => [goodsDetail.value?.id, store.state.userInfo?.id, store.state.hasLogin], loadPaidQuote)
onShow(loadPaidQuote)
const goodsSpec = ref<any>("") const goodsSpec = ref<any>("")
const imgList = ref<any[]>([]) const imgList = ref<any[]>([])
const favorite = ref(false) const favorite = ref(false)
const recommendList = ref<any[]>([]) const recommendList = ref<any[]>([])
const addressFlag = ref(false)
const buyMask = ref(false) const buyMask = ref(false)
const num = ref(1) const num = ref(1)
const skuId = ref<any>("") const skuId = ref<any>("")
@@ -385,13 +371,11 @@ const PromotionList = ref<any>("")
const specList = ref<any[]>([]) const specList = ref<any[]>([])
const selectedSpec = ref<any[]>([]) const selectedSpec = ref<any[]>([])
const nums = ref(0) const nums = ref(0)
const delivery = ref<any>("")
const exchange = ref<any>({}) const exchange = ref<any>({})
const productId = ref(0) const productId = ref(0)
const startTimer = ref(false) const startTimer = ref(false)
const routerVal = ref<any>("") const routerVal = ref<any>("")
const IMLink = ref("") const IMLink = ref("")
const wholesaleList = ref<any[]>([])
const takeDownFromSale = ref(false) const takeDownFromSale = ref(false)
// computed // computed
@@ -520,7 +504,6 @@ async function init(id: any, goodsId: any, distributionId = "", shareId = "") {
if (goodsDetail.value) { if (goodsDetail.value) {
goodsDetail.value.thumbnail = parseGoodsImageUrl(goodsDetail.value.thumbnail) goodsDetail.value.thumbnail = parseGoodsImageUrl(goodsDetail.value.thumbnail)
} }
wholesaleList.value = response.data.result.wholesaleList || []
goodsSpec.value = response.data.result.specs || "" goodsSpec.value = response.data.result.specs || ""
PromotionList.value = response.data.result.promotionMap || "" PromotionList.value = response.data.result.promotionMap || ""
goodsParams.value = response.data.result.goodsParamsDTOList || [] goodsParams.value = response.data.result.goodsParamsDTOList || []
@@ -565,13 +548,6 @@ function changedGoods(val: any) {
selectedGoods.value = val selectedGoods.value = val
} }
function deliveryFun(val: any) {
delivery.value = val
}
function closePopupAddress(val: any) {
addressFlag.value = val
}
function closePopupBuy(val: any) { function closePopupBuy(val: any) {
buyMask.value = val buyMask.value = val
@@ -709,15 +685,11 @@ function handleBuyClick() {
function shutMask(flag: any, buyFlag?: any, type?: any) { function shutMask(flag: any, buyFlag?: any, type?: any) {
promotionShow.value = false promotionShow.value = false
buyMask.value = false buyMask.value = false
addressFlag.value = false
if (flag) { if (flag) {
switch (flag) { switch (flag) {
case 1: case 1:
promotionShow.value = true promotionShow.value = true
break break
case 3:
addressFlag.value = true
break
case 4: case 4:
buyMask.value = true buyMask.value = true
if (buyFlag == "PINTUAN") { if (buyFlag == "PINTUAN") {

View File

@@ -54,10 +54,6 @@ const cityList = ref([
onMounted(() => { onMounted(() => {
if (isLogin('auth')) { if (isLogin('auth')) {
getShippingAddress() getShippingAddress()
} else {
uni.navigateTo({
url: '/pages/passport/login',
})
} }
}) })

View File

@@ -5,7 +5,6 @@
class="showBox" class="showBox"
v-if=" v-if="
promotion.__key == 'SECKILL' || promotion.__key == 'SECKILL' ||
promotion.__key == 'FLASH_DISCOUNT' ||
promotion.__key == 'GROUPBUY' || promotion.__key == 'GROUPBUY' ||
promotion.__key == 'PINTUAN' promotion.__key == 'PINTUAN'
" "
@@ -37,29 +36,6 @@
</view> </view>
</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'"> <view class="u-group-flex-left" v-if="promotion.__key == 'GROUPBUY'">
<span class="u-group-flex-left-span"> <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-if="item.scopeType == 'PORTION_GOODS'">部分商品</text>
<text v-else>{{ item.storeName == 'platform' ? '全平台' : item.storeName + '店铺' }}使用</text> <text v-else>{{ item.storeName == 'platform' ? '全平台' : item.storeName + '店铺' }}使用</text>
</view> </view>
<text>满{{ unitPrice(item.consumeThreshold) }}可用</text> <text>{{ item.consumeThreshold > 0 ? '满' + unitPrice(item.consumeThreshold) + '可用' : '无门槛使用' }}</text>
</view> </view>
<view class="lingqu-btn" @tap="getCoupon(item, index)"> <view class="lingqu-btn" @tap="getCoupon(item, index)">
<view class="lingqu-text" :class="{ cur: yhqFlag[index] }"> <view class="lingqu-text" :class="{ cur: yhqFlag[index] }">

View File

@@ -40,26 +40,6 @@
</view> </view>
</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 v-if="getPromType(prom) == 'POINTS_GOODS'">
<view class="res_prom_item"> <view class="res_prom_item">
<view class="deg_tag">积分活动</view> <view class="deg_tag">积分活动</view>
@@ -93,7 +73,9 @@ const props = defineProps<{
const promotionKeys = computed(() => { const promotionKeys = computed(() => {
if (!props.res || typeof props.res !== 'object') return [] if (!props.res || typeof props.res !== 'object') return []
return Object.keys(props.res).filter((key) => getPromType(key) !== 'COUPON') return Object.keys(props.res).filter((key) =>
['FULL_DISCOUNT', 'PINTUAN', 'SECKILL', 'POINTS_GOODS', 'KANJIA'].includes(getPromType(key))
)
}) })
function getPromType(key: string) { function getPromType(key: string) {

View File

@@ -2,18 +2,6 @@
<view> <view>
<view v-for="(promotionItem, promotionIndex) in promotion" :key="promotionIndex" class="promotion_row" @click="shutMask(1)"> <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"> <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="promotion_col" v-if="item.split('-')[0] == promotionItem.value && item.split('-')[0] == 'FULL_DISCOUNT'">
<!-- 满减,折扣 --> <!-- 满减,折扣 -->
<div class="flex"> <div class="flex">

View File

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

View File

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

View File

@@ -68,7 +68,6 @@
</p> </p>
<!-- 规格 --> <!-- 规格 -->
<p class="sp-type" style="width:300rpx">{{skuItem.goodsSku.simpleSpecs}}</p> <p class="sp-type" style="width:300rpx">{{skuItem.goodsSku.simpleSpecs}}</p>
<p class="sp-type" v-if="skuItem.goodsSku.salesModel == 'WHOLESALE'">批发商品</p>
<view class="sp-number"> <view class="sp-number">
<view class="sp-price"> <view class="sp-price">
<div class="main-color"> <div class="main-color">

View File

@@ -11,48 +11,63 @@
bg-color="#ffffff" bg-color="#ffffff"
> >
<template #left> <template #left>
<view class="nav-content"> <view class="nav-search-box" :style="searchBarStyle" @click="search">
<text class="title">商品分类</text> <u-icon name="search" size="16" color="#999999"></u-icon>
<view class="nav-search-wrap" :style="searchBarStyle"> <text class="search-placeholder">搜索商品</text>
<!-- #ifdef H5 -->
<u-search class="nav-search" @click="search" placeholder="搜索商品" :show-action="false"></u-search>
<!-- #endif -->
<!-- #ifndef H5 -->
<u-search class="nav-search" disabled @click="search" placeholder="搜索商品" :show-action="false"></u-search>
<!-- #endif -->
</view>
</view> </view>
</template> </template>
</u-navbar> </u-navbar>
<view class="content"> <view class="content" v-if="pageTemplate === 1">
<scroll-view scroll-y scroll-with-animation class="left-aside"> <scroll-view scroll-y scroll-with-animation class="left-aside">
<view v-for="(item, index) in tabList" :key="item.id" class="f-item b-b" :class="{ active: item.id === currentId }" @click="tabtap(item, index)"> <view
{{ item.name }} v-for="(item, index) in tabList"
:key="item.id"
class="f-item"
:class="{ active: item.id === currentId }"
@click="tabtap(item, index)"
>
<text class="f-text">{{ item.name }}</text>
</view> </view>
</scroll-view> </scroll-view>
<scroll-view scroll-with-animation scroll-y class="right-aside" :upper-threshold="-100" :lower-threshold="-100"> <scroll-view scroll-with-animation scroll-y class="right-aside" :upper-threshold="-100" :lower-threshold="-100">
<!-- 头部图片 --> <!-- 头部图片 -->
<view class="top-img" id="main-top"> <view class="top-img" id="main-top" v-if="topImg && topImg.image">
<u-image width="500rpx" height="230rpx" @click="navigateToList(topImg.id,topImg.id)" :src="topImg.image" mode=""> <u-image width="100%" height="200rpx" border-radius="12rpx" @click="navigateToList(topImg.id, topImg.id)" :src="topImg.image" mode="aspectFill">
</u-image> </u-image>
</view> </view>
<view v-for="item in categoryList" :key="item.id" class="s-list" :id="'main-' + item.id"> <view v-for="item in categoryList" :key="item.id" class="s-list" :id="'main-' + item.id">
<!-- 分类标题 --> <!-- 分类标题 -->
<text class="s-item">{{ item.name }}</text> <view class="s-header" @click="navigateToList(item.id, item.id)">
<text class="s-item">{{ item.name }}</text>
<u-icon name="arrow-right" size="12" color="#bbbbbb"></u-icon>
</view>
<!-- 分类详情 --> <!-- 分类详情 -->
<view class="t-list"> <view class="t-list">
<template v-for="(children, cIndex) in item.children" :key="children.id"> <template v-for="(children, cIndex) in item.children" :key="children.id">
<view @click="navigateToList(item.id, children.id)" v-if="children.parentId === item.id" class="t-item" <view
:class="{ 'margin-right': (cIndex + 1) % 3 == 0 }"> @click="navigateToList(item.id, children.id)"
<u-image width="70px" height="70px" :src="children.image" :lazy-load="true"> v-if="children.parentId === item.id"
</u-image> class="t-item"
<text>{{ children.name }}</text> >
</view> <view class="t-img-box">
<u-image width="128rpx" height="128rpx" border-radius="12rpx" :src="children.image" :lazy-load="true" mode="aspectFit">
<template #loading><u-loading-icon></u-loading-icon></template>
</u-image>
</view>
<text class="t-name">{{ children.name }}</text>
</view>
</template> </template>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
</view> </view>
<tpl-category-goods
v-else
class="tpl-goods-wrapper"
:category-list="tabList"
:setting="categoryPageSetting"
:template="pageTemplate"
/>
</view> </view>
</template> </template>
@@ -61,10 +76,17 @@ import { ref, computed } from 'vue'
import { onLoad, onReady } from '@dcloudio/uni-app' import { onLoad, onReady } from '@dcloudio/uni-app'
import { getCategoryList } from '@/api/goods.js' import { getCategoryList } from '@/api/goods.js'
import { getThemeStyle } from '@/utils/theme' import { getThemeStyle } from '@/utils/theme'
import { DEFAULT_CATEGORY_PAGE_SETTING } from '@/utils/categoryPageSetting'
import { useStore } from '@/store' import { useStore } from '@/store'
import TplCategoryGoods from '@/components/category/tpl-category-goods.vue'
const store = useStore() const store = useStore()
const categoryPageSetting = computed(
() => store.state.categoryPageSetting || DEFAULT_CATEGORY_PAGE_SETTING
)
const pageTemplate = computed(() => categoryPageSetting.value.template || 1)
const currentId = ref(0) const currentId = ref(0)
const tabList = ref<any[]>([]) // 左侧标题列表 const tabList = ref<any[]>([]) // 左侧标题列表
const categoryList = ref<any[]>([]) // 右侧分类数据列表 const categoryList = ref<any[]>([]) // 右侧分类数据列表
@@ -75,9 +97,9 @@ const themeStyle = computed(() => getThemeStyle(store.state.theme))
const searchBarStyle = computed(() => { const searchBarStyle = computed(() => {
if (searchBarWidth.value) { if (searchBarWidth.value) {
return { width: `${searchBarWidth.value}px` } return { width: searchBarWidth.value + 'px' }
} }
return { width: "460rpx" } return { width: "680rpx" }
}) })
onLoad(() => { onLoad(() => {
@@ -98,18 +120,25 @@ function setSearchBarWidth() {
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
try { try {
const menuButton = uni.getMenuButtonBoundingClientRect() const menuButton = uni.getMenuButtonBoundingClientRect()
const { windowWidth } = uni.getWindowInfo() const leftMargin = uni.upx2px(24) // 搜索框左边距
const capsuleInsetRight = windowWidth - menuButton.left
const titleWidth = uni.upx2px(160) // "商品分类" 标题宽度
const leftPadding = uni.upx2px(60) // 左侧内边距
const gap = uni.upx2px(20) // 搜索框与胶囊间距 const gap = uni.upx2px(20) // 搜索框与胶囊间距
searchBarWidth.value = // 搜索框延伸至胶囊左边沿
windowWidth - capsuleInsetRight - titleWidth - leftPadding - gap const calcWidth = menuButton.left - leftMargin - gap
searchBarWidth.value = Math.max(calcWidth, 200)
} catch (e) { } catch (e) {
searchBarWidth.value = 180 searchBarWidth.value = 240
}
// #endif
// #ifndef MP-WEIXIN
try {
const info = (uni.getWindowInfo ? uni.getWindowInfo() : (uni.getSystemInfoSync ? uni.getSystemInfoSync() : {})); const windowWidth = info.windowWidth || 375;
searchBarWidth.value = windowWidth - uni.upx2px(48)
} catch (e) {
searchBarWidth.value = 340
} }
// #endif // #endif
} }
/** /**
* 查询 * 查询
*/ */
@@ -125,8 +154,10 @@ function search() {
async function loadData() { async function loadData() {
let list = await getCategoryList(0) let list = await getCategoryList(0)
tabList.value = list.data.result tabList.value = list.data.result
currentId.value = list.data.result[0].id if (tabList.value && tabList.value.length) {
loadListContent(0) currentId.value = tabList.value[0].id
loadListContent(0)
}
} }
/** /**
@@ -136,6 +167,7 @@ function loadListContent(index: number) {
topImg.value = tabList.value[index] topImg.value = tabList.value[index]
categoryList.value = tabList.value[index].children categoryList.value = tabList.value[index].children
} }
/** /**
* 一级分类点击 * 一级分类点击
*/ */
@@ -148,7 +180,7 @@ function tabtap(item: any, i: number) {
function navigateToList(sid: any, tid: any) { function navigateToList(sid: any, tid: any) {
uni.navigateTo({ uni.navigateTo({
url: `/pages/navigation/search/searchPage?category=${tid}`, url: '/pages/navigation/search/searchPage?category=' + tid,
}) })
} }
</script> </script>
@@ -156,7 +188,7 @@ function navigateToList(sid: any, tid: any) {
<style lang="scss"> <style lang="scss">
page { page {
height: 100%; height: 100%;
background-color: #fdfaff; background-color: #ffffff;
} }
</style> </style>
@@ -170,36 +202,42 @@ page {
/* 解决H5 的问题 */ /* 解决H5 的问题 */
/* #ifdef H5 */ /* #ifdef H5 */
uni-scroll-view .uni-scroll-view::-webkit-scrollbar { uni-scroll-view .uni-scroll-view::-webkit-scrollbar {
/* 隐藏滚动条,但依旧具备可以滚动的功能 */
display: none; display: none;
} }
/* #endif */ /* #endif */
.s-list {
box-shadow: 0 4rpx 12rpx 0 rgba(0, 0, 0, 0.05);
}
.nav-content {
display: flex;
align-items: center;
}
.nav-search-wrap {
flex-shrink: 0;
}
.nav-search {
width: 100%;
}
.title {
flex-shrink: 0;
text-align: center;
font-size: 28rpx;
padding-right: 16rpx;
white-space: nowrap;
}
.category-wrap { .category-wrap {
height: 100vh; height: 100%;
min-height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
background-color: #ffffff;
.navbar {
border-bottom: 1rpx solid #f2f3f5;
}
.nav-search-box {
height: 64rpx;
background: #f4f5f7;
border-radius: 32rpx;
display: flex;
align-items: center;
padding: 0 24rpx;
margin-left: 24rpx;
box-sizing: border-box;
cursor: pointer;
}
.search-placeholder {
font-size: 26rpx;
color: #999999;
margin-left: 12rpx;
line-height: 1;
}
.content { .content {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -207,85 +245,159 @@ uni-scroll-view .uni-scroll-view::-webkit-scrollbar {
display: flex; display: flex;
color: #333; color: #333;
font-size: 28rpx; font-size: 28rpx;
background: #fff; background: #ffffff;
overflow: hidden; overflow: hidden;
} }
.left-aside { .left-aside {
flex-shrink: 0; flex-shrink: 0;
width: 200rpx; width: 176rpx;
height: 100%; height: 100%;
background-color: #f7f7f7; background-color: #f6f7f9;
border-right: 1rpx solid #ebebeb;
box-sizing: border-box; box-sizing: border-box;
} }
.f-item { .f-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 97rpx; min-height: 96rpx;
padding: 16rpx 14rpx;
box-sizing: border-box;
font-size: 26rpx;
color: #666;
text-align: center;
line-height: 1.35;
position: relative; position: relative;
transition: all 0.2s ease;
&.active { &.active {
font-weight: bold; font-weight: 600;
color: $light-color; color: $light-color;
background: #fff; background: #ffffff;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6rpx;
height: 36rpx;
border-radius: 0 4rpx 4rpx 0;
background: $light-color;
}
} }
} }
.f-text {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
}
.right-aside { .right-aside {
flex: 1; flex: 1;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
padding: 40rpx 0 0 30rpx; padding: 20rpx 20rpx 40rpx;
box-sizing: border-box; box-sizing: border-box;
background: #f8f9fa;
} }
.top-img { .top-img {
width: 500rpx; width: 100%;
height: 230rpx; height: 200rpx;
border-radius: 8px; border-radius: 14rpx;
overflow: hidden; overflow: hidden;
image { margin-bottom: 20rpx;
width: 100%; box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.04);
height: 100%;
}
} }
.s-item {
.s-list {
background: #ffffff;
border-radius: 16rpx;
padding: 24rpx 20rpx 12rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.02);
}
.s-header {
display: flex; display: flex;
align-items: center; align-items: center;
height: 70rpx; justify-content: space-between;
padding-top: 16rpx; padding-bottom: 16rpx;
font-weight: 500; cursor: pointer;
} }
.s-item {
font-size: 26rpx;
font-weight: 600;
color: #222;
}
.t-list { .t-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
width: 100%; width: 100%;
padding-top: 12rpx;
}
.margin-right {
margin-right: 0 !important;
} }
.t-item { .t-item {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
width: 150rpx; width: 33.333%;
margin-right: 25rpx; padding: 10rpx 0 20rpx;
box-sizing: border-box;
}
.t-img-box {
width: 128rpx;
height: 128rpx;
border-radius: 12rpx;
overflow: hidden;
background: #f9f9f9;
margin-bottom: 12rpx;
display: flex;
align-items: center;
justify-content: center;
}
.t-name {
font-size: 24rpx; font-size: 24rpx;
padding-bottom: 20rpx; color: #333;
image { max-width: 140rpx;
width: 140rpx; overflow: hidden;
height: 140rpx; text-overflow: ellipsis;
border-radius: 8px; white-space: nowrap;
margin-bottom: 20rpx; text-align: center;
} line-height: 1.2;
::v-deep .u-image {
width: 140rpx !important;
height: 140rpx !important;
border-radius: 8px !important;
margin-bottom: 20rpx !important;
}
} }
} }
.tpl-goods-wrapper {
flex: 1;
min-height: 0;
height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ */
tpl-category-goods {
flex: 1;
min-height: 0;
height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* #endif */
</style> </style>

View File

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

View File

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

View File

@@ -1,88 +1,137 @@
<template> <template>
<view class="user" :style="themeStyle"> <view class="user-page" :style="themeStyle">
<!-- 个人信息 --> <!-- 顶部沉浸式头部区域 -->
<view class="status_bar"> <view class="hero-header" :style="heroHeaderStyle">
<!-- 这里是状态栏 --> <image
</view> v-if="gradeBackgroundUrl && !gradeBackgroundFailed"
<view class="header" @click="userDetail"> :key="gradeBackgroundUrl"
<view class="head-1"> class="grade-background"
<image class="head-avatar" :src="userInfo.face || userImage"></image> :class="{ 'is-loaded': gradeBackgroundLoaded }"
</view> :src="gradeBackgroundUrl"
<view class="head-2" v-if="userInfo.id"> mode="aspectFill"
<view class="user-name">{{ userInfo.nickName }}</view> @load="gradeBackgroundLoaded = true"
</view> @error="onGradeBackgroundError"
<view class="head-2" v-else> />
<view class="user-name">登录/注册</view> <!-- 用户信息区保持在微信胶囊安全区下方 -->
</view> <view class="user-profile" @click="userDetail">
<u-icon class="header-arrow" name="arrow-right"></u-icon> <view class="avatar-container">
</view> <image class="avatar-img" :src="userInfo.face || userImage" mode="aspectFill"></image>
<!-- 积分,优惠券,关注, -->
<div class="pointBox box">
<view class="point">
<view class="point-item" @click="navigateTo('/pages/mine/deposit/operation')">
<view class="point-label">预存款</view>
<view class="point-value money">{{ unitPrice(walletNum) }}</view>
</view> </view>
<view class="point-item" @click="navigateTo('/pages/cart/coupon/myCoupon')">
<view class="point-label">优惠券</view>
<view class="point-value">{{ couponNum || 0 }}</view>
</view>
<view class="point-item" @click="navigateTo('/pages/mine/myTracks')">
<view class="point-label">足迹</view>
<view class="point-value">{{ footNum || 0 }}</view>
</view>
</view>
<!-- 我的订单 -->
<view class="order">
<view class="order-head">
<text class="order-head-title">我的订单</text>
<view class="order-head-all" @click="navigateTo('/pages/order/myOrder?status=0')">
<text>全部</text>
<u-icon name="arrow-right" size="12" color="#999"></u-icon>
</view>
</view>
<view class="order-row">
<view class="order-item" @click="navigateTo('/pages/order/myOrder?status=1')">
<view class="bag bag2">
<u-icon name="bag-fill" size="22" color="#fff"></u-icon>
</view>
<view class="order-item-label">待支付</view>
</view>
<view class="order-item" @click="navigateTo('/pages/order/myOrder?status=2')">
<view class="bag bag1">
<u-icon name="gift-fill" size="22" color="#fff"></u-icon>
</view>
<view class="order-item-label">待发货</view>
</view>
<view class="order-item" @click="navigateTo('/pages/order/myOrder?status=3')">
<view class="bag bag3">
<u-icon name="car-fill" size="22" color="#fff"></u-icon>
</view>
<view class="order-item-label">待收货</view>
</view>
<view class="order-item" @click="navigateTo('/pages/order/myOrder?status=4')">
<view class="bag bag4">
<u-icon name="star-fill" size="22" color="#fff"></u-icon>
</view>
<view class="order-item-label">待评价</view>
</view>
<view class="order-item" @click="navigateTo('/pages/order/myOrder?status=5')">
<view class="bag bag5">
<u-icon name="server-fill" size="22" color="#fff"></u-icon>
</view>
<view class="order-item-label">售后</view>
</view>
</view>
</view>
</div>
<!-- 常用工具 -->
<tool /> <view class="user-detail-info">
<view class="user-title-row">
<text class="user-nickname">{{ userInfo.id ? userInfo.nickName : '登录 / 注册' }}</text>
</view>
<view class="user-sub-row">
<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="memberHeaderColor"></u-icon>
</view>
</view>
<!-- 沉浸式核心资产栏(纯白字,融入顶部主色调) -->
<view class="hero-assets">
<view class="asset-col" @click="navigateTo('/pages/mine/deposit/operation')">
<view class="asset-number-row">
<text class="symbol">¥</text>
<text class="num money">{{ unitPrice(walletNum) }}</text>
</view>
<view class="asset-title">预存款</view>
</view>
<view class="asset-sep"></view>
<view class="asset-col" @click="navigateTo('/pages/cart/coupon/myCoupon')">
<view class="asset-number-row">
<text class="num">{{ couponNum || 0 }}</text>
<text class="unit">张</text>
</view>
<view class="asset-title">优惠券</view>
</view>
<view class="asset-sep"></view>
<view class="asset-col" @click="navigateTo('/pages/mine/myTracks')">
<view class="asset-number-row">
<text class="num">{{ footNum || 0 }}</text>
<text class="unit">条</text>
</view>
<view class="asset-title">浏览足迹</view>
</view>
</view>
</view>
<!-- 下方主内容卡片区域 -->
<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">
<text class="card-title-text">我的订单</text>
<view class="card-more-btn" @click="navigateTo('/pages/order/myOrder?status=0')">
<text>全部订单</text>
<u-icon name="arrow-right" size="11" color="#a0a0a0"></u-icon>
</view>
</view>
<view class="order-action-row">
<view class="order-action-item" @click="navigateTo('/pages/order/myOrder?status=1')">
<view class="icon-wrap">
<u-icon name="bag-fill" size="27" color="#ff7a00"></u-icon>
</view>
<text class="order-action-label">待支付</text>
</view>
<view class="order-action-item" @click="navigateTo('/pages/order/myOrder?status=2')">
<view class="icon-wrap">
<u-icon name="gift-fill" size="27" color="#ff4d4f"></u-icon>
</view>
<text class="order-action-label">待发货</text>
</view>
<view class="order-action-item" @click="navigateTo('/pages/order/myOrder?status=3')">
<view class="icon-wrap">
<u-icon name="car-fill" size="27" color="#1677ff"></u-icon>
</view>
<text class="order-action-label">待收货</text>
</view>
<view class="order-action-item" @click="navigateTo('/pages/order/myOrder?status=4')">
<view class="icon-wrap">
<u-icon name="star-fill" size="27" color="#13c2c2"></u-icon>
</view>
<text class="order-action-label">待评价</text>
</view>
<view class="order-action-item" @click="navigateTo('/pages/order/myOrder?status=5')">
<view class="icon-wrap">
<u-icon name="server-fill" size="27" color="#20b286"></u-icon>
</view>
<text class="order-action-label">退款/售后</text>
</view>
</view>
</view>
<!-- 服务工具卡片(分组化重构) -->
<tool :user-info="userInfo" />
</view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { import {
onLoad, onLoad,
onShow, onShow,
@@ -90,6 +139,7 @@ import {
onNavigationBarButtonTap, onNavigationBarButtonTap,
} from '@dcloudio/uni-app' } from '@dcloudio/uni-app'
import tool from '@/pages/tabbar/user/utils/tool.vue' import tool from '@/pages/tabbar/user/utils/tool.vue'
import { getPaidMember } from '@/api/paid-member'
import { getCouponsNum, getFootprintNum } from '@/api/members.js' import { getCouponsNum, getFootprintNum } from '@/api/members.js'
import { getUserWallet } from '@/api/members' import { getUserWallet } from '@/api/members'
import configs from '@/config/config' import configs from '@/config/config'
@@ -97,6 +147,13 @@ import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme' import { getThemeStyle } from '@/utils/theme'
import { isLogin, navigateToLogin, unitPrice } from '@/utils/filters.js' 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 store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme)) const themeStyle = computed(() => getThemeStyle(store.state.theme))
const lightColor = computed(() => store.getters.lightColor) const lightColor = computed(() => store.getters.lightColor)
@@ -105,28 +162,163 @@ const aiderLightColor = computed(() => store.getters.aiderLightColor)
const userImage = configs.defaultUserPhoto const userImage = configs.defaultUserPhoto
const userInfo = ref<any>({}) 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 couponNum = ref<string | number>('')
const footNum = ref<string | number>('') const footNum = ref<string | number>('')
const walletNum = ref<string | number>('') const walletNum = ref<string | number>('')
onLoad(() => {}) 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,
}))
onShow(() => { watch(gradeBackgroundUrl, () => {
userInfo.value = isLogin() || {} gradeBackgroundLoaded.value = false
if (isLogin('auth')) { gradeBackgroundFailed.value = false
getUserOrderNum() }, { flush: 'sync' })
} else { 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 walletNum.value = 0
couponNum.value = 0 couponNum.value = 0
footNum.value = 0 footNum.value = 0
} }
}) })
onPullDownRefresh(() => { // 动态计算顶部安全留白,避免顶到灵动岛/状态栏
getUserOrderNum() const headerPaddingTop = ref<number>(80)
userInfo.value = isLogin()
function initSafeTop() {
try {
const sys = uni.getSystemInfoSync()
const statusBar = sys.statusBarHeight || 44
// #ifdef MP-WEIXIN
const menu = uni.getMenuButtonBoundingClientRect()
if (menu && menu.bottom) {
// 胶囊下方保留 8px,减少空白且不遮挡用户信息
headerPaddingTop.value = menu.bottom + 8
} else {
headerPaddingTop.value = statusBar + 44
}
// #endif
// #ifndef MP-WEIXIN
headerPaddingTop.value = statusBar + 20
// #endif
} catch (e) {
headerPaddingTop.value = 80
}
}
onLoad(() => {
initSafeTop()
}) })
async function refreshUserPage() {
userInfo.value = isLogin() || {}
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()
}
}
onShow(refreshUserPage)
onPullDownRefresh(refreshUserPage)
// #ifndef MP // #ifndef MP
onNavigationBarButtonTap((e) => { onNavigationBarButtonTap((e) => {
if (e.index === 0) { if (e.index === 0) {
@@ -146,210 +338,325 @@ function userDetail() {
} }
async function getUserOrderNum() { async function getUserOrderNum() {
uni.stopPullDownRefresh() const memberId = String(userInfo.value.id || '')
try {
Promise.all([ // Each request can fail independently; a missing wallet must not break the other counts.
getCouponsNum(), const results = await Promise.allSettled([
getFootprintNum(), getCouponsNum(),
getUserWallet(), getFootprintNum(),
]).then((res: any[]) => { getUserWallet(),
couponNum.value = res[0].data.result ])
footNum.value = res[1].data.result // The user may have logged out while these requests were running.
walletNum.value = res[2].data.result.memberWallet 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
html, .paid-entry { position: relative; overflow: hidden; border-radius: 20rpx; background: linear-gradient(110deg, #423c36, #756149); margin-bottom: 20rpx; }
body { .entry-bg { position: absolute; inset: 0; width: 100%; height: 100%; }
overflow: auto; .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;
padding-bottom: 40rpx;
box-sizing: border-box;
} }
.money { /* 顶部沉浸式头部 */
.hero-header {
position: relative;
width: 100%;
background: linear-gradient(175deg, #e53238 0%, #eb4448 45%, #ff5e62 100%);
padding-left: 28rpx;
padding-right: 28rpx;
padding-bottom: 44rpx;
border-bottom-left-radius: 36rpx;
border-bottom-right-radius: 36rpx;
/* 静态兜底保留状态栏与导航操作区域 */
padding-top: calc(var(--status-bar-height, 44px) + 72rpx);
box-sizing: border-box;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.user { .grade-background {
.header { position: absolute;
max-width: 100%; top: 0;
padding: calc(50rpx + var(--status-bar-height)) 30rpx 0 6%; left: 0;
height: calc(var(--status-bar-height) + 360rpx); width: 100%;
background-size: cover; height: 100%;
border-bottom-left-radius: 30rpx; opacity: 0;
border-bottom-right-radius: 30rpx; pointer-events: none;
background-image: url("https://lilishop-uniapp.oss-cn-beijing.aliyuncs.com/main-bg.png"); }
background-position: bottom;
background-repeat: no-repeat;
color: #ffffff;
display: flex;
align-items: center;
.head-1 { .grade-background.is-loaded {
flex-shrink: 0; opacity: 1;
width: 152rpx; }
.head-avatar { /* 紧凑用户信息行 */
width: 152rpx; .user-profile {
height: 144rpx; position: relative;
border-radius: 50%; display: flex;
border: 3px solid #fff; align-items: center;
display: block; padding: 4rpx 6rpx;
transition: opacity 0.2s;
&:active {
opacity: 0.88;
}
.avatar-container {
position: relative;
flex-shrink: 0;
.avatar-img {
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);
background-color: #ffffff;
display: block;
}
}
.user-detail-info {
flex: 1;
margin-left: 24rpx;
min-width: 0;
.user-title-row {
display: flex;
align-items: center;
.user-nickname {
font-size: 34rpx;
font-weight: 700;
color: var(--member-header-color, #ffffff);
letter-spacing: 0.5rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
}
.edti-head { .user-sub-row {
position: absolute; margin-top: 8rpx;
width: 40rpx; display: flex;
height: 40rpx; align-items: center;
border-radius: 50%; flex-wrap: wrap;
background-color: rgba(255, 255, 255, 0.3); gap: 12rpx;
top: 100rpx;
right: 0;
.head-avatar { .badge-tag {
width: 100%; display: inline-flex;
height: 100%; align-items: center;
gap: 6rpx;
max-width: 100%;
min-height: 40rpx;
box-sizing: border-box;
font-size: 20rpx;
color: var(--member-header-color, #ffffff);
background: rgba(255, 255, 255, 0.22);
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: var(--member-header-color, #ffffff);
opacity: 0.88;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.profile-arrow {
flex-shrink: 0;
margin-left: 16rpx;
opacity: 0.85;
}
}
/* 核心资产栏 */
.hero-assets {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 24rpx;
padding: 0 12rpx;
.asset-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: transform 0.15s, opacity 0.15s;
&:active {
transform: scale(0.96);
opacity: 0.85;
} }
.head-2 { .asset-number-row {
flex: 1;
margin-left: 30rpx;
min-width: 0;
display: flex; display: flex;
align-items: center; align-items: baseline;
color: var(--member-header-color, #ffffff);
.symbol {
font-size: 22rpx;
font-weight: 600;
margin-right: 4rpx;
opacity: 0.95;
}
.num {
font-size: 36rpx;
font-weight: 700;
letter-spacing: 0.5rpx;
}
.unit {
font-size: 21rpx;
margin-left: 4rpx;
opacity: 0.85;
}
.money {
max-width: 230rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
} }
.header-arrow { .asset-title {
flex-shrink: 0; margin-top: 8rpx;
margin-left: 20rpx; font-size: 24rpx;
color: var(--member-header-color, #ffffff);
opacity: 0.88;
line-height: 1;
} }
} }
.pointBox { .asset-sep {
width: 94%; width: 2rpx;
margin: 0 3%; height: 38rpx;
background: #fff; background-color: var(--member-header-color, #ffffff);
border-radius: 20rpx; opacity: 0.22;
box-shadow: 0 4rpx 24rpx 0 rgba($color: #f6f6f6, $alpha: 1);
} }
}
.point { /* 主内容区(上浮自然压在红底圆角上) */
.main-container {
position: relative;
margin-top: -36rpx;
padding: 0 24rpx;
z-index: 2;
}
/* 通用白底圆角卡片 */
.card-box {
background: #ffffff;
border-radius: 20rpx;
padding: 24rpx;
box-shadow: 0 4rpx 18rpx rgba(17, 30, 79, 0.04);
}
/* 订单卡片 */
.order-box {
.card-title-bar {
display: flex; display: flex;
align-items: center; align-items: center;
text-align: center; justify-content: space-between;
height: 160rpx; padding-bottom: 22rpx;
font-size: $font-sm; border-bottom: 1rpx solid #f6f7fa;
// #ifdef MP-WEIXIN
padding: 24rpx;
// #endif
.point-item { .card-title-text {
flex: 1; font-size: 29rpx;
text-align: center; font-weight: 700;
color: #1a1a1a;
} }
.point-label { .card-more-btn {
color: $u-main-color;
font-size: 28rpx;
}
.point-value {
margin-top: 8rpx;
color: $main-color;
font-size: $font-lg;
}
}
.order {
padding: 8rpx 16rpx 24rpx;
color: #333;
.order-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8rpx 12rpx 20rpx;
}
.order-head-title {
font-size: 26rpx;
font-weight: 600;
color: #111;
}
.order-head-all {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4rpx; gap: 4rpx;
font-size: 24rpx; font-size: 23rpx;
color: #999; color: #999999;
} padding: 4rpx 0;
.order-row { &:active {
display: flex; opacity: 0.7;
align-items: center; }
} }
}
.order-item { .order-action-row {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 24rpx;
.order-action-item {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-align: center; transition: transform 0.15s, opacity 0.15s;
}
.order-item-label { &:active {
margin-top: 12rpx; transform: scale(0.93);
line-height: 1.4; opacity: 0.85;
font-size: 24rpx; }
color: #333;
.icon-wrap {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12rpx;
}
.order-action-label {
font-size: 24rpx;
color: #333333;
font-weight: 500;
line-height: 1;
}
} }
} }
} }
.box {
transform: translateY(-30rpx);
}
.user-name {
font-size: 34rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bag {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.bag1 {
background: #ff4a48;
}
.bag2 {
background: #ff992f;
}
.bag3 {
background: #009ee0;
}
.bag4 {
background: #00d5d5;
}
.bag5 {
background: #28ccb0;
}
</style> </style>

View File

@@ -1,141 +1,126 @@
<template> <template>
<view> <view class="tool-wrapper">
<!-- 常用工具 --> <!-- 卡片 1: 资产与常用 -->
<view class="interact-tools" style="margin-bottom: 15px"> <view class="service-card">
<view class="service-title-bar">
<div class="paddingBox"> <text class="service-title-text">资产与常用</text>
<view class="interact-container"> </view>
<view class="interact-item" @click="navigateTo('/pages/mine/address/addressManage')">
<image class="interact-item-icon" src="/static/mine/myaddress.png" mode=""></image>
<view>地址管理</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/mine/myTracks')">
<image class="interact-item-icon" src="/static/mine/logistics.png" mode=""></image>
<view>我的足迹</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/order/evaluate/myEvaluate')">
<image class="interact-item-icon" src="/static/mine/feedback.png" mode=""></image>
<view>我的评价</view>
</view>
<view class="interact-item" @click="linkMsgDetail()">
<image class="interact-item-icon" src="/static/mine/mycommit.png" mode=""></image>
<view>我的消息</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/mine/myCollect')">
<image class="interact-item-icon" src="/static/mine/myfavorite.png" mode=""></image>
<view>我的关注</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/mine/point/myPoint')">
<image class="interact-item-icon" src="/static/mine/mypoint.png" mode=""></image>
<view>我的积分</view>
</view>
<view class="interact-item" @click="distribution">
<image class="interact-item-icon" src="/static/mine/distribution.png" mode=""></image>
<view>我的分销</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/order/complain/complainList')">
<image class="interact-item-icon" src="/static/mine/shensu.png" mode=""></image>
<view>我的投诉</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/cart/coupon/myCoupon')">
<image class="interact-item-icon" src="/static/mine/mycoupon.png" mode=""></image>
<view>优惠券</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/cart/gift-card/myGiftCards')">
<image class="interact-item-icon" src="/static/mine/pointgift.png" mode=""></image>
<view>礼品卡</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/mine/signIn')">
<image class="interact-item-icon" src="/static/mine/sign.png" mode=""></image>
<view>每日签到</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/cart/coupon/couponCenter')">
<image class="interact-item-icon" src="/static/mine/couponcenter.png" mode=""></image>
<view>领券中心</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/promotion/bargain/log')">
<image class="interact-item-icon" src="/static/mine/kanjia.png" mode=""></image>
<view>砍价记录</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/mine/set/feedBack')">
<image class="interact-item-icon" src="/static/mine/feedback.png" mode=""></image>
<view>意见反馈</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/mine/set/editionIntro')">
<image class="interact-item-icon" src="/static/mine/pointgift.png" mode=""></image>
<view>关于</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/passport/entry/seller/index')">
<image class="interact-item-icon" src="/static/mine/feedback.png" mode=""></image>
<view>店铺入驻</view>
</view>
<!-- <view class="interact-item" @click="inviter()">-->
<!-- <image class="interact-item-icon" src="/static/mine/share.png" mode=""></image>-->
<!-- <view>邀新</view>-->
<!-- </view>-->
<view class="interact-item" @click="navigateTo('/pages/mine/set/setUp')">
<image class="interact-item-icon" src="/static/mine/setting.png" mode=""></image>
<view>设置</view>
</view>
<view class="service-grid">
<view class="service-grid-item" @click="navigateTo('/pages/cart/coupon/couponCenter')">
<image class="grid-icon" src="/static/mine/couponcenter.png" mode="aspectFit"></image>
<text class="grid-label">领券中心</text>
</view> </view>
</div>
<template> <view class="service-grid-item" @click="navigateTo('/pages/mine/point/myPoint')">
<u-popup v-model:show="sharingShow" mode="bottom" border-radius="14"> <image class="grid-icon" src="/static/mine/mypoint.png" mode="aspectFit"></image>
<view style="margin: 10px; text-align: center;"> 请邀请用户扫描二维码或者将地址复制转发给其他用户 </view> <text class="grid-label">我的积分</text>
<view class='qrcode'> </view>
<uqrcode v-if="sharingLink" ref="uqrcode" canvas-id="qrcode" :value="sharingLink" :options="{ margin: 10 }">
</uqrcode>
</view>
<view class="copy-text" @click="getDetail(sharingLink)"> <view class="service-grid-item" @click="navigateTo('/pages/mine/signIn')">
{{sharingLink}} <image class="grid-icon" src="/static/mine/sign.png" mode="aspectFit"></image>
</view> <text class="grid-label">每日签到</text>
<view class="confrim-btn"> </view>
<u-button @click="sharingShow = false;">关闭</u-button>
</view> <view class="service-grid-item" @click="navigateTo('/pages/cart/coupon/myCoupon')">
</u-popup> <image class="grid-icon" src="/static/mine/mycoupon.png" mode="aspectFit"></image>
</template> <text class="grid-label">优惠券</text>
</view>
<view class="service-grid-item" @click="navigateTo('/pages/mine/address/addressManage')">
<image class="grid-icon" src="/static/mine/myaddress.png" mode="aspectFit"></image>
<text class="grid-label">地址管理</text>
</view>
<view class="service-grid-item" @click="navigateTo('/pages/mine/myTracks')">
<image class="grid-icon" src="/static/mine/logistics.png" mode="aspectFit"></image>
<text class="grid-label">我的足迹</text>
</view>
<view class="service-grid-item" @click="navigateTo('/pages/mine/myCollect')">
<image class="grid-icon" src="/static/mine/myfavorite.png" mode="aspectFit"></image>
<text class="grid-label">我的关注</text>
</view>
<view class="service-grid-item" @click="navigateTo('/pages/order/evaluate/myEvaluate')">
<image class="grid-icon" src="/static/mine/feedback.png" mode="aspectFit"></image>
<text class="grid-label">我的评价</text>
</view>
</view>
</view> </view>
<!-- 卡片 2: 更多服务 -->
<view class="service-card">
<view class="service-title-bar">
<text class="service-title-text">更多服务</text>
</view>
<view class="service-grid">
<view class="service-grid-item" @click="distribution">
<image class="grid-icon" src="/static/mine/distribution.png" mode="aspectFit"></image>
<text class="grid-label">我的分销</text>
</view>
<view class="service-grid-item" @click="navigateTo('/pages/cart/gift-card/myGiftCards')">
<image class="grid-icon" src="/static/mine/pointgift.png" mode="aspectFit"></image>
<text class="grid-label">礼品卡</text>
</view>
<view class="service-grid-item" @click="navigateTo('/pages/promotion/bargain/log')">
<image class="grid-icon" src="/static/mine/kanjia.png" mode="aspectFit"></image>
<text class="grid-label">砍价记录</text>
</view>
<view class="service-grid-item" @click="navigateTo('/pages/passport/entry/seller/index')">
<image class="grid-icon" src="/static/mine/feedback.png" mode="aspectFit"></image>
<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>
</view>
<view class="service-grid-item" @click="navigateTo('/pages/order/complain/complainList')">
<image class="grid-icon" src="/static/mine/shensu.png" mode="aspectFit"></image>
<text class="grid-label">我的投诉</text>
</view>
<view class="service-grid-item" @click="navigateTo('/pages/mine/set/feedBack')">
<image class="grid-icon" src="/static/mine/feedback.png" mode="aspectFit"></image>
<text class="grid-label">意见反馈</text>
</view>
<view class="service-grid-item" @click="navigateTo('/pages/mine/set/setUp')">
<image class="grid-icon" src="/static/mine/setting.png" mode="aspectFit"></image>
<text class="grid-label">系统设置</text>
</view>
</view>
</view>
<!-- 分享二维码弹窗(保留原有能力) -->
<u-popup v-model:show="sharingShow" mode="bottom" border-radius="14">
<view style="margin: 10px; text-align: center;">请邀请用户扫描二维码或者将地址复制转发给其他用户</view>
<view class="qrcode">
<uqrcode v-if="sharingLink" ref="uqrcode" canvas-id="qrcode" :value="sharingLink" :options="{ margin: 10 }"></uqrcode>
</view>
<view class="copy-text" @click="getDetail(sharingLink)">
{{ sharingLink }}
</view>
<view class="confrim-btn">
<u-button @click="sharingShow = false;">关闭</u-button>
</view>
</u-popup>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed } from 'vue'
import { distribution as fetchDistribution } from '@/api/distribution' import { distribution as fetchDistribution } from '@/api/distribution'
import configs from '@/config/config' import configs from '@/config/config'
import storage from '@/utils/storage' import storage from '@/utils/storage'
@@ -148,9 +133,47 @@ import {
DISTRIBUTION_STATUS, DISTRIBUTION_STATUS,
} from '@/utils/distributionStatus' } from '@/utils/distributionStatus'
const props = withDefaults(
defineProps<{
userInfo?: any
}>(),
{
userInfo: () => ({}),
}
)
const sharingShow = ref(false) const sharingShow = ref(false)
const sharingLink = ref('') 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) { function handleNavigate(url: string) {
uni.navigateTo({ url }) uni.navigateTo({ url })
} }
@@ -227,7 +250,6 @@ function distribution() {
if (!tipsToLogin('normal')) { if (!tipsToLogin('normal')) {
return return
} }
// 入口按 distributionStatus 路由:有对象 ≠ 有效分销员。清退停住,不进 join / home。
fetchDistribution().then((res) => { fetchDistribution().then((res) => {
if (res.data.result) { if (res.data.result) {
const type = res.data.result.distributionStatus const type = res.data.result.distributionStatus
@@ -264,6 +286,69 @@ function distribution() {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.tool-wrapper {
display: flex;
flex-direction: column;
gap: 20rpx;
margin-top: 20rpx;
}
.service-card {
background: #ffffff;
border-radius: 20rpx;
padding: 24rpx 16rpx 20rpx;
box-shadow: 0 4rpx 18rpx rgba(17, 30, 79, 0.04);
.service-title-bar {
padding: 0 12rpx 20rpx;
border-bottom: 1rpx solid #f6f7fa;
.service-title-text {
font-size: 29rpx;
font-weight: 700;
color: #1a1a1a;
letter-spacing: 0.5rpx;
}
}
.service-grid {
display: flex;
align-items: center;
flex-wrap: wrap;
padding-top: 14rpx;
.service-grid-item {
width: 25%;
flex: 0 0 25%;
box-sizing: border-box;
height: 146rpx;
padding: 12rpx 4rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: transform 0.15s, opacity 0.15s;
&:active {
transform: scale(0.92);
opacity: 0.8;
}
.grid-icon {
width: 52rpx;
height: 52rpx;
margin-bottom: 12rpx;
}
.grid-label {
font-size: 24rpx;
color: #444444;
line-height: 1.2;
}
}
}
}
.copy-text { .copy-text {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -272,50 +357,8 @@ function distribution() {
line-break: anywhere; line-break: anywhere;
} }
.interact-tools {
border-left: none;
border-right: none;
.interactBox {
height: 156rpx;
}
.interact-container {
margin: 0 20rpx;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 4rpx 24rpx 0 rgba($color: #f6f6f6, $alpha: 1);
.interact-item-icon {
width: 56rpx;
height: 56rpx;
margin-bottom: 10rpx;
}
display: flex;
align-items: center;
flex-wrap: wrap;
text-align: center;
.interact-item {
font-size: 24rpx;
width: 25%;
flex: 0 0 25%;
box-sizing: border-box;
height: 150rpx;
padding: 20rpx 8rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #666;
}
}
}
.qrcode { .qrcode {
margin: 0 auto; margin: 0 auto;
width: 200px; width: 200px;
} }
</style> </style>

View File

@@ -2,8 +2,13 @@ import { createStore } from 'vuex'
import storage from '@/utils/storage' import storage from '@/utils/storage'
import config from '@/config/config' import config from '@/config/config'
import { DEFAULT_THEME, loadCachedTheme } from '@/utils/theme' import { DEFAULT_THEME, loadCachedTheme } from '@/utils/theme'
import {
DEFAULT_CATEGORY_PAGE_SETTING,
loadCachedCategoryPageSetting,
} from '@/utils/categoryPageSetting'
const cachedTheme = loadCachedTheme() const cachedTheme = loadCachedTheme()
const cachedCategoryPageSetting = loadCachedCategoryPageSetting()
const store = createStore({ const store = createStore({
state: { state: {
@@ -17,6 +22,8 @@ const store = createStore({
uuid: storage.getUuid(), uuid: storage.getUuid(),
token: '', token: '',
theme: cachedTheme || { ...DEFAULT_THEME }, theme: cachedTheme || { ...DEFAULT_THEME },
categoryPageSetting:
cachedCategoryPageSetting || { ...DEFAULT_CATEGORY_PAGE_SETTING },
}, },
getters: { getters: {
mainColor: (state) => state.theme.mainColor, mainColor: (state) => state.theme.mainColor,
@@ -44,6 +51,9 @@ const store = createStore({
aiderLightColor: theme.aiderLightColor || DEFAULT_THEME.aiderLightColor, aiderLightColor: theme.aiderLightColor || DEFAULT_THEME.aiderLightColor,
} }
}, },
SET_CATEGORY_PAGE_SETTING(state, setting) {
state.categoryPageSetting = setting || { ...DEFAULT_CATEGORY_PAGE_SETTING }
},
}, },
actions: {} actions: {}
}) })

View File

@@ -0,0 +1,70 @@
export const DEFAULT_CATEGORY_PAGE_SETTING = {
template: 1,
showFields: {
subTitle: true,
spec: true,
tags: true,
selfTag: true,
price: true,
linePrice: false,
sales: true,
cart: true,
},
};
const STORAGE_KEY = "category_page_setting";
const EXPIRATION_KEY = "category_page_setting_expiration_time";
function fallback() {
return JSON.parse(JSON.stringify(DEFAULT_CATEGORY_PAGE_SETTING));
}
/** 对已解析的配置对象做字段级清洗(缺键补默认、非法值回退)。 */
function sanitize(raw) {
if (!raw || typeof raw !== "object") return fallback();
const result = fallback();
if ([1, 2, 3, 4].includes(raw.template)) {
result.template = raw.template;
}
if (raw.showFields && typeof raw.showFields === "object") {
for (const key of Object.keys(result.showFields)) {
if (typeof raw.showFields[key] === "boolean") {
result.showFields[key] = raw.showFields[key];
}
}
}
return result;
}
/**
* 归一化分类页设置。入参为 buyer 接口 result(Setting 实体或 null),
* null / 解析失败 / 缺键 / template 非 1-4 一律回退默认值。
*/
export function normalizeCategoryPageSetting(setting = null) {
if (!setting || !setting.settingValue) return fallback();
try {
return sanitize(JSON.parse(setting.settingValue));
} catch {
return fallback();
}
}
export function cacheCategoryPageSetting(data) {
const expirationTime = new Date().setHours(new Date().getHours() + 1);
uni.setStorageSync(EXPIRATION_KEY, expirationTime);
uni.setStorageSync(STORAGE_KEY, data);
}
export function loadCachedCategoryPageSetting() {
const expirationTime = uni.getStorageSync(EXPIRATION_KEY);
const cacheValid =
expirationTime && new Date() <= new Date(Number(expirationTime));
if (!cacheValid) return null;
try {
const cached = uni.getStorageSync(STORAGE_KEY);
// 缓存可能被篡改/来自旧版本,读出后同样过清洗
return cached ? sanitize(cached) : null;
} catch {
return null;
}
}

View File

@@ -9,6 +9,12 @@ const FACE_LOGIN = isDev ? "face_login_dev" : "face_login";
const FINGER_LOGIN = isDev ? "finger_login_dev" : "finger_login"; const FINGER_LOGIN = isDev ? "finger_login_dev" : "finger_login";
const CART_BACKBTN = isDev ? "cart_backbtn_dev" : "cart_backbtn"; const CART_BACKBTN = isDev ? "cart_backbtn_dev" : "cart_backbtn";
const AFTERSALE_DATA = isDev ? "aftersale_data_dev" : "aftersale_data"; 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 { export default {
setInviter(val){ setInviter(val){
uni.setStorageSync('inviter', val) uni.setStorageSync('inviter', val)
@@ -147,6 +153,41 @@ export default {
uni.setStorageSync('imGoodId', val); uni.setStorageSync('imGoodId', val);
}, },
getImGoodsLink() { getImGoodsLink() {
return uni.getStorageSync('imGoodId'); 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
}