feat(shop-zone): 新增专区销量统计功能
- 后端新增专区销量统计相关 VO 并扩展 ShopHomeSection 实体 - 新增批量查询专区销量汇总与商品排行的 Mapper 方法及接口 - Controller 增加获取专区销量统计的接口支持时间范围查询 - 前端接口定义新增专区销量统计类型及请求方法 - 专区管理页面列表新增销量件数、销售额列及销量详情按钮 - 实现专区销量统计弹窗支持时间筛选、汇总展示和排行显示 - 完成前端相关界面和交互设计,保证功能完整可用 - 统计口径基于已支付且未取消/退款订单商品实际成交数据
This commit is contained in:
+16
-179
@@ -1,181 +1,18 @@
|
||||
# 2026-08-17 工作日志
|
||||
|
||||
## 专区商品「移除」假成功 bug 修复
|
||||
- 现象:guilixu.websoft.top/special/zone 移除专区商品提示成功,刷新(重查库)后商品仍在。
|
||||
- 根因:`ShopHomeSectionController.removeGoods` 用 `listByIds` + `updateBatchById` 改 `shop_goods.section_ids`;单专区商品移除后 section_ids 变 null,但 `ShopGoods.sectionIds` 无更新策略注解,MP 全局默认 `NOT_NULL` 会忽略 null 值 → 不生成 `SET section_ids=NULL` → 库字段保持原值(假成功)。多专区商品(变非 null)正常,故仅单专区场景坏。
|
||||
- 修复:`addGoods`/`removeGoods` 改用 `UpdateWrapper.set("section_ids", newVal).eq("goods_id", id)` 显式更新(newVal 可为 null),绕过字段策略。文件:guilixu-java `ShopHomeSectionController.java`。
|
||||
- 部署:需重新编译部署 guilixu-java(线上 shop-api)后端生效;前端无需改动。添加用同样模式统一修复。
|
||||
- 验证:部署后用单专区商品移除测试,刷新不应再出现。
|
||||
- 注:本机环境无 maven,未本地编译;改动为标准 MP 用法(IService.update(Wrapper) / UpdateWrapper.set 允许 null)。
|
||||
|
||||
## 专区白名单功能诊断(功能当前不可用)
|
||||
- 设计意图:专区设为"受限专区"(restricted=1) 后,该专区商品仅白名单用户可用余额购买;小程序结算页 checkout.tsx 已调 `check-permission` 前端拦截。
|
||||
- 致命错配:后台白名单弹窗 `UserSelectModal.vue` 用 `pageUsers`(@/api/system/user → sys_user 后台员工) 选用户;后端 `ShopHomeSectionController.checkPermission` 用 `getLoginUser().getUserId()`(= shop_user 小程序买家 userId)。两套用户体系 id 不一致 → 白名单里加的任何人(后台员工)对真实买家都匹配不上 → 受限专区对买家永远拦截/强制余额,白名单形同虚设。
|
||||
- 次要隐患:后端创建订单接口未做白名单强校验,仅前端拦截,可被绕过。
|
||||
- 修复方向:① 白名单弹窗数据源从 `pageUsers`(sys_user) 改为 `pageShopUser`(@/api/shop/shopUser → shop_user 买家),使存的 user_id 与校验 userId 同体系;② 后端下单接口加白名单强校验防绕过。后台已有 pageShopUser 可用。
|
||||
|
||||
## 专区白名单:用户体系澄清 + 后端强校验(用户 2026-08-17 01:50 指令)
|
||||
- 用户澄清:本项目的买家(小程序用户)就是 `sys_user` 体系,`pageShopUser` 实际不用/返回空。故白名单弹窗用 `pageUsers`(sys_user) 是**正确**的,之前"用户体系错配"判断为误判。
|
||||
- 白名单弹窗 `UserSelectModal.vue` 现状(8-16 已改):`pageUsers({ keywords, ... })`,**不传 isStaff**(搜索覆盖全部 sys_user)。源码无需再改。已 grep 确认 zone 目录下无残留 isStaff/pageShopUser。
|
||||
- 后端补「受限专区白名单强校验」:`OrderBusinessService.java` 新增
|
||||
- import:`ShopHomeSectionService`、`ShopHomeSectionUserService`、`QueryWrapper`、`java.util.Set/LinkedHashSet`;
|
||||
- `@Resource` 注入 `homeSectionService`、`homeSectionUserService`;
|
||||
- `createOrder` 在 `validateDeliveryRegionIfNeeded` 之后调用 `validateSectionPermissionIfNeeded(request, shopOrder, loginUser)`;
|
||||
- 新方法逻辑:遍历 `request.goodsItems` → 取 `ShopGoods.sectionIds` → 找 restricted=1 的专区 → 校验 `loginUser.getUserId()` 是否在 `shop_home_section_user`(count>0),否则抛 `BusinessException("该商品属于受限专区,仅限指定白名单用户购买")`。
|
||||
- 作用:前端 `check-permission` 的后端兜底,防止直接调 `/shop/shop-order` 下单绕过白名单。
|
||||
- 部署:需重新编译部署 guilixu-java(shop-api)。前端无需改动。
|
||||
- 注:本机无 maven,未本地编译;改动为标准 MP 用法(IService.count(QueryWrapper) / getById)。
|
||||
- 待确认:用户 "2.帮我补" 指令只写了前半句,已按上文补后端强校验;若其本意是"补菜单记录"(专区管理入口,动态路由待插入),需另出 INSERT SQL(菜单表结构待确认)。
|
||||
|
||||
## 专区白名单弹窗 checkbox 不显示(第三次修复,根因确认)
|
||||
- 现象:白名单弹窗数据正常(12 条用户),但表格无 checkbox 勾选列,用户无法选择。
|
||||
- 已尝试修复(前两次均未生效):
|
||||
1. 8-16:`reactive` → `computed` + `columnWidth: 48`(与 shopCoupon 对齐)
|
||||
2. 同期:去掉 `isStaff: true`
|
||||
- **真正根因**(读 `ele-pro-table` 源码 `node_modules/ele-admin-pro/es/ele-pro-table/index.js` 第 94-114 行确认):
|
||||
- `ele-pro-table` 内部 `tableSelectionType` computed 有前置条件:必须传 `selection`、`current` 或 `selectionType="radio"` 三者之一,否则返回 `undefined`
|
||||
- 返回 undefined → `tableRowSelection` 也返回 undefined → ant-design-vue Table 收到 `rowSelection=undefined` → **不渲染 checkbox 列**
|
||||
- 我们的弹窗三个条件都不满足(没传 :selection / :current / selectionType),所以不管 row-selection 配置多正确都不会出勾选框
|
||||
- **最终修复**:模板 `<ele-pro-table>` 加 `selection-type="checkbox"`(第 18 行),使源码判断走通:`props2.selectionType !== "radio"` 为 true(是 "checkbox")→ 不走 early return → 返回 "checkbox" → checkbox 列正常渲染。
|
||||
- 改动文件:`src/views/special/zone/components/UserSelectModal.vue`(仅模板加 1 个 prop)
|
||||
- 部署:重新构建前端即可(`npm run dev` 热更新或 `npm run build` 部署)。
|
||||
|
||||
## 专区白名单弹窗 checkbox 修复(8-17 纠正:8-16 修复实际无效)
|
||||
- 反馈:用户截图仍无勾选框,8-16 加的 `selection-type="checkbox"` 未生效。
|
||||
- **重读 ele-pro-table 源码确认 8-16 根因判断错误**(`node_modules/ele-admin-pro/es/ele-pro-table/index.js` 94-103 行):
|
||||
```js
|
||||
const noSelection = typeof props2.selection === "undefined";
|
||||
const noCurrent = typeof props2.current === "undefined";
|
||||
if (noSelection && noCurrent && props2.selectionType !== "radio") {
|
||||
return; // 不渲染
|
||||
}
|
||||
```
|
||||
三个条件**同时满足**才 return。即 `noSelection && noCurrent && selectionType !== "radio"`。我传的 `selection-type="checkbox"` 让 `selectionType !== "radio"` 为 true,反而**满足**了 early return 条件 → 不渲染。这正是 8-16 修复无效的真因。
|
||||
- 另外发现 `tableRowSelection.selectedRowKeys` 强制用内部 ref(源码 111 行),**不支持外部初始化已选**——即使绕过 selection 限制,弹窗打开时已选的人也不会显示勾选。
|
||||
- **正确修复**:弃用 ele-pro-table 的 selection 机制,将 `UserSelectModal.vue` 重写为 antd `a-table`:
|
||||
- 受控 `selectedRowKeys`,watch props.visible 时用 `props.selectedUserIds` 初始化 → 打开弹窗已选即勾选
|
||||
- `rowSelection` computed:`{ columnWidth:48, selectedRowKeys, onChange }`
|
||||
- datasource 用 `pageUsers` 包成 Promise,分页用 antd `pagination` reactive,`@change` 调 reload
|
||||
- search `a-input-search`,@search/@pressEnter reload
|
||||
- confirm emit `selectedRowKeys`
|
||||
- 字段已与 User 模型对齐(userId/realName/mobile/nickname/organizationName),`PageResult<T>={list,count}`。
|
||||
- 改动文件:`src/views/special/zone/components/UserSelectModal.vue`(整体重写约 130 行)。
|
||||
- 部署:刷新页面(`npm run dev` 热更新)或重新构建 admin 前端即可。无需后端改动。
|
||||
|
||||
## 专区小程序码(扫码直接进入专区)
|
||||
- 需求:/special/zone 专区管理加「二维码」入口,生成该专区的小程序码,用户微信扫码直接进入小程序对应专区。
|
||||
- 后端(guilixu-java `ShopHomeSectionController.java`):新增 `GET /api/shop/shop-home-section/{id}/qrcode`
|
||||
- 调微信 `getwxacodeunlimit` 生成无限场景码;`scene=sectionId=xxx`,`page=pages/shop/section-detail`;按专区 `tenantId` 经 `WxMiniappAccessTokenService.getAccessToken(tenantId)` 取 token;prod 用 `release` 否则 `trial`(与现有 getOrderQRCodeUnlimited 一致)。
|
||||
- 复用 Hutool `HttpRequest` 调微信,返回 PNG 流;微信出错时返回 JSON 并识别 `application/json`。
|
||||
- 用户端小程序(guilixu-taro `pages/shop/section-detail.tsx`):解析 `useRouter().params.scene`(小程序码扫码透传的 `sectionId=xxx`),兼容原 `?sectionId=` 普通跳转。
|
||||
- 管理后台(guilixu-admin `views/special/zone/index.vue` + `api/shop/shopZone`):列表操作列加「二维码」按钮 → 弹窗显示小程序码图片;新增 `getSectionQrcode(id)`(responseType blob → objectURL,遇 JSON 错误解析 message 并 reject)。
|
||||
- 部署:① 重新编译部署 guilixu-java(shop-api)使新接口生效;② 重新构建前端 admin 与 taro 小程序(section-detail 需重新发布/体验版,因小程序码 page 指向已发布页面)。
|
||||
- 注:本机无 maven,后端未本地编译;改动完全复用 WxLoginController 既有 getwxacodeunlimit 写法。
|
||||
- 限制:getwxacodeunlimit 的 page 必须已存在于小程序(pages/shop/section-detail 已在 app.config 的 pages 列表);scene 仅限可见字符、≤32 位,`sectionId=xxx` 满足。
|
||||
|
||||
## 专区小程序码报 41030 invalid page 修复
|
||||
- 现象:后台点「二维码」生成小程序码,微信返回 `{"errcode":41030,"errmsg":"invalid page"}`。
|
||||
- 根因:原代码按 profile 决定 env_version——active=dev 时走 `trial`(体验版)。微信 41030=所请求版本的小程序代码包里**没有该页面**。页面 `pages/shop/section-detail` 在源码 app.config 存在,但体验版小程序没上传含此页面的代码 → 失败。
|
||||
- 修复:
|
||||
- 后端(ShopHomeSectionController.getSectionQrcode):去掉按 profile 判断,改为 `envVersion` 请求参数,`@RequestParam(defaultValue="release")`,`env_version` 直接用该参数(release/trial/develop)。
|
||||
- 前端(admin):`getSectionQrcode(id, envVersion='release')` 支持传参;弹窗加「正式版/体验版/开发版」RadioGroup(默认正式版),切换即重新生成。
|
||||
- 关键前提(务必告诉用户):**生成成功 ≠ 扫码能进专区**。
|
||||
1. 生成码所请求的版本(正式/体验)其代码包必须包含 `pages/shop/section-detail` 页面,否则仍 41030。
|
||||
2. 扫码能正确进专区,还要求把「能解析 scene 的 section-detail.tsx」发布到**同一版本**:正式版需发版、体验版需上传体验版、开发版需开发者工具预览。
|
||||
3. 若默认正式版仍 41030,说明线上版本也没有此页面 → 需先发布小程序(该页面虽在 app.config,但可能未上线)。
|
||||
- 部署:需重新编译部署 guilixu-java + 重新构建发布 taro 小程序(到目标版本)。
|
||||
|
||||
## 专区白名单「添加/移除」功能确认 + tenantId 修复
|
||||
- 现状确认:`shop_home_section_user` 白名单的「添加/移除」功能**此前已完整实现**,本次只是排查确认:
|
||||
- 后端 `ShopHomeSectionController`:`GET /{id}/users`(查)、`PUT /{id}/users`(覆盖式保存);`ShopHomeSectionUserService.listBySectionId` → `selectBySectionId`(XML)。
|
||||
- 前端 `index.vue`:列表「白名单」按钮 → `openUsers` 调 `listSectionUsers` 加载已选 → 弹窗;`onSaveUsers` 调 `setSectionUsers` 保存。
|
||||
- `UserSelectModal.vue`:用 `pageUsers`(sys_user) 选人,勾选=添加、取消=移除、保存=覆盖提交;columns 与 User 模型字段(userId/realName/mobile/nickname/organizationName)已核对匹配。
|
||||
- **真 bug 修复**:后端 `setUsers` 新建 `ShopHomeSectionUser` 时**未设 tenantId**(实体有该字段,本项目手动多租户)。若表 `tenant_id` 有 NOT NULL 约束会插入报错;即便不报错也缺租户归属。
|
||||
- 修复:从专区 `homeSectionService.getById(id).getTenantId()` 取租户,set 到每条白名单记录(`ShopHomeSectionController.setUsers`,line ~119)。
|
||||
- 查询侧(`selectBySectionId`)按 `section_id` 隔离,未动;下单强校验 `checkPermission` 也按 section_id 隔离,一致。
|
||||
- 用户体系统一结论(沿用 01:50 澄清):买家=sys_user,`pageUsers` 选人正确,白名单 user_id 与下单校验 userId 同体系。
|
||||
- 部署:重新编译部署 guilixu-java(shop-api)生效后,后台 /special/zone 每条专区「白名单」即可勾选添加、取消移除并保存。
|
||||
|
||||
## 白名单弹窗搜索报错修复([object PointerEvent] 误作 limit 传给后端)
|
||||
- 现象:白名单弹窗点搜索触发 400:`BindException ... Field error in object 'userParam' on field 'limit': rejected value [[object PointerEvent]]`。
|
||||
- 根因:重写后的 `UserSelectModal.vue` 模板 `@search="reload"` / `@pressEnter="reload"`。antd `a-input-search` 的 `search` 事件签名是 `(value, event)`,故 `reload` 实际收到 `reload(搜索词, PointerEvent)`;`reload(page, limit)` 把第二个参数当 `limit` 传给 `pageUsers`,`pageSize` 变成 PointerEvent → 序列化 `[object PointerEvent]` 传给后端 `limit` 字段 → 类型转换失败。
|
||||
- 修复:模板改为 `@search="() => reload(1)"` / `@pressEnter="() => reload(1)"`,只传页码、不传事件对象;`reload(1)` 时 `limit=undefined` → `pageSize` 回退 `pagination.pageSize`。
|
||||
- 改动:`UserSelectModal.vue` 两行模板。
|
||||
- 部署:刷新 admin 前端(`npm run dev` 热更新或重新构建)即可。
|
||||
|
||||
## 白名单保存报 Duplicate entry(逻辑删除×唯一索引死局)
|
||||
- 现象:保存专区白名单 `PUT /{id}/users` 报 `SQLIntegrityConstraintViolationException: Duplicate entry '1-35771-1' for key 'shop_home_section_user.uk_section_user'`,失败 SQL 是 `UPDATE shop_home_section_user SET deleted=1 WHERE tenant_id=10606 AND deleted=0 AND section_id=?`(MP 逻辑删除)。
|
||||
- 根因(已确证):`add_shop_home_section.sql:40` 定义 `UNIQUE KEY uk_section_user (section_id, user_id, deleted)`——唯一索引**包含 deleted 列**;而实体 `ShopHomeSectionUser` 上有 `@TableLogic`(逻辑删除)。`setUsers` 用「`remove` 全部 + `saveBatch`」覆盖式保存,`remove` 被 MP 转成 `UPDATE SET deleted=1`;历史多次保存已囤积 `deleted=1` 旧行,下次再把某条 `deleted=0` 行改成 `deleted=1` 就与旧 `deleted=1` 行撞唯一键。
|
||||
- 修复(绕过逻辑删除,用物理删除):
|
||||
- `ShopHomeSectionUserMapper.java` 新增 `int physicsDeleteBySection(@Param sectionId, @Param tenantId)`;
|
||||
- `ShopHomeSectionUserMapper.xml` 新增 `<delete id="physicsDeleteBySection"> DELETE FROM shop_home_section_user WHERE section_id=? <if tenantId not null>AND tenant_id=?</if> </delete>`(自定义 SQL,MP 不会注入逻辑删除 → 真物理删除);
|
||||
- `ShopHomeSectionController.setUsers` 把 `homeSectionUserService.remove(new QueryWrapper...eq("section_id", id))` 换成 `homeSectionUserMapper.physicsDeleteBySection(id, currentTenantId)`,再 `saveBatch`。每次保存前把该专区所有行(含 deleted=1 残留)真正删掉再插新行,不再囤积,不撞键。
|
||||
- `selectBySectionId` 已带 `deleted=0` 过滤,查询侧无影响。
|
||||
- 说明:此表使用逻辑删除 + 含 deleted 的唯一索引本就是反模式;本修复用物理删除规避,安全且低风险(本机无 maven 未编译)。前端无需改动。
|
||||
- 遗留(无害):其他未再保存过的专区可能仍有历史 deleted=1 重复行,因 `selectBySectionId` 过滤 deleted=0 且保存已改物理删除,不影响业务;如需彻底清可用 `DELETE FROM shop_home_section_user WHERE deleted=1 GROUP BY section_id,user_id HAVING COUNT(*)>1` 之类语句(谨慎,先备份)。
|
||||
|
||||
## 白名单弹窗改为商品式(打开列已加、搜索追加、单条即时增删)
|
||||
- 用户反馈:旧版覆盖式勾选弹窗「勾了别的用户后前面勾过的又不见了」。`UserSelectModal.vue` 重写,统一成与专区「商品」抽屉一致的交互:打开只列已添加的白名单用户;搜索手机/姓名/昵称出候选,点「+添加」即时入库、点「移除」即时删。
|
||||
- 根因(旧交互):覆盖式保存依赖 `selectedRowKeys` 跨打开/重渲染保持,易丢;且 `setUsers` 覆盖写遇到唯一键冲突会整体失败。商品式用「单条即时增删」彻底规避。
|
||||
- 后端(guilixu-java `ShopHomeSectionController`):
|
||||
- `GET /{id}/users` 改为**联表返回用户详情** `List<User>`(realName/mobile/nickname/organizationName/userId),不再只返回关系行(旧返回 ShopHomeSectionUser 仅含 userId,前端展示空白)。新增注入 `UserService`,先取关系行再 `userService.listByIds` 取详情。
|
||||
- 新增 `POST /{id}/users` `addUsers`:追加用户,先查已存在 userIds 跳过(防 uk_section_user 唯一键冲突),再 `saveBatch`,带 tenantId。
|
||||
- 新增 `DELETE /{id}/users` `removeUsers`:物理删除指定用户,调 `homeSectionUserMapper.physicsDeleteBySectionAndUsers(id, userIds, tenantId)`(新增 mapper/XML 方法,foreach user_id IN,绕过 @TableLogic)。
|
||||
- 旧 `PUT /{id}/users` `setUsers`(覆盖式)保留兼容,前端不再使用。
|
||||
- 原 `users` 用的 `homeSectionUserService.listBySectionId` 不再调用(改用 `list(new QueryWrapper...)`)。
|
||||
- 前端:
|
||||
- `api/shop/shopZone/index.ts`:`listSectionUsers` 返回类型改 `User[]`(import system `User`,移除 `SectionUser`);新增 `addSectionUsers(id, userIds)`(POST)、`removeSectionUsers(id, userIds)`(DELETE)。
|
||||
- `components/UserSelectModal.vue` 整体重写:props(`visible`,`sectionId`),内部 watch(visible) 调 `listSectionUsers` 加载已加名单;`pageUsers({keywords})` 搜索候选(排除已加);`addOne`/`removeOne` 即时调接口并重载;移除 footer 与 `@confirm`,改用 `:footer="null"`。
|
||||
- `index.vue`:弹窗调用去掉 `:selectedUserIds` / `@confirm`;`openUsers` 简化为只置 `currentSectionId`+开弹窗;删除 `onSaveUsers`、`currentUserIds`、未用 import(`setSectionUsers`/`listSectionUsers` 在 index 内已无引用)。
|
||||
- 后端 `users` 关键字搜索:sys_user `UserParam.keywords` 已覆盖 username/user_id/nickname/real_name/alias/phone(含手机、姓名、昵称),满足需求。
|
||||
- 部署:重新编译部署 guilixu-java(shop-api)+ 重新构建 admin 前端。本机无 maven 未编译;改动为标准 MP/MyBatis 用法,风险低。
|
||||
|
||||
## 专区白名单弹窗改为右侧抽屉(modal → drawer)
|
||||
- 用户要求:专区「白名单」入口的交互与同页「商品」一致,改成右边弹出的抽屉。
|
||||
- 改动:`src/views/special/zone/components/UserSelectModal.vue` 外层容器由 `<a-modal>` 改为 `<a-drawer placement="right" :width="820">`,内部搜索/候选/已添加列表逻辑原样保留,props/emits(`visible`/`sectionId`/`update:visible`) 不变。
|
||||
- `index.vue` 中 `<UserSelectModal v-model:visible="showUser" :sectionId="currentSectionId" />` 调用方式无需改动(与商品抽屉同为右侧弹出)。
|
||||
- 部署:重新构建 admin 前端(npm run dev 热更新或 build)即可,无需后端改动。
|
||||
|
||||
## 专区白名单手机号改为不脱敏
|
||||
- 需求:专区「白名单」抽屉里手机号不要脱敏显示。
|
||||
- 根因:系统 User 实体 `mobile` 是 `@TableField(exist=false)` 的脱敏字段(`getMobile()` 返回 `DesensitizedUtil.mobilePhone(phone)`);`phone` 才是 DB 真实号码,无 `@JsonIgnore`、无全局脱敏 → 接口同时返回 `phone`(真实) 与 `mobile`(脱敏)。
|
||||
- 改动:`UserSelectModal.vue` 两处展示由 `u.mobile` 改为 `u.phone`(候选行 `u.phone || u.mobile || '-'`;表格列 `dataIndex:'phone'` + `customRender` 回退 `record.mobile`),后端无需改动。
|
||||
- 部署:重新构建 admin 前端即可。
|
||||
|
||||
## 专区商品抽屉分页「20/100 条/页」不可用修复
|
||||
- 现象:专区商品抽屉右下角切 20/100 条/页不生效,始终只展示 10 条。
|
||||
- 根因:`src/views/special/zone/index.vue` 中商品表格的 `:pagination` 把 `pageSize: 10` 写死,且 `onChange` 只接收 `page` 参数、未处理 `pageSize`;翻页事件里还误写成 `goodsPage = page`(应 `.value`)。
|
||||
- 修复:
|
||||
- 新增 `goodsPageSize = ref(10)`;
|
||||
- `loadGoods` 请求参数 `limit` 改为 `goodsPageSize.value`;
|
||||
- 分页配置改为 `pageSize: goodsPageSize`、`showSizeChanger: true`,`onChange(page, pageSize)` 同时更新 `goodsPage.value` 与 `goodsPageSize.value` 后调 `loadGoods`。
|
||||
- 改动文件:`src/views/special/zone/index.vue`。
|
||||
- 部署:重新构建 admin 前端即可。
|
||||
|
||||
## 商城商品导出「完全无反应」修复(/shop/shopGoods 导出功能不可用)
|
||||
- 现象:/shop/shopGoods 点「导出xls」完全没反应(无下载、无报错提示)。
|
||||
- 根因:`src/views/shop/shopGoods/index.vue` 第236行 `const loading = ref(true);`,而 `handleExport` 第一行 `if (loading.value) return;`。`loading` 这个变量只有 `handleExport` 自己会改(导出中设 true / 结束或失败设 false),列表加载的 `reload()`(只调 `tableRef.reload`)、`query()`(只加载分类树)从不碰它 → 页面加载后 loading 永远是初始的 `true` → 导出首行被永久拦截、逻辑从未执行。
|
||||
- 已排除项:按钮 emit、后端 `/shop/shop-goods` 返回 `ApiResult<List<ShopGoods>>`、xlsx@0.18.5 依赖、vite `optimizeDeps.include:['xlsx']` 均正常——不是接口/依赖/打包问题。
|
||||
- 修复(按用户"修守卫+带筛选导出"):
|
||||
1. `index.vue` 第236行 `ref(true)` → `ref(false)`(loading 恢复成"导出中防重复点击"语义,首击可进入)。
|
||||
2. 导出携带当前筛选:`search.vue` emit 签名 `(e:'export', where?: ShopGoodsParam)`,`handleExport` emit('export', where);`index.vue` `handleExport(where?)` 调 `listShopGoods(where || {})`(原 `listShopGoods({})` 导出全部、忽略筛选)。where 来自 search.vue 的 `useSearch<ShopGoodsParam>`,与列表 datasource 用的 where 同源,完全复现筛选。
|
||||
- 改动文件:`src/views/shop/shopGoods/index.vue`、`src/views/shop/shopGoods/components/search.vue`(共5处小改)。
|
||||
- 部署:重新构建 admin 前端(npm run dev 热更新或 npm run build)即可,无需后端改动。
|
||||
- 验证清单:① 进 /shop/shopGoods 点「导出xls」应弹"正在准备导出数据"并下载 xlsx;② 先筛选分类/关键词/状态再导出,xlsx 内容应与筛选结果一致;③ 导出中连点不应重复触发(loading 防重生效)。
|
||||
|
||||
## 商城商品导出增加「封面图」路径列(用户改主意:不内嵌图片,只导出路径)
|
||||
- 背景:用户原想 Excel 内嵌封面图(前端 fetch OSS 图→base64→xlsx !images),但 OSS(oss.wsdns.cn) 与 admin(websoft.top) 跨域,浏览器 fetch 会被 CORS 拦截;且逐张下载会导致导出变慢、xlsx 体积暴涨。用户明确改为「图片导出路径就行了」——即 Excel 里加一列封面图 URL 文本。
|
||||
- 实现:`src/views/shop/shopGoods/index.vue` 的 `handleExport` 导出列末尾新增「封面图」列,值为 `ensureFullUrl(goods.image)`(`@/utils/image` 的 ensureFullUrl:相对路径补成 `https://oss.wsdns.cn/...` 完整 URL,已是 http(s) 原样返回)。表头/每行/`!cols` 列数均由 10 增到 11,封面图列宽 wch:50。
|
||||
- 补 import:`import { ensureFullUrl } from '@/utils/image';`(紧接 xlsx 的 import 之后)。
|
||||
- 改动文件:`src/views/shop/shopGoods/index.vue`(handleExport 内共 4 处:import / 表头 / forEach / !cols)。
|
||||
- 说明:Excel 单元格中是 URL 文本,运营复制/点开即可在浏览器看原图;不是内嵌缩略图。OSS 图片无 CORS 依赖,导出不再受跨域影响。
|
||||
- 部署:重新构建 admin 前端即可,无需后端改动。
|
||||
|
||||
## 注册页复制:website-admin → guilixu-admin 的表选型分析
|
||||
- 用户想复制 website-admin 的 `/register`(四步建站:邮箱验证→企业信息→站点初始化→开通)到 guilixu-admin。
|
||||
- 关键事实:
|
||||
- website-admin 注册「创建站点」调 `@/api/cms/site.createSite` → `POST /api/cms/cms-website`,落 **modules.cms_website**(mp-java,cms-api.websoft.top)。`superAdminRegister`(建租户)走其主后端 SERVER_API_URL。
|
||||
- guilixu-admin 现有 `views/passport/register|register2` 调 `createCmsWebSite` → `SERVER_API_URL/superAdminRegister`;但 **guilixu-java 里无 superAdminRegister 方法**(仅 SecurityConfig 放行 `/api/cms/website/createWebsite`)→ 该端点生产上实际未接通/404。
|
||||
- guilixu-admin 的 cms 站点管理 UI(`api/cms/cmsWebsite`)走 `CMS_API_BASE_URL=cms-api.websoft.top` = **mp-java/modules**,即 modules.cms_website。
|
||||
- **db_guilixu(guilixu-java)没有 cms_website 表**:全仓搜 CmsWebsite/cms_website 仅 MybatisPlusConfig 一行被注释的 `"cms_website"`;有 `shop_setting`(`@TableName("shop_setting")`,字段 category/settingKey/settingValue = K/V 商城配置,非站点主记录)。
|
||||
- 结论(待与用户确认):复制注册应选 **modules.cms_website**(走 CMS_API_BASE_URL,与 website-admin 的 createSite、guilixu-admin 现有 cmsWebsite UI 完全一致),**不要**用 db_guilixu.shop_setting(语义不符)+ db_guilixu.cms_website 根本不存在。另需决定:guilixu-admin 是单租户(10606)后台,注册多半只「在 10606 下建微官网」而非新建租户 → 应跳过 superAdminRegister,仅保留 createSite(cms_website) 一步。
|
||||
## 专区销量统计功能(/special/zone)
|
||||
- 调研:专区与商品靠 `ShopGoods.section_ids`(逗号串)关联;`ShopOrderGoods` 无 sectionId,有 `goodsId/totalNum/price/payStatus/orderStatus/tenantId`;商品有 `sales` 累计字段。
|
||||
- 确认方案:方案B(订单真实成交聚合)+ 列表汇总列 + 独立统计弹窗,指标=销量件数+销售额。
|
||||
- 后端 guilixu-java(shop 模块)改动:
|
||||
- ShopHomeSection 实体加 salesNum/salesAmount(@TableField(exist=false))
|
||||
- 新建 VO:SectionSalesSummaryVO / ShopSectionGoodsRankVO / SectionSalesStatsVO(vo 包)
|
||||
- ShopHomeSectionMapper 加 selectSectionSalesSummary(批量)/ selectSectionGoodsRank(TOP50)
|
||||
- ShopHomeSectionServiceImpl.pageRel 批量填充汇总 + 新增 getSectionSalesStats
|
||||
- ShopHomeSectionController 新增 GET /{id}/stats?start=&end=
|
||||
- 口径:payStatus=1 且 orderStatus NOT IN(2,6),tenantId 隔离,时间对应 create_time
|
||||
- 前端 guilixu-admin:
|
||||
- shopZone API 加 getSectionSalesStats;model 加字段与 VO 类型
|
||||
- zone/index.vue 列表加「销量件数」「销售额」列 + 操作列「销量」按钮
|
||||
- 新建 SectionSalesStatsModal.vue(时间筛选 + 汇总卡片 + 商品排行)
|
||||
- vite build 通过(exit 0)
|
||||
- 待办:后端需 maven 编译并部署到 shop-api.websoft.top 才生效(本环境未部署)。
|
||||
|
||||
@@ -80,3 +80,14 @@
|
||||
- **正确做法**:清空/置空场景改用 `UpdateWrapper.set("section_ids", newVal)` 显式 set(newVal 可 null),绕过字段策略。
|
||||
- 已修复点:`ShopHomeSectionController.addGoods`/`removeGoods`(2026-08-17)改用此模式。同项目其它"置空某字段"的更新都要警惕此坑。
|
||||
|
||||
### 专区销量统计(2026-08-17 新增)
|
||||
- 需求:专区管理页 `/special/zone` 统计专区销量(件数 + 销售额),用户确认 **方案B(订单真实成交聚合)+ 列表汇总列 + 独立统计弹窗**。
|
||||
- **后端(guilixu-java,仅此,无需 mp-java)**:
|
||||
- `ShopHomeSection` 实体加 `salesNum`/`salesAmount`(`@TableField(exist=false)` 统计字段,`pageRel` 分页后批量填充)。
|
||||
- 新增 VO:`SectionSalesSummaryVO`(列表汇总)、`ShopSectionGoodsRankVO`(商品排行项)、`SectionSalesStatsVO`(含 `List<ShopSectionGoodsRankVO> goodsRank`)。
|
||||
- `ShopHomeSectionMapper.xml` 新增两条 JOIN 聚合 SQL:`selectSectionSalesSummary`(批量,`FIND_IN_SET(section_id, g.section_ids)` 关联商品 → 聚合 `shop_order_goods`)、`selectSectionGoodsRank`(单专区 TOP50 排行)。
|
||||
- `ShopHomeSectionController` 新增 `GET /shop/shop-home-section/{id}/stats?start=&end=`。
|
||||
- **统计口径**:`shop_order_goods.pay_status=1`(已付款) 且 `order_status NOT IN (2已取消, 6退款成功)`;按 `tenant_id` 隔离;时间范围对应 `shop_order_goods.create_time`(yyyy-MM-dd HH:mm:ss)。
|
||||
- ⚠️ 订单商品表**无 sectionId**,专区销量=该专区 `section_ids` 关联商品的销量汇总;商品若属多个专区会被各专区重复计入(业务口径已知)。
|
||||
- **前端(guilixu-admin)**:`src/api/shop/shopZone` 加 `getSectionSalesStats` + 类型;`zone/index.vue` 列表加「销量件数」「销售额」两列 + 操作列「销量」按钮;新建 `src/views/special/zone/components/SectionSalesStatsModal.vue`(时间范围 今日/近7天/近30天/自定义 + 汇总卡片 + 商品销量排行)。前端 `vite build` 已通过。
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
-- ============================================================
|
||||
-- 报表统计菜单(tenant_id=10606,挂在「商城」顶级菜单下)
|
||||
-- 作用:让前端动态路由 /statistics 自动生效(component=shop/statistics)
|
||||
-- 说明:sys_menu 在 gxwebsoft_core 库;本 SQL 用全限定名,可在任意有权限的库执行。
|
||||
-- ============================================================
|
||||
|
||||
-- 1) 插入报表统计菜单
|
||||
-- parent_id / app_id 自动取自「商城」顶级菜单(title 含"商城",parent_id=0)
|
||||
-- NOT EXISTS 防止重复插入(重复执行安全)
|
||||
INSERT INTO gxwebsoft_core.sys_menu
|
||||
(parent_id, title, path, component, menu_type, sort_number, authority, icon, hide, tenant_id, app_id, deleted)
|
||||
SELECT
|
||||
p.menu_id, -- 商城父级 menu_id
|
||||
'报表统计',
|
||||
'/statistics',
|
||||
'shop/statistics',
|
||||
0, -- 0=菜单
|
||||
60, -- 排序号(放商城菜单末尾)
|
||||
'shop:statistics:view',
|
||||
'BarChartOutlined',
|
||||
0, -- 0=显示
|
||||
10606,
|
||||
p.app_id,
|
||||
0
|
||||
FROM gxwebsoft_core.sys_menu p
|
||||
WHERE p.tenant_id = 10606
|
||||
AND p.parent_id = 0
|
||||
AND p.title LIKE '%商城%'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM gxwebsoft_core.sys_menu s
|
||||
WHERE s.tenant_id = 10606 AND s.component = 'shop/statistics'
|
||||
)
|
||||
LIMIT 1;
|
||||
|
||||
-- 2) 把新菜单分配给「商城」父级已有的角色(否则角色没有该菜单权限,左侧导航看不到)
|
||||
INSERT INTO gxwebsoft_core.sys_role_menu (role_id, menu_id)
|
||||
SELECT rm.role_id, m.menu_id
|
||||
FROM gxwebsoft_core.sys_menu m
|
||||
JOIN gxwebsoft_core.sys_menu p ON p.menu_id = m.parent_id
|
||||
JOIN gxwebsoft_core.sys_role_menu rm ON rm.menu_id = p.menu_id
|
||||
WHERE m.tenant_id = 10606
|
||||
AND m.component = 'shop/statistics'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM gxwebsoft_core.sys_role_menu x
|
||||
WHERE x.role_id = rm.role_id AND x.menu_id = m.menu_id
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 排查辅助:若第1步未插入(商城父级标题不含"商城"),先用下面语句确认父级 menu_id
|
||||
-- SELECT menu_id, title, app_id FROM gxwebsoft_core.sys_menu
|
||||
-- WHERE tenant_id=10606 AND parent_id=0 AND title LIKE '%商城%';
|
||||
-- ============================================================
|
||||
@@ -0,0 +1,74 @@
|
||||
import request from '@/utils/request';
|
||||
import type { ApiResult } from '@/api';
|
||||
import { MODULES_API_URL } from '@/config/setting';
|
||||
import type {
|
||||
ShopOrderStatsOverview,
|
||||
ShopOrderTrendItem,
|
||||
ShopGoodsRankItem,
|
||||
ShopOrderStatusDist,
|
||||
StatsRangeParams
|
||||
} from './model';
|
||||
|
||||
/**
|
||||
* 经营概览(KPI 卡片)
|
||||
*/
|
||||
export async function getShopOrderStatsOverview(
|
||||
params: StatsRangeParams
|
||||
): Promise<ShopOrderStatsOverview> {
|
||||
const res = await request.get<ApiResult<ShopOrderStatsOverview>>(
|
||||
MODULES_API_URL + '/shop/shop-order/stats/overview',
|
||||
{ params }
|
||||
);
|
||||
if (res.data.code === 0) {
|
||||
return res.data.data as ShopOrderStatsOverview;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 销售趋势(type=day|week|month)
|
||||
*/
|
||||
export async function getShopOrderStatsTrend(
|
||||
params: StatsRangeParams & { type?: string }
|
||||
): Promise<ShopOrderTrendItem[]> {
|
||||
const res = await request.get<ApiResult<ShopOrderTrendItem[]>>(
|
||||
MODULES_API_URL + '/shop/shop-order/stats/trend',
|
||||
{ params }
|
||||
);
|
||||
if (res.data.code === 0) {
|
||||
return res.data.data as ShopOrderTrendItem[];
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品销量排行
|
||||
*/
|
||||
export async function getShopOrderGoodsRank(
|
||||
params: StatsRangeParams & { limit?: number }
|
||||
): Promise<ShopGoodsRankItem[]> {
|
||||
const res = await request.get<ApiResult<ShopGoodsRankItem[]>>(
|
||||
MODULES_API_URL + '/shop/shop-order/stats/goods-rank',
|
||||
{ params }
|
||||
);
|
||||
if (res.data.code === 0) {
|
||||
return res.data.data as ShopGoodsRankItem[];
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单/退款分布
|
||||
*/
|
||||
export async function getShopOrderStatsStatusDist(
|
||||
params: StatsRangeParams
|
||||
): Promise<ShopOrderStatusDist> {
|
||||
const res = await request.get<ApiResult<ShopOrderStatusDist>>(
|
||||
MODULES_API_URL + '/shop/shop-order/stats/status-dist',
|
||||
{ params }
|
||||
);
|
||||
if (res.data.code === 0) {
|
||||
return res.data.data as ShopOrderStatusDist;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 商城报表统计 - 类型定义
|
||||
*/
|
||||
|
||||
/** 经营概览(GMV + 实收双口径) */
|
||||
export interface ShopOrderStatsOverview {
|
||||
/** GMV(含未支付),按订单面额 total_price 求和 */
|
||||
gmvSales: number;
|
||||
/** 实收金额(仅已支付),按 pay_price 求和 */
|
||||
paidSales: number;
|
||||
/** 订单总数(含未支付) */
|
||||
orderCount: number;
|
||||
/** 已支付订单数 */
|
||||
paidOrderCount: number;
|
||||
/** 客单价 = 实收 / 已支付订单数 */
|
||||
customerUnitPrice: number;
|
||||
/** 退款金额 */
|
||||
refundAmount: number;
|
||||
/** 区间新增会员数 */
|
||||
newUserCount: number;
|
||||
/** 使用优惠券的订单数 */
|
||||
couponUsedCount: number;
|
||||
}
|
||||
|
||||
/** 销售趋势周期项 */
|
||||
export interface ShopOrderTrendItem {
|
||||
/** 周期:day=yyyy-MM-dd / week=yyyy-ww / month=yyyy-MM */
|
||||
period: string;
|
||||
gmvSales: number;
|
||||
paidSales: number;
|
||||
orderCount: number;
|
||||
paidOrderCount: number;
|
||||
}
|
||||
|
||||
/** 商品销量排行项 */
|
||||
export interface ShopGoodsRankItem {
|
||||
goodsId: number;
|
||||
goodsName: string;
|
||||
totalNum: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
/** 订单状态分布项 */
|
||||
export interface ShopOrderStatusItem {
|
||||
status: number;
|
||||
statusName: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** 订单/退款分布 */
|
||||
export interface ShopOrderStatusDist {
|
||||
statusCounts: ShopOrderStatusItem[];
|
||||
orderCount: number;
|
||||
paidOrderCount: number;
|
||||
refundCount: number;
|
||||
refundAmount: number;
|
||||
/** 退款率(%) */
|
||||
refundRate: number;
|
||||
}
|
||||
|
||||
/** 区间参数(start/end 格式 yyyy-MM-dd HH:mm:ss) */
|
||||
export interface StatsRangeParams {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import type {
|
||||
HomeSection,
|
||||
HomeSectionParam,
|
||||
SectionPermission,
|
||||
SectionGoods
|
||||
SectionGoods,
|
||||
SectionSalesStatsVO
|
||||
} from './model';
|
||||
import type { User } from '@/api/system/user/model';
|
||||
import { MODULES_API_URL } from '@/config/setting';
|
||||
@@ -238,3 +239,20 @@ export async function getSectionQrcode(
|
||||
}
|
||||
return window.URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
/**
|
||||
* 专区销量统计(销量件数 + 销售额 + 商品排行), 支持时间范围 start/end(yyyy-MM-dd HH:mm:ss)
|
||||
*/
|
||||
export async function getSectionSalesStats(
|
||||
id: number,
|
||||
params?: { start?: string; end?: string }
|
||||
) {
|
||||
const res = await request.get<ApiResult<SectionSalesStatsVO>>(
|
||||
MODULES_API_URL + '/shop/shop-home-section/' + id + '/stats',
|
||||
{ params }
|
||||
);
|
||||
if (res.data.code === 0 && res.data.data) {
|
||||
return res.data.data;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ export interface HomeSection {
|
||||
createTime?: string;
|
||||
// 修改时间
|
||||
updateTime?: string;
|
||||
// 销量件数(统计, 非DB字段)
|
||||
salesNum?: number;
|
||||
// 销售额(统计, 非DB字段)
|
||||
salesAmount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,3 +90,31 @@ export interface SectionUser {
|
||||
sectionId?: number;
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 专区销量统计(含商品排行)
|
||||
*/
|
||||
export interface SectionSalesStatsVO {
|
||||
// 专区ID
|
||||
sectionId?: number;
|
||||
// 销量件数
|
||||
salesNum?: number;
|
||||
// 销售额
|
||||
salesAmount?: number;
|
||||
// 统计开始时间
|
||||
startTime?: string;
|
||||
// 统计结束时间
|
||||
endTime?: string;
|
||||
// 商品销量排行
|
||||
goodsRank?: SectionGoodsRankItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 专区商品销量排行项
|
||||
*/
|
||||
export interface SectionGoodsRankItem {
|
||||
goodsId?: number;
|
||||
goodsName?: string;
|
||||
salesNum?: number;
|
||||
salesAmount?: number;
|
||||
}
|
||||
|
||||
+56
-264
@@ -1,21 +1,26 @@
|
||||
/**
|
||||
* 统计数据 store
|
||||
* 说明:今日概况(销售额/订单数/新增会员/用券数)改为调用后端聚合接口
|
||||
* /shop/shop-order/stats/overview,不再前端拉全量订单求和,也不再依赖 cms_statistics 表。
|
||||
*/
|
||||
import { defineStore } from 'pinia';
|
||||
import dayjs from 'dayjs';
|
||||
import { pageUsers } from '@/api/system/user';
|
||||
import { pageShopOrder, shopOrderTotal, listShopOrder } from '@/api/shop/shopOrder';
|
||||
import {
|
||||
addCmsStatistics,
|
||||
listCmsStatistics,
|
||||
updateCmsStatistics
|
||||
} from '@/api/cms/cmsStatistics';
|
||||
import { CmsStatistics } from '@/api/cms/cmsStatistics/model';
|
||||
import { safeNumber, hasValidId } from '@/utils/type-guards';
|
||||
import { pageShopOrder, shopOrderTotal } from '@/api/shop/shopOrder';
|
||||
import { getShopOrderStatsOverview } from '@/api/shop/shopOrderStats';
|
||||
import { safeNumber } from '@/utils/type-guards';
|
||||
|
||||
export interface StatisticsState {
|
||||
// 统计数据
|
||||
statistics: CmsStatistics | null;
|
||||
statistics: {
|
||||
userCount: number;
|
||||
orderCount: number;
|
||||
totalSales: number;
|
||||
todaySales: number;
|
||||
monthSales: number;
|
||||
todayOrders: number;
|
||||
todayUsers: number;
|
||||
} | null;
|
||||
// 加载状态
|
||||
loading: boolean;
|
||||
// 最后更新时间
|
||||
@@ -40,65 +45,14 @@ export const useStatisticsStore = defineStore('statistics', {
|
||||
}),
|
||||
|
||||
getters: {
|
||||
/**
|
||||
* 获取用户总数
|
||||
*/
|
||||
userCount: (state): number => {
|
||||
return safeNumber(state.statistics?.userCount);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取订单总数
|
||||
*/
|
||||
orderCount: (state): number => {
|
||||
return safeNumber(state.statistics?.orderCount);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取总销售额
|
||||
*/
|
||||
totalSales: (state): number => {
|
||||
return safeNumber(state.statistics?.totalSales);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取今日销售额
|
||||
*/
|
||||
todaySales: (state): number => {
|
||||
return safeNumber(state.statistics?.todaySales);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取本月销售额
|
||||
*/
|
||||
monthSales: (state): number => {
|
||||
return safeNumber(state.statistics?.monthSales);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取今日订单数
|
||||
*/
|
||||
todayOrders: (state): number => {
|
||||
return safeNumber(state.statistics?.todayOrders);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取今日新增用户
|
||||
*/
|
||||
todayUsers: (state): number => {
|
||||
return safeNumber(state.statistics?.todayUsers);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取今日使用优惠券数量(安全取值)
|
||||
*/
|
||||
safeCouponUsedCount: (state): number => {
|
||||
return safeNumber(state.couponUsedCount);
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查缓存是否有效
|
||||
*/
|
||||
userCount: (state): number => safeNumber(state.statistics?.userCount),
|
||||
orderCount: (state): number => safeNumber(state.statistics?.orderCount),
|
||||
totalSales: (state): number => safeNumber(state.statistics?.totalSales),
|
||||
todaySales: (state): number => safeNumber(state.statistics?.todaySales),
|
||||
monthSales: (state): number => safeNumber(state.statistics?.monthSales),
|
||||
todayOrders: (state): number => safeNumber(state.statistics?.todayOrders),
|
||||
todayUsers: (state): number => safeNumber(state.statistics?.todayUsers),
|
||||
safeCouponUsedCount: (state): number => safeNumber(state.couponUsedCount),
|
||||
isCacheValid: (state): boolean => {
|
||||
if (!state.lastUpdateTime) return false;
|
||||
const now = Date.now();
|
||||
@@ -107,40 +61,35 @@ export const useStatisticsStore = defineStore('statistics', {
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* 获取统计数据
|
||||
* @param forceRefresh 是否强制刷新
|
||||
*/
|
||||
async fetchStatistics(forceRefresh = false) {
|
||||
// 如果缓存有效且不强制刷新,直接返回缓存数据
|
||||
if (!forceRefresh && this.isCacheValid && this.statistics) {
|
||||
return this.statistics;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
// 并行获取各种统计数据,使用Promise.allSettled确保部分失败不影响整体
|
||||
const [usersResult, ordersResult, totalResult, statisticsResult] =
|
||||
// 今日区间(与后端 dayjs startOf/endOf('day') 对齐)
|
||||
const todayStart = dayjs().startOf('day').format('YYYY-MM-DD HH:mm:ss');
|
||||
const todayEnd = dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
// 并行获取:累计用户数 / 累计订单数 / 累计实收 / 今日概览(后端聚合)
|
||||
const [usersResult, ordersResult, totalResult, overviewResult] =
|
||||
await Promise.allSettled([
|
||||
pageUsers({ page: 1, limit: 1 }),
|
||||
pageShopOrder({ page: 1, limit: 1 }),
|
||||
shopOrderTotal(),
|
||||
listCmsStatistics({})
|
||||
getShopOrderStatsOverview({ start: todayStart, end: todayEnd })
|
||||
]);
|
||||
|
||||
// 安全提取结果
|
||||
const users =
|
||||
usersResult.status === 'fulfilled' ? usersResult.value : null;
|
||||
const orders =
|
||||
ordersResult.status === 'fulfilled' ? ordersResult.value : null;
|
||||
const total =
|
||||
totalResult.status === 'fulfilled' ? totalResult.value : null;
|
||||
const statisticsData =
|
||||
statisticsResult.status === 'fulfilled'
|
||||
? statisticsResult.value
|
||||
: null;
|
||||
const overview =
|
||||
overviewResult.status === 'fulfilled' ? overviewResult.value : null;
|
||||
|
||||
// 记录失败的API调用
|
||||
if (usersResult.status === 'rejected') {
|
||||
console.error('❌ 用户API调用失败:', usersResult.reason);
|
||||
}
|
||||
@@ -150,178 +99,41 @@ export const useStatisticsStore = defineStore('statistics', {
|
||||
if (totalResult.status === 'rejected') {
|
||||
console.error('❌ 订单总额API调用失败:', totalResult.reason);
|
||||
}
|
||||
if (statisticsResult.status === 'rejected') {
|
||||
console.error('❌ 统计数据API调用失败:', statisticsResult.reason);
|
||||
if (overviewResult.status === 'rejected') {
|
||||
console.error('❌ 今日概览API调用失败:', overviewResult.reason);
|
||||
}
|
||||
|
||||
// 添加调试日志
|
||||
console.log('🔍 统计数据获取结果:', {
|
||||
users: users,
|
||||
orders: orders,
|
||||
total: total,
|
||||
statisticsData: statisticsData
|
||||
});
|
||||
const userCount =
|
||||
users && typeof users === 'object' && 'count' in users
|
||||
? safeNumber((users as any).count)
|
||||
: 0;
|
||||
const orderCount =
|
||||
orders && typeof orders === 'object' && 'count' in orders
|
||||
? safeNumber((orders as any).count)
|
||||
: 0;
|
||||
const totalSales = safeNumber(total);
|
||||
|
||||
let statistics: CmsStatistics;
|
||||
// 今日数据走后端聚合接口(实收 + 总数 + 新增会员 + 用券数)
|
||||
const todaySales = overview ? safeNumber(overview.paidSales) : 0;
|
||||
const todayOrders = overview ? safeNumber(overview.orderCount) : 0;
|
||||
const todayUsers = overview ? safeNumber(overview.newUserCount) : 0;
|
||||
const couponUsedCount = overview
|
||||
? safeNumber(overview.couponUsedCount)
|
||||
: 0;
|
||||
|
||||
// 安全获取用户数量,添加更详细的验证
|
||||
const userCount = (() => {
|
||||
if (!users) {
|
||||
console.warn('⚠️ 用户API返回空数据');
|
||||
return 0;
|
||||
}
|
||||
if (typeof users === 'object' && 'count' in users) {
|
||||
const count = users.count;
|
||||
console.log('✅ 用户数量:', count);
|
||||
return safeNumber(count);
|
||||
}
|
||||
console.warn('⚠️ 用户API返回数据格式不正确:', users);
|
||||
return 0;
|
||||
})();
|
||||
|
||||
// 安全获取订单数量
|
||||
const orderCount = (() => {
|
||||
if (!orders) {
|
||||
console.warn('⚠️ 订单API返回空数据');
|
||||
return 0;
|
||||
}
|
||||
if (typeof orders === 'object' && 'count' in orders) {
|
||||
const count = orders.count;
|
||||
console.log('✅ 订单数量:', count);
|
||||
return safeNumber(count);
|
||||
}
|
||||
console.warn('⚠️ 订单API返回数据格式不正确:', orders);
|
||||
return 0;
|
||||
})();
|
||||
|
||||
// 实时计算今日数据(不依赖可能未更新的统计表)
|
||||
const todayStart = dayjs().startOf('day').format('YYYY-MM-DD HH:mm:ss');
|
||||
const todayEnd = dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
// 安全获取今日订单列表、销售额和优惠券使用量
|
||||
let todayOrders = 0;
|
||||
let todaySales = 0;
|
||||
let couponUsedCount = 0;
|
||||
try {
|
||||
const todayOrderList = await listShopOrder({
|
||||
createTimeStart: todayStart,
|
||||
createTimeEnd: todayEnd
|
||||
});
|
||||
if (Array.isArray(todayOrderList)) {
|
||||
todayOrders = todayOrderList.length;
|
||||
todaySales = todayOrderList.reduce((acc, order) => {
|
||||
return acc + (order.payStatus ? safeNumber(order.payPrice) : 0);
|
||||
}, 0);
|
||||
couponUsedCount = todayOrderList.filter((order) => {
|
||||
const couponType = order.couponType;
|
||||
return couponType !== undefined && couponType !== null && couponType !== 0;
|
||||
}).length;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('⚠️ 获取今日订单列表失败:', e);
|
||||
}
|
||||
|
||||
// 安全获取今日新增用户
|
||||
let todayUsers = 0;
|
||||
try {
|
||||
const todayUsersResult = await pageUsers({
|
||||
page: 1,
|
||||
limit: 1,
|
||||
createTimeStart: todayStart,
|
||||
createTimeEnd: todayEnd
|
||||
});
|
||||
if (todayUsersResult && typeof todayUsersResult === 'object' && 'count' in todayUsersResult) {
|
||||
todayUsers = safeNumber(todayUsersResult.count);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('⚠️ 获取今日新增用户失败:', e);
|
||||
}
|
||||
|
||||
const totalSales = (() => {
|
||||
if (!total) {
|
||||
console.warn('⚠️ 订单总额API返回空数据');
|
||||
return 0;
|
||||
}
|
||||
if (Array.isArray(total)) {
|
||||
// 如果是数组,计算总金额
|
||||
const sum = total.reduce((acc, order) => {
|
||||
const amount = order.payPrice || order.totalPrice || 0;
|
||||
return acc + safeNumber(amount);
|
||||
}, 0);
|
||||
console.log('✅ 总销售额(数组计算):', sum);
|
||||
return sum;
|
||||
}
|
||||
const amount = safeNumber(total);
|
||||
console.log('✅ 总销售额(直接值):', amount);
|
||||
return amount;
|
||||
})();
|
||||
|
||||
if (statisticsData && statisticsData.length > 0) {
|
||||
// 更新现有统计数据
|
||||
const existingStatistics = statisticsData[0];
|
||||
|
||||
// 确保数据存在且有有效的 ID
|
||||
if (hasValidId(existingStatistics)) {
|
||||
const updateData: Partial<CmsStatistics> = {
|
||||
id: existingStatistics.id,
|
||||
userCount: userCount,
|
||||
orderCount: orderCount,
|
||||
totalSales: totalSales,
|
||||
todaySales: todaySales,
|
||||
todayOrders: todayOrders,
|
||||
todayUsers: todayUsers
|
||||
};
|
||||
|
||||
// 异步更新数据库
|
||||
setTimeout(() => {
|
||||
updateCmsStatistics(updateData).catch((error) => {
|
||||
console.error('更新统计数据失败:', error);
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// 更新本地数据
|
||||
statistics = {
|
||||
...existingStatistics,
|
||||
...updateData,
|
||||
this.statistics = {
|
||||
userCount,
|
||||
orderCount,
|
||||
totalSales,
|
||||
todaySales,
|
||||
monthSales: todaySales,
|
||||
todayOrders,
|
||||
todayUsers
|
||||
};
|
||||
} else {
|
||||
// 如果现有数据无效,使用基础数据
|
||||
statistics = {
|
||||
userCount: userCount,
|
||||
orderCount: orderCount,
|
||||
totalSales: totalSales,
|
||||
todaySales: todaySales,
|
||||
todayOrders: todayOrders,
|
||||
todayUsers: todayUsers
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// 创建新的统计数据
|
||||
statistics = {
|
||||
userCount: userCount,
|
||||
orderCount: orderCount,
|
||||
totalSales: totalSales,
|
||||
todaySales: todaySales,
|
||||
todayOrders: todayOrders,
|
||||
todayUsers: todayUsers
|
||||
};
|
||||
|
||||
// 异步保存到数据库
|
||||
setTimeout(() => {
|
||||
addCmsStatistics(statistics).catch((error) => {
|
||||
console.error('保存统计数据失败:', error);
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
this.statistics = statistics;
|
||||
this.couponUsedCount = couponUsedCount;
|
||||
this.lastUpdateTime = Date.now();
|
||||
|
||||
return statistics;
|
||||
return this.statistics;
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error);
|
||||
throw error;
|
||||
@@ -330,44 +142,27 @@ export const useStatisticsStore = defineStore('statistics', {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新统计数据
|
||||
*/
|
||||
updateStatistics(statistics: Partial<CmsStatistics>) {
|
||||
updateStatistics(statistics: Partial<StatisticsState['statistics']>) {
|
||||
if (this.statistics) {
|
||||
this.statistics = { ...this.statistics, ...statistics };
|
||||
this.lastUpdateTime = Date.now();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除缓存
|
||||
*/
|
||||
clearCache() {
|
||||
this.statistics = null;
|
||||
this.lastUpdateTime = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* 强制刷新统计数据
|
||||
*/
|
||||
async forceRefresh() {
|
||||
console.log('🔄 强制刷新统计数据...');
|
||||
this.clearCache();
|
||||
return await this.fetchStatistics(true);
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置缓存有效期
|
||||
*/
|
||||
setCacheExpiry(expiry: number) {
|
||||
this.cacheExpiry = expiry;
|
||||
},
|
||||
|
||||
/**
|
||||
* 开始自动刷新
|
||||
* @param interval 刷新间隔(毫秒),默认5分钟
|
||||
*/
|
||||
startAutoRefresh(interval = 5 * 60 * 1000) {
|
||||
this.stopAutoRefresh();
|
||||
this.refreshTimer = window.setInterval(() => {
|
||||
@@ -375,9 +170,6 @@ export const useStatisticsStore = defineStore('statistics', {
|
||||
}, interval);
|
||||
},
|
||||
|
||||
/**
|
||||
* 停止自动刷新
|
||||
*/
|
||||
stopAutoRefresh() {
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:pagination="false"
|
||||
row-key="goodsId"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'index'">{{ index + 1 }}</template>
|
||||
<template v-else-if="column.key === 'totalAmount'">
|
||||
¥{{ Number((record as ShopGoodsRankItem).totalAmount || 0).toFixed(2) }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import type { ShopGoodsRankItem } from '@/api/shop/shopOrderStats/model';
|
||||
|
||||
const props = defineProps<{ data?: ShopGoodsRankItem[] | null }>();
|
||||
|
||||
const rows = computed<ShopGoodsRankItem[]>(() => props.data ?? []);
|
||||
|
||||
const columns = [
|
||||
{ title: '排名', key: 'index', width: 70 },
|
||||
{ title: '商品名称', dataIndex: 'goodsName', key: 'goodsName' },
|
||||
{
|
||||
title: '销量',
|
||||
dataIndex: 'totalNum',
|
||||
key: 'totalNum',
|
||||
width: 100,
|
||||
sorter: (a: ShopGoodsRankItem, b: ShopGoodsRankItem) => a.totalNum - b.totalNum
|
||||
},
|
||||
{
|
||||
title: '销售额(元)',
|
||||
dataIndex: 'totalAmount',
|
||||
key: 'totalAmount',
|
||||
width: 150,
|
||||
sorter: (a: ShopGoodsRankItem, b: ShopGoodsRankItem) =>
|
||||
a.totalAmount - b.totalAmount
|
||||
}
|
||||
];
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-label">{{ label }}</div>
|
||||
<div class="stat-card-value">{{ value }}</div>
|
||||
<div v-if="sub" class="stat-card-sub">{{ sub }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.stat-card-label {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.stat-card-value {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.stat-card-sub {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
margin-top: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="status-dist">
|
||||
<a-row :gutter="16" class="refund-metrics">
|
||||
<a-col :span="6" v-for="m in metrics" :key="m.label">
|
||||
<div class="metric">
|
||||
<div class="metric-value" :style="{ color: m.color }">{{ m.value }}</div>
|
||||
<div class="metric-label">{{ m.label }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<v-chart class="status-chart" :option="chartOption" autoresize />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { PieChart } from 'echarts/charts';
|
||||
import { TooltipComponent, LegendComponent } from 'echarts/components';
|
||||
import VChart from 'vue-echarts';
|
||||
import type { ShopOrderStatusDist } from '@/api/shop/shopOrderStats/model';
|
||||
|
||||
use([CanvasRenderer, PieChart, TooltipComponent, LegendComponent]);
|
||||
|
||||
const props = defineProps<{ data?: ShopOrderStatusDist | null }>();
|
||||
|
||||
const dist = computed<ShopOrderStatusDist>(
|
||||
() =>
|
||||
props.data ?? {
|
||||
statusCounts: [],
|
||||
orderCount: 0,
|
||||
paidOrderCount: 0,
|
||||
refundCount: 0,
|
||||
refundAmount: 0,
|
||||
refundRate: 0
|
||||
}
|
||||
);
|
||||
|
||||
const metrics = computed(() => [
|
||||
{ label: '订单总数', value: dist.value.orderCount, color: 'rgba(0,0,0,0.85)' },
|
||||
{ label: '已支付', value: dist.value.paidOrderCount, color: '#00704A' },
|
||||
{
|
||||
label: '退款/售后',
|
||||
value: dist.value.refundCount,
|
||||
color: '#ff4d4f'
|
||||
},
|
||||
{
|
||||
label: '退款金额',
|
||||
value: '¥' + Number(dist.value.refundAmount || 0).toFixed(2),
|
||||
color: '#ff4d4f'
|
||||
},
|
||||
{
|
||||
label: '退款率',
|
||||
value: Number(dist.value.refundRate || 0).toFixed(2) + '%',
|
||||
color: '#fa8c16'
|
||||
}
|
||||
]);
|
||||
|
||||
const chartOption = computed(() => ({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { bottom: 0, type: 'scroll' },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['42%', '68%'],
|
||||
center: ['50%', '45%'],
|
||||
data: (dist.value.statusCounts || []).map((i) => ({
|
||||
name: i.statusName,
|
||||
value: i.count
|
||||
})),
|
||||
label: { formatter: '{b}\n{c}' },
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.2)'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-dist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.refund-metrics {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.metric {
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: 14px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.metric-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.metric-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.status-chart {
|
||||
height: 360px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<v-chart class="trend-chart" :option="chartOption" autoresize />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { LineChart, BarChart } from 'echarts/charts';
|
||||
import {
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent
|
||||
} from 'echarts/components';
|
||||
import VChart from 'vue-echarts';
|
||||
import type { ShopOrderTrendItem } from '@/api/shop/shopOrderStats/model';
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
LineChart,
|
||||
BarChart,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent
|
||||
]);
|
||||
|
||||
const props = defineProps<{
|
||||
data?: ShopOrderTrendItem[] | null;
|
||||
chartType?: 'line' | 'bar';
|
||||
}>();
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const list = props.data ?? [];
|
||||
const seriesType = props.chartType === 'bar' ? 'bar' : 'line';
|
||||
const periods = list.map((d) => d.period);
|
||||
const gmv = list.map((d) => Number(d.gmvSales) || 0);
|
||||
const paid = list.map((d) => Number(d.paidSales) || 0);
|
||||
const orders = list.map((d) => Number(d.orderCount) || 0);
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['GMV(含未付)', '实收(已付)', '订单数'], bottom: 0 },
|
||||
grid: { left: 60, right: 24, top: 30, bottom: periods.length > 30 ? 70 : 50 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: periods,
|
||||
boundaryGap: seriesType === 'bar'
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '金额(元)' },
|
||||
{ type: 'value', name: '订单数', splitLine: { show: false } }
|
||||
],
|
||||
dataZoom:
|
||||
periods.length > 30
|
||||
? [{ type: 'inside' }, { type: 'slider', height: 18 }]
|
||||
: undefined,
|
||||
series: [
|
||||
{
|
||||
name: 'GMV(含未付)',
|
||||
type: seriesType,
|
||||
data: gmv,
|
||||
smooth: true,
|
||||
itemStyle: { color: '#91cc75' }
|
||||
},
|
||||
{
|
||||
name: '实收(已付)',
|
||||
type: seriesType,
|
||||
data: paid,
|
||||
smooth: true,
|
||||
itemStyle: { color: '#00704A' }
|
||||
},
|
||||
{
|
||||
name: '订单数',
|
||||
type: seriesType,
|
||||
yAxisIndex: 1,
|
||||
data: orders,
|
||||
smooth: true,
|
||||
itemStyle: { color: '#5470c6' }
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trend-chart {
|
||||
height: 380px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<div class="statistics-page">
|
||||
<!-- 顶部筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<a-radio-group v-model:value="quick" @change="onQuick">
|
||||
<a-radio-button value="today">今日</a-radio-button>
|
||||
<a-radio-button value="7">近7天</a-radio-button>
|
||||
<a-radio-button value="30">近30天</a-radio-button>
|
||||
<a-radio-button value="month">本月</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-range-picker
|
||||
v-model:value="dateRange"
|
||||
:allow-clear="false"
|
||||
@change="onDateChange"
|
||||
/>
|
||||
<a-button type="primary" @click="reload" :loading="loading">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
查询
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-tabs v-model:activeKey="activeKey" @change="onTabChange">
|
||||
<!-- 经营概览 -->
|
||||
<a-tab-pane key="overview" tab="经营概览">
|
||||
<div v-if="loading && !overviewData" class="block-loading">
|
||||
<a-spin />
|
||||
</div>
|
||||
<a-row v-else :gutter="[16, 16]">
|
||||
<a-col
|
||||
:xs="12"
|
||||
:sm="8"
|
||||
:md="6"
|
||||
v-for="c in overviewCards"
|
||||
:key="c.label"
|
||||
>
|
||||
<StatCard :label="c.label" :value="c.value" :sub="c.sub" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 销售趋势 -->
|
||||
<a-tab-pane key="trend" tab="销售趋势">
|
||||
<div class="trend-toolbar">
|
||||
<a-radio-group v-model:value="trendType" @change="loadTrend">
|
||||
<a-radio-button value="day">按日</a-radio-button>
|
||||
<a-radio-button value="week">按周</a-radio-button>
|
||||
<a-radio-button value="month">按月</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-radio-group v-model:value="trendChartType" style="margin-left: 12px">
|
||||
<a-radio-button value="line">折线</a-radio-button>
|
||||
<a-radio-button value="bar">柱状</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
<div v-if="loading && !trendData" class="block-loading">
|
||||
<a-spin />
|
||||
</div>
|
||||
<TrendChart v-else :data="trendData" :chart-type="trendChartType" />
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 商品分析 -->
|
||||
<a-tab-pane key="goods" tab="商品分析">
|
||||
<div v-if="loading && !goodsData" class="block-loading">
|
||||
<a-spin />
|
||||
</div>
|
||||
<GoodsRankTable v-else :data="goodsData" />
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 订单与退款 -->
|
||||
<a-tab-pane key="status" tab="订单与退款">
|
||||
<div v-if="loading && !statusData" class="block-loading">
|
||||
<a-spin />
|
||||
</div>
|
||||
<StatusDistChart v-else :data="statusData" />
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue/es';
|
||||
import { SearchOutlined } from '@ant-design/icons-vue';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import StatCard from './components/StatCard.vue';
|
||||
import TrendChart from './components/TrendChart.vue';
|
||||
import GoodsRankTable from './components/GoodsRankTable.vue';
|
||||
import StatusDistChart from './components/StatusDistChart.vue';
|
||||
import {
|
||||
getShopOrderStatsOverview,
|
||||
getShopOrderStatsTrend,
|
||||
getShopOrderGoodsRank,
|
||||
getShopOrderStatsStatusDist
|
||||
} from '@/api/shop/shopOrderStats';
|
||||
import type {
|
||||
ShopOrderStatsOverview,
|
||||
ShopOrderTrendItem,
|
||||
ShopGoodsRankItem,
|
||||
ShopOrderStatusDist
|
||||
} from '@/api/shop/shopOrderStats/model';
|
||||
|
||||
const fmt = 'YYYY-MM-DD HH:mm:ss';
|
||||
|
||||
// 日期区间,默认近30天
|
||||
const dateRange = ref<[Dayjs, Dayjs]>([
|
||||
dayjs().subtract(29, 'day').startOf('day'),
|
||||
dayjs().endOf('day')
|
||||
]);
|
||||
const quick = ref<string>('30');
|
||||
const activeKey = ref<string>('overview');
|
||||
const trendType = ref<'day' | 'week' | 'month'>('day');
|
||||
const trendChartType = ref<'line' | 'bar'>('line');
|
||||
|
||||
const loading = ref(false);
|
||||
const overviewData = ref<ShopOrderStatsOverview | null>(null);
|
||||
const trendData = ref<ShopOrderTrendItem[] | null>(null);
|
||||
const goodsData = ref<ShopGoodsRankItem[] | null>(null);
|
||||
const statusData = ref<ShopOrderStatusDist | null>(null);
|
||||
|
||||
const rangeParams = computed(() => ({
|
||||
start: dateRange.value[0].startOf('day').format(fmt),
|
||||
end: dateRange.value[1].endOf('day').format(fmt)
|
||||
}));
|
||||
|
||||
const overviewCards = computed(() => {
|
||||
const d = overviewData.value;
|
||||
if (!d) return [];
|
||||
const money = (v: number) => '¥' + Number(v || 0).toFixed(2);
|
||||
return [
|
||||
{ label: 'GMV(含未付)', value: money(d.gmvSales), sub: '订单面额求和' },
|
||||
{ label: '实收金额', value: money(d.paidSales), sub: '仅已支付' },
|
||||
{ label: '订单总数', value: d.orderCount, sub: '含未支付' },
|
||||
{ label: '已支付订单数', value: d.paidOrderCount, sub: 'pay_status=1' },
|
||||
{ label: '客单价', value: money(d.customerUnitPrice), sub: '实收/已付单数' },
|
||||
{ label: '退款金额', value: money(d.refundAmount), sub: 'refund_money' },
|
||||
{ label: '新增会员', value: d.newUserCount, sub: '区间新增' },
|
||||
{ label: '使用优惠券', value: d.couponUsedCount, sub: 'coupon_type≠0' }
|
||||
];
|
||||
});
|
||||
|
||||
// 快捷选项(直接用 quick.value,v-model 已先行更新)
|
||||
const onQuick = () => {
|
||||
const v = quick.value;
|
||||
const now = dayjs();
|
||||
if (v === 'today') {
|
||||
dateRange.value = [now.startOf('day'), now.endOf('day')];
|
||||
} else if (v === '7') {
|
||||
dateRange.value = [now.subtract(6, 'day').startOf('day'), now.endOf('day')];
|
||||
} else if (v === '30') {
|
||||
dateRange.value = [
|
||||
now.subtract(29, 'day').startOf('day'),
|
||||
now.endOf('day')
|
||||
];
|
||||
} else if (v === 'month') {
|
||||
dateRange.value = [now.startOf('month'), now.endOf('day')];
|
||||
}
|
||||
reload();
|
||||
};
|
||||
|
||||
const onDateChange = () => {
|
||||
// 手动选区间时取消快捷高亮
|
||||
quick.value = '';
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
if (activeKey.value === 'overview') loadOverview();
|
||||
else if (activeKey.value === 'trend') loadTrend();
|
||||
else if (activeKey.value === 'goods') loadGoods();
|
||||
else if (activeKey.value === 'status') loadStatus();
|
||||
};
|
||||
|
||||
const onTabChange = () => reload();
|
||||
|
||||
const loadOverview = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
overviewData.value = await getShopOrderStatsOverview(rangeParams.value);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载经营概览失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadTrend = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
trendData.value = await getShopOrderStatsTrend({
|
||||
...rangeParams.value,
|
||||
type: trendType.value
|
||||
});
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载销售趋势失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadGoods = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
goodsData.value = await getShopOrderGoodsRank({
|
||||
...rangeParams.value,
|
||||
limit: 10
|
||||
});
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载商品排行失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadStatus = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
statusData.value = await getShopOrderStatsStatusDist(rangeParams.value);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载订单分布失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadOverview();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.statistics-page {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.trend-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.block-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 60px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
title="专区销量统计"
|
||||
:footer="null"
|
||||
width="760"
|
||||
:destroy-on-close="true"
|
||||
@update:visible="(v: boolean) => emit('update:visible', v)"
|
||||
>
|
||||
<div>
|
||||
<!-- 时间范围筛选 -->
|
||||
<a-space style="margin-bottom: 16px; flex-wrap: wrap">
|
||||
<a-radio-group v-model:value="rangeType" @change="reload">
|
||||
<a-radio-button value="all">全部</a-radio-button>
|
||||
<a-radio-button value="today">今日</a-radio-button>
|
||||
<a-radio-button value="7d">近7天</a-radio-button>
|
||||
<a-radio-button value="30d">近30天</a-radio-button>
|
||||
<a-radio-button value="custom">自定义</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-range-picker
|
||||
v-if="rangeType === 'custom'"
|
||||
v-model:value="customRange"
|
||||
show-time
|
||||
style="width: 380px"
|
||||
@change="reload"
|
||||
/>
|
||||
</a-space>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<!-- 汇总卡片 -->
|
||||
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||
<a-col :span="12">
|
||||
<a-card :bordered="false" style="background: #fafafa">
|
||||
<div style="color: #999; font-size: 13px">销量件数</div>
|
||||
<div style="font-size: 24px; font-weight: 600; margin-top: 4px">
|
||||
{{ stats?.salesNum != null ? stats.salesNum : 0 }}
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-card :bordered="false" style="background: #fafafa">
|
||||
<div style="color: #999; font-size: 13px">销售额</div>
|
||||
<div style="font-size: 24px; font-weight: 600; margin-top: 4px">
|
||||
¥{{
|
||||
stats?.salesAmount != null
|
||||
? Number(stats.salesAmount).toFixed(2)
|
||||
: '0.00'
|
||||
}}
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 商品销量排行 -->
|
||||
<div style="color: #999; font-size: 13px; margin-bottom: 8px">
|
||||
商品销量排行(TOP 50)
|
||||
</div>
|
||||
<a-table
|
||||
:dataSource="stats?.goodsRank || []"
|
||||
:columns="rankColumns"
|
||||
row-key="goodsId"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
:scroll="{ y: 360 }"
|
||||
>
|
||||
<template #bodyCell="{ column, text, index }">
|
||||
<template v-if="column.key === 'idx'">
|
||||
{{ index + 1 }}
|
||||
</template>
|
||||
<template v-if="column.key === 'salesNum'">
|
||||
{{ text != null ? text : 0 }}
|
||||
</template>
|
||||
<template v-if="column.key === 'salesAmount'">
|
||||
¥{{ text != null ? Number(text).toFixed(2) : '0.00' }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-spin>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { getSectionSalesStats } from '@/api/shop/shopZone';
|
||||
import type { SectionSalesStatsVO } from '@/api/shop/shopZone/model';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
sectionId: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', v: boolean): void;
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const stats = ref<SectionSalesStatsVO | null>(null);
|
||||
const rangeType = ref<'today' | '7d' | '30d' | 'all' | 'custom'>('all');
|
||||
const customRange = ref<[Dayjs, Dayjs] | null>(null);
|
||||
|
||||
const rankColumns = [
|
||||
{ title: '#', key: 'idx', align: 'center', width: 60 },
|
||||
{ title: '商品名称', dataIndex: 'goodsName', key: 'goodsName' },
|
||||
{ title: '销量件数', dataIndex: 'salesNum', key: 'salesNum', align: 'center', width: 100 },
|
||||
{ title: '销售额', dataIndex: 'salesAmount', key: 'salesAmount', align: 'center', width: 120 }
|
||||
] as any[];
|
||||
|
||||
function fmt(d: Date): string {
|
||||
const p = (n: number) => (n < 10 ? '0' + n : '' + n);
|
||||
return (
|
||||
d.getFullYear() +
|
||||
'-' +
|
||||
p(d.getMonth() + 1) +
|
||||
'-' +
|
||||
p(d.getDate()) +
|
||||
' ' +
|
||||
p(d.getHours()) +
|
||||
':' +
|
||||
p(d.getMinutes()) +
|
||||
':' +
|
||||
p(d.getSeconds())
|
||||
);
|
||||
}
|
||||
|
||||
function buildParams(): { start?: string; end?: string } {
|
||||
const now = new Date();
|
||||
if (rangeType.value === 'today') {
|
||||
const s = new Date(now);
|
||||
s.setHours(0, 0, 0, 0);
|
||||
return { start: fmt(s), end: fmt(now) };
|
||||
}
|
||||
if (rangeType.value === '7d') {
|
||||
const s = new Date(now);
|
||||
s.setDate(s.getDate() - 7);
|
||||
s.setHours(0, 0, 0, 0);
|
||||
return { start: fmt(s), end: fmt(now) };
|
||||
}
|
||||
if (rangeType.value === '30d') {
|
||||
const s = new Date(now);
|
||||
s.setDate(s.getDate() - 30);
|
||||
s.setHours(0, 0, 0, 0);
|
||||
return { start: fmt(s), end: fmt(now) };
|
||||
}
|
||||
if (rangeType.value === 'custom' && customRange.value) {
|
||||
return {
|
||||
start: fmt(customRange.value[0].toDate()),
|
||||
end: fmt(customRange.value[1].toDate())
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function reload() {
|
||||
if (!props.sectionId) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
getSectionSalesStats(props.sectionId, buildParams())
|
||||
.then((res) => {
|
||||
stats.value = res;
|
||||
})
|
||||
.catch((e) => {
|
||||
message.error(e?.message || '统计失败');
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
// 打开弹窗 / 切换时间范围时自动加载
|
||||
watch(
|
||||
() => [props.visible, props.sectionId, rangeType.value, customRange.value],
|
||||
() => {
|
||||
if (props.visible && props.sectionId) {
|
||||
reload();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
@@ -69,6 +69,8 @@
|
||||
<a-divider type="vertical" />
|
||||
<a @click="openGoods(record)">商品</a>
|
||||
<a-divider type="vertical" />
|
||||
<a @click="openStats(record)">销量</a>
|
||||
<a-divider type="vertical" />
|
||||
<a @click="openQrcode(record)">二维码</a>
|
||||
<a-divider type="vertical" />
|
||||
<a-popconfirm
|
||||
@@ -93,6 +95,12 @@
|
||||
:sectionId="currentSectionId"
|
||||
/>
|
||||
|
||||
<!-- 专区销量统计弹窗 -->
|
||||
<SectionSalesStatsModal
|
||||
v-model:visible="showStats"
|
||||
:sectionId="currentSectionId"
|
||||
/>
|
||||
|
||||
<!-- 专区商品抽屉 -->
|
||||
<a-drawer
|
||||
:width="860"
|
||||
@@ -225,6 +233,7 @@
|
||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||
import ZoneEdit from './components/zoneEdit.vue';
|
||||
import UserSelectModal from './components/UserSelectModal.vue';
|
||||
import SectionSalesStatsModal from './components/SectionSalesStatsModal.vue';
|
||||
import { getCompressedImageUrl } from '@/utils/image';
|
||||
import {
|
||||
pageHomeSections,
|
||||
@@ -330,6 +339,23 @@
|
||||
align: 'center',
|
||||
width: 90
|
||||
},
|
||||
{
|
||||
title: '销量件数',
|
||||
dataIndex: 'salesNum',
|
||||
key: 'salesNum',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
customRender: ({ text }: any) => (text != null ? text : 0)
|
||||
},
|
||||
{
|
||||
title: '销售额',
|
||||
dataIndex: 'salesAmount',
|
||||
key: 'salesAmount',
|
||||
align: 'center',
|
||||
width: 120,
|
||||
customRender: ({ text }: any) =>
|
||||
'¥' + (text != null ? Number(text).toFixed(2) : '0.00')
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
@@ -431,6 +457,13 @@
|
||||
loadGoods(row.sectionId || 0);
|
||||
};
|
||||
|
||||
/* 打开销量统计弹窗 */
|
||||
const showStats = ref(false);
|
||||
const openStats = (row: HomeSection) => {
|
||||
currentSectionId.value = row.sectionId || 0;
|
||||
showStats.value = true;
|
||||
};
|
||||
|
||||
/* 生成专区小程序码(按当前选择的版本) */
|
||||
const genQrcode = (id: number) => {
|
||||
qrcodeUrl.value = '';
|
||||
|
||||
Reference in New Issue
Block a user