Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d8080c7bf | |||
| 5943f1536d | |||
| bec3a6f1b8 | |||
| 65926a35b6 | |||
| ced8c7cff7 | |||
| fe97f49afe | |||
| 6556944ffc | |||
| 3c4e30c227 | |||
| 47fb8d1bd0 | |||
| 4ba0427417 | |||
| 6e57fba192 | |||
| d458115da9 | |||
| f986efce8c | |||
| 4a7b217c2b | |||
| b5c67988a6 | |||
| 053b776d9a | |||
| 780606f05a | |||
| a04a8baec7 | |||
| 6a5b7ff711 |
+1
-1
@@ -1,4 +1,4 @@
|
||||
VITE_APP_NAME=后台管理系统
|
||||
#VITE_API_URL=http://127.0.0.1:9200/api
|
||||
VITE_API_URL=https://shop-api.websoft.top/api
|
||||
VITE_API_URL=https://guilixu-api.websoft.top/api
|
||||
VITE_CMS_API_URL=https://cms-api.websoft.top/api
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
VITE_APP_NAME=后台管理系统
|
||||
VITE_API_URL=https://shop-api.websoft.top/api
|
||||
VITE_API_URL=https://guilixu-api.websoft.top/api
|
||||
VITE_CMS_API_URL=https://cms-api.websoft.top/api
|
||||
|
||||
@@ -259,7 +259,7 @@
|
||||
- `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)。
|
||||
- 前端订阅 API 域名硬编码 `https://websopy-api.websoft.top/api/app/subscription`(api/index.ts WEBSOPY_API_BASE),主站 API 走 guilixu-api.websoft.top。用户对比的 `websoft.top/api/_app/...` 是另一个入口(疑 nginx 转发 _app→app)。
|
||||
|
||||
## 续费"弹二维码自动完成支付"修复:清除旧 Redis 支付标记
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
|
||||
## 后端实现检查(用户要求检查 guilixu-java 是否实现完)
|
||||
- **guilixu-java 已实现完整**:entity 加 regionMode/regionIds、param 加查询字段、迁移 SQL(ALTER+历史数据迁移+索引+回滚)、DeliveryRegionChecker(黑名单命中抛"暂不配送")、RegionCodeResolver(名称→编码)、OrderBusinessService 第 662/668 行已接入校验。
|
||||
- **用户纠正(18:09):** guilixu-admin 的**商城接口实际调 `https://shop-api.websoft.top`(= guilixu-java)**,不是 mp-java。前端 shopExpressTemplateDetail API 用默认 `@/utils/request`,baseURL = `MODULES_API_URL` = `localStorage ApiUrl || VITE_API_URL`;该部署的 ApiUrl 配的就是 shop-api.websoft.top/api,即 **guilixu-java 才是生效后端**。
|
||||
- **用户纠正(18:09):** guilixu-admin 的**商城接口实际调 `https://guilixu-api.websoft.top`(= guilixu-java)**,不是 mp-java。前端 shopExpressTemplateDetail API 用默认 `@/utils/request`,baseURL = `MODULES_API_URL` = `localStorage ApiUrl || VITE_API_URL`;该部署的 ApiUrl 配的就是 guilixu-api.websoft.top/api,即 **guilixu-java 才是生效后端**。
|
||||
- **结论反转:guilixu-java 这套实现正确且生效,不是死代码,后端已完整,无需改 mp-java。** mp-java 与此功能无关(本部署 商城不走 mp-java)。
|
||||
- 后端 `region/regions-data.json` 用 v/l/c 精简格式(与 RegionCodeResolver 读取一致),无字段名 bug。
|
||||
- 后端完成度:entity/param/SQL/DeliveryRegionChecker/RegionCodeResolver/下单接入(OrderBusinessService 第 87 行调用 validateDeliveryRegionIfNeeded)全齐。
|
||||
- **剩余工作 = 纯前端(guilixu-admin)**:① model 加 regionMode/regionIds;② 新建双栏多选地区组件(替换单路径级联 `RegionsSelect`,支持"全国/指定配送/指定不发货"模式切换+多选多省);③ 编辑表单加模式切换+多选;④ 列表回显模式与地区汇总。截图里的 Transfer Modal 前端尚不存在。
|
||||
- 教训:判断"改了哪个后端才生效"要以**运行时的 ApiUrl(localStorage/localStorage 配置的 ApiUrl,本部署=guilixu-java)**为准,不能默认 `MODULES_API_URL`=mp-java。guilixu-admin 的 商城走 guilixu-java。
|
||||
- 教训:判断"改了哪个后端才生效"要以**运行时的 ApiUrl(localStorage/localStorage 配置的 ApiUrl,本部署=guilixu-java)**为准,不能默认 `MODULES_API_URL`=mp-java。guilixu-admin 的 商城走 guilixu-java。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
- 用户已 `UPDATE shop_goods set express_template_id=11 WHERE express_template_id IS NULL and tenant_id=10606`,以为这就禁了偏远地区下单。实际结论:**这条只绑运费模板(算运费用),没有生成「不发货黑名单」数据,不会拦截下单**。
|
||||
- 后端拦截已端到端接通:`OrderBusinessService.createOrder` → `validateDeliveryRegionIfNeeded`(第87行) → `DeliveryRegionChecker`(按 tenant_id 查 `shop_express_template_detail` 中 region_mode=2 且 status=0 的明细,收货地址行政区划码命中即抛"XX暂不配送,请更换收货地址")。
|
||||
- 链路确认:guilixu-taro `BaseUrl=https://shop-api.websoft.top/api`、`TenantId=10606`,下单走 guilixu-java(db_guilixu);单租户下买家 tenantId=10606,校验能命中黑名单。✅
|
||||
- 链路确认:guilixu-taro `BaseUrl=https://guilixu-api.websoft.top/api`、`TenantId=10606`,下单走 guilixu-java(db_guilixu);单租户下买家 tenantId=10606,校验能命中黑名单。✅
|
||||
- **admin 前端明细编辑表单未暴露 regionMode=2(只 0/1)**,故黑名单只能 SQL 直插 `shop_express_template_detail`(region_mode=2, region_ids=偏远省6位码逗号串, status=0, tenant_id=10606, template_id=11)。已给 INSERT 模板与省码清单。
|
||||
- 注意:黑名单是**租户级、全店生效**(不区分绑定哪个模板);自提(deliveryType=1)/selfTakeMerchantId 下单跳过校验。
|
||||
|
||||
@@ -13,3 +13,13 @@
|
||||
- 改 `src/views/shop/shopExpressTemplateDetail/index.vue`:「配送地区」列按 region_mode 显示「不发货:xxx / 配送:xxx / 默认全国」,城市码聚合为省名显示。
|
||||
- 类型检查(vue-tsc)确认本次改动无新错误(唯一相关报错是预存的 `$router` 用法,原文件即有,未引入)。
|
||||
- 结论:黑名单现可经后台 UI 配置;SQL 直插仍作为备选(二者后台存城市码、SQL 存省份码,后端比对为编码集合 contains,都生效、互不冲突)。
|
||||
- 已生成插偏远八省黑名单完整 SQL:`offline-remote-areas-blacklist-10606.sql`(template_id=11, region_mode=2, region_ids=新疆650000/西藏540000/青海630000/宁夏640000/内蒙古150000/海南460000/黑龙江230000/甘肃620000, status=0, tenant_id=10606),含前置查重 + 后置验证语句。
|
||||
|
||||
## 微信支付报错 APPID_MCHID_NOT_MATCH(tenant 10606 下单失败)
|
||||
|
||||
- 现象:用户下单报 `创建微信支付订单失败:Wrong HttpStatusCode[400] ... APPID_MCHID_NOT_MATCH`。服务商模式 JSAPI,请求体 sp_appid=`wx541db955e7a62709`/sp_mchid=`1720296183`、sub_appid=`wxcad054ee427aaca1`/sub_mchid=`1744894035`。
|
||||
- 根因:传给微信的 `sub_appid` 与 `sub_mchid` 在微信侧不匹配(该子商户应用ID未绑定到该子商户号)。代码无 bug。
|
||||
- 4 个值来源:`gxwebsoft_core.sys_payment`(tenant 10606 微信JSAPI记录)字段 `app_id`/`mch_id`/`sub_app_id`/`sub_mch_id`;拼装在 `ShopOrderServiceImpl.java:809-912`(服务商模式)。
|
||||
- 已验证 `sub_app_id=wxcad054ee427aaca1` 就是 `guilixu-taro` 用户端小程序真实 appid(project.config.json),故 sub_appid 是对的、与买家 openid 一致。
|
||||
- 结论:问题在 `sub_app_id↔sub_mchid` 绑定被破坏——最可能(1)商户平台子商户 `1744894035` 对 appid 的授权被移除/失效;(2)`sys_payment.sub_mch_id` 被误改。
|
||||
- 修复:微信商户平台(服务商 `1720296183` 服务商平台→特约商户管理→开发配置)为子商户 `1744894035` 绑定 appid `wxcad054ee427aaca1`;并 `SELECT` 核对 `sys_payment` 的 sub_mch_id 仍为 `1744894035`。无需改代码,新订单号直接读最新配置。
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# 2026-08-14 工作日志
|
||||
|
||||
## 修复商品分类层级(10409 → 10421)
|
||||
|
||||
- 问题:guilixu-admin 后台 `/shop/shopGoodsCategory` 页面中,10409「二级分类」与 10421「特别专区」显示为同级。
|
||||
- 原因:前端按 `parentId` 渲染树形层级,说明 10409 的 `parent_id` 不是 10421(实际可能为 0)。
|
||||
- 处理:
|
||||
- 确认后端表为 `shop_goods_category`(guilixu-java / db_guilixu),关键字段 `category_id`、`parent_id`。
|
||||
- 生成修复 SQL:`fix-category-parent-10409.sql`,将 10409 的 `parent_id` 更新为 10421。
|
||||
- 同时提供了后台 UI 的临时操作方式:编辑 10409 把上级分类改为 10421。
|
||||
- 文件:`/Users/gxwebsoft/VUE/guilixu-admin/fix-category-parent-10409.sql`
|
||||
|
||||
## 商品分类编辑弹窗新增「链接地址」字段
|
||||
|
||||
- 需求:在 `shopGoodsCategoryEdit.vue` 的「分类标识」下方新增「链接地址」输入项,存到 `path` 字段。
|
||||
- 改动:`src/views/shop/shopGoodsCategory/components/shopGoodsCategoryEdit.vue`
|
||||
- 在「分类标识」form-item 之后新增「链接地址」项,绑定 `form.path`(form 已有该字段,无需改 model/后端)。
|
||||
- 重置逻辑 Object.assign 补上 `path: undefined`,保证新增时清空。
|
||||
- 注意:仅前端 UI 改动,后端表 `shop_goods_category.path` 字段已存在,无需迁移。
|
||||
|
||||
## 商品分类列表树形连线高度调紧凑
|
||||
|
||||
- 问题:后台 `/shop/shopGoodsCategory` 列表中,树形层级连线 SVG 高度 40px,导致行显得太高、不紧凑。
|
||||
- 改动:`src/views/shop/shopGoodsCategory/index.vue`
|
||||
- `renderTreePrefix` 中 SVG 高度 `H` 从 `40` 改为 `28`,`mid` 自动为 `14`。
|
||||
- 效果:二级/三级分类前的树形引导线高度降低,整行更紧凑。
|
||||
@@ -0,0 +1,23 @@
|
||||
# 2026-08-15 工作日志
|
||||
|
||||
## 专区功能(ShopHomeSection)接口报错修复
|
||||
|
||||
- 现象:访问专区列表接口报 `NoSuchPropertyException: ShopHomeSectionParam.deleted`。
|
||||
- 根因:模板生成的 `ShopHomeSectionParam`(guilixu-java `src/main/java/com/gxwebsoft/shop/param/ShopHomeSectionParam.java`)**漏了 `deleted` 字段**;同项目 Mapper XML(`ShopHomeSectionMapper.xml`)按模板带 `<if test="param.deleted == null"> AND a.deleted = 0</if>` 逻辑删除过滤,OGNL 求值时属性不存在即抛异常。
|
||||
- 对比:`ShopGoodsParam` 有 `deleted` 字段(@QueryField EQ + @Schema),为标准写法;`ShopHomeSection` 实体本身有 `@TableLogic private Integer deleted;`,表启用逻辑删除。
|
||||
- 修复:给 `ShopHomeSectionParam` 末尾补 `private Integer deleted;`(带 @QueryField + @Schema),与模板一致。
|
||||
- 验证:重新编译部署 guilixu-java(端口 9200 本地实例)后接口恢复正常。
|
||||
- ⚠️ 同类预警:其他新生成的 Param 若同样漏 `deleted`,会报一模一样的错,补字段即可。
|
||||
|
||||
## 专区功能菜单未显示(待办)
|
||||
|
||||
- 前端页面 `src/views/special/zone/index.vue` 与 API 层 `src/api/shop/shopZone/` 已完成。
|
||||
- 但因 guilixu-admin 为动态路由(菜单由后端接口拉取),后端菜单表尚未插入「专区管理」记录,故左侧导航看不到入口。
|
||||
- 待处理:在后端菜单表(sys_menu 类)插入菜单记录(component=special/zone,tenant_id=10606),刷新/重登后生效。
|
||||
|
||||
## 专区功能交互入口约定(答疑结论)
|
||||
|
||||
- 白名单用户:专区列表每行「白名单」按钮 → 弹窗勾选用户 → 确定(PUT /{id}/users 覆盖式保存到 shop_home_section_user 表)。前端 UserSelectModal 链路完整。
|
||||
- 商品关联:专区页「商品」按钮只是**查看**该专区下商品(GET /{id}/goods,后端用 FIND_IN_SET(section_id, section_ids) 查询);**往专区加商品不在此页操作**,要去「商品管理 → 编辑商品 → 所属专区(多选)」勾选专区并保存,写入 goods.section_ids。
|
||||
- 后端接口齐全:users/goods 查询与设置、check-permission 权限校验均已实现。
|
||||
- ⚠️ 待确认:白名单弹窗当前按 isStaff=true 查后台员工用户(@/api/system/user 的 pageUsers);若受限专区面向小程序买家,需调整用户来源。
|
||||
@@ -0,0 +1,30 @@
|
||||
# 2026-08-16 工作日志
|
||||
|
||||
## 专区白名单用户弹窗修复
|
||||
|
||||
### 1. 去掉 isStaff 限制
|
||||
- 文件:`src/views/special/zone/components/UserSelectModal.vue`
|
||||
- 改动:`pageUsers` 调用移除 `isStaff: true` 参数,使白名单搜索覆盖全部用户(不再只查后台员工)。
|
||||
|
||||
### 2. 修复勾选框不显示
|
||||
- 现象:弹窗表格数据正常但无 checkbox 勾选列,用户无法选择。
|
||||
- 根因:`rowSelection` 用 `reactive()` 包整个对象的方式在 `ele-pro-table` 中不生效;且缺少 `columnWidth` 导致勾选列无宽度不渲染。
|
||||
- 对比:项目内 `shopCoupon/index.vue` 用 `computed(() => ({ columnWidth, selectedRowKeys, onChange }))` 模式可正常工作;字典页用 `{ columnWidth: 32 }` 最简写法也可。
|
||||
- 修复:
|
||||
- `reactive` → `computed` 返回 rowSelection 对象(与 shopCoupon 一致)
|
||||
- 补 `columnWidth: 48`
|
||||
- `selectedRowKeys` 独立为 `ref<number[]>`,通过 computed 注入 rowSelection
|
||||
- 所有引用点同步更新(模板计数、confirm、watch 初始化)
|
||||
|
||||
## 专区商品抽屉改造为可编辑
|
||||
|
||||
- 需求:专区「商品」按钮打开的抽屉从只读列表升级为支持添加/移除商品。
|
||||
- 后端(guilixu-java `ShopHomeSectionController.java`)新增两个接口:
|
||||
- `POST /{id}/goods` — 添加商品到专区(接收 goodsId[],将 section_id 追加到每个 goods 的 section_ids 逗号串中,用 LinkedHashSet 去重)
|
||||
- `DELETE /{id}/goods` — 从专区移除商品(接收 goodsId[],从 section_ids 中移除该 section_id,空则置 null)
|
||||
- 前端 API(`src/api/shop/shopZone/index.ts`)新增 `addSectionGoods`、`removeSectionGoods`。
|
||||
- 前端 UI(`src/views/special/zone/index.vue`)商品抽屉改造:
|
||||
- 顶部:搜索框 + 「搜索」按钮 + 「全部添加(N)」确认按钮
|
||||
- 搜索结果区:虚线框展示候选商品列表,每行有「+添加」单条操作(自动过滤已在专区内的商品)
|
||||
- 已关联商品表格:每行加「移除」按钮(二次确认后调用 remove 接口)
|
||||
- 数据源:搜索商品复用已有 `pageShopGoods` API(@/api/shop/shopGoods)
|
||||
@@ -0,0 +1,32 @@
|
||||
# 2026-08-17 工作日志
|
||||
|
||||
## 专区销量统计功能(/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 编译并部署到 guilixu-api.websoft.top 才生效(本环境未部署)。
|
||||
|
||||
## 修复专区商品抽屉分页点击无效
|
||||
- 文件:`src/views/special/zone/index.vue`
|
||||
- 问题:抽屉里 `a-table` 的 `:pagination` 直接内联写 `{ current: goodsPage, pageSize: goodsPageSize, total: goodsTotal }`,对象属性中的 ref 不会被 Vue 自动解包,Pagination 拿到的是 ref 对象而非 number,导致分页按钮点不动。
|
||||
- 修复:
|
||||
- 提取 `goodsPagination = computed(() => ({ total: goodsTotal.value, current: goodsPage.value, pageSize: goodsPageSize.value, showSizeChanger: true, onChange: ... }))`
|
||||
- `:pagination="goodsPagination"`
|
||||
- `onChange` 里 `loadGoods(currentSectionId.value)` 修正为 `.value`
|
||||
- 验证:`vite build` 通过(exit 0)。
|
||||
|
||||
## 专区商品图片走压缩 URL
|
||||
- 文件:`src/views/special/zone/index.vue`
|
||||
- 改动:专区商品抽屉的已关联商品列表「图片」列,从直接 `:src="text"` 改为 `:src="getCompressedImageUrl(text, { width: 120 })"`,缩小缩略图体积并统一走 OSS 图片处理。
|
||||
- 说明:`getCompressedImageUrl` 已导入,无需额外引入。
|
||||
@@ -0,0 +1,40 @@
|
||||
# 2026-08-18 工作日志
|
||||
|
||||
## 专区商城价 = 市场价 × 折扣系数 功能(已实现)
|
||||
|
||||
客户要求:专区的商城价显示为市场价的 0.78(并按此价成交)。已与用户确认:展示+成交价都按系数、每专区单独配(默认1.00)、基数=市场价 salePrice。
|
||||
|
||||
### 后端 guilixu-java(仅此,商城走 guilixu-java)
|
||||
- `ShopHomeSection` + `ShopHomeSectionParam` 增加 `discountRate`(BigDecimal, 默认1.00)。
|
||||
- 迁移 SQL:`guilixu-admin/zone-discount-rate-10606.sql`(`ALTER TABLE shop_home_section ADD discount_rate DECIMAL(10,2) NOT NULL DEFAULT 1.00`)。需在生产库执行。
|
||||
- `OrderCreateRequest.OrderGoodsItem` 增加 `sectionId`(每商品项携带专区ID)。
|
||||
- `OrderBusinessService` **两处**价格解析(①`validateAndCalculateTotal` 算总额 ②`createOrder` 落库订单商品价)都加了专区折扣逻辑:若 `item.sectionId` 命中且商品 `section_ids` 含它、且专区 `discountRate≠1`,则 `actualPrice = goods.getSalePrice() × rate`(保留两位小数,HALF_UP)。后端不信任前端传价,按 sectionId 重算,防篡改。
|
||||
|
||||
### 后台 guilixu-admin
|
||||
- `src/api/shop/shopZone/model/index.ts` 的 `HomeSection` 增加 `discountRate?: number`。
|
||||
- `src/views/special/zone/components/zoneEdit.vue`:表单加「折扣系数」输入框(a-input-number,0.01~1,默认1,提示"专区商城价=市场价×系数"),表单默认值 `discountRate: 1`。
|
||||
|
||||
### 小程序 guilixu-taro(仅「专区→立即购买」链路打通)
|
||||
- `src/api/shop/shopHomeSection/index.ts` 增加 `getHomeSection(id)`。
|
||||
- `src/api/shop/shopHomeSection/model/index.ts` 的 `HomeSection` 增加 `discountRate?`。
|
||||
- `section-detail.tsx`:进入时 `getHomeSection` 取折扣系数并展示(价格=市场价×系数,划线价显示市场价),跳转详情带 `sectionId`+`discountRate`。
|
||||
- `product-detail.tsx`:读 `sectionId`/`discountRate`,按系数展示;立即购买时把系数写入 `buy_now` 存储并带参跳结算。
|
||||
- `src/api/shop/shopOrder/model/index.ts` 的 `OrderGoodsItem` 增加 `sectionId?`。
|
||||
- `checkout.tsx`:增加 `getItemDisplayPrice`/`getItemSectionId`,按系数估价并随下单提交 `sectionId`。
|
||||
|
||||
### ⚠️ 已知限制
|
||||
- **购物车链路不支持专区折扣**:服务端 `shop_cart` 表无 `sectionId` 字段,从专区「加入购物车」再结算会走原价(仅「立即购买」享受折扣)。若要购物车也生效,需给 `shop_cart` 表加 `section_id` 并在下单时透传——后续需求。
|
||||
- 未执行迁移 SQL、未重新部署后端、未构建发布前端,需按环境操作。
|
||||
|
||||
### 验证
|
||||
- 前端 `tsc --noEmit` 因环境配置(taro 缺 @tarojs/taro 类型、admin 大量既存错误)未完整跑通,但本次改动文件未产生任何新报错。
|
||||
- 后端 Maven 不在本环境,未编译;改动为标准 Java,已逐行核对。
|
||||
|
||||
## 购物车链路打通专区折扣(补充实现)
|
||||
|
||||
用户要求把"购物车也支持专区折扣"补上。在「立即购买」已实现基础上,打通购物车链路:
|
||||
|
||||
- **后端 guilixu-java**:`ShopCart` 实体加 `sectionId`(真实列)+`discountRate`(exist=false 关联字段);`ShopCartMapper.xml` 的 selectSql 增加 `LEFT JOIN shop_home_section hs ON a.section_id = hs.section_id` 并选 `hs.discount_rate AS discount_rate`(section_id 为 NULL 时不影响普通商品);`ShopCartServiceImpl.addToCart` 在"已存在累加"分支若本次新传 sectionId 非空则刷新 `existCart.sectionId`(让专区加购覆盖普通加购的 null)。迁移SQL:`zone-cart-section-id-10606.sql`(`ALTER TABLE shop_cart ADD section_id INT NULL`)。
|
||||
- **小程序 guilixu-taro**:`CartContext.addItem` 增可选 `sectionId` 参数并透传给加购接口;`refresh` 映射带 `sectionId`/`discountRate`;`CartItem` 接口加两字段;`calcPrice` 在 `discountRate≠1` 时用 `salePrice×rate`;`section-detail`/`product-detail` 加购调用传 `sectionId`;`cart.tsx` 单价展示用折扣价;`checkout.tsx` 的 `getItemDisplayPrice` 折扣分支改读顶层 `salePrice`(购物车项 product 为空,否则算不出折扣价)。
|
||||
- 下单成交价仍由 `OrderBusinessService` 按 `sectionId` 重算,购物车项带 sectionId 后自动生效,无需改下单逻辑。
|
||||
- 验证:taro `tsc --noEmit` 对本次改动 5 个文件零新报错。
|
||||
@@ -1,67 +1,12 @@
|
||||
# 项目长期记忆
|
||||
|
||||
## 项目结构
|
||||
|
||||
### 关键项目路径
|
||||
- **管理后台**:`/Users/gxwebsoft/VUE/guilixu-admin`(Vue3 + Ant Design Vue)
|
||||
- **用户端小程序**:`/Users/gxwebsoft/VUE/guilixu-taro`(Taro + React)
|
||||
- **商家端小程序**:`/Users/gxwebsoft/VUE/xinlong-shop-taro`(Taro + React)
|
||||
|
||||
### ⚠️ 跨项目教训(重要)
|
||||
- 商家端发货/门店/订单相关功能在 **xinlong-shop-taro**,不要改到 guilixu-taro
|
||||
- 用户端订单详情等功能在 **guilixu-taro**
|
||||
- 跨项目改动前,先用 grep/glob 在多个候选项目里确认"哪个项目有该功能源码",再动手
|
||||
- 已发生两次误改 guilixu-taro 的教训(useClerk 全局上下文、订单详情 deliveryType 修复)
|
||||
|
||||
## 业务约定
|
||||
|
||||
### deliveryType 配送方式枚举
|
||||
- `0` = 快递配送
|
||||
- `1` = 无需发货 / 自提
|
||||
- `2` = 商家送货
|
||||
|
||||
来源:管理后台 `src/views/shop/shopOrder/components/deliveryModal.vue`
|
||||
|
||||
## Java 后端架构(重要)
|
||||
|
||||
### 双后端 / MODULES_API_URL 模式(2026-07-25 分析结论)
|
||||
- **mp-java**(`/Users/gxwebsoft/JAVA/mp-java`)= 独立的「modules 服务」,用 `modules` 库;cms(235类)、shop 等业务模块都在它这里,持续高频开发。
|
||||
- **guilixu-java**(`/Users/gxwebsoft/JAVA/guilixu-java`)= 桂礼序主后端,用 `db_guilixu` 库;是 mp-java 的「子集分叉」。
|
||||
- **guilixu-admin 前端已预设双基地址**:`SERVER_API_URL`(主后端) + `MODULES_API_URL`(localStorage ApiUrl / VITE_API_URL)。
|
||||
- ⚠️ **运行时实际指向以 `localStorage ApiUrl` 为准**(2026-08-06 用户纠正):**guilixu-admin 的「商城」接口实际调 `https://shop-api.websoft.top`(= guilixu-java)**,并非 mp-java。**判断"改哪个后端才生效"不能默认 MODULES_API_URL=mp-java,必须查 ApiUrl 运行时配置。** cms 接口另走 `VITE_CMS_API_URL`=cms-api.websoft.top(mp-java)。
|
||||
- **结论(仅限 cms 模块):guilixu-java 里的 cms 包(152类)是 mp-java cms 的旧分叉子集、且前端并不调它(前端调 modules 服务),属重复/死代码,应删除而非复制或扩展。**
|
||||
- 复制「整个 cms 到 guilixu-java」不可行:mp-java 完整 cms 的 CmsApp/CmsWebsiteService 依赖 `project` 模块(guilixu-java 没有),会编译失败。
|
||||
- 注意:mp-java 与 guilixu-java 是独立 git 仓库(`git.websoft.top/gxwebsoft/mp-java.git` 与 `guilixu-java.git`),各自独立部署、独立库。
|
||||
|
||||
### ⚠️ 双后端改动同步规则(2026-08-05 修正)
|
||||
- **cms 模块**改动:仅 mp-java(guilixu-java 的 cms 是死代码)。
|
||||
- **shop 等共享业务模块**(如 `ShopGoodsController` / Service / Mapper / task):**mp-java 与 guilixu-java 都有活代码且结构一致(同 `com.gxwebsoft.shop` 包)**,用户确认改动要**两端同步**(例:2026-08-05 shopGoods `/data` 统计优化,两端各改 6 处)。
|
||||
- 改动前先 grep 两端确认文件是否存在,避免只改一处导致另一端行为不一致。
|
||||
|
||||
### ⚠️ 运费模板不发货地区需求(2026-08-06 结论,已按用户纠正修正)
|
||||
- 需求:运费模板支持「不发货地区(黑名单)」,ShopExpressTemplateDetail 加 regionMode/regionIds 字段 + DeliveryRegionChecker 下单校验。
|
||||
- **后端(guilixu-java)已完整实现**:entity/param/迁移SQL/DeliveryRegionChecker/RegionCodeResolver/OrderBusinessService 接入(第 87 行调用 validateDeliveryRegionIfNeeded)均就位。
|
||||
- **关键事实(用户 2026-08-06 18:09 纠正):guilixu-admin 的「商城」接口实际调 `https://shop-api.websoft.top`(= guilixu-java)**。前端 shopExpressTemplateDetail 用默认 `@/utils/request`,baseURL = MODULES_API_URL = localStorage ApiUrl || VITE_API_URL;本部署的 ApiUrl 即 shop-api.websoft.top/api。故 guilixu-java 是生效后端,其实现正确生效,不是死代码。
|
||||
- **结论:本功能(admin 配置)无需改 mp-java**(本部署 商城不走 mp-java)。
|
||||
- **端到端链路已确认打通(2026-08-12)**:用户端 `guilixu-taro` 的 `BaseUrl = https://shop-api.websoft.top/api`(config/env.js 全环境),`TenantId='10606'`,下单 `POST /shop/shop-order` 即走 guilixu-java(db_guilixu);且单租户下买家 tenantId=10606(微信支付证书也依赖此值),故 `DeliveryRegionChecker` 按 `tenant_id=10606` 能命中黑名单。✅ 功能完整闭环。
|
||||
- **✅ 后台 UI 已支持「不发货黑名单」模式(2026-08-12 完成)**:`shopExpressTemplateDetailEdit.vue` 已加「配送模式」三态 radio(0全国/1指定配送/2指定不发货);mode=2 复用 `RegionMultiSelect` 选地区,保存写 `region_mode=2` + `region_ids`(城市码逗号串);回显按 `region_mode` 初始化。列表页 `index.vue` 的「配送地区」列按 `region_mode` 显示「不发货:xxx / 配送:xxx / 默认全国」,并把城市码聚合为省名。后端比对是编码集合 contains,故后台存城市码与 SQL 存省份码都正确拦截、互不冲突。
|
||||
- 仍可用 SQL 直接 INSERT 黑名单明细(region_mode=2, region_ids=偏远省码, status=0, tenant_id=10606)作为备选,无需经过 UI。
|
||||
- 接口方案文档:`docs/运费模板-不发货地区-接口方案.md`。
|
||||
|
||||
### shopGoods status 字段(上架/下架)
|
||||
- `0` = 已上架 / 上架
|
||||
- `1` = 待上架 / 下架
|
||||
- `2` = 待审核,`3` = 审核不通过
|
||||
- 编辑表单「状态」radio 绑定 `form.status`:0=上架、1=下架。
|
||||
- 一键上下架、批量上下架均通过 `updateShopGoods({ ...record, status })` 直接改 status 实现(不额外用 isShow 字段)。
|
||||
- 列表 tag 仍显示:0=已上架、1=待上架、2=待审核、3=审核不通过。
|
||||
|
||||
### 运费模板配送地区多选(2026-08-11 已实现前端)
|
||||
- 需求:配送地区选择从「省+市单选级联」升级为「省+市两级多选穿梭弹窗」(仿客户截图双栏:左候选/右已选)。
|
||||
- 前端组件:`src/components/RegionMultiSelect/index.vue`(树形可展开,省级"选择"=选该省全部市,市级可单独选;@update:value 城市编码数组)。
|
||||
- **直接对接后端已有的 `regionMode`(0=全国/1=指定配送白名单/2=不发货黑名单) + `regionIds`(逗号分隔城市编码串) 机制,无需后端改动**。城市编码即 `regions-data.json` 的 city value(6位),与 `regionIds` 完全一致。
|
||||
- 数据字段:model 用 `regionMode` / `regionIds`(已有字段,非新建);旧 `provinceId`/`cityId` 仅作回显兼容。
|
||||
- 列表多值展示:`index.vue` customRender 遍历 `regionIds` 反查 regionMap,超 22 字截断 + tooltip。
|
||||
- 下单校验 `DeliveryRegionChecker` 读 `regionIds` 直接生效(regionMode=2 为不发货黑名单)。
|
||||
- ⚠️ 教训:第一版误用 `provinceIds`/`cityIds` 字段名,与后端 `regionMode`/`regionIds` 不匹配,MyBatis-Plus 自动忽略未知字段导致"没存"。改字段名对接即可,不必新增后端字段。
|
||||
## 专区折扣系数功能(2026-08-18 新增)
|
||||
- 需求:专区商城价 = 市场价(salePrice) × discountRate(每专区可配,默认 1.00;本专区用 0.78)。
|
||||
- **后端 guilixu-java**:`ShopHomeSection.discountRate` 字段 + 迁移SQL(`guilixu-admin/zone-discount-rate-10606.sql`);`OrderCreateRequest.OrderGoodsItem.sectionId`;`OrderBusinessService` 两处价格解析按 sectionId 重算成交价= salePrice×rate(后端算,不信任前端价)。
|
||||
- **后台 guilixu-admin**:`zoneEdit.vue` 有「折扣系数」输入;`shopZone/model` HomeSection 有 `discountRate`。
|
||||
- **小程序 guilixu-taro**:`section-detail`→`product-detail`→`checkout` 透传 sectionId+discountRate,「立即购买」与「购物车」两条链路均按系数生效(购物车补充见下)。
|
||||
- **购物车链路补充(同日)**:`shop_cart` 表加 `section_id` 列(迁移SQL见 `zone-cart-section-id-10606.sql`);`ShopCart` 实体加 `sectionId`(真实列)+`discountRate`(exist=false)、`ShopCartMapper.xml` LEFT JOIN 专区取 `discount_rate`;`addToCart` 在"已存在累加"分支若新传 sectionId 非空则刷新 `existCart.sectionId`(专区加购覆盖普通加购的 null);`CartContext.addItem` 透传 sectionId、`calcPrice` 与 `cart.tsx` 按系数展示;`checkout.tsx` 的 `getItemDisplayPrice` 折扣分支改读顶层 `salePrice`(购物车项 product 为空)。两条链路现在都生效。
|
||||
- 生效前置:执行迁移SQL、重新部署 guilixu-java、构建发布 taro。
|
||||
|
||||
## 双后端事实(沿用)
|
||||
- 商城接口实际调 `https://guilixu-api.websoft.top`(= guilixu-java, db_guilixu);cms 走 mp-java。改商城相关先确认运行时 ApiUrl。详见历史 MEMORY。
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- ============================================================
|
||||
-- 修复历史已支付订单的订单商品支付状态(专区销量统计数据补偿)
|
||||
-- 适用后端:guilixu-java(本部署商城实际后端,db_guilixu)
|
||||
-- 租户:10606
|
||||
-- 背景:下单时 shop_order_goods.pay_status 默认 0,支付成功链路此前只更新
|
||||
-- 主订单 shop_order.pay_status,不会同步订单商品表。专区销量统计按
|
||||
-- shop_order_goods.pay_status=1 聚合,导致历史已支付订单从未被统计。
|
||||
-- 作用:把主订单已支付(pay_status=1)且订单商品仍为未支付(pay_status=0)的记录,
|
||||
-- 同步为已支付,并把 order_status 对齐主订单(退款订单 order_status=6 会被统计口径排除)。
|
||||
-- 执行前请先备份,并在低峰期执行;可先 SELECT 预览影响行数:
|
||||
-- SELECT COUNT(*) FROM shop_order_goods og
|
||||
-- JOIN shop_order o ON og.order_id = o.order_id AND o.tenant_id = 10606
|
||||
-- WHERE og.tenant_id = 10606 AND o.pay_status = 1 AND og.pay_status = 0;
|
||||
-- ============================================================
|
||||
|
||||
UPDATE shop_order_goods og
|
||||
JOIN shop_order o ON og.order_id = o.order_id AND o.tenant_id = 10606
|
||||
SET og.pay_status = 1,
|
||||
og.order_status = o.order_status
|
||||
WHERE og.tenant_id = 10606
|
||||
AND o.pay_status = 1
|
||||
AND og.pay_status = 0;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- ============================================================
|
||||
-- 偏远地区不发货黑名单(租户 10606,全店生效)
|
||||
-- ============================================================
|
||||
-- 作用:下单时 guilixu-java 的 DeliveryRegionChecker 按 tenant_id=10606
|
||||
-- 取 region_mode=2 且 status=0 的明细,将收货地址行政区划码与
|
||||
-- region_ids 比对,命中即拦截并提示「XX暂不配送,请更换收货地址」。
|
||||
-- 注意:黑名单是租户级、全店生效,不区分商品绑定的运费模板。
|
||||
-- 自提/无需发货(deliveryType=1) 不参与此校验。
|
||||
-- 省份码(与 regions-data.json 完全一致):
|
||||
-- 新疆650000 西藏540000 青海630000 宁夏640000 内蒙古150000
|
||||
-- 海南460000 黑龙江230000 甘肃620000
|
||||
-- ============================================================
|
||||
|
||||
-- 1) 先确认是否已有黑名单(避免重复插入)
|
||||
SELECT id, template_id, region_mode, region_ids, status, tenant_id
|
||||
FROM shop_express_template_detail
|
||||
WHERE tenant_id = 10606 AND region_mode = 2 AND status = 0;
|
||||
|
||||
-- 2) 若上一步无记录,再执行插入(template_id=11 即「偏远地区不发」模板)
|
||||
INSERT INTO shop_express_template_detail
|
||||
(template_id, type, province_id, city_id, region_mode, region_ids,
|
||||
first_num, first_amount, extra_amount, extra_num, status, deleted,
|
||||
tenant_id, create_time, update_time, sort_number)
|
||||
VALUES
|
||||
(11, 0, NULL, NULL, 2,
|
||||
'650000,540000,630000,640000,150000,460000,230000,620000',
|
||||
0, 0, 0, 0, 0, 0, 10606, NOW(), NOW(), 0);
|
||||
|
||||
-- 3) 验证(应能看到刚插入的这条,且 region_ids 非空)
|
||||
SELECT id, template_id, region_mode, region_ids, status, tenant_id
|
||||
FROM shop_express_template_detail
|
||||
WHERE tenant_id = 10606 AND region_mode = 2 AND status = 0;
|
||||
@@ -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 '%商城%';
|
||||
-- ============================================================
|
||||
@@ -31,8 +31,10 @@ export interface ShopGoods {
|
||||
canExpress?: number;
|
||||
// 商品分类
|
||||
category?: string;
|
||||
// 商品分类ID
|
||||
// 商品分类ID(主分类,向后兼容)
|
||||
categoryId?: number;
|
||||
// 商品分类ID列表(方案B:可归属多个分类)
|
||||
categoryIds?: number[];
|
||||
parentName?: string;
|
||||
categoryName?: string;
|
||||
// 一级分类
|
||||
@@ -149,6 +151,8 @@ export interface ShopGoods {
|
||||
activityType?: number;
|
||||
// 配送方式:0送上门 1限自提
|
||||
deliveryMode?: number;
|
||||
// 所属专区(逗号分隔的 sectionId 字符串,对应 shop_goods.section_ids 列)
|
||||
sectionIds?: string;
|
||||
}
|
||||
|
||||
export interface BathSet {
|
||||
@@ -174,6 +178,8 @@ export interface ShopGoodsCollectParam {
|
||||
export interface ShopGoodsParam extends PageParam {
|
||||
parentId?: number;
|
||||
categoryId?: number;
|
||||
// 方案B:按多个分类查询,与单值 categoryId 二选一,多值时优先
|
||||
categoryIds?: number[];
|
||||
goodsId?: number;
|
||||
status?: number;
|
||||
goodsName?: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import request from '@/utils/request';
|
||||
import type { ApiResult, PageResult } from '@/api';
|
||||
import type {
|
||||
HomeSection,
|
||||
HomeSectionParam,
|
||||
SectionPermission,
|
||||
SectionGoods,
|
||||
SectionSalesStatsVO
|
||||
} from './model';
|
||||
import type { User } from '@/api/system/user/model';
|
||||
import { MODULES_API_URL } from '@/config/setting';
|
||||
|
||||
/**
|
||||
* 分页查询首页专区
|
||||
*/
|
||||
export async function pageHomeSections(params: HomeSectionParam) {
|
||||
const res = await request.get<ApiResult<PageResult<HomeSection>>>(
|
||||
MODULES_API_URL + '/shop/shop-home-section/page',
|
||||
{
|
||||
params
|
||||
}
|
||||
);
|
||||
if (res.data.code === 0) {
|
||||
return res.data.data;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询首页专区列表
|
||||
*/
|
||||
export async function listHomeSections(params?: HomeSectionParam) {
|
||||
const res = await request.get<ApiResult<HomeSection[]>>(
|
||||
MODULES_API_URL + '/shop/shop-home-section',
|
||||
{
|
||||
params
|
||||
}
|
||||
);
|
||||
if (res.data.code === 0 && res.data.data) {
|
||||
return res.data.data;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据id查询首页专区
|
||||
*/
|
||||
export async function getHomeSection(id: number) {
|
||||
const res = await request.get<ApiResult<HomeSection>>(
|
||||
MODULES_API_URL + '/shop/shop-home-section/' + id
|
||||
);
|
||||
if (res.data.code === 0 && res.data.data) {
|
||||
return res.data.data;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加首页专区
|
||||
*/
|
||||
export async function addHomeSection(data: HomeSection) {
|
||||
const res = await request.post<ApiResult<unknown>>(
|
||||
MODULES_API_URL + '/shop/shop-home-section',
|
||||
data
|
||||
);
|
||||
if (res.data.code === 0) {
|
||||
return res.data.message;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改首页专区
|
||||
*/
|
||||
export async function updateHomeSection(data: HomeSection) {
|
||||
const res = await request.put<ApiResult<unknown>>(
|
||||
MODULES_API_URL + '/shop/shop-home-section',
|
||||
data
|
||||
);
|
||||
if (res.data.code === 0) {
|
||||
return res.data.message;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除首页专区
|
||||
*/
|
||||
export async function removeHomeSection(id?: number) {
|
||||
const res = await request.delete<ApiResult<unknown>>(
|
||||
MODULES_API_URL + '/shop/shop-home-section/' + id
|
||||
);
|
||||
if (res.data.code === 0) {
|
||||
return res.data.message;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询专区白名单用户(返回用户详情:userId/realName/mobile/nickname/organizationName)
|
||||
*/
|
||||
export async function listSectionUsers(id: number) {
|
||||
const res = await request.get<ApiResult<User[]>>(
|
||||
MODULES_API_URL + '/shop/shop-home-section/' + id + '/users'
|
||||
);
|
||||
if (res.data.code === 0 && res.data.data) {
|
||||
return res.data.data;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存专区白名单用户(覆盖式,保留以备兼容)
|
||||
*/
|
||||
export async function setSectionUsers(id: number, userIds: number[]) {
|
||||
const res = await request.put<ApiResult<unknown>>(
|
||||
MODULES_API_URL + '/shop/shop-home-section/' + id + '/users',
|
||||
userIds
|
||||
);
|
||||
if (res.data.code === 0) {
|
||||
return res.data.message;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加白名单用户到专区(单条/批量追加)
|
||||
*/
|
||||
export async function addSectionUsers(id: number, userIds: number[]) {
|
||||
const res = await request.post<ApiResult<unknown>>(
|
||||
MODULES_API_URL + '/shop/shop-home-section/' + id + '/users',
|
||||
userIds
|
||||
);
|
||||
if (res.data.code === 0) {
|
||||
return res.data.message;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从专区移除白名单用户(单条/批量)
|
||||
*/
|
||||
export async function removeSectionUsers(id: number, userIds: number[]) {
|
||||
const res = await request.delete<ApiResult<unknown>>(
|
||||
MODULES_API_URL + '/shop/shop-home-section/' + id + '/users',
|
||||
{ data: userIds }
|
||||
);
|
||||
if (res.data.code === 0) {
|
||||
return res.data.message;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询专区商品分页
|
||||
*/
|
||||
export async function listSectionGoods(
|
||||
id: number,
|
||||
params?: { page?: number; limit?: number }
|
||||
) {
|
||||
const res = await request.get<ApiResult<PageResult<SectionGoods>>>(
|
||||
MODULES_API_URL + '/shop/shop-home-section/' + id + '/goods',
|
||||
{
|
||||
params: { page: 1, limit: 10, ...params }
|
||||
}
|
||||
);
|
||||
if (res.data.code === 0) {
|
||||
return res.data.data;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加商品到专区
|
||||
*/
|
||||
export async function addSectionGoods(id: number, goodsIds: number[]) {
|
||||
const res = await request.post<ApiResult<unknown>>(
|
||||
MODULES_API_URL + '/shop/shop-home-section/' + id + '/goods',
|
||||
goodsIds
|
||||
);
|
||||
if (res.data.code === 0) {
|
||||
return res.data.message;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从专区移除商品
|
||||
*/
|
||||
export async function removeSectionGoods(id: number, goodsIds: number[]) {
|
||||
const res = await request.delete<ApiResult<unknown>>(
|
||||
MODULES_API_URL + '/shop/shop-home-section/' + id + '/goods',
|
||||
{ data: goodsIds }
|
||||
);
|
||||
if (res.data.code === 0) {
|
||||
return res.data.message;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验专区访问权限
|
||||
*/
|
||||
export async function checkSectionPermission(sectionIds: number[]) {
|
||||
const res = await request.post<ApiResult<SectionPermission[]>>(
|
||||
MODULES_API_URL + '/shop/shop-home-section/check-permission',
|
||||
{ sectionIds }
|
||||
);
|
||||
if (res.data.code === 0 && res.data.data) {
|
||||
return res.data.data;
|
||||
}
|
||||
return Promise.reject(new Error(res.data.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取专区小程序码(图片流),返回可用于 <img src> 的 objectURL
|
||||
* @param id 专区ID
|
||||
* @param envVersion release(正式版) / trial(体验版) / develop(开发版)
|
||||
*/
|
||||
export async function getSectionQrcode(
|
||||
id: number,
|
||||
envVersion: string = 'release'
|
||||
): Promise<string> {
|
||||
const res = await request.get(
|
||||
MODULES_API_URL + '/shop/shop-home-section/' + id + '/qrcode',
|
||||
{ responseType: 'blob', params: { envVersion } } as any
|
||||
);
|
||||
const blob = res.data as Blob;
|
||||
// 微信/后端出错时会返回 JSON 而非图片,这里解析出错误信息
|
||||
if (blob && blob.type && blob.type.indexOf('image') === -1) {
|
||||
let msg = '生成小程序码失败';
|
||||
try {
|
||||
const json = JSON.parse(await blob.text());
|
||||
if (json && json.error) msg = json.error;
|
||||
} catch (e) {
|
||||
/* 忽略解析失败 */
|
||||
}
|
||||
return Promise.reject(new Error(msg));
|
||||
}
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { PageParam } from '@/api';
|
||||
|
||||
/**
|
||||
* 首页专区
|
||||
*/
|
||||
export interface HomeSection {
|
||||
// 专区ID
|
||||
sectionId?: number;
|
||||
// 租户id
|
||||
tenantId?: number;
|
||||
// 专区标题
|
||||
title?: string;
|
||||
// 专区副标题
|
||||
subtitle?: string;
|
||||
// banner 图片URL
|
||||
banner?: string;
|
||||
// 关联分类ID(逗号分隔字符串,可选)
|
||||
categoryIds?: string;
|
||||
// 样式类型 0默认
|
||||
styleType?: number;
|
||||
// 是否受限专区 0否 1是
|
||||
restricted?: number;
|
||||
// 专区商城价折扣系数, 成交价=市场价×系数, 默认1.00不打折
|
||||
discountRate?: number;
|
||||
// 排序号(数字越小越靠前)
|
||||
sortNumber?: number;
|
||||
// 状态 0正常 1禁用
|
||||
status?: number;
|
||||
// 生效开始时间
|
||||
startTime?: string;
|
||||
// 生效结束时间
|
||||
endTime?: string;
|
||||
// 用户ID
|
||||
userId?: number;
|
||||
// 是否删除 0否 1是
|
||||
deleted?: number;
|
||||
// 创建时间
|
||||
createTime?: string;
|
||||
// 修改时间
|
||||
updateTime?: string;
|
||||
// 销量件数(统计, 非DB字段)
|
||||
salesNum?: number;
|
||||
// 销售额(统计, 非DB字段)
|
||||
salesAmount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 首页专区搜索条件
|
||||
*/
|
||||
export interface HomeSectionParam extends PageParam {
|
||||
// 专区标题(模糊)
|
||||
title?: string;
|
||||
// 状态 0正常 1禁用
|
||||
status?: number;
|
||||
// 是否受限专区 0/1
|
||||
restricted?: number;
|
||||
// 排序号
|
||||
sortNumber?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 专区权限校验结果
|
||||
*/
|
||||
export interface SectionPermission {
|
||||
// 专区ID
|
||||
sectionId?: number;
|
||||
// 是否受限专区
|
||||
restricted?: boolean;
|
||||
// 当前用户是否允许访问
|
||||
allowed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 专区商品(只读展示,字段按需取用)
|
||||
*/
|
||||
export interface SectionGoods {
|
||||
goodsId?: number;
|
||||
name?: string;
|
||||
image?: string;
|
||||
price?: string;
|
||||
salePrice?: string;
|
||||
stock?: number;
|
||||
status?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 专区白名单用户
|
||||
*/
|
||||
export interface SectionUser {
|
||||
id?: number;
|
||||
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;
|
||||
}
|
||||
@@ -170,7 +170,7 @@ export async function updateUserStatus(userId?: number, status?: number) {
|
||||
/**
|
||||
* 修改推荐状态
|
||||
*/
|
||||
export async function updateUserRecommend(form) {
|
||||
export async function updateUserRecommend(form: { userId: number; recommend: number }) {
|
||||
const res = await request.put<ApiResult<unknown>>(
|
||||
SERVER_API_URL + '/system/user/recommend',
|
||||
form
|
||||
|
||||
+60
-268
@@ -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,
|
||||
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.statistics = {
|
||||
userCount,
|
||||
orderCount,
|
||||
totalSales,
|
||||
todaySales,
|
||||
monthSales: todaySales,
|
||||
todayOrders,
|
||||
todayUsers
|
||||
};
|
||||
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);
|
||||
|
||||
@@ -52,15 +52,17 @@
|
||||
|
||||
<a-tree-select
|
||||
allow-clear
|
||||
multiple
|
||||
:max-tag-count="2"
|
||||
:tree-data="navigationList"
|
||||
tree-default-expand-all
|
||||
style="width: 180px"
|
||||
style="width: 200px"
|
||||
:listHeight="700"
|
||||
placeholder="请选择分类"
|
||||
:value="where.categoryId || undefined"
|
||||
placeholder="请选择分类(可多选)"
|
||||
:value="where.categoryIds && where.categoryIds.length ? where.categoryIds : undefined"
|
||||
:dropdown-style="{ overflow: 'auto' }"
|
||||
@update:value="(value?: number) => (where.categoryId = value)"
|
||||
@change="onCategoryId"
|
||||
@update:value="(value?: number[]) => (where.categoryIds = value || [])"
|
||||
@change="onCategoryIds"
|
||||
/>
|
||||
<a-input-search
|
||||
allow-clear
|
||||
@@ -149,6 +151,7 @@
|
||||
status: undefined,
|
||||
stock: undefined,
|
||||
categoryId: undefined,
|
||||
categoryIds: [],
|
||||
merchantId: undefined,
|
||||
keywords: ''
|
||||
});
|
||||
@@ -164,7 +167,7 @@
|
||||
(e: 'imageImport'): void;
|
||||
(e: 'detailImport'): void;
|
||||
(e: 'convertContent'): void;
|
||||
(e: 'export'): void;
|
||||
(e: 'export', where?: ShopGoodsParam): void;
|
||||
}>();
|
||||
|
||||
// 新增
|
||||
@@ -204,9 +207,9 @@
|
||||
emit('convertContent');
|
||||
};
|
||||
|
||||
// 导出
|
||||
// 导出(携带当前筛选条件 where)
|
||||
const handleExport = () => {
|
||||
emit('export');
|
||||
emit('export', where);
|
||||
};
|
||||
|
||||
const handleSearch = (e) => {
|
||||
@@ -238,9 +241,10 @@
|
||||
reload();
|
||||
};
|
||||
|
||||
// 按分类查询
|
||||
const onCategoryId = (id: number) => {
|
||||
where.categoryId = id;
|
||||
// 按分类查询(方案B:支持多选,主分类取首个)
|
||||
const onCategoryIds = (ids: number[]) => {
|
||||
where.categoryIds = ids || [];
|
||||
where.categoryId = ids && ids.length ? ids[0] : undefined;
|
||||
emit('search', where);
|
||||
};
|
||||
|
||||
|
||||
@@ -32,18 +32,31 @@
|
||||
v-model:value="form.name"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="商品分类" name="categoryId">
|
||||
<a-form-item label="商品分类" name="categoryIds">
|
||||
<a-tree-select
|
||||
allow-clear
|
||||
multiple
|
||||
:max-tag-count="3"
|
||||
:tree-data="navigationList"
|
||||
tree-default-expand-all
|
||||
style="width: 320px"
|
||||
placeholder="请选择分类"
|
||||
:value="form.categoryId || undefined"
|
||||
placeholder="请选择分类(可多选)"
|
||||
:value="form.categoryIds && form.categoryIds.length ? form.categoryIds : undefined"
|
||||
:listHeight="700"
|
||||
:dropdown-style="{ overflow: 'auto' }"
|
||||
@update:value="(value?: number) => (form.categoryId = value)"
|
||||
@change="onCategoryId"
|
||||
@update:value="(value?: number[]) => (form.categoryIds = value || [])"
|
||||
@change="onCategoryIds"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="所属专区" name="sectionIds">
|
||||
<a-select
|
||||
v-model:value="sectionIdList"
|
||||
mode="multiple"
|
||||
:max-tag-count="3"
|
||||
:options="zoneOptions"
|
||||
style="width: 320px"
|
||||
placeholder="请选择所属专区(可多选)"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="商品编码" name="code">
|
||||
@@ -707,6 +720,7 @@ import {ShopExpressTemplate} from '@/api/shop/shopExpressTemplate/model';
|
||||
import {listShopExpressTemplate} from '@/api/shop/shopExpressTemplate';
|
||||
import {listShopCommissionRole} from '@/api/shop/shopCommissionRole';
|
||||
import {listShopGoodsRoleCommission} from '@/api/shop/shopGoodsRoleCommission';
|
||||
import {listHomeSections} from '@/api/shop/shopZone';
|
||||
|
||||
// 是否是修改
|
||||
const isUpdate = ref(false);
|
||||
@@ -857,6 +871,9 @@ const skuList = ref<ShopGoodsSku[]>([]);
|
||||
const files = ref<ItemType[]>([]);
|
||||
const goodsSpec = ref<ShopGoodsSpec>();
|
||||
const category = ref<string[]>([]);
|
||||
// 所属专区(多选)
|
||||
const sectionIdList = ref<number[]>([]);
|
||||
const zoneOptions = ref<{ label: string; value: number }[]>([]);
|
||||
|
||||
// 批量设置
|
||||
const batchPrice = ref<number | undefined>(undefined);
|
||||
@@ -928,12 +945,13 @@ const addEnsureTag = (item = '') => {
|
||||
ensureTagItem.value = '';
|
||||
};
|
||||
|
||||
// 选择栏目
|
||||
const onCategoryId = (id: number) => {
|
||||
form.categoryId = id;
|
||||
// 选择栏目(方案B:支持多选,主分类取列表首个,向后兼容旧字段)
|
||||
const onCategoryIds = (ids: number[]) => {
|
||||
form.categoryIds = ids || [];
|
||||
form.categoryId = ids && ids.length ? ids[0] : undefined;
|
||||
// 💾 在新增模式下,用户手动选择栏目时也保存到本地存储
|
||||
if (!isUpdate.value && id) {
|
||||
saveLastCategory(id);
|
||||
if (!isUpdate.value && ids && ids.length) {
|
||||
saveLastCategory(ids[0]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1204,6 +1222,7 @@ const form = reactive<ShopGoods>({
|
||||
canExpress: 1,
|
||||
unitName: '',
|
||||
categoryId: undefined,
|
||||
categoryIds: [],
|
||||
parentName: undefined,
|
||||
categoryName: undefined,
|
||||
specs: 0,
|
||||
@@ -1449,14 +1468,18 @@ const chooseSupplier = (item: ShopMerchant) => {
|
||||
};
|
||||
|
||||
const chooseGoodsCategory = (item: ShopGoodsCategory, value: any) => {
|
||||
form.categoryId = value[1].value;
|
||||
const cid = value[1].value as number;
|
||||
form.categoryId = cid;
|
||||
form.categoryIds = [cid];
|
||||
form.parentName = value[0].label;
|
||||
form.categoryName = value[1].label;
|
||||
};
|
||||
const chooseTakeawayCategory = (item: ShopGoodsCategory, value: any) => {
|
||||
const cid = item[0] as number;
|
||||
form.categoryParent = '店铺分类';
|
||||
form.categoryChildren = value[0].label;
|
||||
form.categoryId = item[0];
|
||||
form.categoryId = cid;
|
||||
form.categoryIds = [cid];
|
||||
};
|
||||
|
||||
const chooseImage = (data: FileRecord) => {
|
||||
@@ -1788,8 +1811,17 @@ const save = () => {
|
||||
if (form.specs === 1 && goodsSpec.value && spec.value.length) {
|
||||
goodsSpec.value.specValue = JSON.stringify(spec.value);
|
||||
}
|
||||
// 方案B:主分类兜底取多分类第一个,保证旧字段/旧逻辑兼容
|
||||
if (form.categoryIds && form.categoryIds.length && !form.categoryId) {
|
||||
form.categoryId = form.categoryIds[0];
|
||||
}
|
||||
|
||||
const formData: any = {
|
||||
...form,
|
||||
// 所属专区:数字数组 -> 逗号分隔字符串
|
||||
sectionIds: sectionIdList.value.length
|
||||
? sectionIdList.value.join(',')
|
||||
: '',
|
||||
content: content.value,
|
||||
category: JSON.stringify(category.value),
|
||||
files: JSON.stringify(files.value),
|
||||
@@ -1953,8 +1985,20 @@ watch(
|
||||
async (visible) => {
|
||||
if (visible) {
|
||||
await getExpressTemplateList();
|
||||
// 加载所属专区选项
|
||||
listHomeSections({})
|
||||
.then((list) => {
|
||||
zoneOptions.value = (list || []).map((z) => ({
|
||||
label: z.title || '',
|
||||
value: z.sectionId as number
|
||||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
zoneOptions.value = [];
|
||||
});
|
||||
|
||||
images.value = [];
|
||||
sectionIdList.value = [];
|
||||
category.value = [];
|
||||
files.value = [];
|
||||
videos.value = [];
|
||||
@@ -1963,6 +2007,13 @@ watch(
|
||||
ensureTagItem.value = '';
|
||||
if (props.data) {
|
||||
assignObject(form, props.data);
|
||||
// 所属专区:逗号分隔字符串 -> 数字数组
|
||||
sectionIdList.value = form.sectionIds
|
||||
? form.sectionIds
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
: [];
|
||||
if (form.commissionType === undefined || form.commissionType === null) {
|
||||
form.commissionType = 10;
|
||||
}
|
||||
@@ -2050,15 +2101,21 @@ watch(
|
||||
if (form.ensureTag) {
|
||||
ensureTag.value = form.ensureTag.split(',');
|
||||
}
|
||||
// 🎯 优先级设置栏目:
|
||||
// 1. 如果传入了 categoryId(从栏目页面点击添加),使用传入的
|
||||
// 2. 否则使用上次保存的栏目
|
||||
if (props.data.categoryId) {
|
||||
// 🎯 优先级设置栏目(方案B:支持多分类回填):
|
||||
// 1. 如果传入了 categoryIds,使用多分类
|
||||
// 2. 否则如果传入了 categoryId,兼容单分类
|
||||
// 3. 否则使用上次保存的栏目
|
||||
if (props.data.categoryIds && props.data.categoryIds.length) {
|
||||
form.categoryIds = props.data.categoryIds;
|
||||
form.categoryId = props.data.categoryId ?? props.data.categoryIds[0];
|
||||
} else if (props.data.categoryId) {
|
||||
form.categoryId = props.data?.categoryId;
|
||||
form.categoryIds = [props.data.categoryId];
|
||||
} else {
|
||||
const lastCategory = getLastCategory();
|
||||
if (lastCategory) {
|
||||
form.categoryId = lastCategory;
|
||||
form.categoryIds = [lastCategory];
|
||||
}
|
||||
}
|
||||
await getRoleList();
|
||||
|
||||
@@ -211,6 +211,7 @@
|
||||
import { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model';
|
||||
import { listShopGoodsCategory } from '@/api/shop/shopGoodsCategory';
|
||||
import { utils, writeFile } from 'xlsx';
|
||||
import { ensureFullUrl } from '@/utils/image';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// 表格实例
|
||||
@@ -233,7 +234,7 @@
|
||||
// 是否显示批量移动弹窗
|
||||
const showMove = ref(false);
|
||||
// 加载状态
|
||||
const loading = ref(true);
|
||||
const loading = ref(false);
|
||||
|
||||
// 表格数据源
|
||||
const datasource: DatasourceFunction = ({
|
||||
@@ -269,6 +270,15 @@
|
||||
key: 'name',
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'categoryName',
|
||||
key: 'categoryName',
|
||||
align: 'center',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
customRender: ({ text }) => text || '--'
|
||||
},
|
||||
{
|
||||
title: '价格',
|
||||
key: 'priceGroup',
|
||||
@@ -420,8 +430,8 @@
|
||||
: base;
|
||||
};
|
||||
|
||||
/* 导出商品列表 */
|
||||
const handleExport = async () => {
|
||||
/* 导出商品列表(可携带当前筛选条件) */
|
||||
const handleExport = async (where?: ShopGoodsParam) => {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
@@ -439,11 +449,12 @@
|
||||
'库存',
|
||||
'状态',
|
||||
'推荐',
|
||||
'创建时间'
|
||||
'创建时间',
|
||||
'封面图'
|
||||
]
|
||||
];
|
||||
|
||||
const list = await listShopGoods({});
|
||||
const list = await listShopGoods(where || {});
|
||||
if (!list || list.length === 0) {
|
||||
message.warning('没有数据可以导出');
|
||||
loading.value = false;
|
||||
@@ -462,7 +473,8 @@
|
||||
`${goods.stock || 0}`,
|
||||
statusMap[goods.status || 0] || '',
|
||||
goods.recommend === 1 ? '是' : '否',
|
||||
`${goods.createTime || ''}`
|
||||
`${goods.createTime || ''}`,
|
||||
ensureFullUrl(`${goods.image || ''}`)
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -484,7 +496,8 @@
|
||||
{ wch: 10 },
|
||||
{ wch: 12 },
|
||||
{ wch: 8 },
|
||||
{ wch: 20 }
|
||||
{ wch: 20 },
|
||||
{ wch: 50 }
|
||||
];
|
||||
|
||||
message.destroy();
|
||||
|
||||
@@ -90,6 +90,14 @@
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="链接地址" name="path">
|
||||
<a-input
|
||||
allow-clear
|
||||
placeholder="可选,如:/pages/goods/list?categoryId=1"
|
||||
v-model:value="form.path"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group v-model:value="form.status">
|
||||
<a-radio :value="0">正常</a-radio>
|
||||
@@ -286,6 +294,7 @@
|
||||
image: undefined,
|
||||
adImage: undefined,
|
||||
parentId: undefined,
|
||||
path: undefined,
|
||||
sortNumber: 100,
|
||||
comments: undefined,
|
||||
status: 0
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<img
|
||||
v-if="record.adImage"
|
||||
:src="getCompressedImageUrl(record.adImage, { width: 144, quality: 85 })"
|
||||
style="width: 72px; height: 40px; object-fit: cover; border-radius: 4px;"
|
||||
style="max-width: 56px; height: 28px; object-fit: cover; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
@@ -176,7 +176,7 @@
|
||||
const depth = depthMap.value.get(record.categoryId || 0) || 0;
|
||||
if (depth === 0) return '';
|
||||
const W = 20;
|
||||
const H = 40;
|
||||
const H = 20;
|
||||
const mid = H / 2;
|
||||
// 回溯祖先链:chain[0]=顶级 ... chain[depth]=当前节点
|
||||
const chain: number[] = [];
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,188 @@
|
||||
<!-- 白名单用户管理抽屉(右侧弹出:打开只列已添加,搜索手机/姓名昵称追加,单条即时增删) -->
|
||||
<template>
|
||||
<a-drawer
|
||||
:width="820"
|
||||
:visible="visible"
|
||||
:maskClosable="false"
|
||||
title="专区白名单用户"
|
||||
placement="right"
|
||||
:body-style="{ paddingBottom: '16px' }"
|
||||
@update:visible="updateVisible"
|
||||
>
|
||||
<!-- 搜索可添加的用户 -->
|
||||
<div style="margin-bottom: 12px; display: flex; gap: 8px; align-items: center;">
|
||||
<a-input-search
|
||||
v-model:value="searchText"
|
||||
placeholder="搜索手机号码 / 姓名 / 昵称"
|
||||
allow-clear
|
||||
style="flex: 1; max-width: 360px;"
|
||||
@search="searchToAdd"
|
||||
@pressEnter="searchToAdd"
|
||||
/>
|
||||
<a-button type="primary" :loading="searchLoading" @click="searchToAdd">
|
||||
搜索
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果(点击 + 添加) -->
|
||||
<div
|
||||
v-if="candidates.length > 0"
|
||||
style="margin-bottom: 12px; border: 1px dashed #d9d9d9; border-radius: 6px; padding: 8px;"
|
||||
>
|
||||
<div style="font-size: 12px; color: #999; margin-bottom: 6px;">搜索结果(点击 + 添加)</div>
|
||||
<div
|
||||
v-for="u in candidates"
|
||||
:key="u.userId"
|
||||
style="display: flex; align-items: center; padding: 4px 0; gap: 8px; border-bottom: 1px solid #f0f0f0;"
|
||||
>
|
||||
<span style="flex: 1; font-size: 13px;">{{ u.realName || '-' }}</span>
|
||||
<span style="color: #999; font-size: 12px;">{{ u.phone || u.mobile || '-' }}</span>
|
||||
<span style="color: #999; font-size: 12px;">{{ u.nickname || '' }}</span>
|
||||
<a-button type="link" size="small" style="padding: 0;" @click="addOne(u)">+ 添加</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已添加的白名单用户 -->
|
||||
<a-divider style="margin: 8px 0;">已添加({{ addedUsers.length }})</a-divider>
|
||||
<a-spin :spinning="addedLoading">
|
||||
<a-table
|
||||
v-if="addedUsers.length > 0"
|
||||
row-key="userId"
|
||||
:columns="columns"
|
||||
:data-source="addedUsers"
|
||||
:pagination="false"
|
||||
:scroll="{ y: 320 }"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-popconfirm title="确定移出该白名单用户吗?" @confirm="removeOne(record)">
|
||||
<a-button type="link" danger size="small">移除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<a-empty v-else description="暂无白名单用户,请用上方搜索添加" />
|
||||
</a-spin>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { listSectionUsers, addSectionUsers, removeSectionUsers } from '@/api/shop/shopZone';
|
||||
import { pageUsers } from '@/api/system/user';
|
||||
import type { User } from '@/api/system/user/model';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
sectionId?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', visible: boolean): void;
|
||||
}>();
|
||||
|
||||
const updateVisible = (value: boolean) => {
|
||||
emit('update:visible', value);
|
||||
};
|
||||
|
||||
// 已添加的白名单用户
|
||||
const addedUsers = ref<User[]>([]);
|
||||
const addedLoading = ref(false);
|
||||
|
||||
// 搜索结果(候选)
|
||||
const searchText = ref('');
|
||||
const candidates = ref<User[]>([]);
|
||||
const searchLoading = ref(false);
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'userId', key: 'userId', align: 'center', width: 80 },
|
||||
{ title: '姓名', dataIndex: 'realName', key: 'realName', align: 'center' },
|
||||
{ title: '手机号码', dataIndex: 'phone', key: 'phone', align: 'center', customRender: ({ text, record }: any) => text || record.mobile || '-' },
|
||||
{ title: '昵称', dataIndex: 'nickname', key: 'nickname', align: 'center' },
|
||||
// { title: '所属部门', dataIndex: 'organizationName', key: 'organizationName', align: 'center' },
|
||||
{ title: '操作', key: 'action', align: 'center', width: 90 }
|
||||
];
|
||||
|
||||
/* 加载已添加的白名单 */
|
||||
const loadAdded = () => {
|
||||
if (!props.sectionId) return;
|
||||
addedLoading.value = true;
|
||||
listSectionUsers(props.sectionId)
|
||||
.then((list: User[]) => {
|
||||
addedUsers.value = list || [];
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
message.error(e.message || '加载白名单失败');
|
||||
})
|
||||
.finally(() => {
|
||||
addedLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
/* 搜索可添加的用户(排除已添加的) */
|
||||
const searchToAdd = () => {
|
||||
const kw = searchText.value.trim();
|
||||
if (!kw) {
|
||||
message.warning('请输入手机号码 / 姓名 / 昵称');
|
||||
return;
|
||||
}
|
||||
searchLoading.value = true;
|
||||
pageUsers({ keywords: kw, page: 1, limit: 20 } as any)
|
||||
.then((res: any) => {
|
||||
const addedIds = new Set(addedUsers.value.map((u) => u.userId));
|
||||
candidates.value = (res?.list || []).filter((u: User) => !addedIds.has(u.userId));
|
||||
if (candidates.value.length === 0) {
|
||||
message.info('没有可添加的新用户');
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
message.error(e.message || '搜索失败');
|
||||
})
|
||||
.finally(() => {
|
||||
searchLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
/* 添加单个用户到白名单 */
|
||||
const addOne = (u: User) => {
|
||||
if (!props.sectionId) return;
|
||||
addSectionUsers(props.sectionId, [u.userId as number])
|
||||
.then((msg: string) => {
|
||||
message.success(msg || '添加成功');
|
||||
candidates.value = candidates.value.filter((c) => c.userId !== u.userId);
|
||||
loadAdded();
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
message.error(e.message || '添加失败');
|
||||
});
|
||||
};
|
||||
|
||||
/* 从白名单移除单个用户 */
|
||||
const removeOne = (u: User) => {
|
||||
if (!props.sectionId) return;
|
||||
removeSectionUsers(props.sectionId, [u.userId as number])
|
||||
.then((msg: string) => {
|
||||
message.success(msg || '移除成功');
|
||||
loadAdded();
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
message.error(e.message || '移除失败');
|
||||
});
|
||||
};
|
||||
|
||||
// 打开弹窗时加载已添加名单
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
searchText.value = '';
|
||||
candidates.value = [];
|
||||
loadAdded();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
<style lang="less"></style>
|
||||
@@ -0,0 +1,266 @@
|
||||
<!-- 首页专区编辑弹窗 -->
|
||||
<template>
|
||||
<ele-modal
|
||||
:width="640"
|
||||
:visible="visible"
|
||||
:maskClosable="false"
|
||||
:title="isUpdate ? '编辑专区' : '添加专区'"
|
||||
:body-style="{ paddingBottom: '28px' }"
|
||||
@update:visible="updateVisible"
|
||||
@ok="save"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
:label-col="{ flex: '90px' }"
|
||||
:wrapper-col="{ flex: '1' }"
|
||||
>
|
||||
<a-form-item label="专区标题" name="title">
|
||||
<a-input
|
||||
allow-clear
|
||||
placeholder="请输入专区标题"
|
||||
v-model:value="form.title"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="副标题" name="subtitle">
|
||||
<a-input
|
||||
allow-clear
|
||||
placeholder="可选,专区副标题"
|
||||
v-model:value="form.subtitle"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="banner图" name="banner">
|
||||
<a-upload
|
||||
:before-upload="handleBannerUpload"
|
||||
:show-upload-list="false"
|
||||
accept="image/*"
|
||||
>
|
||||
<a-button v-if="!form.banner">
|
||||
<template #icon><UploadOutlined /></template>
|
||||
上传 banner
|
||||
</a-button>
|
||||
<div v-else class="flex items-center gap-2">
|
||||
<img :src="form.banner" style="width: 120px; height: 48px; object-fit: cover; border-radius: 4px; border: 1px solid #d9d9d9;" />
|
||||
<a-button type="text" danger size="small" @click.stop="form.banner = undefined">删除</a-button>
|
||||
</div>
|
||||
</a-upload>
|
||||
</a-form-item>
|
||||
<a-form-item label="关联分类" name="categoryIds">
|
||||
<a-input
|
||||
allow-clear
|
||||
placeholder="可选,分类ID逗号分隔,如:1,2,3"
|
||||
v-model:value="form.categoryIds"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="样式类型" name="styleType">
|
||||
<a-input-number
|
||||
:min="0"
|
||||
:max="99"
|
||||
style="width: 100%"
|
||||
placeholder="默认0"
|
||||
v-model:value="form.styleType"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="是否受限" name="restricted">
|
||||
<a-switch
|
||||
size="small"
|
||||
v-model:checked="form.restricted"
|
||||
:checked-value="1"
|
||||
:un-checked-value="0"
|
||||
checked-children="受限专区"
|
||||
un-checked-children="普通专区"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="折扣系数" name="discountRate">
|
||||
<a-input-number
|
||||
:min="0.01"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
placeholder="默认1.00表示不打折"
|
||||
v-model:value="form.discountRate"
|
||||
/>
|
||||
<div class="text-xs text-gray-400 mt-1">
|
||||
专区商城价 = 市场价 × 系数,例:填 0.78 则专区价显示为市场价的 0.78 折。
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="排序号" name="sortNumber">
|
||||
<a-input-number
|
||||
:min="0"
|
||||
:max="9999"
|
||||
style="width: 100%"
|
||||
placeholder="数值越小越靠前"
|
||||
v-model:value="form.sortNumber"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-switch
|
||||
size="small"
|
||||
v-model:checked="form.status"
|
||||
:checked-value="0"
|
||||
:un-checked-value="1"
|
||||
checked-children="正常"
|
||||
un-checked-children="禁用"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="生效开始" name="startTime">
|
||||
<a-date-picker
|
||||
show-time
|
||||
style="width: 100%"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="可选,专区生效开始时间"
|
||||
:value="form.startTime || undefined"
|
||||
@update:value="(val: any) => (form.startTime = val || undefined)"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="生效结束" name="endTime">
|
||||
<a-date-picker
|
||||
show-time
|
||||
style="width: 100%"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="可选,专区生效结束时间"
|
||||
:value="form.endTime || undefined"
|
||||
@update:value="(val: any) => (form.endTime = val || undefined)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</ele-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { UploadOutlined } from '@ant-design/icons-vue';
|
||||
import { assignObject } from 'ele-admin-pro';
|
||||
import { addHomeSection, updateHomeSection } from '@/api/shop/shopZone';
|
||||
import { uploadFile } from '@/api/system/file';
|
||||
import type { HomeSection } from '@/api/shop/shopZone/model';
|
||||
import type { FormInstance } from 'ant-design-vue/es/form';
|
||||
|
||||
const isUpdate = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
data?: HomeSection | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'done'): void;
|
||||
(e: 'update:visible', visible: boolean): void;
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const formRef = ref<FormInstance | null>(null);
|
||||
|
||||
const form = reactive<HomeSection>({
|
||||
sectionId: undefined,
|
||||
tenantId: undefined,
|
||||
title: undefined,
|
||||
subtitle: undefined,
|
||||
banner: undefined,
|
||||
categoryIds: undefined,
|
||||
styleType: 0,
|
||||
restricted: 0,
|
||||
discountRate: 1,
|
||||
sortNumber: 100,
|
||||
status: 0,
|
||||
startTime: undefined,
|
||||
endTime: undefined,
|
||||
userId: undefined,
|
||||
deleted: 0,
|
||||
createTime: undefined,
|
||||
updateTime: undefined
|
||||
});
|
||||
|
||||
const updateVisible = (value: boolean) => {
|
||||
emit('update:visible', value);
|
||||
};
|
||||
|
||||
// 表单验证规则
|
||||
const rules: Record<string, any> = {
|
||||
title: [
|
||||
{
|
||||
required: true,
|
||||
type: 'string',
|
||||
message: '请输入专区标题',
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!formRef.value) return;
|
||||
formRef.value
|
||||
.validate()
|
||||
.then(() => {
|
||||
loading.value = true;
|
||||
const saveOrUpdate = isUpdate.value ? updateHomeSection : addHomeSection;
|
||||
saveOrUpdate({ ...form })
|
||||
.then((msg) => {
|
||||
loading.value = false;
|
||||
message.success(msg);
|
||||
updateVisible(false);
|
||||
emit('done');
|
||||
})
|
||||
.catch((e) => {
|
||||
loading.value = false;
|
||||
message.error(e.message);
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
// banner 上传
|
||||
const handleBannerUpload = async (file: File) => {
|
||||
const isImage = file.type?.startsWith('image/');
|
||||
if (!isImage) {
|
||||
message.error('只能上传图片文件');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const res = await uploadFile(file);
|
||||
if (res?.path) {
|
||||
form.banner = res.path;
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '上传失败');
|
||||
}
|
||||
return false; // 阻止默认上传
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
if (props.data) {
|
||||
assignObject(form, props.data);
|
||||
isUpdate.value = true;
|
||||
} else {
|
||||
Object.assign(form, {
|
||||
sectionId: undefined,
|
||||
tenantId: undefined,
|
||||
title: undefined,
|
||||
subtitle: undefined,
|
||||
banner: undefined,
|
||||
categoryIds: undefined,
|
||||
styleType: 0,
|
||||
restricted: 0,
|
||||
sortNumber: 100,
|
||||
status: 0,
|
||||
startTime: undefined,
|
||||
endTime: undefined
|
||||
});
|
||||
isUpdate.value = false;
|
||||
}
|
||||
} else {
|
||||
formRef.value?.resetFields();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,604 @@
|
||||
<!-- 首页专区管理 -->
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="ele-body">
|
||||
<a-card :bordered="false" :body-style="{ padding: '16px' }">
|
||||
<ele-pro-table
|
||||
ref="tableRef"
|
||||
row-key="sectionId"
|
||||
:columns="columns"
|
||||
:datasource="datasource"
|
||||
:customRow="customRow"
|
||||
tool-class="ele-toolbar-form"
|
||||
class="sys-org-table"
|
||||
>
|
||||
<template #toolbar>
|
||||
<a-space :size="10" style="flex-wrap: wrap">
|
||||
<a-input-search
|
||||
v-model:value="where.title"
|
||||
placeholder="搜索专区标题"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@search="() => reload()"
|
||||
@pressEnter="() => reload()"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="where.status"
|
||||
placeholder="状态"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
@change="() => reload()"
|
||||
>
|
||||
<a-select-option :value="0">正常</a-select-option>
|
||||
<a-select-option :value="1">禁用</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="primary" class="ele-btn-icon" @click="openEdit()">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
<span>添加专区</span>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'banner'">
|
||||
<img
|
||||
v-if="record.banner"
|
||||
:src="getCompressedImageUrl(record.banner, { width: 300, quality: 85 })"
|
||||
style="max-width: 80px; height: 32px; object-fit: cover; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'restricted'">
|
||||
<a-tag :color="record.restricted === 1 ? 'red' : 'default'">
|
||||
{{ record.restricted === 1 ? '受限专区' : '普通专区' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-switch
|
||||
:checked="record.status === 0"
|
||||
checked-children="正常"
|
||||
un-checked-children="禁用"
|
||||
size="small"
|
||||
@change="(val: boolean) => toggleStatus(record, val)"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a @click="openEdit(record)">编辑</a>
|
||||
<a-divider type="vertical" />
|
||||
<a @click="openUsers(record)">白名单</a>
|
||||
<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
|
||||
title="确定要删除此专区吗?"
|
||||
@confirm="remove(record)"
|
||||
>
|
||||
<a class="ele-text-danger">删除</a>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</ele-pro-table>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<ZoneEdit v-model:visible="showEdit" :data="current" @done="reload" />
|
||||
|
||||
<!-- 白名单用户管理弹窗 -->
|
||||
<UserSelectModal
|
||||
v-model:visible="showUser"
|
||||
:sectionId="currentSectionId"
|
||||
/>
|
||||
|
||||
<!-- 专区销量统计弹窗 -->
|
||||
<SectionSalesStatsModal
|
||||
v-model:visible="showStats"
|
||||
:sectionId="currentSectionId"
|
||||
/>
|
||||
|
||||
<!-- 专区商品抽屉 -->
|
||||
<a-drawer
|
||||
:width="860"
|
||||
:visible="showGoods"
|
||||
title="专区商品"
|
||||
:maskClosable="false"
|
||||
@update:visible="(v: boolean) => (showGoods = v)"
|
||||
>
|
||||
<!-- 添加商品区域 -->
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 8px; align-items: center;">
|
||||
<a-input-search
|
||||
v-model:value="goodsSearchText"
|
||||
placeholder="搜索商品名称"
|
||||
allow-clear
|
||||
style="flex: 1; max-width: 320px;"
|
||||
@search="searchGoodsToAdd"
|
||||
@pressEnter="searchGoodsToAdd"
|
||||
/>
|
||||
<a-button type="primary" :loading="goodsSearchLoading" @click="searchGoodsToAdd">
|
||||
搜索
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
v-if="candidateGoods.length > 0"
|
||||
title="确定将搜索结果全部添加到此专区吗?"
|
||||
@confirm="addCandidateGoods"
|
||||
>
|
||||
<a-button type="primary" ghost>
|
||||
全部添加 ({{ candidateGoods.length }})
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
<!-- 候选商品列表(搜索结果,点击单条添加) -->
|
||||
<div v-if="candidateGoods.length > 0" style="margin-bottom: 12px; border: 1px dashed #d9d9d9; border-radius: 6px; padding: 8px;">
|
||||
<div style="font-size: 12px; color: #999; margin-bottom: 6px;">搜索结果(点击 + 添加)</div>
|
||||
<div
|
||||
v-for="g in candidateGoods"
|
||||
:key="g.goodsId"
|
||||
style="display: flex; align-items: center; padding: 4px 0; gap: 8px; border-bottom: 1px solid #f0f0f0;"
|
||||
>
|
||||
<img v-if="g.image" :src="g.image" style="width: 32px; height: 32px; object-fit: cover; border-radius: 4px;" />
|
||||
<span style="flex: 1; font-size: 13px;">{{ g.name }}</span>
|
||||
<span style="color: #999; font-size: 12px;">¥{{ g.price }}</span>
|
||||
<a-button type="link" size="small" @click="addOneGood(g)" style="padding: 0;">+ 添加</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已关联商品列表 -->
|
||||
<a-spin :spinning="goodsLoading">
|
||||
<a-table
|
||||
row-key="goodsId"
|
||||
:dataSource="goodsList"
|
||||
:columns="goodsColumns"
|
||||
:pagination="goodsPagination"
|
||||
:scroll="{ y: 380 }"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, text, record }">
|
||||
<template v-if="column.key === 'image'">
|
||||
<img
|
||||
v-if="text"
|
||||
:src="getCompressedImageUrl(text, { width: 120 })"
|
||||
style="width: 40px; height: 40px; object-fit: cover; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-popconfirm title="确定从该专区移除此商品吗?" @confirm="removeOneGood(record)">
|
||||
<a-button type="link" danger size="small">移除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-spin>
|
||||
</a-drawer>
|
||||
|
||||
<!-- 专区小程序码弹窗 -->
|
||||
<a-modal
|
||||
:visible="showQrcode"
|
||||
:title="qrcodeTitle"
|
||||
:footer="null"
|
||||
:width="360"
|
||||
:maskClosable="false"
|
||||
@update:visible="closeQrcode"
|
||||
>
|
||||
<div style="text-align: center; padding: 8px 0 4px;">
|
||||
<div style="margin-bottom: 10px;">
|
||||
<a-radio-group v-model:value="qrcodeEnv" size="small" @change="onQrcodeEnvChange">
|
||||
<a-radio-button value="release">正式版</a-radio-button>
|
||||
<a-radio-button value="trial">体验版</a-radio-button>
|
||||
<a-radio-button value="develop">开发版</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
<a-spin :spinning="qrcodeLoading">
|
||||
<img
|
||||
v-if="qrcodeUrl"
|
||||
:src="qrcodeUrl"
|
||||
style="width: 240px; height: 240px; object-fit: contain; border: 1px solid #f0f0f0; border-radius: 8px;"
|
||||
/>
|
||||
<div v-else style="width: 240px; height: 240px; line-height: 240px; color: #999;">
|
||||
加载中...
|
||||
</div>
|
||||
</a-spin>
|
||||
<div style="margin-top: 12px; font-size: 13px; color: #888;">
|
||||
微信扫一扫,直接进入该专区
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import type { EleProTable } from 'ele-admin-pro';
|
||||
import { toDateString } from 'ele-admin-pro';
|
||||
import type {
|
||||
DatasourceFunction,
|
||||
ColumnItem
|
||||
} 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,
|
||||
removeHomeSection,
|
||||
updateHomeSection,
|
||||
listSectionGoods,
|
||||
addSectionGoods,
|
||||
removeSectionGoods,
|
||||
getSectionQrcode
|
||||
} from '@/api/shop/shopZone';
|
||||
import { pageShopGoods } from '@/api/shop/shopGoods';
|
||||
import type {
|
||||
HomeSection,
|
||||
HomeSectionParam
|
||||
} from '@/api/shop/shopZone/model';
|
||||
|
||||
// 表格实例
|
||||
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
||||
|
||||
// 当前编辑数据
|
||||
const current = ref<HomeSection | null>(null);
|
||||
// 是否显示编辑弹窗
|
||||
const showEdit = ref(false);
|
||||
|
||||
// 白名单弹窗
|
||||
const showUser = ref(false);
|
||||
const currentSectionId = ref<number>(0);
|
||||
|
||||
// 商品抽屉
|
||||
const showGoods = ref(false);
|
||||
const goodsLoading = ref(false);
|
||||
const goodsList = ref<any[]>([]);
|
||||
const goodsTotal = ref(0);
|
||||
const goodsPage = ref(1);
|
||||
const goodsPageSize = ref(10);
|
||||
|
||||
// 已关联商品分页配置
|
||||
const goodsPagination = computed(() => ({
|
||||
total: goodsTotal.value,
|
||||
current: goodsPage.value,
|
||||
pageSize: goodsPageSize.value,
|
||||
showSizeChanger: true,
|
||||
onChange: (page: number, pageSize: number) => {
|
||||
goodsPage.value = page;
|
||||
goodsPageSize.value = pageSize;
|
||||
loadGoods(currentSectionId.value);
|
||||
}
|
||||
}));
|
||||
|
||||
// 商品搜索/添加
|
||||
const goodsSearchText = ref('');
|
||||
const goodsSearchLoading = ref(false);
|
||||
const candidateGoods = ref<any[]>([]);
|
||||
|
||||
// 专区小程序码弹窗
|
||||
const showQrcode = ref(false);
|
||||
const qrcodeLoading = ref(false);
|
||||
const qrcodeUrl = ref('');
|
||||
const qrcodeTitle = ref('专区小程序码');
|
||||
const qrcodeEnv = ref('release'); // release(正式版) / trial(体验版) / develop(开发版)
|
||||
const qrcodeSectionId = ref(0);
|
||||
|
||||
// 表格数据源
|
||||
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
|
||||
return pageHomeSections({
|
||||
...where,
|
||||
...orders,
|
||||
page,
|
||||
limit
|
||||
}).then((res) => {
|
||||
return { list: res?.list || [], count: res?.count || 0 };
|
||||
});
|
||||
};
|
||||
|
||||
// 表格列配置
|
||||
const columns = ref<ColumnItem[]>([
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'sectionId',
|
||||
key: 'sectionId',
|
||||
align: 'center',
|
||||
width: 70
|
||||
},
|
||||
{
|
||||
title: '专区标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
align: 'left',
|
||||
minWidth: 160
|
||||
},
|
||||
{
|
||||
title: 'banner',
|
||||
dataIndex: 'banner',
|
||||
key: 'banner',
|
||||
align: 'center',
|
||||
width: 110
|
||||
},
|
||||
{
|
||||
title: '是否受限',
|
||||
dataIndex: 'restricted',
|
||||
key: 'restricted',
|
||||
align: 'center',
|
||||
width: 110
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sortNumber',
|
||||
key: 'sortNumber',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
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',
|
||||
key: 'createTime',
|
||||
align: 'center',
|
||||
width: 120,
|
||||
sorter: true,
|
||||
customRender: ({ text }: any) => toDateString(text, 'yyyy-MM-dd')
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 326,
|
||||
fixed: 'right',
|
||||
align: 'center',
|
||||
hideInSetting: true
|
||||
}
|
||||
] as unknown as ColumnItem[]);
|
||||
|
||||
// 商品表格列
|
||||
const goodsColumns = [
|
||||
{ title: 'ID', dataIndex: 'goodsId', key: 'goodsId', align: 'center', width: 70 },
|
||||
{ title: '商品名称', dataIndex: 'name', key: 'name', align: 'left' },
|
||||
{
|
||||
title: '图片',
|
||||
dataIndex: 'image',
|
||||
key: 'image',
|
||||
align: 'center',
|
||||
width: 80
|
||||
},
|
||||
{ title: '商城价', dataIndex: 'price', key: 'price', align: 'center', width: 90 },
|
||||
{ title: '库存', dataIndex: 'stock', key: 'stock', align: 'center', width: 80 },
|
||||
{ title: '操作', key: 'action', align: 'center', width: 80 }
|
||||
] as any[];
|
||||
|
||||
/* 搜索条件 */
|
||||
const where = ref<HomeSectionParam>({
|
||||
title: '',
|
||||
status: undefined,
|
||||
restricted: undefined,
|
||||
sortNumber: undefined
|
||||
});
|
||||
|
||||
/* 搜索 */
|
||||
const reload = (w?: HomeSectionParam) => {
|
||||
if (w) {
|
||||
where.value = { ...where.value, ...w };
|
||||
}
|
||||
tableRef?.value?.reload({ where: where.value });
|
||||
};
|
||||
|
||||
/* 打开编辑弹窗 */
|
||||
const openEdit = (row?: HomeSection) => {
|
||||
current.value = row ?? null;
|
||||
showEdit.value = true;
|
||||
};
|
||||
|
||||
/* 切换状态 */
|
||||
const toggleStatus = (row: HomeSection, val: boolean) => {
|
||||
const newStatus = val ? 0 : 1;
|
||||
updateHomeSection({ ...row, status: newStatus })
|
||||
.then((msg) => {
|
||||
message.success(msg);
|
||||
row.status = newStatus;
|
||||
})
|
||||
.catch((e) => {
|
||||
message.error(e.message);
|
||||
});
|
||||
};
|
||||
|
||||
/* 删除单个 */
|
||||
const remove = (row: HomeSection) => {
|
||||
const hide = message.loading('请求中..', 0);
|
||||
removeHomeSection(row.sectionId)
|
||||
.then((msg) => {
|
||||
hide();
|
||||
message.success(msg);
|
||||
reload();
|
||||
})
|
||||
.catch((e) => {
|
||||
hide();
|
||||
message.error(e.message);
|
||||
});
|
||||
};
|
||||
|
||||
/* 打开白名单弹窗 */
|
||||
const openUsers = (row: HomeSection) => {
|
||||
currentSectionId.value = row.sectionId || 0;
|
||||
showUser.value = true;
|
||||
};
|
||||
|
||||
/* 打开商品抽屉 */
|
||||
const openGoods = (row: HomeSection) => {
|
||||
currentSectionId.value = row.sectionId || 0;
|
||||
goodsPage.value = 1;
|
||||
goodsSearchText.value = '';
|
||||
candidateGoods.value = [];
|
||||
showGoods.value = true;
|
||||
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 = '';
|
||||
qrcodeLoading.value = true;
|
||||
getSectionQrcode(id, qrcodeEnv.value)
|
||||
.then((url) => {
|
||||
qrcodeUrl.value = url;
|
||||
})
|
||||
.catch((e) => {
|
||||
message.error(e?.message || '生成小程序码失败');
|
||||
showQrcode.value = false;
|
||||
})
|
||||
.finally(() => {
|
||||
qrcodeLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
/* 打开专区小程序码弹窗 */
|
||||
const openQrcode = (row: HomeSection) => {
|
||||
const id = row.sectionId || 0;
|
||||
qrcodeSectionId.value = id;
|
||||
qrcodeTitle.value = '专区小程序码 - ' + (row.title || id);
|
||||
if (qrcodeUrl.value) {
|
||||
window.URL.revokeObjectURL(qrcodeUrl.value);
|
||||
qrcodeUrl.value = '';
|
||||
}
|
||||
qrcodeLoading.value = true;
|
||||
showQrcode.value = true;
|
||||
genQrcode(id);
|
||||
};
|
||||
|
||||
/* 切换小程序版本后重新生成 */
|
||||
const onQrcodeEnvChange = () => {
|
||||
if (qrcodeSectionId.value && showQrcode.value) {
|
||||
genQrcode(qrcodeSectionId.value);
|
||||
}
|
||||
};
|
||||
|
||||
/* 关闭专区小程序码弹窗并回收资源 */
|
||||
const closeQrcode = (v: boolean) => {
|
||||
showQrcode.value = v;
|
||||
if (!v && qrcodeUrl.value) {
|
||||
window.URL.revokeObjectURL(qrcodeUrl.value);
|
||||
qrcodeUrl.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const loadGoods = (sectionId: number) => {
|
||||
goodsLoading.value = true;
|
||||
listSectionGoods(sectionId, { page: goodsPage.value, limit: goodsPageSize.value })
|
||||
.then((res) => {
|
||||
goodsList.value = res?.list || [];
|
||||
goodsTotal.value = res?.count || 0;
|
||||
})
|
||||
.catch((e) => {
|
||||
message.error(e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
goodsLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
/* 搜索可添加的商品 */
|
||||
const searchGoodsToAdd = () => {
|
||||
if (!goodsSearchText.value.trim()) {
|
||||
message.warning('请输入商品名称');
|
||||
return;
|
||||
}
|
||||
goodsSearchLoading.value = true;
|
||||
pageShopGoods({ keywords: goodsSearchText.value, page: 1, limit: 20 })
|
||||
.then((res) => {
|
||||
candidateGoods.value = (res?.list || []).filter(
|
||||
(g: any) => !goodsList.value.some((eg: any) => eg.goodsId === g.goodsId)
|
||||
);
|
||||
if (candidateGoods.value.length === 0) {
|
||||
message.info('没有找到可添加的新商品');
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
message.error(e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
goodsSearchLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
/* 添加单条商品到专区 */
|
||||
const addOneGood = (good: any) => {
|
||||
addSectionGoods(currentSectionId.value, [good.goodsId])
|
||||
.then((msg) => {
|
||||
message.success(msg);
|
||||
candidateGoods.value = candidateGoods.value.filter((g: any) => g.goodsId !== good.goodsId);
|
||||
loadGoods(currentSectionId.value);
|
||||
})
|
||||
.catch((e) => {
|
||||
message.error(e.message);
|
||||
});
|
||||
};
|
||||
|
||||
/* 批量添加候选商品 */
|
||||
const addCandidateGoods = () => {
|
||||
const ids = candidateGoods.value.map((g: any) => g.goodsId);
|
||||
addSectionGoods(currentSectionId.value, ids)
|
||||
.then((msg) => {
|
||||
message.success(`已添加 ${ids.length} 个商品`);
|
||||
candidateGoods.value = [];
|
||||
loadGoods(currentSectionId.value);
|
||||
})
|
||||
.catch((e) => {
|
||||
message.error(e.message);
|
||||
});
|
||||
};
|
||||
|
||||
/* 从专区移除单条商品 */
|
||||
const removeOneGood = (record: any) => {
|
||||
removeSectionGoods(currentSectionId.value, [record.goodsId])
|
||||
.then((msg) => {
|
||||
message.success(msg);
|
||||
loadGoods(currentSectionId.value);
|
||||
})
|
||||
.catch((e) => {
|
||||
message.error(e.message);
|
||||
});
|
||||
};
|
||||
|
||||
/* 行属性:双击编辑 */
|
||||
const customRow = (record: HomeSection) => {
|
||||
return {
|
||||
onDblclick: () => {
|
||||
openEdit(record);
|
||||
}
|
||||
};
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 专区折扣:购物车记录所属专区ID(用于购物车链路按专区折扣系数结算)
|
||||
-- 作用库:guilixu-java 的 db_guilixu(商城实际库)
|
||||
ALTER TABLE shop_cart
|
||||
ADD COLUMN section_id INT NULL COMMENT '专区ID(从专区加入购物车时记录, 用于专区折扣价计算, 普通加购为null)'
|
||||
AFTER goods_id;
|
||||
|
||||
-- 验证
|
||||
-- SELECT id, goods_id, section_id FROM shop_cart WHERE section_id IS NOT NULL LIMIT 10;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 专区商城价折扣系数:成交价 = 市场价(salePrice) × discount_rate
|
||||
-- 作用于 guilixu-java 的 db_guilixu 库(shop_home_section 表)
|
||||
-- 租户 10606 生效环境执行;默认 1.00 表示不打折,本专区按需求填 0.78
|
||||
|
||||
ALTER TABLE shop_home_section
|
||||
ADD COLUMN discount_rate DECIMAL(10, 2) NOT NULL DEFAULT 1.00
|
||||
COMMENT '专区商城价折扣系数, 成交价=市场价×系数, 1.00表示不打折';
|
||||
|
||||
-- 如需将「某专区」直接设为 0.78 折(替换 <section_id>):
|
||||
-- UPDATE shop_home_section SET discount_rate = 0.78 WHERE section_id = <section_id> AND tenant_id = 10606;
|
||||
Reference in New Issue
Block a user