29 Commits

Author SHA1 Message Date
c9e074366f =========== V3.0.1 ==========
- 稳定版
- 可以基于此版本作为其他项目的底座
2026-07-16 22:16:53 +08:00
b07ceaa8f1 =========== V3.0.1 ========== 2026-07-16 22:08:07 +08:00
1662daf436 fix(dashboard): 修复刷新统计数据未更新问题
- 交换基本信息和今日数据概况面板位置,基本信息放左侧,今日数据概况放右侧
- 修改刷新统计数据方法调用,强制传入参数跳过缓存
- 删除测试通知按钮,简化界面
- 确保刷新数据按钮可用且显示加载状态
- 添加刷新成功提示,增强用户体验
2026-07-16 21:32:19 +08:00
1d69923e06 refactor(shop-dashboard): 调整商城后台Dashboard布局及面板结构
- 基本信息面板改为左右两列并排显示,响应式列数调整为2列
- 今日数据概况与基本信息合并至同一行,分别占用半宽空间
- 今日数据概况面板栅格调整为2列,样式和间距优化
- 删除快捷操作面板及相关图标导入和缓存清理功能
- 快速入口功能调整为动态渲染卡片布局取代原按键列表
- 优化部分样式,如待办任务列表的内边距和字体大小调整
- 头像显示增加默认图片占位,避免空白显示
2026-07-16 21:25:22 +08:00
0b6563aad9 refactor(cms): 更新商城名称及相关展示文案
- 将网页标题由“企业商城”修改为“小程序商城”
- 修改仪表盘技术支持项显示名称为“企业官网”
- 更新登录页默认配置中站点名称为“小程序商城”
2026-07-16 14:13:33 +08:00
6c50ffa58b refactor(cms): 更新商城名称及相关展示文案
- 将网页标题由“企业商城”修改为“小程序商城”
- 修改仪表盘技术支持项显示名称为“企业官网”
- 更新登录页默认配置中站点名称为“小程序商城”
2026-07-16 14:10:23 +08:00
6fcc120e91 fix(subscription): 修复续费配置查询被租户隔离导致问题
- 修正 `MybatisPlusConfig.java` 中租户拦截器忽略表列表,新增 `"app_setting"` 以防止租户隔离导致配置查询失败
- 解决续费时 `platform_miniprogram` 配置查询因租户过滤返回空的问题,从而避免报“配置不存在或解析失败”
- 复核 `app_setting` 表查询应无租户隔离,与代码注释设计意图保持一致
- 记录排查方法,使用 pymysql 连接远程数据库验证数据状态
- 纠正广告编辑保存逻辑,取消广告图片 URL 拼接 OSS 压缩处理后缀
- 修改 cmsAd 组件 `chooseFile` 函数,图片 URL 直接使用原始链接,不再添加图片压缩参数
- 保持其他 cms 组件未改动,根据用户需求仅调整广告相关逻辑
2026-07-15 22:51:47 +08:00
015cd50c08 feat(subscription): 对齐续费接口为一步生成支付码
- 修正续费请求路径为后端实际的 /renew-pay/{id},避免 404 错误
- 续费接口新增 method 和 envVersion 参数支持余额与微信支付
- 续费请求返回支付结果,微信支付返回小程序码,余额支付直接成功
- 续费不再分步调起支付,前端确认续费逻辑简化为一步调用后端接口
- 后端 renewPay 优化,避免放弃支付导致服务中断及时长丢失
- 后端续费激活逻辑调整,续费保留原订阅有效期累计时长
- 排查并解决微信小程序支付配置缺失导致续费失败的问题
- 代码及注释同步更新,完善续费流程整体一致性与稳定性
2026-07-15 22:19:54 +08:00
5b5bb3b0e5 style(shopGoodsBrowse): 移除商品名称列的居中对齐
- 删除了商品名称列中多余的 align: 'center' 属性
- 保持了 ellipsis 属性以支持文本省略显示
- 优化了表格列的展示样式一致性
2026-07-15 18:55:37 +08:00
ace46ffea6 style(shopGoodsBrowse): 移除商品名称列的居中对齐
- 删除了商品名称列中多余的 align: 'center' 属性
- 保持了 ellipsis 属性以支持文本省略显示
- 优化了表格列的展示样式一致性
2026-07-15 17:07:32 +08:00
a2c4f49ee6 refactor(dashboard): 重构基本信息面板数据源及运行天数计算
- 统一从 app_product.product_id=65 和 app_subscription 获取面板数据,替代原硬编码和 tenant/siteStore
- 字段映射调整,软件名称、版本号、运行状态、创建时间等均改用 product 与 subscription 字段
- 删除硬编码的 systemInfo reactive,移除不必要的 reactive 导入
- 新增 productStatusText 计算属性,对 publishStatus 状态做文字和颜色映射
- 重写运行天数计算,改用订阅生效时间 startTime,修正续费重置计数的边界响应
- 模板绑定全部字段,增加骨架屏 loading 状态,避免界面闪烁
- 添加「系统运行」字段提示信息,明确起算时间语义
- 保持 loadSubscriptionInfo 调用 getProductDetail,无新增接口请求
- 确保 vue-tsc 类型校验无新增错误
2026-07-15 16:52:43 +08:00
00346d96d8 feat(shop-dashboard): 优化系统信息展示及状态显示
- 使用骨架屏组件优化系统名称、版本号、创建时间、系统运行天数等信息加载体验
- 引入发布状态映射,使用标签显示应用运行状态,提升状态直观性
- 分离系统信息数据源,区分身份信息和时间线数据来源
- 实现系统运行天数计算,支持基于订阅起始时间动态展示
- shopOrder组件调整支付凭证图片尺寸及位置,优化界面布局
- shopDealerWithdrawEdit组件新增paymentVoucher字段,支持支付凭证信息录入
2026-07-15 16:37:55 +08:00
071a3bf110 fix(shopOrder): 默认tab改为全部,订单详情页增支付凭证图
- 将 shopOrder 选项卡默认值从 'undelivered' 改为 'all',确保首页展示全部订单数据
- 修正初次加载时因缺少 statusFilter 导致数据不符的问题
- 订单详情页新增“支付凭证”显示,支持线下付款上传凭证图片预览
- 引入确保完整URL显示的工具函数 ensureFullUrl,配合压缩图像展示
- form reactive 新增 paymentVoucher 字段,符合 ShopOrder 模型定义
- 仅线下付款订单显示该凭证图,条件渲染自然处理
2026-07-15 15:53:14 +08:00
d5e040b1e6 feat(shopOrder): 实现电脑版后台全局新订单红点提示
- 新建模块级全局单例 useOrderNotify,统一轮询检测新订单,避免多组件重复请求
- 轮询检测到新订单时触发叮声、语音播报,通知所有已注册回调刷新数据
- 订单管理菜单及 dashboard 快速入口显示红点提示新订单未查看
- 进入订单列表页时调用 clearBadge 同步最新订单ID并清除红点,标记订单已查看
- layout 全局启动新订单提醒,任意页面均生效
- menu-title 组件新增纯红点(dot)徽章显示支持
- dashboard 快速入口图标加红点显示及脉冲动画,提升视觉提示效果
- 维护新订单提醒回调集合,组件卸载自动移除回调避免内存泄漏
- 新订单提醒配置项和状态均为模块共享响应式,支持多组件无冲突使用
2026-07-15 15:26:48 +08:00
3a951c70f5 feat(shop): 列表商品图片改用 OSS 压缩函数
- src/views/shop/shopGoodsBrowse/index.vue 中引入 getCompressedImageUrl
- 商品图片 :src 改为使用 getCompressedImageUrl(record.goodsImage)
- 统一默认宽度 240px,质量 90,自动补全 OSS 域名
- 跳过已含 x-oss-process 的 URL 保持预览行为不变
2026-07-15 14:50:22 +08:00
2abd7e3372 fix(shop): 修正订阅流程及产品价格单位处理
- 后端 generatePayQrcode 价格计算改为 price/100 转元,年付按 10 个月计价
- 前端 dashboard 订阅价格同步调整,保持与后端一致的单位换算
- 解决 generatePayQrcode price_type 参数无生效导致的报错问题
- confirmSubscribe 改用 subscribe + pay 两步流程,规避后端未重启的问题
- 移除 confirmSubscribe 中的 isFreeProduct 分支,改用订阅状态判断
- 订阅信息加载策略调整,固定使用 productId=65 查询产品详情
- 保证前端代码通过 vue-tsc 校验,未引入新 TS 错误
2026-07-15 14:27:47 +08:00
7c73f9f821 feat(subscription): 优化订阅及支付二维码生成流程
- 统一调用 subscribe 接口创建订阅,确保后端设置价格类型
- 免费应用订阅成功后直接激活,提示订阅成功并刷新订阅信息
- 付费应用订阅成功后调用 pay 接口生成支付小程序码
- 支持显示支付二维码,展示付费信息,弹出支付模态框
- 增加支付状态轮询逻辑以跟踪支付进度
- 优化错误处理,打印错误日志并提示用户重试
2026-07-15 14:25:14 +08:00
09af76e4ea fix(subscription): 修复价格单位错误导致金额多100倍问题
- 确认 product.price 实际单位为分,修正所有价格计算需除以100转为元
- 后端 generatePayQrcode 方法价格计算改为 price/100,年付按10个月计费,季度付按3个月计费
- 前端 dashboard/index.vue 月付价格除以100,年付价格按月付价乘以10计算,保持与后端一致
- 保持 subscribe 价格计算逻辑,避免价格单位不一致的错误
- 修复后价格显示和支付逻辑统一,解决用户反馈金额偏大问题
2026-07-15 14:12:54 +08:00
29a18ffbc6 feat(appSubscription): 实现Dashboard订阅到期显示及扫码支付功能
- dashboard订阅到期时间信息支持三态渲染:激活显示剩余天数和已激活标签、待支付显示金额及去支付按钮、无订阅显示立即订阅按钮
- 通过租户ID查询应用产品,实现按租户过滤的订阅展示逻辑
- 新增appProduct分页接口及类型定义,提供应用列表获取能力
- 实现「立即订阅」流程,支持免费应用直接激活,付费应用生成微信扫码支付二维码及轮询订单状态
- 实现待支付订阅复用支付接口生成支付二维码,避免重复创建订阅
- 小程序端扫码支付流程支持订单号查询及支付确认,后端使用Redis标记支付成功状态
- 修复后端generatePayQrcode接口缺少priceType字段导致的数据库报错,前端兼容现有逻辑无需修改
- 移除dashboard中siteInfo旧的expirationTime字段相关代码,改为读订阅expireTime
- 新增订阅及支付相关样式,优化界面体验
- 支付状态轮询实现超时提示及自动停止机制,提升用户支付体验
- 组件卸载时确保支付轮询停止,避免内存泄漏
- 独立加载订阅信息,不影响其他数据请求,避免页面加载失败影响订阅展示
2026-07-15 14:11:14 +08:00
e0d27f67d5 feat(shopOrder): 新增确认线下收款上传支付凭证功能
- 重构确认线下收款为独立组件 OfflinePaymentModal.vue
- 新增支付凭证字段 paymentVoucher,支持图片上传与预览
- 上传限制为 jpg/png 格式,单张图片最大 10MB
- API confirmOfflinePayment 新增 paymentVoucher 参数
- 页面移除原 Modal.confirm 逻辑,改为调用新组件弹窗
- 修复 buyerRemarks 和 merchantRemarks 类型错误
- 后端 shop_order 表待新增 payment_voucher 字段和接口支持
- 同步更新相关接口模型与视图交互逻辑

feat(shopGoodsBrowse): 新增用户商品浏览记录管理功能

- 新增 ShopGoodsBrowse 接口模型及分页查询、删除接口
- 前端新增浏览记录管理页面,实现列表展示和删除
- 支持基于用户ID、商品ID、浏览来源和日期范围搜索
- 列表显示商品图片、名称、价格、浏览次数及时间等信息
- 添加图片预览和操作确认弹窗,保障操作体验和安全
2026-07-15 13:37:27 +08:00
a59d428e78 feat(shopOrder): 新增确认线下收款上传支付凭证功能
- 重构确认线下收款为独立组件 OfflinePaymentModal.vue
- 新增支付凭证字段 paymentVoucher,支持图片上传与预览
- 上传限制为 jpg/png 格式,单张图片最大 10MB
- API confirmOfflinePayment 新增 paymentVoucher 参数
- 页面移除原 Modal.confirm 逻辑,改为调用新组件弹窗
- 修复 buyerRemarks 和 merchantRemarks 类型错误
- 后端 shop_order 表待新增 payment_voucher 字段和接口支持
- 同步更新相关接口模型与视图交互逻辑

feat(shopGoodsBrowse): 新增用户商品浏览记录管理功能

- 新增 ShopGoodsBrowse 接口模型及分页查询、删除接口
- 前端新增浏览记录管理页面,实现列表展示和删除
- 支持基于用户ID、商品ID、浏览来源和日期范围搜索
- 列表显示商品图片、名称、价格、浏览次数及时间等信息
- 添加图片预览和操作确认弹窗,保障操作体验和安全
2026-07-15 13:30:14 +08:00
9e65007e65 feat(appSubscription): 新增应用订阅模块及商城欢迎横幅Logo支持
- 新增 useAppSubscriptionStore,封装 websopy 特殊接口的应用订阅功能
- 实现订阅列表分页查询、详情获取、支付状态查询、订阅管理等接口调用
- 适配不同支付方式,支持余额支付、微信支付及小程序支付预支付流程
- 在商城 Dashboard 欢迎横幅左侧新增商城 Logo 显示,读取商城设置的 shopLogo 字段
- 使用图像压缩接口处理 Logo 大小和质量,优化加载体验
- 优化 Dashboard 待处理事项展示,恢复退款和优惠券使用项
- 统一订阅接口调用地址为独立域名,复用请求工具共享认证和错误处理
2026-07-15 12:49:05 +08:00
e2dd8dbc18 feat(cms): 更新系统名称并优化租户信息获取逻辑
- 将系统名称由“小程序开发”修改为“小程序商城”
- 修改多处组件中系统名称的引用,统一使用系统信息对象
- 调整快捷入口路由,更新优惠券和会员管理的跳转路径
- 使用 tenantStore 替代原有的接口调用,统一获取租户公司信息
- 优化租户创建时间的计算方式,直接从 store 获取数据
- 移除不再使用的 getTenantInfo 方法,改为通过 pinia 状态管理获取
- 调整部分模板数据绑定,修正创建时间显示逻辑
2026-07-15 12:04:34 +08:00
08b697556b feat(cms): 更新系统名称并优化租户信息获取逻辑
- 将系统名称由“小程序开发”修改为“小程序商城”
- 修改多处组件中系统名称的引用,统一使用系统信息对象
- 调整快捷入口路由,更新优惠券和会员管理的跳转路径
- 使用 tenantStore 替代原有的接口调用,统一获取租户公司信息
- 优化租户创建时间的计算方式,直接从 store 获取数据
- 移除不再使用的 getTenantInfo 方法,改为通过 pinia 状态管理获取
- 调整部分模板数据绑定,修正创建时间显示逻辑
2026-07-15 12:00:14 +08:00
b0ff82599a feat(shop): 新增Dashboard待付款订单统计及支持
- Dashboard待处理事项新增“待付款订单”项,使用statusFilter=0查询
- 点击待付款订单跳转至/shop/shopOrder?tab=unpaid页面
- shopOrder页面恢复“待付款”标签页,validTabs列表包含unpaid
- shopOrder页面根据路由query初始化活动标签,支持unpaid选项
- 新增dot-gold样式用于待付款订单高亮显示
- 订单数量接口新增待付款订单数获取及异常处理逻辑
- 调整组件中待处理事项列表,支持待付款订单数据响应式更新
2026-07-15 03:46:23 +08:00
57b584df1b feat(shop): 支持Dashboard订单总数点击跳转指定tab
- 修改Dashboard中订单总数和总营业额跳转链接,默认携带tab参数
- 订单总数点击跳转至/shop/shopOrder?tab=all页面
- 待发货订单跳转至/shop/shopOrder?tab=undelivered,退款申请跳转至?tab=refunded
- shopOrder页面根据路由查询参数初始化激活tab,确保页面首次加载可正确显示对应订单列表
- 为无效或缺省tab参数设置默认值undelivered
- 简化订单页面tab切换逻辑复用公共状态映射函数
2026-07-15 02:35:01 +08:00
6350a9b5a9 fix(shopOrder): 调整商品头像显示尺寸并优化图片压缩
- 修改订单商品头像大小,从80调整为50
- 调整图片压缩函数调用,设置压缩宽度为100
- 修正商品信息组件中头像图片的压缩调用,统一宽度参数
- 引入图片压缩工具方法以支持新参数调用
2026-07-15 01:40:12 +08:00
614e843673 fix(shopOrder): 修复初次加载订单状态筛选问题
- 提取 getStatusFilterByTab 函数实现 tab key 与 statusFilter 的映射
- datasource 函数中当 statusFilter 未设置时,自动根据 activeKey 推导赋值
- onTabs 函数简化为复用 getStatusFilterByTab 函数,减少重复代码
- 确保初次加载时待发货等状态的 tab 能正确筛选订单数据
2026-07-15 01:29:44 +08:00
894592c290 feat(shop): 新增线下付款支持及优化Dashboard数据统计
- 新增线下付款(payType=9)支付方式,支持订单中标识和确认收款操作
- 实现商家确认线下付款后订单进入待发货状态功能
- 更新支付方式组件和订单列表及详情页的支付状态显示
- 优化shop/dashboard快捷操作按钮为商城常用功能,修正快速入口路由路径
- 修复Dashboard待发货订单数统计时未过滤商城订单导致数据不准的问题
- 修复Dashboard运行天数为0的异常,将租户信息请求独立处理,避免某些请求失败影响
- 修复statisticsStore中couponUsedCount与getter同名冲突,改名为safeCouponUsedCount并同步更新引用
2026-07-14 22:17:39 +08:00
33 changed files with 3224 additions and 422 deletions

View File

@@ -6,3 +6,27 @@
-`src/views/shop/dashboard/index.vue` 的欢迎横幅右侧添加新订单提醒开关栏(开关 + 测试按钮),检测到新订单后自动调用 loadData() 刷新 Dashboard 数据。
-`src/views/cms/dashboard/index.vue` 的概况卡片标题栏添加新订单提醒控件,同样检测到新订单后刷新数据。
- 提取了 CMS Dashboard 的 loadData 函数使其可复用。
## 更新 shop/dashboard 快捷操作按钮为商城常用功能
- 快捷操作按钮从原来的通用功能(参数配置/用户管理/站点管理/登录日志)改为商城系统常用功能:订单管理、商品管理、商品分类、优惠券管理、会员管理、商城设置、清除缓存。
- 路由路径统一使用 `/shop/shopXxx` 格式(与 `/shop/shopOrder` 等已确认路径一致),修复了快速入口中 `/shopGoods``/shop/shopGoods` 的路径不一致问题。
- 快速入口九宫格也同步改为商城相关入口。
- 图标导入更新:移除 UngroupOutlined/CalendarOutlined/UserOutlined/FileTextOutlined新增 ShoppingCartOutlined/AppstoreOutlined/GiftOutlined/TeamOutlined/SettingOutlined。
## 修复 shop/dashboard 待发货订单数量统计不准确
- 根因:订单列表页 datasource 中 `where.type = 0`(只查商城订单),但 Dashboard 统计未传 `type: 0`,导致把预定订单/外卖(type=1)和会员卡订单(type=2)也计入。
- 修复:为 pendingShipmentCount 和 pendingRefundCount 查询都加上 `type: 0`
## 修复 Dashboard 运行天数为 0 的问题
- 根因:`getTenantInfo()` 被放在 `await Promise.all([...])` 之后,如果 `statisticsStore.fetchStatistics()` 抛异常(`couponUsedCount` 错误),`Promise.all` reject 后直接跳到 `catch``getTenantInfo` 根本不会执行,`tenantCreateTime` 永远是空值。
- 修复:在 `shop/dashboard/index.vue``cms/dashboard/index.vue``loadData` 中,把 `getTenantInfo()` 放到 `Promise.all` 外面,用 `.then` 独立执行,不受其他请求失败影响。
- 运行天数计算基于 `getTenantInfo()` 返回的 `Company.createTime`(租户创建时间)。
## 修复 statisticsStore couponUsedCount 冲突
- 根因:`couponUsedCount` 同时被定义成 state 属性和 getterPinia 中同名冲突导致 action 里 `this.couponUsedCount = ...` 报错:`'set' on proxy: trap returned falsish`
- 修复:将 getter 改名为 `safeCouponUsedCount``shop/dashboard/index.vue` 中同步更新引用(该引用当前被注释,但保持一致性)。

View File

@@ -0,0 +1,279 @@
# 2026-07-15
## shopOrder 选项卡默认改为全部
- 需求:`/shop/shopOrder` 选项卡默认从"待发货"改为"全部"。
- 改动 `src/views/shop/shopOrder/index.vue``activeKey` 初始值在路由参数 `tab` 无效时的回退值由 `'undelivered'` 改为 `'all'`
- `datasource``getStatusFilterByTab('all')` 返回 `undefined`,即不传 `statusFilter`,查全部订单,符合预期。
## 修复 shopOrder 页面默认待发货tab数据不正确
- 根因:`activeKey` 默认为 `'undelivered'`,但 `datasource` 函数只设了 `where.type = 0`,没有设 `statusFilter``statusFilter` 只在用户点击 tab 触发 `onTabs` 时才设置。所以页面初次加载时查的是全部订单而非待发货订单。
- 修复:提取 `getStatusFilterByTab(key)` 公共映射函数,在 `datasource` 中当 `where.statusFilter` 未设置时从 `activeKey` 自动推导。`onTabs` 也简化为复用同一函数。
- 文件:`src/views/shop/shopOrder/index.vue`
## Dashboard 订单总数点击跳转支持指定 tab
- Dashboard 中"订单总数"/"总营业额"跳转链接改为 `/shop/shopOrder?tab=all`"待发货订单"改为 `?tab=undelivered`"退款申请"改为 `?tab=refunded`
- shopOrder 页面读取 `route.query.tab` 初始化 `activeKey`,无效值回退到 `'undelivered'`
- 文件:`src/views/shop/dashboard/index.vue``src/views/shop/shopOrder/index.vue`
## Dashboard 待处理事项新增"待付款订单"
- 在待发货订单上方新增"待付款订单"统计项,使用 `statusFilter=0` 查询,点击跳转 `/shop/shopOrder?tab=unpaid`
- shopOrder 页面取消注释"待付款"tab`validTabs` 加入 `unpaid`
- 新增 `dot-gold` 样式。
- 文件:`src/views/shop/dashboard/index.vue``src/views/shop/shopOrder/index.vue`
## Dashboard 引入 useTenantStore创建时间读租户信息
- 引入 `useTenantStore``loadData` 中调用 `tenantStore.fetchTenantInfo()` 替代直接调用 `getTenantInfo()` API。
- 基本信息"创建时间"改为读取 `tenantStore.company?.createTime`
- 运行天数也改为从 `tenantStore.company?.createTime` 计算,移除独立的 `tenantCreateTime` ref。
- 文件:`src/views/shop/dashboard/index.vue`
## Dashboard 欢迎横幅新增商城Logo
- 通过 `getShopSettingCategoryValues('basic')` 获取商城设置,读取 `shopLogo` 字段。
- 使用 `getCompressedImageUrl(shopLogo, { width: 200, quality: 90 })` 压缩图片。
- 在 welcome-banner 左侧用 `a-avatar` (64px, square) 展示,无 Logo 时不显示。
- 文件:`src/views/shop/dashboard/index.vue`
## 新增 useAppSubscriptionStorewebsopy 应用订阅特殊接口)
- 新增 `src/api/app/appSubscription/model.ts`AppSubscription 实体及参数/返回类型,对照后端 `com.gxwebsoft.app.entity.AppSubscription`
- 新增 `src/api/app/appSubscription/index.ts`:封装 `AppSubscriptionController` 全部 14 个接口。
- 特殊接口域名 `https://websopy-api.websoft.top/api`(与项目主 API 域名不同),复用 `@/utils/request`(共享登录态 token、401 处理),传绝对 URL。
- 后端无 context-pathbaseURL=`https://websopy-api.websoft.top/api`,接口路径用 `/app/subscription/xxx`
- 后端 `BaseController` 成功 code=0`success(IPage)` 转成 `PageResult{list,count}`,与项目 `@/api` 的 PageResult 结构一致。
- 新增 `src/store/modules/appSubscription.ts``useAppSubscriptionStore`Pinia Options API 风格,对齐 site.ts/statistics.ts聚合列表/详情/支付状态/订阅管理方法。列表/详情缓存5分钟`checkStatus`/`checkPurchased` 实时请求;订阅/支付/管理类操作成功后调 `invalidateCache()` 失效缓存。
## 确认线下收款新增上传凭证功能
- 背景:原"确认线下收款"用 `Modal.confirm` + `createVNode` 动态拼一个 input只有备注没有上传凭证。用户需要加凭证上传。
- 字段决策:新增 `paymentVoucher?: string` 字段(不复用 comments/merchantRemarks因为凭证是图片URL非文本。顺带修复 `buyerRemarks`/`merchantRemarks` 类型 bug`undefined``string?`)。
- 重构为独立组件 `src/views/shop/shopOrder/components/OfflinePaymentModal.vue`
-`<a-modal>` + `<a-form>` + `<a-upload list-type="picture-card">` 单张图片上传。
- 上传走 `uploadOss``src/api/system/file`),存储返回的 `path`(与项目 `UploadCert` 组件约定一致),预览用 `getUrl(path)` 补全。
- 凭证必填,备注可选;校验 jpg/png、≤10MB支持点击预览大图。
- API `confirmOfflinePayment(id, remarks?, paymentVoucher?)` 新增第三参数,通过 query params 传给后端。
- `index.vue`:移除原 `Modal.confirm`/`createVNode` 逻辑,改为打开新组件(`v-model:visible` + `:data="current"` + `@done="reload"`),与 `DeliveryModal` 模式一致。
- 类型检查:新增 0 错误,顺带消掉原 `okType: 'success'` 的类型错误antdv4 ButtonType 不含 success新组件去掉了 ok-type
- 改动文件:`src/api/shop/shopOrder/model/index.ts``src/api/shop/shopOrder/index.ts``src/views/shop/shopOrder/components/OfflinePaymentModal.vue`(新增)、`src/views/shop/shopOrder/index.vue`
- 后端待办(已完成):后端项目 `/Users/gxwebsoft/JAVA/guilixu-java`MyBatis-Plus 3.4.3.3,无 Flyway/LiquibaseDDL 手动管理SQL 脚本放 `sql/` 目录):
- `ShopOrder.java` 实体新增 `paymentVoucher` 字段payTime 之后)。
- `ShopOrderController.confirmOfflinePayment` 新增 `@RequestParam paymentVoucher`
- `ShopOrderService` 接口 + `ShopOrderServiceImpl` 实现方法签名加 `paymentVoucher` 参数lambdaUpdate 加 `.set(paymentVoucher 非空, ShopOrder::getPaymentVoucher, paymentVoucher)`
- DDL 脚本 `sql/shop_order_payment_voucher.sql``ALTER TABLE shop_order ADD COLUMN payment_voucher VARCHAR(500) NULL ... AFTER pay_time`
- 注意:方法签名从 2 参变 3 参,全项目仅 Controller 一处调用,无断裂。
## Dashboard 到期时间显示订阅信息 + 立即订阅/扫码支付
- 改造 `src/views/shop/dashboard/index.vue` 基本信息「到期时间」行三态渲染active 显示 expireTime + 已激活 tag + ≤7天到期提醒pending 显示待支付 + 去支付;无订阅显示立即订阅)。
- **productId 关联条件**`app_product.tenantId = userStore.info.tenantId`(非 siteInfo.appId。通过 `pageProducts({tenantId,current:1,size:1})` 取该租户应用。
- 新增 `src/api/app/appProduct/{model.ts,index.ts}`AppProduct 类型 + `pageProducts` 接口GET /api/app/product/page分页参数 current/size注意与项目 page/limit 不同)。
- 「立即订阅」流程:选 month/year → 免费应用调 `subscribe()` 直接激活;付费应用调 `generatePayQrcode()` 生成小程序码 → Modal 展示二维码 → 每 2.5s 轮询 `checkStatus(subscriptionNo)`paid 后关闭+刷新(最多 5 分钟。envVersion 按 `import.meta.env.DEV` 自动判断 trial/release。
- pending 订阅「去支付」复用 `subscriptionStore.pay(id,'wechat')`(用 `'miniappQrcode' in result` 类型守卫窄化)生成小程序码,不重复创建订阅。
- 小程序端支付页 `websopy-taro/src/passport/pay/index.tsx`:扫码 scene=subscriptionNo → detail-by-no → mp-prepay → requestPayment → mp-confirm后端写 Redis `wxpay:paid:{no}=1`Web 端 checkStatus 据此感知。
- 移除 dashboard 不再使用的 `siteInfo` 解构(原到期时间读 `siteInfo.expirationTime`,已改为订阅 expireTime修复 noUnusedLocals 报错。
## 修复后端 generatePayQrcode 报错 "Field 'price_type' doesn't have a default value"
- 现象dashboard 点「立即订阅」付费应用调 `generatePayQrcode` 时,后端 insert app_subscription 报 `price_type` 无默认值。
- 根因:后端 `AppSubscriptionController.generatePayQrcode`websopy-java创建订阅时漏了 `setPriceType()`,而 DB `price_type` 为 NOT NULL 无默认值。对比 `subscribe` 方法有 `setPriceType`
- 参考 `websopy-pc/app/pages/console/pay/[subscriptionNo].vue`:它用 `subscribe` 创建订阅 + `pay(id,'wechat')` 生成二维码(两步),避开了 `generatePayQrcode`,所以没遇到此 bug。
- 修复(后端 `AppSubscriptionController.java` generatePayQrcode 方法):补全 `setPriceType(product.getPriceType())` + `setOriginalPrice`/`setPayStatus(0)`/`setTenantId`,对齐 subscribe。**前端无需改动**,重启后端即可。
- 注意:`subscribe` 价格计算用 `price/100`(当分转元),`generatePayQrcode` 直接用 `price`当元两者不一致schema 标注 price 为「元」,故 `generatePayQrcode` 价格逻辑更合理,保留不动。
## 修复价格多了100倍product.price 实际单位是分)
- 用户反馈:`generatePayQrcode` 算出的金额多了100倍。确认 `product.price` 实际存的是**分**(非 schema 标注的元),故 `subscribe``/100` 是对的,`generatePayQrcode` 直接用 price 是错的。
- 后端 `AppSubscriptionController.generatePayQrcode` 价格计算改为 `price/100` 转元(对齐 subscribe年付按10个月*10对齐 subscribe 年付优惠),季付*3月付*1。
- 前端 `dashboard/index.vue` 参考价同步修正:`monthPrice = price/100``yearPrice = monthPrice*10`(原为 price 直接 + *12
- 结论product.price 实际单位是**分**,所有价格展示/计算都需 /100 转元。
## 改用 subscribe+pay 避开 generatePayQrcode 的 price_type 报错
- 现象:用户反馈 `generatePayQrcode` 仍报 `price_type` 无默认值(报错 SQL 字段集是修复前的,说明后端未重启/未重编译,`setPriceType` 修复未生效)。
- 关键认知:`generatePayQrcode` 后端**不接收 priceType 参数**(从 `product.getPriceType()` 取),所以前端传 priceType 给它也没用,无法绕过。
- 修复前端方案不依赖后端重启dashboard `confirmSubscribe` 改用 `subscribe` + `pay` 两步(参考 websopy-pc 的 `pay/[subscriptionNo].vue` 模式):
- `subscribe` 创建订阅(后端有 `setPriceType`,不报错;价格 `/100` 正确)→ 免费 `status=active` 直接成功
- 付费 `status=pending``pay(subscriptionId,'wechat')` 生成小程序码 → Modal 展示 + 轮询 `checkStatus`
- 移除 `confirmSubscribe` 里的 `isFreeProduct` 分支(改用 `subResult.status` 判断);`isFreeProduct` 仍用于订阅 Modal 的免费提示。
- vue-tsc 验证 dashboard 零 error TS总错误 1778 不变)。
- 后端 `generatePayQrcode``setPriceType` 修复仍保留(重启后端后该接口也能用),但前端已不依赖它。
## 订阅 productId 改为固定 65小程序商城
- 需求dashboard 立即订阅请求的 productId 从动态(按 `tenantId``app_product`)改为写死 `65`
- 改动 `src/views/shop/dashboard/index.vue`
- imports`pageProducts``getProductDetail`
- `loadSubscriptionInfo`:移除 `userStore.info.tenantId` 判断和 `pageProducts({tenantId,current:1,size:1})` 调用,改为 `getProductDetail(65)` 查固定产品详情;定义 `FIXED_PRODUCT_ID = 65` 常量,订阅列表过滤和 `currentProduct.productId` 统一使用该常量。
- `confirmSubscribe` 无需改动(用 `currentProduct.productId`,已自动是 65
- `getProductDetail(65)` 返回的 product 含 name/priceType/price前端参考价 `monthPrice = price/100``yearPrice = monthPrice*10` 仍适用。
- vue-tsc 验证 dashboard 零 error TS总错误 1778 不变)。
## shopGoodsBrowse 列表商品图片改用 OSS 压缩函数
- 文件:`src/views/shop/shopGoodsBrowse/index.vue`
- 引入 `getCompressedImageUrl`(来自 `src/utils/image.ts`),将商品图片 `:src="record.goodsImage"` 改为 `:src="getCompressedImageUrl(record.goodsImage)"`
-`shopGoods``shopOrder``dashboard` 等页面保持一致:默认宽度 240px、质量 90、自动补全 OSS 域名、跳过已含 `x-oss-process` 的 URL。
- 预览行为未改(仍走 a-image 默认 src 预览)。
## 电脑版后台新订单红点提示(全局)
- 需求:电脑版后台有新订单时显示红点,让管理员知道有新订单。
- 关键发现:系统已有菜单红点基础设施(`menu-title.vue``item.meta.badge` 渲染徽章;`user.ts` `setMenuBadge(path,value,color)``layout/index.vue``.ele-menu-badge` 样式),但 `useOrderNotify` 原为组件级dashboard/cms dashboard/shopOrder 三处各自独立轮询),未接通红点。
- 改造 `src/views/shop/shopOrder/useOrderNotify.ts` 为**模块级全局单例**
- 轮询/状态/红点全部模块级变量enabled、hasNewOrder、lastOrderId、timer多组件调用只一个轮询。
- 检测到新订单 → triggerNotify叮声+语音+遍历回调Set+ `setOrderBadge(true)``userStore.setMenuBadge('/shop/shopOrder','dot','#ff4d4f')`
- 新增 `clearBadge()`:立即清红点 + 重查最新订单同步 lastOrderId标记"已查看")。
- `onNewOrder` 回调用 Set 管理,组件 onBeforeUnmount 自动移除;`start()` 幂等,由 layout 调一次。
- `src/layout/components/menu-title.vue``badge==='dot'` 时用 `<a-badge dot />` 纯红点,否则原数字徽章逻辑。
- `src/layout/index.vue`setup 调 `useOrderNotify().start()` 全局启动,后台任意页面生效。
- `src/views/shop/shopOrder/index.vue``onMounted` + `onActivated``clearBadge()`(兼容 keep-alive `cache-key`,用 `orderBadgeMounted` 标志防首次双触发)。
- `src/views/shop/dashboard/index.vue``quickLinks` 改 computed订单管理项 `badge: hasNewOrder.value``quick-icon` 加红点 span + pulse 动画样式。
- 决策(用户确认):红点=新订单未查看(进订单页清除);位置=订单管理菜单+dashboard快速入口订单管理图标声音保留并全局化。
- 构建验证:`vite build --outDir dist_verify` 通过EXIT:0。原 `pnpm build` 失败仅因 vite 清空 `dist/assets` 被安全删除保护拦截83文件>50阈值与代码无关。
## shopOrder 订单详情页orderInfo.vue支付方式下增加支付凭证图
- 背景:之前 `OfflinePaymentModal` 确认线下收款时已支持上传 `paymentVoucher`OSS path但订单详情页只展示「线下付款」tag看不到凭证图。
- 改动 `src/views/shop/shopOrder/components/orderInfo.vue`
- 模板:在「支付方式」`<a-descriptions-item>` 之后、「开票状态」之前新增「支付凭证」项,`v-if="form.paymentVoucher"``:span="3"` 占满整行。
- 缩略图用 `a-image``:width="120"``getCompressedImageUrl(form.paymentVoucher, { width: 750 })`(按用户要求宽度 750`preview-src-list``ensureFullUrl(form.paymentVoucher)` 给原图预览。
- 引入 `ensureFullUrl` 来自 `@/utils/image`(已有 `getCompressedImageUrl`)。
- form reactive 默认值新增 `paymentVoucher: undefined`,对齐 `ShopOrder` model。
- `ShopOrder` model 中 `paymentVoucher?: string` 字段早就存在payTime 之后),无需改 model/API。
- 用户场景仅线下付款payType=9的订单才有此凭证图条件渲染自然处理。
## Dashboard 基本信息 6 字段数据源重构product + subscription
- 需求dashboard「基本信息」面板原先混用硬编码/tenant/siteStore改为统一从 `app_product.product_id=65` + `app_subscription` 读取,语义更准确。
- 字段映射决策(用户确认):
- 系统名称 → `currentProduct.productName`(原硬编码 '小程序商城'
- 版本号 → `currentProduct.version`(原硬编码 '2.0.0'
- 运行状态 → `currentProduct.publishStatus` + 中文映射 + a-tag 颜色(原 `siteStore.statusText`
- 创建时间 → `currentSubscription.startTime`(原 `tenantStore.company?.createTime`
- 到期时间 → `currentSubscription.expireTime`(保持不变,原本就是这个)
- 系统运行天数 → 自 `currentSubscription.startTime` 起累计(原自 `tenantStore.company?.createTime`;后改为 startTime与"创建时间"行同源,语义统一)
- 改动 `src/views/shop/dashboard/index.vue`
- 删除硬编码 `systemInfo` reactive已确认 cms 那两个 systemInfo 是独立 const互不影响
- import 移除 `reactive`
- 新增 `productStatusText` computedpublishStatus → {text,color} 映射published=已发布/green、pending_review=审核中/orange、developing=开发中/blue、rejected=已驳回/red、deprecated=已下架/red未知状态显示原始值无 product 显示 '-'。
- 重写 `runDays` computed数据源由 `tenantStore.company?.createTime` 改为 `currentSubscription.value?.startTime`(与"创建时间"行同源),注释说明续费新增订阅记录会重置计数的边界行为。
- 模板 6 个字段全部重绑,每个字段加 `subscriptionLoading` 骨架屏分支(避免空白/老数据闪现)。
- 「系统运行」加 `a-tooltip title="自您首次开通小程序商城起"`(用户要求)。
- 数据流复用:`loadSubscriptionInfo()` 原本就调 `getProductDetail(65)` 填充 `currentProduct`,无需新增接口调用。
- 类型校验vue-tsc 对 `shop/dashboard/index.vue` 行级错误 0 条cms 那批历史错误与本次无关。
## Dashboard 到期时间续费功能 + 标签矛盾修复
- 背景dashboard「到期时间」行原先在订阅已过期时同时显示绿色「已激活」标签 + 「(已过期)」红字,状态自相矛盾;且即将到期/已过期时无续费入口(用户反馈"没有按钮呢")。
- 改动 `src/views/shop/dashboard/index.vue`
- 标签动态化:`a-tag :color="isExpired ? 'red' : 'green'"`,文案 `已过期/已激活`已过期时显示「已过期N天」。
- 新增续费按钮:`showRenewButton``daysToExpire <= 7` 即即将到期或已过期时显示),点击 `onRenew`
- 复用订阅 Modal新增 `subscribeMode`'subscribe'|'renew'+ `renewing` 状态Modal 的 title/okText/confirm-loading/@ok 按 mode 动态切换;免费应用 alert 文案也按 mode 区分。
- `confirmRenew``subscriptionStore.renew(sub.id, selectedPeriod)`POST /app/subscription/renew/{id}?period=month|year成功后 `refreshSubscription()`
- `onSubscribe` 补设 `subscribeMode='subscribe'`
- 续费 API 语义:`renewSubscription` 返回 string同步完成疑似余额扣款/免费续期)。若后端续费需走微信扫码支付,需改为返回支付二维码结构并接 pay 流程——待后端确认。
- 关键字段再确认(用户):开始时间用 `appSubscription.startTime`,到期时间用 `appSubscription.expireTime`daysToExpire 与 `dayjs()`(浏览器本地时间)对比。
## Dashboard 续费改走 subscribe+pay 扫码(后端 renew 接口未实现)
- 触发:用户截图显示 `POST /api/app/subscription/renew/90` 返回 404确认后端没有实现 renew 接口。
- 方案:续费不再调 `store.renew` / `renewSubscription`,改为复用现有 `subscribe + pay` 扫码支付链路(与首次订阅完全一致)。后端 subscribe 对已有 active 订阅会处理续期/创建新记录。
- 改动 `src/views/shop/dashboard/index.vue``confirmRenew`
- 删掉 `await subscriptionStore.renew(sub.id, ...)` 改为 `subscribe({ productId, subscriptionPeriod })`
- 免费应用走 `subResult.status === 'active'` 分支,提示「续费成功」并刷新。
- 付费应用走 `pay(subscriptionId, 'wechat', envVersion)` → 拿到 `miniappQrcode` → 弹支付 Modal + `startPolling`
- `payProductName` fallback 文案改为「应用续费」(与订阅的「应用订阅」区分)。
- `onRenew` 入口、`subscribeMode='renew'`、续费 Modal title/按钮复用不变UI 上用户感知不到底层调的是 subscribe。
- 教训/记录:调后端接口前最好先确认接口是否真实存在;前端 `api/app/appSubscription/index.ts``renewSubscription` 函数可保留(防止后端后续补上接口),但 dashboard 暂不调用。
## Dashboard 续费最终定稿方案1 renew+pay前端已就绪等后端实现
- 触发:上一步用 subscribe 走续费后端返回「您已订阅该应用无需重复购买」——subscribe 对已有 active 订阅是拒绝的,续费不能复用 subscribe。
- 定稿方案用户确认方案1 —— 后端实现 `/renew/{id}?period=xxx` 返回 `SubscribeResult`(含 subscriptionId前端再调 `pay` 生成小程序码。与 subscribe+pay 对称,职责清晰。
- 三处改动(前端已全部就绪,等后端补 renew 接口即可联调通):
1. `src/api/app/appSubscription/index.ts` `renewSubscription`:返回类型 `string``SubscribeResult`;取 `res.data.data`(原取 `res.data.message`)。
2. `src/store/modules/appSubscription.ts` `renew`:返回类型 `string``SubscribeResult`,返回 result 而非 msg。
3. `src/views/shop/dashboard/index.vue` `confirmRenew`:改回调 `subscriptionStore.renew(sub.id, period)``renewResult` → 免费(status=active)直接成功;付费走 `pay(renewResult.subscriptionId, 'wechat', envVersion)` → 弹码 + `startPolling`。与 confirmSubscribe 完全对称,仅第一步 subscribe→renew、传 sub.id 而非 productId。
- **后端接口约定(待实现)**`POST /api/app/subscription/renew/{id}?period=month|year`
- 入参id=原订阅IDperiod=month/year
- 逻辑:校验原订阅归属 → 创建 pending 续费记录startTime=原expireTime或nowexpireTime=新到期)→ 返回 `{ subscriptionId, subscriptionNo, status, message, payPrice?, orderNo? }`(同 SubscribeResult
- 前端拿到后调 `pay/{subscriptionId}` 生成微信小程序码,与订阅流程一致
- 续费按钮触发条件不变:`showRenewButton`daysToExpire<=7即将到期或已过期
## 续费最终对齐:后端已有 /renew-pay路径不匹配导致 404改前端即可
- 关键发现:后端 `AppSubscriptionController` 其实**已经实现了续费**,方法是 `renewPay`line 849-910路径是 `/api/app/subscription/renew-pay/{id}`(带连字符),不是前端调的 `/renew/{id}`,所以之前 404。
- 后端 renew-pay 逻辑(已实现,无需改动):
- 校验订阅存在 + priceType=subscription + 产品存在
- 计算续费价格年付按10个月对齐 subscribe
- 计算新到期:`baseTime = max(原expireTime, now)``newExpireTime = baseTime + period`(未过期从原到期延后,已过期从现在延后,不丢时长)
- 原订阅状态改 pending、覆盖 payPrice/period/expireTime
- method=balance → handleBalancePay直接扣款激活method=wechat → handleWechatPay返回 miniappQrcode
- 前端三处改动对齐后端从方案1改为方案2"renew一步返码"
1. `api/.../index.ts` `renewSubscription`:路径 `/renew/{id}``/renew-pay/{id}`;参数加 `method/envVersion`;返回类型 `SubscribeResult``PayResult | WechatNativePayResult`
2. `store/.../appSubscription.ts` `renew`:参数加 `method/envVersion`;返回类型 `PayResult | WechatNativePayResult`
3. `dashboard/index.vue` `confirmRenew``renew(sub.id, period, 'wechat', envVersion)` 直接拿支付结果 → `'paid' in result` 余额成功分支;`'miniappQrcode' in result` 弹码+轮询。不再二次调 pay。
- 教训:前端 404 时应先 grep 后端 Controller 确认接口是否真实存在及准确路径(带连字符的 `/renew-pay` 容易被误写为 `/renew`)。后端项目位于 `/Users/gxwebsoft/JAVA/websopy-java/`Controller 全路径 `com.gxwebsoft.app.controller.AppSubscriptionController`
## 续费报错"微信小程序配置不存在或解析失败"根因排查
- 报错:`POST /renew-pay/90` 返回"生成小程序码失败: 微信小程序配置不存在或解析失败 — 请检查 app_setting 表中 key='platform_miniprogram' 的记录"。用户说"之前订阅还可以"。
- 根因机制(`WxMiniprogramUtil.getAccessToken`
- access_token 有 Redis 缓存key=`WX_ACCESS_TOKEN:5`DEFAULT_TENANT_ID=5TTL=7000秒约2小时
- **缓存命中时直接返回 token不读 app_setting 配置**;缓存未命中才调 `getMiniprogramSetting()``app_setting` 表 key='platform_miniprogram'。
- 所以"之前订阅可以"= access_token 缓存命中掩盖了配置读取问题;现在续费失败=缓存过期,走到读配置那步发现读不到。
- 这**不是续费特有 bug**现在去测订阅subscribe+pay也会同样失败。
- `AppSettingServiceImpl.getByKey` 只按 setting_key 查、不按租户过滤(注释"平台配置不按租户隔离"),排除租户隔离问题。
- 排查方向(按可能性):
1. app_setting 表 platform_miniprogram 记录被误删/setting_value 被清空。
2. setting_value JSON 格式损坏(多/少逗号引号),`JSON.parseObject` 抛异常 → catch return null。
3. 表里有多条 setting_key='platform_miniprogram' 记录MyBatis-Plus getOne 抛 TooManyResultsException → catch return null。
- 后端 handleWechatPay 开头会调 `diagnoseConfig()` 打印详细日志app_setting 是否存在、setting_value 原始内容、解析出的 appId/appSecret看后端日志最快定位。
- 修复配置丢了去小程序配置页重新保存batchSave 会写入);多条记录则清理只留一条。
- 关键文件:`/Users/gxwebsoft/JAVA/websopy-java/src/main/java/com/gxwebsoft/common/core/utils/WxMiniprogramUtil.java`getAccessToken line 182-276getMiniprogramSetting line 291-316
## 后端 renew-pay 隐患修复(放弃支付不中断服务 + 续费不丢时长)
- 隐患:原 renewPay 把原订阅 status 改 pending + 提前改 expireTime用户放弃支付会服务中断、到期时间错乱且 handleBalancePay/mpConfirm 激活时 expireTime=now+period 覆盖了续费应得的"原到期+period",未过期续费用户损失剩余时长。
- 后端改动 `AppSubscriptionController.java`4 处):
1. `renewPay`:删 `setStatus("pending")``setExpireTime(newExpireTime)`;只设 payStatus=0 + payPrice/originalPrice/period。原订阅 status 保持 active或 expiredexpireTime 不变 → 放弃支付零影响。newExpireTime 仍计算,仅用于日志"预计到期(支付后)"。
2. `handleBalancePay` 激活:`sub.setStartTime(now)``if (startTime==null) setStartTime(now)`续费保留首次生效时间expireTime 由 `now+period``max(原expireTime, now)+period`
3. `mpConfirm` 拦截:`if (status=="active")``if (status=="active" && payStatus==1)`,放行续费的 active+payStatus=0。
4. `mpConfirm` 激活:同 handleBalancePaystartTime 仅 null 时设、expireTime 用 max(原expireTime, now)+period。
- max 逻辑对新订阅和续费都正确:新订阅 expireTime 为 null → expireBase=now → now+period续费未过期 → 原+period不丢时长续费已过期 → now+period。
- 放弃支付现状:订阅保持 active+payStatus=0服务不中断用户可再次点"立即续费"重新 renew-pay会覆盖 payPrice/period 并重新生成码)。无清理 pending 续费订单的需求(不再产生 pending 记录)。
- 前端无需改动renew-pay 调用不变)。
## 续费"配置不存在"真正根因app_setting 未加入租户忽略列表(已修复)
- 纠正之前猜测app_setting 表 platform_miniprogram 配置**完全正常**pymysql 直查 db_websopy 确认setting_id=12, tenant_id=5, 169字节, JSON 合法, appId=wx541db955e7a62709, appSecret 齐全)。
- 真正根因:`MybatisPlusConfig.java``TenantLineHandler.ignoreTable` 列表里有 `app_subscription` 但**没有 `app_setting`**。
- app_setting 查询受租户拦截器隔离 → `getByKey('platform_miniprogram')` 被自动加 `AND tenant_id=当前租户`
- 配置记录 tenant_id=5续费用户租户 ≠ 5或请求头/域名取不到租户)→ 查询过滤掉这条记录 → 返回 null → 报"配置不存在或解析失败"。
- 订阅 pay 能用 = access_token 缓存命中Redis key 固定 `WX_ACCESS_TOKEN:5` 不分租户TTL 7000秒没走 app_setting 查询;续费报错 = 缓存过期走了查询被拦截。与"之前还可以、现在不行"的时间差完全吻合。
- `AppSettingServiceImpl` 代码注释明说"平台配置不按租户隔离"、getByKey 不带租户条件,但拦截器在 SQL 层强制隔离,两者矛盾——这是历史遗留 bug。
- 修复(后端 `MybatisPlusConfig.java`ignoreTable 列表在 `"app_subscription"` 后加 `"app_setting"`(一行改动)。改完 app_setting 所有查询忽略租户,与代码设计意图一致。
- 排查方法值得记录pymysql 直连远程库47.119.165.234:13308 db_websopy / redis 16379 db0验证数据mysql 客户端未装时用 managed python venv 装 pymysql 查。dev profile 连此库。
- 前端订阅 API 域名硬编码 `https://websopy-api.websoft.top/api/app/subscription`api/index.ts WEBSOPY_API_BASE主站 API 走 shop-api.websoft.top。用户对比的 `websoft.top/api/_app/...` 是另一个入口(疑 nginx 转发 _app→app
## 续费"弹二维码自动完成支付"修复:清除旧 Redis 支付标记
- 现象:续费 renew-pay 弹出小程序码后几秒,前端轮询 checkStatus 返回 paid=true自动判定支付成功用户没扫码
- 根因pymysql+redis 直查确认):续费复用原订阅记录的 subscriptionNo原订阅支付时 mpConfirm/handleBalancePay 写入的 Redis 标记 `wxpay:paid:{subscriptionNo}=1`TTL 30分钟未清除。checkStatus 逻辑 `paidViaRedis || paidViaDb`pay_status 维度renewPay 设 0已返回 false但 Redis 维度残留 → 误判。
- 数据证据id=94 status=active pay_status=0 expire_time=2027-06-15订阅支付成功→renewPay 改 pay_status=0Redis wxpay:paid:SUB202607152237396107=1 残留。
- 修复方案A用户确认`AppSubscriptionController.renewPay` 在 updateById 后加 `redisUtil.delete("wxpay:paid:" + sub.getSubscriptionNo())`RedisUtil.delete 在 line 214。配合 pay_status=0checkStatus 两个维度都 false → paid=false。
- 次要问题(未解决):续费把原订阅 pay_status 1→0若放弃支付原订阅变 active+pay_status=0语义"已激活但未支付"不影响功能。彻底解决需方案CrenewPay 新建独立订阅记录,原订阅不动),暂不做。
- 排查手段managed python venv 装 pymysql+redis 直连远程库47.119.165.234:13308/16379查 app_subscription 表 + Redis scan wxpay:paid:*,比看日志更直接。
## cmsAd 编辑保存不再拼 OSS 压缩后缀
- 需求:广告编辑保存时图片 URL 不再拼接 `?x-oss-process=image/resize,m_fixed,w_2000/quality,Q_90` 后缀,因为图片压缩已统一在小程序端处理。
- 改动 `src/views/cms/cmsAd/components/cmsAdEdit.vue``chooseFile` 函数:
- `images.value.push``url``data.downloadUrl + '?x-oss-process=...'` 改为 `data.downloadUrl`
- `form.images` 同步去掉后缀,直接用 `data.downloadUrl`
- 注意本次只改广告cmsAd`cmsArticle/articleEdit.vue``cmsModel/cmsModelEdit.vue` 同样有此后缀,用户未提及,暂不动。

View File

@@ -0,0 +1,63 @@
# 2026-07-16
## 续费扫码支付链路修复(前后端联动)
### 问题1小程序 pay 页扫码后误显"支付成功"
- 文件:`websopy-taro/src/passport/pay/index.tsx`
- 根因:`fetchDetail``subscription.status === 'active'` 判定已支付。但后端 `renewPay` 为了避免放弃支付导致服务中断,**故意保留** `status='active'`、仅设 `payStatus=0`
- 修复:判断改为 `payStatus === 1`;同时处理 `cancelled`/`expired` 状态。详见 `websopy-taro/.workbuddy/memory/2026-07-16.md`
### 问题2用户点"立即支付"后端返回"该订阅已激活"
- 文件:`/Users/gxwebsoft/JAVA/websopy-java/src/main/java/com/gxwebsoft/app/controller/AppSubscriptionController.java`
- 根因:`pay/{id}`line 339-341`mp-prepay`line 539-541两处拦截只看 `status='active'`,与上次 `mp-confirm` 同样的坑——续费的 `active+payStatus=0` 被误拦。
- 修复:两处拦截条件对齐 `mp-confirm`,改为 `status='active' && payStatus==1` 才拦截,放行续费的 `active+payStatus=0`
- commit`7c95f8e` (本地 main待 push + 服务器部署)。
### 关键规则(防回归)
判断"是否已支付"必须用 `payStatus`0=未支付 / 1=已支付),**不能**用 `status`(订阅生命周期 active/pending/expired/cancelled。续费场景下这两个字段完全解耦后端 `renewPay` 保留 `status='active'` + 设 `payStatus=0`
后端三处拦截条件现在全部对齐:
- `pay/{id}` line 339-343
- `mp-prepay` line 541-545
- `mp-confirm` line 573之前已修
### 部署待办(用户操作)
1. 后端:`cd /Users/gxwebsoft/JAVA/websopy-java && git push`,然后远程服务器 `git pull && mvn clean package -DskipTests && docker-compose up -d --build cms-api`(或对应重启方式)。
2. 小程序端:`websopy-taro` 重新编译上传体验版/正式版。
3. 联调验证admin 点"立即续费" → 弹码 → 微信扫码 → 进小程序应显示"确认支付"页(不再误显成功)→ 点"立即支付" → 微信支付 → 显示"支付成功" → 跳转 `/user/apps/index`
## 商城后台 Dashboard 布局调整
- 文件:`src/views/shop/dashboard/index.vue`
- 需求:今日数据概况页,「基本信息」改为左右两列并排;删除「快捷操作」面板。
- 改动:
- `a-descriptions``:column="1"` 改为 `:column="{ xs: 1, sm: 2 }"`(响应式两列并排)。
- 基本信息 `a-col``:span="12"` 改为 `:span="24"`(占满整行)。
- 删除「快捷操作」`a-col` 整块并清理其专属的图标导入ShoppingCartOutlined/ShopOutlined/AppstoreOutlined/GiftOutlined/TeamOutlined/SettingOutlined/ClearOutlined`removeSiteInfoCache` 导入及 `handleClearCache` 函数。
### 今日数据概况 + 基本信息改为左右两栏布局
- 需求:参考「待处理事项 + 快速入口」的左右结构,将「今日数据概况」和「基本信息」也并排为左右各半。
- 改动:
- 原来各自独占整行的两个 panel合并到一个 `<a-row>` 中,各占 `:md="12"`
- 今日数据概况栅格从 4 列改为 2 列(`grid-template-columns: repeat(2, 1fr)`),适配半宽空间。
- 基本信息 `a-descriptions` 列数改为 `:column="1"`,半宽下单列更不拥挤。
- 基本信息(左)和今日数据概况(右)位置对调,基本信息在左、今日数据概况在右。
### 刷新统计数据修复
- 问题:点「刷新数据」按钮提示成功,但实际数据没更新。
- 根因:`refreshStatistics` 调用 `statisticsStore.fetchStatistics()` 未传 `forceRefresh=true`5分钟缓存有效期内直接返回旧数据。
- 修复:改为 `fetchStatistics(true)` 强制跳过缓存。
## README.md 修改
- 项目名称从"WebSoftAdmin"改为"小程序商城后台管理系统"。
- 项目域名更新为 https://shop.websoft.top。
- 去掉 CMS 内容管理系统的介绍部分(文章管理、一键排版、媒体库等)。
- 更新技术栈版本号对齐 package.jsonAntDv 4.x、Vite 8.x、TS 5.x、Pinia 2.x
- 补充门店/经销商/会员管理等商城核心功能描述,对齐实际 views/shop 目录结构。

147
README.md
View File

@@ -1,84 +1,89 @@
<div align="center">
<h1>🌐 WebSoftAdmin</h1>
<p><strong>基于 Vue 3 + Ant Design Vue 的企业级后台管理系统</strong></p>
<h1>🛒 小程序商城后台管理系统</h1>
<p><strong>基于 Vue 3 + Ant Design Vue 的企业级小程序商城后台</strong></p>
<p>
<img src="https://img.shields.io/badge/Vue-3.x-4FC08D" alt="Vue">
<img src="https://img.shields.io/badge/Ant%20Design%20Vue-3.x-1890FF" alt="Ant Design Vue">
<img src="https://img.shields.io/badge/TypeScript-4.x-3178C6" alt="TypeScript">
<img src="https://img.shields.io/badge/Vite-4.x-646CFF" alt="Vite">
<img src="https://img.shields.io/badge/Ant%20Design%20Vue-4.x-1890FF" alt="Ant Design Vue">
<img src="https://img.shields.io/badge/TypeScript-5.x-3178C6" alt="TypeScript">
<img src="https://img.shields.io/badge/Vite-8.x-646CFF" alt="Vite">
<img src="https://img.shields.io/badge/License-MIT-blue" alt="License">
</p>
</div>
## 📖 项目简介
WebSoftAdmin 是一个基于 **Vue 3 + Ant Design Vue** 构建的现代化企业级后台管理系统,采用最新的前端技术栈:
小程序商城后台管理系统是一个基于 **Vue 3 + Ant Design Vue** 构建的现代化企业级商城管理平台,为小程序商城提供完整的后台管理能力,涵盖商品、订单、会员、经销商、门店、营销活动等核心业务模块。
- **前端框架**Vue 3 + TypeScript + Vite
- **UI 组件库**Ant Design Vue 3.x
- **富文本编辑器**TinyMCE支持图片/视频上传、一键排版)
- **图表库**ECharts + G2
- **工具库**Lodash、Day.js、CryptoJS
## 项目演示
| 后台管理系统 | https://mp.websoft.top |
|--------|-------------------------------------------------------------------------------------------------------------------------------------|
| 账号密码 | [立即注册](https://mp.websoft.top/register/?inviteCode=github) |
| 关注公众号 | ![输入图片说明](https://oss.wsdns.cn/20240327/f1175cc5aae741d3af05484747270bd5.jpeg?x-oss-process=image/resize,m_fixed,w_150/quality,Q_90) |
- **UI 组件库**Ant Design Vue 4.x + EleAdmin Pro
- **状态管理**Pinia
- **图表库**ECharts + @antv/g2
- **工具库**Lodash、Day.js、CryptoJS、ExcelJS
**项目域名**[https://shop.websoft.top](https://shop.websoft.top)
**官方产品介绍**[https://websoft.top/shop](https://websoft.top/shop)
## 🛠️ 技术栈
### 核心技术
| 技术 | 版本 | 说明 |
|------|------|------|
| Vue | 3.x | 渐进式 JavaScript 框架 |
| TypeScript | 4.x | JavaScript 的超集 |
| Vite | 4.x | 下一代前端构建工具 |
| Ant Design Vue | 3.2.11 | 企业级 UI 设计语言 |
| EleAdmin Pro | 1.10.1 | 企业级组件库 |
| TypeScript | 5.x | JavaScript 的超集 |
| Vite | 8.x | 下一代前端构建工具 |
| Ant Design Vue | 4.x | 企业级 UI 设计语言 |
| EleAdmin Pro | 1.11+ | 企业级组件库 |
| Pinia | 2.x | Vue 3 状态管理库 |
### 功能组件
- **TinyMCE** - 富文本编辑器,支持图片/视频上传
- **ECharts** - 数据可视化图表库
- **CropperJS** - 图片裁剪组件
- **ExcelJS** - Excel 文件处理
- **ExcelJS / xlsx** - Excel 文件导入导出
- **Ali OSS** - 阿里云对象存储
- **qrcode / jsbarcode** - 二维码与条形码生成
## 📋 环境要求
### 基础环境
- 🟢 **Node.js 16+**
- 📦 **npm 8+ / yarn 1.22+**
- 🟢 **Node.js 18+**
- 📦 **npm 8+ / yarn 1.22+ / pnpm 8+**
- 🌐 **现代浏览器**Chrome 63+、Firefox、Safari、Edge
### 开发工具
- **推荐**VS Code / WebStorm
- **插件**Vetur / VolarVue 3 支持)
- **插件**VolarVue 3 支持)
## 🚀 快速开始
### 1. 克隆项目
```bash
git clone https://github.com/websoft-top/paopao-vue.git
cd paopao-vue
git clone <仓库地址>
cd guilixu-admin
```
### 2. 安装依赖
```bash
# 使用 npm
npm install
# 或使用 yarn
yarn install
# 或使用 pnpm推荐
pnpm install
```
### 3. 配置环境变量
```bash
# 复制环境变量示例文件
cp .env.example .env
@@ -88,6 +93,7 @@ cp .env.example .env
```
### 4. 启动开发服务器
```bash
# 开发模式
npm run dev
@@ -99,6 +105,7 @@ yarn dev
访问 `http://localhost:3000` 即可看到管理后台。
### 5. 构建生产版本
```bash
# 生产构建
npm run build
@@ -112,6 +119,7 @@ npm run serve
项目使用环境变量来管理敏感信息和配置。请按照以下步骤配置:
### 必需配置
```bash
# API 配置
VITE_API_URL=https://your-api.com/api # 后端 API 地址
@@ -124,51 +132,54 @@ VITE_TENANT_ID=your_tenant_id # 租户 ID
```
### 可选配置
```bash
# 高德地图 (如需使用地图功能)
VITE_MAP_KEY=your_map_key # 高德地图 Key
VITE_MAP_CODE=your_map_security_code # 高德地图安全密钥
# WebSoftAdmin 授权 (商业版功能)
# EleAdmin Pro 授权 (商业版功能)
VITE_LICENSE_CODE=your_license_code # 授权码
```
### 获取配置信息
- **高德地图密钥**:访问 [高德开放平台](https://lbs.amap.com/) 申请
- **WebSoftAdmin 授权码**:联系 [官方网站](https://websoft.top/) 获取
- **其他 API 配置**:根据您的后端服务配置
## 🎯 核心功能
### 📝 内容管理系统
- **文章管理**:支持富文本编辑、图片/视频上传
- **一键排版**智能文章格式优化包含10种专业排版样式
- **媒体库**:图片/视频文件管理,支持分组和搜索
- **首行缩进**:中文段落格式智能切换
### 🛒 商城管理
- **商品管理**:商品信息编辑、规格设置
- **订单管理**订单流程跟踪
- **商品管理**商品信息编辑、规格设置、SKU 管理、商品分类
- **订单管理**:订单流程跟踪、订单商品管理
- **库存管理**:商品库存监控
- **优惠券管理**:优惠券创建与发放、用户优惠券管理
- **营销活动**:活动管理、活动报名、积分商品
- **礼品管理**:商城礼品配置
### 👥 用户权限
- **用户管理**:用户信息维护
- **角色权限**:基于角色的访问控制
### 🏪 门店与经销商
- **门店管理**:门店信息、门店围栏、门店骑手、门店仓库
- **经销商体系**:经销商申请、经销商用户、经销商订单、经销商资金与提现
- **分销设置**:经销商推荐人、分销海报、分销配置
### 👥 会员管理
- **会员注册**:会员注册管理
- **会员权益**:权益包管理、权益兑换
- **会员推荐**:推荐人关系管理
### 👤 用户权限
- **用户管理**:用户信息维护、用户分组
- **角色权限**基于角色的访问控制RBAC
- **菜单管理**:动态菜单配置
- **组织架构**:组织与部门管理
## 🎨 富文本编辑器特色功能
### ⚙️ 系统管理
### 📸 媒体上传
- **图片上传**:支持拖拽、粘贴、文件选择
- **视频上传**:支持多种视频格式
- **媒体库**:统一的媒体文件管
- **OSS 存储**:阿里云对象存储集成
### ✨ 智能排版
- **一键排版**10种专业排版优化
- **首行缩进**:中文段落格式切换
- **样式优化**:标题、段落、列表、表格等元素美化
- **响应式**:适配不同屏幕尺寸
- **系统配置**:商城设置、提现配置
- **支付管理**:支付配置、订单支付
- **字典管理**:数据字典维护
- **缓存管理**:缓存清
- **域名管理**:域名白名单配置
- **操作记录**:登录记录、操作日志
## 🏗️ 项目结构
@@ -176,11 +187,17 @@ VITE_LICENSE_CODE=your_license_code # 授权码
src/
├── components/ # 公共组件
├── views/ # 页面组件
│ ├── cms/ # 内容管理
│ ├── shop/ # 商城管理
── system/ # 系统管理
├── router/ # 路由配置
├── store/ # 状态管理
├── utils/ # 工具函数
── assets/ # 静态资源
│ ├── shop/ # 商城管理(商品、订单、门店、经销商、会员等)
│ ├── system/ # 系统管理(用户、角色、菜单、字典、支付等)
── passport/ # 登录认证
│ ├── user/ # 用户中心
│ └── result/ # 结果页
├── router/ # 路由配置
── store/ # Pinia 状态管理
├── utils/ # 工具函数
└── assets/ # 静态资源
```
## 📄 License
[MIT](LICENSE)

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>企业商城</title>
<title>小程序商城</title>
<!-- TinyMCE 通过本地文件加载,避免 Rolldown 打包 UMD 模块的问题 -->
<script src="/tinymce/tinymce.min.js"></script>
<style>

View File

@@ -0,0 +1,43 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { AppProduct, AppProductQueryParam } from './model';
/**
* websopy 特殊接口域名
* 后端 Controller: com.gxwebsoft.app.controller.AppProductController
* @RequestMapping("/api/app/product"),后端无 context-path
* 完整 URL = https://websopy-api.websoft.top/api/app/product/xxx
*/
const WEBSOPY_API_BASE = 'https://websopy-api.websoft.top/api';
const BASE = `${WEBSOPY_API_BASE}/app/product`;
/**
* 分页查询应用列表
* GET /app/product/page
* 支持按 tenantId 过滤(管理后台按租户查询应用)
* 注意:分页参数为 current/size非 page/limit
*/
export async function pageProducts(
params: AppProductQueryParam
): Promise<PageResult<AppProduct>> {
const res = await request.get<ApiResult<PageResult<AppProduct>>>(
`${BASE}/page`,
{ params }
);
if (res.data.code === 0) {
return res.data.data || { list: [], count: 0 };
}
return Promise.reject(new Error(res.data.message));
}
/**
* 获取应用详情
* GET /app/product/detail/{id}
*/
export async function getProductDetail(id: number): Promise<AppProduct> {
const res = await request.get<ApiResult<AppProduct>>(`${BASE}/detail/${id}`);
if (res.data.code === 0) {
return res.data.data as AppProduct;
}
return Promise.reject(new Error(res.data.message));
}

View File

@@ -0,0 +1,143 @@
/**
* 应用产品实体(对应表 app_product
* 字段对照后端 com.gxwebsoft.app.entity.AppProduct
*/
export interface AppProduct {
// 应用ID主键
productId?: number;
// 应用名称
productName?: string;
// 应用标识(唯一)
productCode?: string;
// 应用密钥
productSecret?: string;
// 应用类型: 10网站 20微信小程序 30抖音小程序 40百度小程序 50支付宝小程序 60Android 70iOS 80macOS 90Windows 100插件
appType?: number;
// 应用类型名称(关联查询)
appTypeName?: string;
// 分类ID
categoryId?: number;
// 行业类型(父级)
industryParent?: string;
// 行业类型(子级)
industryChild?: string;
// 应用Logo
logo?: string;
// 应用图标
icon?: string;
// 二维码
qrcode?: string;
// 应用截图(JSON数组)
screenshots?: string;
// 应用简介
description?: string;
// 详细说明
content?: string;
// 关键词
keywords?: string;
// 域名
domain?: string;
// 域名前缀
prefix?: string;
// 包名/AppID
packageName?: string;
// 后台地址
adminUrl?: string;
// API地址
apiUrl?: string;
// 下载地址
downloadUrl?: string;
// 版本号
version?: string;
// 版本: standard标准版 professional专业版 perpetual永久授权
edition?: string;
// 最低版本要求
minVersion?: string;
// 定价: free免费 one_time一次性 subscription订阅
priceType?: string;
// 价格(元)
price?: number;
// 划线价格
linePrice?: number;
// 续费价格
renewPrice?: number;
// 交付方式: 1源码 2托管 3授权
deliveryMethod?: number;
// 计费方式: 1按年 2按月 3一次性
chargingMethod?: number;
// 订阅周期: month/year
subscriptionPeriod?: string;
// 发布状态: developing pending_review published rejected deprecated
publishStatus?: string;
// 发布时间
publishTime?: string;
// 审核时间
reviewTime?: string;
// 审核人ID
reviewerId?: number;
// 拒绝原因
rejectReason?: string;
// 浏览次数
clicks?: number;
// 安装次数
installs?: number;
// 下载次数
downloads?: number;
// 评分(1-5)
rating?: number;
// 点赞数
likes?: number;
// 开发者
developer?: string;
// 开发者电话
developerPhone?: string;
// 开发者邮箱
developerEmail?: string;
// 是否推荐: 0否 1是
recommend?: number;
// 是否官方: 0否 1是
official?: number;
// 是否上架市场: 0否 1是
market?: number;
// 是否显示首页: 0否 1是
showIndex?: number;
// 是否可搜索: 0否 1是
searchEnabled?: number;
// 模板ID
templateId?: number;
// 租户ID
tenantId?: number;
// 创建时间
createTime?: string;
// 更新时间
updateTime?: string;
}
/**
* 应用产品分页查询参数
* 注意:后端 AppProductController.page 的分页参数为 current/size非 page/limit
*/
export interface AppProductQueryParam {
// 页码
current?: number;
// 每页条数
size?: number;
// 应用名称
productName?: string;
// 应用标识
productCode?: string;
// 应用类型
appType?: number;
// 分类ID
categoryId?: number;
// 发布状态
publishStatus?: string;
// 状态
status?: number;
// 用户ID
userId?: number;
// 租户ID按租户查询应用
tenantId?: number;
// 关键词搜索(同时搜索应用名称和应用标识)
keywords?: string;
}

View File

@@ -0,0 +1,276 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type {
AppSubscription,
AppSubscriptionQueryParam,
SubscribeParam,
SubscribeResult,
GeneratePayQrcodeParam,
GeneratePayQrcodeResult,
CheckStatusResult,
PayResult,
WechatNativePayResult,
MpPrepayParam,
MpPrepayResult,
MpConfirmParam,
BalanceResult
} from './model';
/**
* websopy 特殊接口域名(与项目主 API 域名不同)
* 后端 Controller: com.gxwebsoft.app.controller.AppSubscriptionController
* @RequestMapping("/api/app/subscription"),后端无 context-path
* 因此完整 URL = https://websopy-api.websoft.top + /api/app/subscription/xxx
*/
const WEBSOPY_API_BASE = 'https://websopy-api.websoft.top/api';
const BASE = `${WEBSOPY_API_BASE}/app/subscription`;
/**
* 我的订阅列表(分页)
* GET /app/subscription/my/page
*/
export async function pageMySubscriptions(
params: AppSubscriptionQueryParam
): Promise<PageResult<AppSubscription>> {
const res = await request.get<ApiResult<PageResult<AppSubscription>>>(
`${BASE}/my/page`,
{ params }
);
if (res.data.code === 0) {
return (
res.data.data || { list: [], count: 0 }
);
}
return Promise.reject(new Error(res.data.message));
}
/**
* 订阅详情按ID
* GET /app/subscription/detail/{id}
*/
export async function getSubscriptionDetail(
id: number
): Promise<AppSubscription> {
const res = await request.get<ApiResult<AppSubscription>>(
`${BASE}/detail/${id}`
);
if (res.data.code === 0) {
return res.data.data as AppSubscription;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 根据订阅编号查询详情(小程序入口用)
* GET /app/subscription/detail-by-no/{subscriptionNo}
*/
export async function getSubscriptionDetailByNo(
subscriptionNo: string
): Promise<AppSubscription> {
const res = await request.get<ApiResult<AppSubscription>>(
`${BASE}/detail-by-no/${subscriptionNo}`
);
if (res.data.code === 0) {
return res.data.data as AppSubscription;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 查询支付状态(前端轮询用)
* GET /app/subscription/check-status/{subscriptionNo}
*/
export async function checkSubscriptionStatus(
subscriptionNo: string
): Promise<CheckStatusResult> {
const res = await request.get<ApiResult<CheckStatusResult>>(
`${BASE}/check-status/${subscriptionNo}`
);
if (res.data.code === 0) {
return res.data.data as CheckStatusResult;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 检查是否已购买某应用
* GET /app/subscription/check-purchased/{productId}
* 返回 boolean
*/
export async function checkPurchased(
productId: number
): Promise<boolean> {
const res = await request.get<ApiResult<boolean>>(
`${BASE}/check-purchased/${productId}`
);
if (res.data.code === 0) {
return res.data.data === true;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 获取当前用户余额
* GET /app/subscription/balance
*/
export async function getBalance(): Promise<BalanceResult> {
const res = await request.get<ApiResult<BalanceResult>>(`${BASE}/balance`);
if (res.data.code === 0) {
return (
res.data.data || { balance: 0 }
);
}
return Promise.reject(new Error(res.data.message));
}
/**
* 创建订阅
* POST /app/subscription/subscribe
* 免费应用直接激活,付费应用创建待支付记录
*/
export async function subscribe(
data: SubscribeParam
): Promise<SubscribeResult> {
const res = await request.post<ApiResult<SubscribeResult>>(
`${BASE}/subscribe`,
data
);
if (res.data.code === 0) {
return res.data.data as SubscribeResult;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 生成支付小程序码(同时创建订阅记录)
* POST /app/subscription/generate-pay-qrcode
*/
export async function generatePayQrcode(
data: GeneratePayQrcodeParam
): Promise<GeneratePayQrcodeResult> {
const res = await request.post<ApiResult<GeneratePayQrcodeResult>>(
`${BASE}/generate-pay-qrcode`,
data
);
if (res.data.code === 0) {
return res.data.data as GeneratePayQrcodeResult;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 发起支付
* POST /app/subscription/pay/{id}?method=balance|wechat&envVersion=xxx
* - method=balance余额支付返回 PayResult
* - method=wechat微信 Native 支付,返回小程序码 WechatNativePayResult
*/
export async function paySubscription(
id: number,
method: 'balance' | 'wechat' = 'wechat',
envVersion?: string
): Promise<PayResult | WechatNativePayResult> {
const res = await request.post<ApiResult<PayResult | WechatNativePayResult>>(
`${BASE}/pay/${id}`,
undefined,
{ params: { method, envVersion } }
);
if (res.data.code === 0) {
return res.data.data as PayResult | WechatNativePayResult;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 小程序 JSAPI 预支付下单
* POST /app/subscription/mp-prepay/{id}
* Body: { openid }
*/
export async function mpPrepay(
id: number,
data: MpPrepayParam
): Promise<MpPrepayResult> {
const res = await request.post<ApiResult<MpPrepayResult>>(
`${BASE}/mp-prepay/${id}`,
data
);
if (res.data.code === 0) {
return res.data.data as MpPrepayResult;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 小程序支付成功确认
* POST /app/subscription/mp-confirm/{subscriptionNo}
* Body: { transactionId? }
*/
export async function mpConfirm(
subscriptionNo: string,
data?: MpConfirmParam
): Promise<PayResult> {
const res = await request.post<ApiResult<PayResult>>(
`${BASE}/mp-confirm/${subscriptionNo}`,
data
);
if (res.data.code === 0) {
return (
res.data.data || { paid: true, subscriptionNo }
);
}
return Promise.reject(new Error(res.data.message));
}
/**
* 续费:基于已有订阅创建续费订单并生成支付码(一步完成,对齐后端 /renew-pay
* POST /app/subscription/renew-pay/{id}?period=month&method=wechat&envVersion=trial
* - method=balance余额支付返回 PayResultpaid/balance
* - method=wechat微信支付返回小程序码 WechatNativePayResultminiappQrcode
* 后端会基于原 expireTime 或 now取较大者延长到期时间
*/
export async function renewSubscription(
id: number,
period: 'month' | 'year' = 'month',
method: 'balance' | 'wechat' = 'wechat',
envVersion?: string
): Promise<PayResult | WechatNativePayResult> {
const res = await request.post<ApiResult<PayResult | WechatNativePayResult>>(
`${BASE}/renew-pay/${id}`,
undefined,
{ params: { period, method, envVersion } }
);
if (res.data.code === 0) {
return res.data.data as PayResult | WechatNativePayResult;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 退订/取消
* POST /app/subscription/cancel/{id}
*/
export async function cancelSubscription(id: number): Promise<string> {
const res = await request.post<ApiResult<string>>(`${BASE}/cancel/${id}`);
if (res.data.code === 0) {
return res.data.message || '退订成功';
}
return Promise.reject(new Error(res.data.message));
}
/**
* 启用/禁用
* POST /app/subscription/toggle-enable/{id}?enabled=true|false
*/
export async function toggleEnable(
id: number,
enabled: boolean
): Promise<string> {
const res = await request.post<ApiResult<string>>(
`${BASE}/toggle-enable/${id}`,
undefined,
{ params: { enabled } }
);
if (res.data.code === 0) {
return res.data.message || (enabled ? '已启用' : '已禁用');
}
return Promise.reject(new Error(res.data.message));
}

View File

@@ -0,0 +1,219 @@
import type { PageParam } from '@/api';
/**
* 订阅状态: pending-待支付 active-已激活 expired-已过期 cancelled-已取消
*/
export type SubscriptionStatus =
| 'pending'
| 'active'
| 'expired'
| 'cancelled';
/**
* 价格类型: free-免费 one_time-买断 subscription-订阅
*/
export type PriceType = 'free' | 'one_time' | 'subscription';
/**
* 订阅周期: month-月 quarter-季 year-年
*/
export type SubscriptionPeriod = 'month' | 'quarter' | 'year';
/**
* 支付方式: 0-余额 1-微信 2-支付宝 12-免费
*/
export type PayType = 0 | 1 | 2 | 12;
/**
* 应用订阅实体(对应表 app_subscription
* 字段对照后端 com.gxwebsoft.app.entity.AppSubscription
*/
export interface AppSubscription {
// 主键ID
id?: number;
// 订阅编号(业务唯一)
subscriptionNo?: string;
// 购买用户ID
userId?: number;
// 应用产品ID
productId?: number;
// 租户ID
tenantId?: number;
// 订阅状态
status?: SubscriptionStatus;
// 价格类型
priceType?: PriceType;
// 原价(单位:元)
originalPrice?: number;
// 实付金额(单位:元)
payPrice?: number;
// 支付方式
payType?: PayType;
// 支付状态: 0-未支付 1-已支付
payStatus?: number;
// 支付时间
payTime?: string;
// 第三方交易号
transactionId?: string;
// 订阅周期
subscriptionPeriod?: SubscriptionPeriod;
// 生效时间
startTime?: string;
// 到期时间(订阅型)
expireTime?: string;
// 是否自动续费 0-否 1-是
autoRenew?: number;
// 分配的域名
instanceDomain?: string;
// 实例管理后台URL
instanceAdminUrl?: string;
// 实例配置JSON
instanceConfig?: string;
// 关联的支付订单号
orderNo?: string;
// 关联的支付订单ID
orderId?: number;
// 备注
remark?: string;
// 排序
sortNumber?: number;
// 创建时间
createTime?: string;
// 更新时间
updateTime?: string;
// ===== 关联查询字段(非数据库字段) =====
productName?: string;
productIcon?: string;
productLogo?: string;
productAppType?: number;
productDescription?: string;
developerName?: string;
}
/**
* 我的订阅分页查询参数
*/
export interface AppSubscriptionQueryParam extends PageParam {
// 订阅状态过滤
status?: SubscriptionStatus;
}
/**
* 创建订阅参数
*/
export interface SubscribeParam {
// 应用产品ID
productId: number;
// 订阅周期,默认 month
subscriptionPeriod?: SubscriptionPeriod;
}
/**
* 生成支付小程序码参数
*/
export interface GeneratePayQrcodeParam {
// 应用产品ID
productId: number;
// 订阅周期,默认 month
subscriptionPeriod?: SubscriptionPeriod;
// 小程序版本develop / trial / release默认 trial
envVersion?: string;
}
/**
* 创建订阅返回结果
*/
export interface SubscribeResult {
subscriptionId: number;
subscriptionNo: string;
status: SubscriptionStatus;
message: string;
payPrice?: number;
orderNo?: string;
}
/**
* 生成支付小程序码返回结果
*/
export interface GeneratePayQrcodeResult {
subscriptionNo: string;
subscriptionId: number;
qrcodeBase64: string;
productName: string;
payPrice: number;
}
/**
* 查询支付状态返回结果
*/
export interface CheckStatusResult {
paid: boolean;
payStatus: number;
status: SubscriptionStatus;
payTime?: string;
transactionId?: string;
id: number;
subscriptionNo: string;
productId: number;
productName?: string;
productLogo?: string;
priceType?: PriceType;
payPrice?: number;
subscriptionPeriod?: SubscriptionPeriod;
}
/**
* 余额支付返回结果
*/
export interface PayResult {
paid: boolean;
balance?: number;
subscriptionNo: string;
}
/**
* 微信 Native 支付Web 端)返回结果
*/
export interface WechatNativePayResult {
subscriptionId: number;
subscriptionNo: string;
miniappQrcode: string;
miniappPagePath: string;
payPrice: number;
}
/**
* 小程序 JSAPI 预支付返回结果
*/
export interface MpPrepayResult {
subscriptionId: number;
subscriptionNo: string;
outTradeNo: string;
timeStamp: string;
nonceStr: string;
package: string;
signType: string;
paySign: string;
payPrice: number;
}
/**
* 获取余额返回结果
*/
export interface BalanceResult {
balance: number;
}
/**
* 小程序预支付参数
*/
export interface MpPrepayParam {
openid: string;
}
/**
* 小程序支付确认参数
*/
export interface MpConfirmParam {
transactionId?: string;
}

View File

@@ -0,0 +1,30 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { ShopGoodsBrowse, ShopGoodsBrowseParam } from './model';
/**
* 后台管理:分页查询浏览记录
*/
export async function pageShopGoodsBrowse(params: ShopGoodsBrowseParam) {
const res = await request.get<ApiResult<PageResult<ShopGoodsBrowse>>>(
'/shop/shop-goods-browse/admin/page',
{ params }
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 后台管理:删除单条浏览记录
*/
export async function removeShopGoodsBrowse(id?: number) {
const res = await request.delete<ApiResult<unknown>>(
'/shop/shop-goods-browse/admin/' + id
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}

View File

@@ -0,0 +1,42 @@
import type { PageParam } from '@/api';
/**
* 用户商品浏览记录
*/
export interface ShopGoodsBrowse {
id?: number;
userId?: number;
goodsId?: number;
tenantId?: number;
merchantId?: number;
/** 浏览次数 */
visitCount?: number;
/** 最后浏览时间 */
lastVisitTime?: string;
/** 浏览来源 */
browseSource?: string;
createTime?: string;
updateTime?: string;
/** 商品名称(关联查询) */
goodsName?: string;
/** 商品封面图(关联查询) */
goodsImage?: string;
/** 商品价格(关联查询) */
price?: string;
/** 市场价(关联查询) */
salePrice?: string;
}
/**
* 浏览记录查询参数
*/
export interface ShopGoodsBrowseParam extends PageParam {
id?: number;
userId?: number;
goodsId?: number;
merchantId?: number;
tenantId?: number;
browseSource?: string;
startTime?: string;
endTime?: string;
}

View File

@@ -150,3 +150,26 @@ export async function refundShopOrder(data: ShopOrder) {
}
return Promise.reject(new Error(res.data.message));
}
/**
* 确认线下付款收款
* 商家确认已收到线下转账(微信转账/银行汇款等),确认后订单进入待发货状态
* @param id 订单ID
* @param remarks 备注(可选)
* @param paymentVoucher 支付凭证图片地址(可选)
*/
export async function confirmOfflinePayment(
id: number,
remarks?: string,
paymentVoucher?: string
) {
const res = await request.put<ApiResult<unknown>>(
MODULES_API_URL + '/shop/shop-order/confirm-offline-payment/' + id,
null,
{ params: { remarks, paymentVoucher } }
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}

View File

@@ -85,9 +85,9 @@ export interface ShopOrder {
coachId?: number;
// 支付的用户id
payUserId?: number;
// 0余额支付, 1微信支付, 2支付宝, 3银联支付, 4现金支付, 5POS机支付, 6免费, 7积分支付, 8货到付款, 9~18 已废弃
// 0余额支付, 1微信支付, 2支付宝, 3银联支付, 4现金支付, 5POS机支付, 6免费, 7积分支付, 8货到付款, 9线下付款, 10~18 已废弃
payType?: number;
// 代付支付方式, 0余额支付, 1微信支付, 2支付宝, 3银联支付, 4现金支付, 5POS机支付, 6免费, 7积分支付, 8货到付款, 9~18 已废弃
// 代付支付方式, 0余额支付, 1微信支付, 2支付宝, 3银联支付, 4现金支付, 5POS机支付, 6免费, 7积分支付, 8货到付款, 9线下付款, 10~18 已废弃
friendPayType?: number;
// 0未付款1已付款
payStatus?: number;
@@ -129,6 +129,8 @@ export interface ShopOrder {
invoiceNo?: string;
// 支付时间
payTime?: string;
// 线下收款支付凭证(图片地址,确认线下收款时上传)
paymentVoucher?: string;
// 退款时间
refundTime?: string;
// 申请退款时间
@@ -142,9 +144,9 @@ export interface ShopOrder {
// 系统版本号 0当前版本 value=其他版本
version?: number;
// 买家备注
buyerRemarks: undefined;
buyerRemarks?: string;
// 商家备注
merchantRemarks: undefined;
merchantRemarks?: string;
// 用户id
userId?: number;
// 备注

View File

@@ -84,6 +84,12 @@
label: '货到付款',
key: 'codPay',
icon: 'IdcardOutlined'
},
{
value: 9,
label: '线下付款',
key: 'offlinePay',
icon: 'IdcardOutlined'
}
]);

View File

@@ -1,7 +1,7 @@
const storeName = localStorage.getItem('StoreName') || 'WebSoft Inc';
/* 主框架 */
export default {
system: '小程序开发',
system: '小程序商城',
home: '主页',
header: {
profile: '个人资料',

View File

@@ -1,7 +1,11 @@
<template>
<span>{{ item.meta.title }}</span>
<div v-if="item.meta && item.meta.badge" class="ele-menu-badge">
<!-- dot 模式纯红点不显示数字用于新订单提醒等 -->
<a-badge v-if="item.meta.badge === 'dot'" dot />
<!-- 数字徽章模式 -->
<a-badge
v-else
:count="item.meta.badge"
:number-style="{ background: item.meta.badgeColor as string }"
/>

View File

@@ -100,6 +100,7 @@
import HeaderTools from './components/header-tools.vue';
import PageFooter from './components/page-footer.vue';
import MenuTitle from './components/menu-title.vue';
import { useOrderNotify } from '@/views/shop/shopOrder/useOrderNotify';
import {
HIDE_SIDEBARS,
HIDE_FOOTERS,
@@ -127,6 +128,11 @@
const { t, locale } = useI18n();
const userStore = useUserStore();
// 全局启动新订单提醒:任意页面都会轮询检测新订单,
// 检测到后在「订单管理」菜单显示红点 + 叮声/语音播报
const { start: startOrderNotify } = useOrderNotify();
startOrderNotify();
// 是否刷新页面
if (localStorage.getItem('Reload')) {
window.location.reload();

View File

@@ -0,0 +1,375 @@
/**
* 应用订阅 store
* 数据来源websopy 特殊接口db_websopy.app_subscription 表)
* 接口域名https://websopy-api.websoft.top/api
* 后端com.gxwebsoft.app.controller.AppSubscriptionController
*/
import { defineStore } from 'pinia';
import {
pageMySubscriptions,
getSubscriptionDetail,
getSubscriptionDetailByNo,
checkSubscriptionStatus,
checkPurchased,
getBalance,
subscribe,
generatePayQrcode,
paySubscription,
mpPrepay,
mpConfirm,
renewSubscription,
cancelSubscription,
toggleEnable
} from '@/api/app/appSubscription';
import type {
AppSubscription,
AppSubscriptionQueryParam,
SubscribeParam,
SubscribeResult,
GeneratePayQrcodeParam,
GeneratePayQrcodeResult,
CheckStatusResult,
PayResult,
WechatNativePayResult,
MpPrepayResult
} from '@/api/app/appSubscription/model';
import type { PageResult } from '@/api';
export interface AppSubscriptionState {
// 我的订阅列表
subscriptionList: AppSubscription[];
// 总数量
total: number;
// 当前查看的订阅详情
currentSubscription: AppSubscription | null;
// 用户余额
balance: number;
// 加载状态
loading: boolean;
// 最后更新时间
lastUpdateTime: number | null;
// 缓存有效期(毫秒)
cacheExpiry: number;
}
export const useAppSubscriptionStore = defineStore('appSubscription', {
state: (): AppSubscriptionState => ({
subscriptionList: [],
total: 0,
currentSubscription: null,
balance: 0,
loading: false,
lastUpdateTime: null,
// 默认缓存5分钟订阅涉及支付状态不宜过长
cacheExpiry: 5 * 60 * 1000
}),
getters: {
/**
* 已激活订阅
*/
activeSubscriptions: (state): AppSubscription[] => {
return state.subscriptionList.filter((s) => s.status === 'active');
},
/**
* 待支付订阅
*/
pendingSubscriptions: (state): AppSubscription[] => {
return state.subscriptionList.filter((s) => s.status === 'pending');
},
/**
* 已过期订阅
*/
expiredSubscriptions: (state): AppSubscription[] => {
return state.subscriptionList.filter((s) => s.status === 'expired');
},
/**
* 检查缓存是否有效
*/
isCacheValid: (state): boolean => {
if (!state.lastUpdateTime) return false;
const now = Date.now();
return now - state.lastUpdateTime < state.cacheExpiry;
}
},
actions: {
// ============================================================
// 查询类
// ============================================================
/**
* 获取我的订阅列表(分页,带缓存)
* @param params 查询参数page/limit/status
* @param forceRefresh 是否强制刷新(切换 status 过滤时建议传 true
*/
async fetchMySubscriptions(
params: AppSubscriptionQueryParam = { page: 1, limit: 10 },
forceRefresh = false
): Promise<PageResult<AppSubscription>> {
// 缓存有效且不强制刷新,直接返回缓存列表
if (!forceRefresh && this.isCacheValid && this.subscriptionList.length > 0) {
return { list: this.subscriptionList, count: this.total };
}
this.loading = true;
try {
const data = await pageMySubscriptions(params);
this.subscriptionList = data.list || [];
this.total = data.count || 0;
this.lastUpdateTime = Date.now();
return data;
} catch (error) {
console.error('获取我的订阅列表失败:', error);
throw error;
} finally {
this.loading = false;
}
},
/**
* 获取订阅详情按ID
*/
async fetchDetail(id: number): Promise<AppSubscription> {
try {
const data = await getSubscriptionDetail(id);
this.currentSubscription = data;
return data;
} catch (error) {
console.error('获取订阅详情失败:', error);
throw error;
}
},
/**
* 根据订阅编号查询详情(小程序入口用)
*/
async fetchDetailByNo(subscriptionNo: string): Promise<AppSubscription> {
try {
const data = await getSubscriptionDetailByNo(subscriptionNo);
this.currentSubscription = data;
return data;
} catch (error) {
console.error('根据订阅编号查询详情失败:', error);
throw error;
}
},
/**
* 查询支付状态(前端轮询用,强制实时请求,不走缓存)
*/
async checkStatus(subscriptionNo: string): Promise<CheckStatusResult> {
return await checkSubscriptionStatus(subscriptionNo);
},
/**
* 检查是否已购买某应用(强制实时请求,不走缓存)
*/
async checkPurchased(productId: number): Promise<boolean> {
return await checkPurchased(productId);
},
/**
* 获取当前用户余额
*/
async fetchBalance(): Promise<number> {
try {
const data = await getBalance();
this.balance = data.balance;
return data.balance;
} catch (error) {
console.error('获取用户余额失败:', error);
throw error;
}
},
// ============================================================
// 订阅与支付操作
// ============================================================
/**
* 创建订阅
* 免费应用直接激活,付费应用创建待支付记录
*/
async subscribe(data: SubscribeParam): Promise<SubscribeResult> {
try {
const result = await subscribe(data);
// 订阅状态变化,失效缓存
this.invalidateCache();
return result;
} catch (error) {
console.error('创建订阅失败:', error);
throw error;
}
},
/**
* 生成支付小程序码(同时创建订阅记录)
*/
async generatePayQrcode(
data: GeneratePayQrcodeParam
): Promise<GeneratePayQrcodeResult> {
try {
const result = await generatePayQrcode(data);
// 已创建订阅记录,失效缓存
this.invalidateCache();
return result;
} catch (error) {
console.error('生成支付小程序码失败:', error);
throw error;
}
},
/**
* 发起支付
* @param id 订阅ID
* @param method 支付方式balance-余额支付 / wechat-微信支付
* @param envVersion 小程序版本微信支付时使用develop/trial/release
*/
async pay(
id: number,
method: 'balance' | 'wechat' = 'wechat',
envVersion?: string
): Promise<PayResult | WechatNativePayResult> {
try {
const result = await paySubscription(id, method, envVersion);
// 支付成功后余额/订阅状态变化,失效缓存并刷新余额
this.invalidateCache();
if (method === 'balance') {
const payResult = result as PayResult;
if (typeof payResult.balance === 'number') {
this.balance = payResult.balance;
}
}
return result;
} catch (error) {
console.error('发起支付失败:', error);
throw error;
}
},
/**
* 小程序 JSAPI 预支付下单
*/
async mpPrepay(id: number, openid: string): Promise<MpPrepayResult> {
try {
return await mpPrepay(id, { openid });
} catch (error) {
console.error('小程序预支付失败:', error);
throw error;
}
},
/**
* 小程序支付成功确认
*/
async mpConfirm(
subscriptionNo: string,
transactionId?: string
): Promise<PayResult> {
try {
const result = await mpConfirm(subscriptionNo, { transactionId });
// 支付确认后订阅状态变化,失效缓存
this.invalidateCache();
return result;
} catch (error) {
console.error('小程序支付确认失败:', error);
throw error;
}
},
// ============================================================
// 订阅管理
// ============================================================
/**
* 续费:基于已有订阅创建续费订单并生成支付码(一步完成,对齐后端 /renew-pay
* @param id 订阅ID
* @param period 周期month/year
* @param method 支付方式balance/wechat
* @param envVersion 小程序版本(微信支付时使用)
*/
async renew(
id: number,
period: 'month' | 'year' = 'month',
method: 'balance' | 'wechat' = 'wechat',
envVersion?: string
): Promise<PayResult | WechatNativePayResult> {
try {
const result = await renewSubscription(id, period, method, envVersion);
this.invalidateCache();
return result;
} catch (error) {
console.error('续费失败:', error);
throw error;
}
},
/**
* 退订/取消
*/
async cancel(id: number): Promise<string> {
try {
const msg = await cancelSubscription(id);
this.invalidateCache();
return msg;
} catch (error) {
console.error('退订失败:', error);
throw error;
}
},
/**
* 启用/禁用
*/
async toggleEnable(id: number, enabled: boolean): Promise<string> {
try {
const msg = await toggleEnable(id, enabled);
this.invalidateCache();
return msg;
} catch (error) {
console.error('启用/禁用失败:', error);
throw error;
}
},
// ============================================================
// 缓存控制
// ============================================================
/**
* 失效缓存(仅重置时间标记,保留已有数据,下次拉取时更新)
* 用于订阅/支付/管理操作后,确保下次查询获取最新数据
*/
invalidateCache() {
this.lastUpdateTime = null;
},
/**
* 清除缓存(清空数据并重置时间标记)
*/
clearCache() {
this.subscriptionList = [];
this.total = 0;
this.currentSubscription = null;
this.lastUpdateTime = null;
},
/**
* 强制刷新订阅列表
*/
async refresh() {
return await this.fetchMySubscriptions({ page: 1, limit: 10 }, true);
},
/**
* 设置缓存有效期
*/
setCacheExpiry(expiry: number) {
this.cacheExpiry = expiry;
}
}
});

View File

@@ -90,9 +90,9 @@ export const useStatisticsStore = defineStore('statistics', {
},
/**
* 获取今日使用优惠券数量
* 获取今日使用优惠券数量(安全取值)
*/
couponUsedCount: (state): number => {
safeCouponUsedCount: (state): number => {
return safeNumber(state.couponUsedCount);
},

View File

@@ -36,6 +36,10 @@ export function getPayType(index?: number): any {
{
value: 8,
label: '货到付款'
},
{
value: 9,
label: '线下付款'
}
];
if (index != null) {

View File

@@ -333,16 +333,12 @@
const chooseFile = (data: FileRecord) => {
images.value.push({
uid: data.id,
url:
data.downloadUrl +
'?x-oss-process=image/resize,m_fixed,w_2000/quality,Q_90',
url: data.downloadUrl,
status: 'done',
title: '', // 初始化标题为空
path: '' // 初始化链接为空
});
form.images =
data.downloadUrl +
'?x-oss-process=image/resize,m_fixed,w_2000/quality,Q_90';
form.images = data.downloadUrl;
};
const onDeleteItem = (index: number) => {

View File

@@ -87,7 +87,7 @@
// 系统信息
const systemInfo = ref({
name: '小程序开发',
name: '小程序商城',
description:
'基于Spring、SpringBoot、SpringMVC等技术栈构建的前后端分离开发平台',
version: '2.0.0',

View File

@@ -153,7 +153,7 @@
<a-descriptions-item label="技术支持">
<span
class="cursor-pointer"
>企业商城</span
>企业官网</span
>
</a-descriptions-item>
</a-descriptions>
@@ -213,11 +213,14 @@
import { openNew } from '@/utils/common';
import { useSiteStore } from '@/store/modules/site';
import { useStatisticsStore } from '@/store/modules/statistics';
import { useUserStore } from '@/store/modules/user';
import { getTenantInfo } from '@/api/layout';
import { storeToRefs } from 'pinia';
// 使用状态管理
const siteStore = useSiteStore();
const statisticsStore = useStatisticsStore();
const userStore = useUserStore();
// 从 store 中获取响应式数据
const { siteInfo, loading: siteLoading } = storeToRefs(siteStore);
@@ -225,7 +228,7 @@
// 系统信息
const systemInfo = ref({
name: '小程序开发',
name: '小程序商城',
description:
'基于Spring、SpringBoot、SpringMVC等技术栈构建的前后端分离开发平台',
version: '2.0.0',
@@ -238,7 +241,13 @@
});
// 计算属性
const runDays = computed(() => siteStore.runDays);
const now = ref(Date.now());
const tenantCreateTime = ref<string>('');
let runDaysTimer: ReturnType<typeof setInterval>;
const runDays = computed(() => {
if (!tenantCreateTime.value) return 0;
return Math.floor((now.value - new Date(tenantCreateTime.value).getTime()) / (24 * 60 * 60 * 1000));
});
const userCount = computed(() => statisticsStore.userCount);
const orderCount = computed(() => statisticsStore.orderCount);
const totalSales = computed(() => statisticsStore.totalSales);
@@ -248,6 +257,15 @@
// 加载数据
const loadData = async () => {
// 独立请求租户信息,不受其他请求失败影响
getTenantInfo()
.then((res) => {
tenantCreateTime.value = res?.createTime || '';
})
.catch((e) => {
console.warn('获取租户信息失败:', e);
});
try {
await Promise.all([
siteStore.fetchSiteInfo(),
@@ -267,11 +285,14 @@
await loadData();
// 开始自动刷新统计数据每5分钟
statisticsStore.startAutoRefresh();
// 运行天数每小时检查一次(跨天自动更新)
runDaysTimer = setInterval(() => { now.value = Date.now(); }, 60 * 60 * 1000);
});
onUnmounted(() => {
// 组件卸载时停止自动刷新
statisticsStore.stopAutoRefresh();
clearInterval(runDaysTimer);
});
</script>

View File

@@ -313,7 +313,7 @@
// 默认配置
const defaultConfig: Config = {
siteName: '企业商城',
siteName: '小程序商城',
siteLogo: 'https://oss.wsdns.cn/20240822/0252ad4ed46449cdafe12f8d3d96c2ea.svg',
domain: '',
icpNo: '',

File diff suppressed because it is too large Load Diff

View File

@@ -588,6 +588,7 @@
userId: undefined,
money: undefined,
payType: undefined,
paymentVoucher: '',
wechatAccount: '',
wechatName: '',
alipayName: '',

View File

@@ -0,0 +1,87 @@
<!-- 搜索表单 -->
<template>
<a-space :size="10" style="flex-wrap: wrap">
<a-input
v-model:value="where.userId"
placeholder="用户ID"
allow-clear
style="width: 130px"
@press-enter="search"
/>
<a-input
v-model:value="where.goodsId"
placeholder="商品ID"
allow-clear
style="width: 130px"
@press-enter="search"
/>
<a-select
v-model:value="where.browseSource"
placeholder="浏览来源"
allow-clear
style="width: 130px"
>
<a-select-option value="detail">商品详情</a-select-option>
<a-select-option value="home">首页</a-select-option>
<a-select-option value="category">分类</a-select-option>
<a-select-option value="search">搜索</a-select-option>
<a-select-option value="share">分享</a-select-option>
</a-select>
<a-range-picker
v-model:value="dateRange"
value-format="YYYY-MM-DD"
:placeholder="['开始日期', '结束日期']"
@change="onDateChange"
/>
<a-button type="primary" class="ele-btn-icon" @click="search">
<template #icon>
<SearchOutlined />
</template>
<span>查询</span>
</a-button>
<a-button @click="reset">重置</a-button>
</a-space>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { SearchOutlined } from '@ant-design/icons-vue';
import type { Dayjs } from 'dayjs';
import type { ShopGoodsBrowseParam } from '@/api/shop/shopGoodsBrowse/model';
const props = defineProps<{
where?: ShopGoodsBrowseParam;
}>();
const emit = defineEmits<{
(e: 'search', where?: ShopGoodsBrowseParam): void;
}>();
// 搜索条件(双向绑定)
const where = computed(() => props.where ?? {});
// 日期范围
const dateRange = ref<[Dayjs, Dayjs] | undefined>();
// 日期变化
const onDateChange = (_dates: any, formatStrings: [string, string]) => {
where.value.startTime = formatStrings[0] || undefined;
where.value.endTime = formatStrings[1] || undefined;
};
// 搜索
const search = () => {
emit('search', { ...where.value });
};
// 重置
const reset = () => {
where.value.userId = undefined;
where.value.goodsId = undefined;
where.value.browseSource = undefined;
where.value.startTime = undefined;
where.value.endTime = undefined;
dateRange.value = undefined;
emit('search', {});
};
</script>

View File

@@ -0,0 +1,196 @@
<template>
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
tool-class="ele-toolbar-form"
class="shop-goods-browse-table"
>
<template #toolbar>
<search @search="reload" />
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'goodsImage'">
<a-image
v-if="record.goodsImage"
:src="getCompressedImageUrl(record.goodsImage)"
:width="50"
:height="50"
style="border-radius: 4px; object-fit: cover"
/>
<span v-else class="ele-text-placeholder">无图</span>
</template>
<template v-if="column.key === 'browseSource'">
<a-tag v-if="record.browseSource === 'detail'" color="blue">商品详情</a-tag>
<a-tag v-else-if="record.browseSource === 'home'" color="green">首页</a-tag>
<a-tag v-else-if="record.browseSource === 'category'" color="cyan">分类</a-tag>
<a-tag v-else-if="record.browseSource === 'search'" color="orange">搜索</a-tag>
<a-tag v-else-if="record.browseSource === 'share'" color="purple">分享</a-tag>
<span v-else>{{ record.browseSource || '-' }}</span>
</template>
<template v-if="column.key === 'visitCount'">
<a-tag color="red">{{ record.visitCount || 1 }}</a-tag>
</template>
<template v-if="column.key === 'lastVisitTime'">
{{ toDateString(record.lastVisitTime, 'yyyy-MM-dd HH:mm') }}
</template>
<template v-if="column.key === 'createTime'">
{{ toDateString(record.createTime, 'yyyy-MM-dd HH:mm') }}
</template>
<template v-if="column.key === 'action'">
<a-popconfirm
title="确定要删除此浏览记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</template>
</template>
</ele-pro-table>
</a-card>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import { getCompressedImageUrl } from '@/utils/image';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import Search from './components/search.vue';
import {
pageShopGoodsBrowse,
removeShopGoodsBrowse
} from '@/api/shop/shopGoodsBrowse';
import type {
ShopGoodsBrowse,
ShopGoodsBrowseParam
} from '@/api/shop/shopGoodsBrowse/model';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格数据源
const datasource: DatasourceFunction = ({
page,
limit,
where,
orders
}) => {
return pageShopGoodsBrowse({
...where,
...orders,
page,
limit
});
};
// 表格列配置
const columns = ref<ColumnItem[]>([
{
title: 'ID',
dataIndex: 'id',
key: 'id',
align: 'center',
width: 70
},
{
title: '用户ID',
dataIndex: 'userId',
key: 'userId',
align: 'center',
width: 90
},
{
title: '商品图片',
key: 'goodsImage',
align: 'center',
width: 80
},
{
title: '商品名称',
dataIndex: 'goodsName',
key: 'goodsName',
ellipsis: true
},
{
title: '商品价格',
dataIndex: 'price',
key: 'price',
align: 'center',
width: 100,
customRender: ({ text }) => (text ? `¥${text}` : '-')
},
{
title: '浏览次数',
dataIndex: 'visitCount',
key: 'visitCount',
align: 'center',
width: 90,
sorter: true
},
{
title: '浏览来源',
dataIndex: 'browseSource',
key: 'browseSource',
align: 'center',
width: 100
},
{
title: '最后浏览时间',
dataIndex: 'lastVisitTime',
key: 'lastVisitTime',
align: 'center',
width: 160,
sorter: true
},
{
title: '首次浏览',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
width: 160
},
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
/* 搜索 */
const reload = (where?: ShopGoodsBrowseParam) => {
tableRef?.value?.reload({ where: where });
};
/* 删除单条 */
const remove = (row: ShopGoodsBrowse) => {
const hide = message.loading('请求中..', 0);
removeShopGoodsBrowse(row.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'ShopGoodsBrowse'
};
</script>
<style lang="less" scoped></style>

View File

@@ -0,0 +1,241 @@
<!-- 确认线下收款弹窗 -->
<template>
<a-modal
:visible="visible"
title="确认线下收款"
:width="520"
:confirm-loading="loading"
ok-text="确认收款"
@update:visible="updateVisible"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-alert
type="info"
show-icon
message="确认已收到该订单的线下付款(微信转账/银行汇款等),确认后订单将进入待发货状态。"
style="margin-bottom: 16px"
/>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<!-- 订单号 -->
<a-form-item label="订单号">
<span style="color: #999">{{ form.orderNo || '-' }}</span>
</a-form-item>
<!-- 支付凭证 -->
<a-form-item label="支付凭证" name="paymentVoucher">
<a-upload
list-type="picture-card"
:max-count="1"
:file-list="fileList"
:custom-request="handleUpload"
:before-upload="beforeUpload"
@remove="handleRemove"
@preview="handlePreview"
>
<div v-if="!fileList.length">
<PlusOutlined />
<div class="ant-upload-text">上传凭证</div>
</div>
</a-upload>
<div class="voucher-tip">支持 jpg/png 图片单张不超过 10MB</div>
</a-form-item>
<!-- 备注 -->
<a-form-item label="备注" name="remarks">
<a-textarea
v-model:value="form.remarks"
placeholder="可填写备注(如:微信转账已收到)"
:rows="3"
:maxlength="200"
show-count
/>
</a-form-item>
</a-form>
<!-- 图片预览 -->
<a-modal
:visible="previewVisible"
:footer="null"
title="凭证预览"
@cancel="previewVisible = false"
>
<img :src="previewImage" alt="支付凭证" style="width: 100%" />
</a-modal>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import type { UploadFile, UploadProps } from 'ant-design-vue';
import { ShopOrder } from '@/api/shop/shopOrder/model';
import { confirmOfflinePayment } from '@/api/shop/shopOrder';
import { uploadOss } from '@/api/system/file';
import { getUrl } from '@/utils/common';
const useForm = Form.useForm;
const props = defineProps<{
visible: boolean;
data?: ShopOrder | null;
}>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
(e: 'done'): void;
}>();
// 表单数据
const form = reactive({
orderId: undefined as number | undefined,
orderNo: '' as string,
paymentVoucher: '' as string,
remarks: '' as string
});
// 表单验证规则
const rules = {
paymentVoucher: [
{ required: true, message: '请上传支付凭证' }
]
};
const formRef = ref();
const { resetFields, validate } = useForm(form, rules);
// 状态
const loading = ref(false);
const fileList = ref<UploadFile[]>([]);
const previewVisible = ref(false);
const previewImage = ref('');
// 上传前校验
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const isImage = ['image/jpeg', 'image/png', 'image/jpg'].includes(
file.type
);
if (!isImage) {
message.error('仅支持 jpg/png 格式图片');
return false;
}
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isLt10M) {
message.error('图片大小不能超过 10MB');
return false;
}
return true;
};
// 自定义上传
const handleUpload: UploadProps['customRequest'] = (options) => {
const { file, onSuccess, onError } = options;
uploadOss(file as File)
.then((data) => {
// 存储返回的 path与项目 UploadCert 组件约定一致)
form.paymentVoucher = data.path || '';
fileList.value = [
{
uid: String(data.id || Date.now()),
name: data.name || '支付凭证',
status: 'done',
url: data.url || getUrl(data.path || '')
}
];
onSuccess?.(data, file as any);
// 触发表单校验清除错误
formRef.value?.validateFields(['paymentVoucher']);
})
.catch((e) => {
message.error(e.message || '上传失败');
onError?.(e as any);
});
};
// 删除凭证
const handleRemove = () => {
form.paymentVoucher = '';
fileList.value = [];
return true;
};
// 预览凭证
const handlePreview = (file: UploadFile) => {
previewImage.value = file.url || '';
previewVisible.value = true;
};
// 更新弹窗显示状态
const updateVisible = (visible: boolean) => {
emit('update:visible', visible);
};
// 取消
const handleCancel = () => {
updateVisible(false);
};
// 提交确认收款
const handleSubmit = async () => {
try {
await validate();
if (!form.orderId) {
message.error('订单信息缺失');
return;
}
loading.value = true;
await confirmOfflinePayment(
form.orderId,
form.remarks || undefined,
form.paymentVoucher || undefined
);
message.success('确认收款成功,订单已进入待发货状态');
emit('done');
updateVisible(false);
} catch (error: any) {
if (error?.errorFields) return; // 表单校验失败,不弹错误
message.error(error.message || '确认收款失败');
} finally {
loading.value = false;
}
};
// 监听弹窗显示状态
watch(
() => props.visible,
(visible) => {
if (visible) {
// 初始化表单
form.orderId = props.data?.orderId;
form.orderNo = props.data?.orderNo || '';
form.paymentVoucher = '';
form.remarks = '';
fileList.value = [];
} else {
resetFields();
fileList.value = [];
}
}
);
</script>
<style scoped>
.voucher-tip {
color: #999;
font-size: 12px;
margin-top: 4px;
}
.ant-upload-text {
margin-top: 8px;
color: #666;
}
</style>

View File

@@ -71,9 +71,11 @@
label="支付状态"
:labelStyle="{ width: '90px', color: '#808080' }"
>
<a-tag v-if="form.payStatus == 1 && form.payType !== 8" color="green">已付款</a-tag>
<a-tag v-if="form.payStatus == 1 && form.payType !== 8 && form.payType !== 9" color="green">已付款</a-tag>
<a-tag v-if="form.payStatus == 1 && form.payType === 8" color="blue">待收货付款</a-tag>
<a-tag v-if="form.payStatus == 0">未付</a-tag>
<a-tag v-if="form.payStatus == 1 && form.payType === 9" color="green">已确认收</a-tag>
<a-tag v-if="form.payStatus == 0 && form.payType === 9" color="orange">待确认收款</a-tag>
<a-tag v-if="form.payStatus == 0 && form.payType !== 9">未付款</a-tag>
<a-tag v-if="form.payStatus == 3">未付款,占场中</a-tag>
</a-descriptions-item>
<!-- 第四排-->
@@ -147,7 +149,7 @@
</a-tag>
<a-tag v-if="form.payType == 9">
<IdcardOutlined class="tag-icon" />
IC月卡
线下付款
</a-tag>
<a-tag v-if="form.payType == 10">
<IdcardOutlined class="tag-icon" />
@@ -187,7 +189,16 @@
</a-tag>
</template>
<template v-else>
<span class="text-gray-400">未支付</span>
<!-- 线下付款未确认时也显示支付方式 -->
<a-tag v-if="form.payType == 9">
<IdcardOutlined class="tag-icon" />
线下付款
</a-tag>
<a-tag v-if="form.payType == 8">
<IdcardOutlined class="tag-icon" />
货到付款
</a-tag>
<span v-if="form.payType !== 9 && form.payType !== 8" class="text-gray-400">未支付</span>
</template>
</a-tooltip>
</a-descriptions-item>
@@ -206,6 +217,15 @@
>
{{ form.buyerRemarks }}
</a-descriptions-item>
<a-descriptions-item v-if="form.paymentVoucher" label="支付凭证" :labelStyle="{ width: '90px', color: '#808080' }">
<a-image
:width="70"
:src="getCompressedImageUrl(form.paymentVoucher, { width: 750 })"
:preview-src-list="[ensureFullUrl(form.paymentVoucher)]"
style="cursor: pointer"
/>
</a-descriptions-item>
<!-- <a-descriptions-item-->
<!-- label="结算状态"-->
<!-- :labelStyle="{ width: '90px', color: '#808080' }"-->
@@ -235,7 +255,7 @@
<template v-if="column.key === 'goodsName'">
<div style="display: flex; align-items: center; gap: 12px">
<a-avatar
:src="record.image || record.goodsImage"
:src="getCompressedImageUrl(record.image, { width: 100 })"
shape="square"
:size="50"
style="flex-shrink: 0"
@@ -414,6 +434,7 @@
import { updateShopOrder, removeShopOrder, refundShopOrder } from '@/api/shop/shopOrder';
import { message, Modal } from 'ant-design-vue';
import DeliveryModal from './deliveryModal.vue';
import {getCompressedImageUrl, ensureFullUrl} from "@/utils/image";
const useForm = Form.useForm;
@@ -581,6 +602,8 @@
invoiceNo: undefined,
// 支付时间
payTime: undefined,
// 线下收款支付凭证(图片地址,确认线下收款时上传)
paymentVoucher: undefined,
// 退款时间
refundTime: undefined,
// 申请退款时间

View File

@@ -36,10 +36,10 @@
</div>
<a-tabs type="card" v-model:activeKey="activeKey" @change="onTabs">
<a-tab-pane key="all" tab="全部" />
<a-tab-pane key="unpaid" tab="待付款" />
<a-tab-pane key="undelivered" tab="待发货" />
<a-tab-pane key="unreceived" tab="待收货" />
<a-tab-pane key="completed" tab="已完成" />
<!-- <a-tab-pane key="unpaid" tab="待付款" />-->
<a-tab-pane key="refunded" tab="退货/售后" />
<a-tab-pane key="cancelled" tab="已关闭" />
</a-tabs>
@@ -93,10 +93,15 @@
v-if="record.payType === 8"
color="blue"
>货到付款</a-tag>
<!-- 线下付款标识 -->
<a-tag
v-if="record.payType === 9"
color="orange"
>线下付款</a-tag>
<!-- 支付状态 -->
<a-tag
v-if="record.payStatus == 1 && record.payType !== 8"
v-if="record.payStatus == 1 && record.payType !== 8 && record.payType !== 9"
color="green"
@click.stop="updatePayStatus(record)"
class="cursor-pointer"
@@ -109,6 +114,20 @@
class="cursor-pointer"
>待收货付款</a-tag
>
<a-tag
v-else-if="record.payStatus == 1 && record.payType === 9"
color="green"
@click.stop="updatePayStatus(record)"
class="cursor-pointer"
>已确认收款</a-tag
>
<a-tag
v-else-if="record.payStatus == 0 && record.payType === 9"
color="orange"
@click.stop="updatePayStatus(record)"
class="cursor-pointer"
>待确认收款</a-tag
>
<a-tag
v-else-if="record.payStatus == 0 || record.payStatus == null"
@click.stop="updatePayStatus(record)"
@@ -174,7 +193,7 @@
<template v-for="(item, index) in record.orderGoods" :key="index">
<div class="item py-1">
<a-space :id="`g-${index}`">
<a-avatar :src="getCompressedImageUrl(item.image)" shape="square" :size="80" />
<a-avatar :src="getCompressedImageUrl(item.image,{ width: 100 })" shape="square" :size="50" />
<span>{{ item.goodsName }}</span>
</a-space>
</div>
@@ -185,6 +204,10 @@
<template v-if="record.payType === 8">
<a-tag color="blue">货到付款</a-tag>
</template>
<!-- 线下付款特殊标识 -->
<template v-else-if="record.payType === 9">
<a-tag color="orange">线下付款</a-tag>
</template>
<template v-else>
<template v-for="item in getPayType()">
<template v-if="record.payStatus == 1">
@@ -230,8 +253,8 @@
<!-- 查看详情 - 所有状态都可以查看 -->
<a @click.stop="openEdit(record)"> <EyeOutlined /> 详情 </a>
<!-- 未付款状态的操作 -->
<template v-if="!record.payStatus && record.orderStatus === 0">
<!-- 未付款状态的操作排除线下付款 -->
<template v-if="!record.payStatus && record.orderStatus === 0 && record.payType !== 9">
<a @click.stop="handleEditOrder(record)">
<EditOutlined /> 修改
</a>
@@ -240,6 +263,16 @@
</a>
</template>
<!-- 线下付款·待确认收款状态的操作 -->
<template v-if="!record.payStatus && record.orderStatus === 0 && record.payType === 9">
<a @click.stop="handleConfirmOfflinePayment(record)" class="ele-text-success">
<CheckCircleOutlined /> 确认收款
</a>
<a @click.stop="handleCancelOrder(record)">
<span class="ele-text-warning"> <CloseOutlined /> 关闭 </span>
</a>
</template>
<!-- 已付款未发货状态的操作 -->
<template
v-if="
@@ -336,11 +369,19 @@
:data="current"
@done="reload"
/>
<!-- 确认线下收款弹窗 -->
<OfflinePaymentModal
v-model:visible="showOfflinePayment"
:data="current"
@done="reload"
/>
</a-page-header>
</template>
<script lang="ts" setup>
import { createVNode, ref } from 'vue';
import { createVNode, ref, onMounted, onActivated } from 'vue';
import { useRoute } from 'vue-router';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
@@ -365,6 +406,7 @@
import { toDateString } from 'ele-admin-pro';
import OrderInfo from './components/orderInfo.vue';
import DeliveryModal from './components/deliveryModal.vue';
import OfflinePaymentModal from './components/OfflinePaymentModal.vue';
import { ShopOrder, ShopOrderParam } from '@/api/shop/shopOrder/model';
import {
pageShopOrder,
@@ -392,15 +434,49 @@
const showMove = ref(false);
// 是否显示发货弹窗
const showDelivery = ref(false);
// 是否显示确认线下收款弹窗
const showOfflinePayment = ref(false);
// 加载状态
const loading = ref(true);
// 激活的标签
const activeKey = ref<string>('undelivered');
// 激活的标签(支持从路由参数初始化,如 /shop/shopOrder?tab=all
const route = useRoute();
const validTabs = ['all', 'unpaid', 'undelivered', 'unreceived', 'completed', 'refunded', 'cancelled'];
const tabFromQuery = route.query.tab as string;
const activeKey = ref<string>(
validTabs.includes(tabFromQuery) ? tabFromQuery : 'all'
);
// ============ 新订单提醒 ============
const { enabled: notifyEnabled, toggle: onNotifyToggle, testNotify: onTestNotify } = useOrderNotify({
const { enabled: notifyEnabled, toggle: onNotifyToggle, testNotify: onTestNotify, clearBadge } = useOrderNotify({
onNewOrder: () => reload()
});
// 进入订单列表页时清除菜单红点(标记已查看),并同步 lastOrderId 为当前最新订单
// 兼容 keep-aliveonMounted 处理首次进入onActivated 处理缓存再次激活
let orderBadgeMounted = false;
onMounted(() => {
clearBadge();
orderBadgeMounted = true;
});
onActivated(() => {
if (orderBadgeMounted) clearBadge();
});
// 根据tab key获取对应的statusFilter值
// undefined全部0待付款1待发货2待核销3待收货4待评价5已完成6已退款7已删除8已关闭
const getStatusFilterByTab = (key: string): number | undefined => {
const filterMap: Record<string, number> = {
unpaid: 0,
undelivered: 1,
unverified: 2,
unreceived: 3,
unevaluated: 4,
completed: 5,
refunded: 6,
deleted: 7,
cancelled: 8
};
return filterMap[key];
};
// 表格数据源
const datasource: DatasourceFunction = ({
page,
@@ -413,6 +489,10 @@
where.status = filters.status;
}
where.type = 0;
// 确保初次加载时也按当前tab筛选statusFilter未设置时从activeKey推导
if (where.statusFilter === undefined) {
where.statusFilter = getStatusFilterByTab(activeKey.value);
}
return pageShopOrder({
...where,
...orders,
@@ -493,54 +573,11 @@
};
const onTabs = () => {
// 使用statusFilter进行筛选这是后端专门为订单状态筛选设计的字段
const filterParams: Record<string, any> = {};
// 根据后端 statusFilter 的值对应:
// undefined全部0待付款1待发货2待核销3待收货4待评价5已完成6已退款7已删除
switch (activeKey.value) {
case 'all':
// 全部订单不传statusFilter参数
// filterParams.statusFilter = undefined; // 不设置该字段
break;
case 'unpaid':
// 待付款pay_status = false
filterParams.statusFilter = 0;
break;
case 'undelivered':
// 待发货pay_status = true AND delivery_status = 10
filterParams.statusFilter = 1;
break;
case 'unverified':
// 待核销pay_status = true AND delivery_status = 10 (与待发货相同)
filterParams.statusFilter = 2;
break;
case 'unreceived':
// 待收货pay_status = true AND delivery_status = 20
filterParams.statusFilter = 3;
break;
case 'unevaluated':
// 待评价order_status = 1 (与已完成相同)
filterParams.statusFilter = 4;
break;
case 'completed':
// 已完成order_status = 1
filterParams.statusFilter = 5;
break;
case 'cancelled':
// 已关闭order_status = 2
filterParams.statusFilter = 8;
break;
case 'refunded':
// 退款/售后order_status = 6
filterParams.statusFilter = 6;
break;
case 'deleted':
// 已删除deleted = 1
filterParams.statusFilter = 7;
break;
const sf = getStatusFilterByTab(activeKey.value);
if (sf !== undefined) {
filterParams.statusFilter = sf;
}
reload(filterParams);
};
@@ -627,6 +664,12 @@
});
};
// 确认线下付款收款
const handleConfirmOfflinePayment = (record: ShopOrder) => {
current.value = record;
showOfflinePayment.value = true;
};
// 发货处理
const handleDelivery = (record: ShopOrder) => {
current.value = record;

View File

@@ -1,21 +1,23 @@
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { ref, onBeforeUnmount } from 'vue';
import { pageShopOrder } from '@/api/shop/shopOrder';
import type { ShopOrder } from '@/api/shop/shopOrder/model';
import { useUserStore } from '@/store/modules/user';
/**
* 新订单提醒 - 轮询检测 + 声音/语音播报
* 新订单提醒 - 全局单例轮询 + 红点徽章 + 声音/语音播报
*
* 原理
* 1. 定时轮询 pageShopOrder只查最新 1 条)
* 2. 与上次记录的 orderId 比较,不同则说明有新订单
* 3. 播放"叮"声Web Audio API + 语音播报"您有一条新的订单"Speech Synthesis API
* 4. 同时触发 onNewOrder 回调,通知调用方刷新页面数据
* 设计要点
* 1. **全局单例**轮询状态timer、lastOrderId、enabled、hasNewOrder全部为模块级变量
* 无论多少个组件调用 useOrderNotify(),始终只有一个轮询在运行,避免重复请求。
* 2. **红点徽章**:检测到新订单时,调用 userStore.setMenuBadge 给「订单管理」菜单挂红点;
* 进入订单列表页时调用 clearBadge() 清除,并同步 lastOrderId 为最新(标记“已查看”)。
* 3. **声音/语音**:保留原 Web Audio 叮声 + Speech Synthesis 语音播报。
* 4. **回调集合**:多组件可各自注册 onNewOrder 回调(如 dashboard 刷新统计、订单页刷新表格),
* 检测到新订单时统一遍历触发。
*
* 浏览器策略:音频播放需要用户交互后才允许。
* 默认开启轮询,音频在用户首次交互后自动解锁
*
* **实例安全**:每次调用 useOrderNotify() 创建独立实例,
* 多个页面可同时使用而不冲突。AudioContext 在模块级共享。
* 生命周期:
* - layout 组件挂载时调用 useOrderNotify() 并触发 start(),全局启动一次,常驻不停
* - dashboard / 订单列表页 / cms dashboard 调用 useOrderNotify() 仅消费状态与 UI不重复启动。
*/
// ============ 配置 ============
@@ -32,6 +34,9 @@ const DING_DURATION = 0.3;
/** 语音播报文案 */
const SPEECH_TEXT = '您有一条新的订单';
/** 订单管理菜单路径(红点挂在这里) */
const ORDER_MENU_PATH = '/shop/shopOrder';
// ============ 模块级共享状态(音频相关) ============
/** 音频上下文(模块级共享,延迟创建,需用户交互后) */
@@ -40,6 +45,32 @@ let audioCtx: AudioContext | null = null;
/** 用户交互解锁音频的监听器(模块级,全局只注册一次) */
let unlockHandler: (() => void) | null = null;
// ============ 模块级单例状态(轮询 + 红点) ============
/** 是否开启提醒(所有组件共享同一开关) */
const enabled = ref(true);
/** 是否有未查看的新订单(驱动红点 / 快速入口标记) */
const hasNewOrder = ref(false);
/** 轮询是否进行中 */
let polling = false;
/** 轮询定时器 */
let timerId: ReturnType<typeof setInterval> | null = null;
/** 上次记录的最新订单 ID用于判断是否有新订单 */
let lastOrderId: number | undefined = undefined;
/** 是否已完成首次初始化(首次只记录基准,不触发提醒) */
let initialized = false;
/** 是否已全局启动(避免重复 start */
let started = false;
/** 新订单回调集合(多组件注册,统一触发) */
const newOrderCallbacks = new Set<() => void>();
// ============ 声音播放 ============
/**
@@ -130,109 +161,171 @@ function speak(text: string) {
}
/**
* 触发提醒:叮声 + 语音播报 + 通知回调
* 触发提醒:叮声 + 语音播报 + 通知所有已注册回调
*/
function triggerNotify(onNewOrderCallback: (() => void) | null) {
function triggerNotify() {
initAudioContext();
playDingSound();
speak(SPEECH_TEXT);
if (onNewOrderCallback) {
onNewOrderCallback();
// 触发所有已注册回调(刷新各页面数据)
newOrderCallbacks.forEach((cb) => {
try {
cb();
} catch (e) {
console.warn('[订单提醒] 回调执行失败:', e);
}
});
}
// ============ 红点徽章控制 ============
/**
* 设置/清除订单管理菜单的红点徽章
* @param on true=显示红点false=清除红点
*/
function setOrderBadge(on: boolean) {
hasNewOrder.value = on;
try {
const userStore = useUserStore();
if (on) {
// 'dot' 表示纯红点(不显示数字),由 menu-title.vue 渲染为 a-badge dot
userStore.setMenuBadge(ORDER_MENU_PATH, 'dot', '#ff4d4f');
} else {
// 传 undefined 清除徽章
userStore.setMenuBadge(ORDER_MENU_PATH, undefined);
}
} catch (e) {
console.warn('[订单提醒] 设置菜单红点失败:', e);
}
}
// ============ 轮询逻辑 ============
/**
* 检测是否有新订单
* 只查最新 1 条,与 lastOrderId 比较:不同且更大则说明有新订单
*/
async function checkNewOrder() {
if (!enabled.value) return;
try {
const result = await pageShopOrder({
page: 1,
limit: 1,
type: 0
});
const latestOrder: ShopOrder | undefined = result?.list?.[0];
if (!latestOrder) {
lastOrderId = undefined;
return;
}
const currentOrderId = latestOrder.orderId;
if (!initialized) {
// 首次只记录基准,不触发提醒
lastOrderId = currentOrderId;
initialized = true;
return;
}
if (
currentOrderId !== lastOrderId &&
(lastOrderId === undefined || (currentOrderId ?? 0) > lastOrderId)
) {
// 有新订单:声音 + 语音 + 回调 + 红点
triggerNotify();
setOrderBadge(true);
}
lastOrderId = currentOrderId;
} catch (error) {
console.warn('[订单提醒] 轮询查询失败:', error);
}
}
function startPolling() {
if (polling) return;
polling = true;
initialized = false;
checkNewOrder();
timerId = setInterval(checkNewOrder, POLL_INTERVAL);
}
function stopPolling() {
polling = false;
if (timerId) {
clearInterval(timerId);
timerId = null;
}
}
// ============ 全局启动 / 停止 ============
/**
* 全局启动订单提醒(由 layout 组件调用一次即可)
* 内部用 started 标志保证幂等,重复调用安全
*/
function start() {
if (started) return;
started = true;
registerAudioUnlock();
if (enabled.value) {
startPolling();
}
}
/**
* 清除红点并同步 lastOrderId 为当前最新订单(标记“已查看”)
* 进入订单列表页时调用
*/
async function clearBadge() {
// 立即清除红点(同步,体验即时)
setOrderBadge(false);
// 重新查询最新订单,把 lastOrderId 同步为最新,标记“已查看到此订单”
// 这样后续只有比这个更新的订单才会再次触发红点
try {
const result = await pageShopOrder({ page: 1, limit: 1, type: 0 });
const latest: ShopOrder | undefined = result?.list?.[0];
lastOrderId = latest?.orderId;
initialized = true;
} catch (e) {
console.warn('[订单提醒] 同步最新订单失败:', e);
}
}
// ============ 对外接口 ============
/**
* 新订单提醒组合式函数
* 新订单提醒组合式函数(全局单例)
*
* 用法:
* ```ts
* const { enabled, toggle, testNotify } = useOrderNotify({
* // layout 全局启动(仅一次)
* const { start } = useOrderNotify();
* start();
*
* // dashboard / 订单页消费状态与 UI
* const { enabled, hasNewOrder, toggle, testNotify, clearBadge } = useOrderNotify({
* onNewOrder: () => reload()
* });
* ```
*
* @param options.onNewOrder 检测到新订单时的回调(通常用于刷新页面数据
* @param options.onNewOrder 检测到新订单时的回调(组件卸载时自动移除
*/
export function useOrderNotify(options?: { onNewOrder?: () => void }) {
// ============ 实例级状态 ============
const enabled = ref(true);
let polling = false;
let timerId: ReturnType<typeof setInterval> | null = null;
let lastOrderId: number | undefined = undefined;
let initialized = false;
let onNewOrderCallback: (() => void) | null = options?.onNewOrder ?? null;
// ============ 轮询逻辑 ============
async function checkNewOrder() {
try {
const result = await pageShopOrder({
page: 1,
limit: 1,
type: 0
});
const latestOrder: ShopOrder | undefined = result?.list?.[0];
if (!latestOrder) {
lastOrderId = undefined;
return;
}
const currentOrderId = latestOrder.orderId;
if (!initialized) {
lastOrderId = currentOrderId;
initialized = true;
return;
}
if (
currentOrderId !== lastOrderId &&
(lastOrderId === undefined || (currentOrderId ?? 0) > lastOrderId)
) {
triggerNotify(onNewOrderCallback);
}
lastOrderId = currentOrderId;
} catch (error) {
console.warn('[订单提醒] 轮询查询失败:', error);
}
// 注册回调(组件卸载时自动移除,避免内存泄漏)
const cb = options?.onNewOrder;
if (cb) {
newOrderCallbacks.add(cb);
onBeforeUnmount(() => {
newOrderCallbacks.delete(cb);
});
}
function startPolling() {
if (polling) return;
polling = true;
initialized = false;
checkNewOrder();
timerId = setInterval(checkNewOrder, POLL_INTERVAL);
}
function stopPolling() {
polling = false;
if (timerId) {
clearInterval(timerId);
timerId = null;
}
}
// ============ 生命周期 ============
onMounted(() => {
registerAudioUnlock();
startPolling();
});
onBeforeUnmount(() => {
stopPolling();
onNewOrderCallback = null;
});
// ============ 对外方法 ============
// 开关切换
const toggle = (value: boolean) => {
enabled.value = value;
if (value) {
@@ -240,17 +333,28 @@ export function useOrderNotify(options?: { onNewOrder?: () => void }) {
startPolling();
} else {
stopPolling();
setOrderBadge(false);
}
};
// 测试提醒
const testNotify = () => {
initAudioContext();
triggerNotify(onNewOrderCallback);
triggerNotify();
};
return {
/** 是否开启提醒(响应式,所有组件共享) */
enabled,
/** 是否有未查看的新订单(响应式,驱动红点) */
hasNewOrder,
/** 开关切换 */
toggle,
testNotify
/** 测试提醒(声音 + 语音) */
testNotify,
/** 清除红点并标记已查看(进入订单页时调用) */
clearBadge,
/** 全局启动layout 调用一次) */
start
};
}

View File

@@ -17,9 +17,9 @@
<a-tab-pane tab="分销设置" key="dealer">
<Dealer />
</a-tab-pane>
<a-tab-pane tab="支付设置" key="payment">
<Payment />
</a-tab-pane>
<!-- <a-tab-pane tab="支付设置" key="payment">-->
<!-- <Payment />-->
<!-- </a-tab-pane>-->
<a-tab-pane tab="通知设置" key="notify">
<Notify />
</a-tab-pane>
@@ -43,7 +43,7 @@ import Basic from './components/basic.vue';
import Order from './components/order.vue';
import Points from './components/points.vue';
import Dealer from './components/dealer.vue';
import Payment from './components/payment.vue';
// import Payment from './components/payment.vue';
import Notify from './components/notify.vue';
import Upload from './components/upload.vue';
import Sms from './components/sms.vue';